A source turns some corpus of text (a website, an API, a feed) into
fictions and chapters that Candela narrates. Adding one is a self-contained,
~30-minute task: scaffold a module, implement one HTTP client against your
service, map the responses into the shared models, and turn the contract test
green. There are two central edits at the very end and no others — no
touching a giant when, no editing the settings screen, no SourceIds entry.
Reference source (living documentation): source-hackernews — a small,
complete JSON-backed source. When this guide says "see how a real source does
it," open that module.
- JDK 17. That's it. You do not need to build the whole app to develop a
source — everything you write is exercised by a module-scoped unit test
(
:source-<id>:testDebugUnitTest), which is fast and needs no device. - Familiarity with Kotlin coroutines and OkHttp. No Hilt/KSP knowledge needed — the annotation does the wiring.
scripts/new-source.sh <id> "<Display Name>"
# e.g.
scripts/new-source.sh demofeed "Demo Feed"<id> is the stable, lowercase identifier (letters/digits, e.g. demofeed);
"<Display Name>" is what the user sees. This generates:
source-<id>/
build.gradle.kts # android-library + hilt + ksp + the test kit
src/main/.../<Name>Source.kt # @SourcePlugin, FictionSource stubbed with NotFound
src/main/.../net/<Name>Api.kt # IO-pinned request() wrapper, status mapping pre-written
src/main/.../di/<Name>Module.kt # provides this source's qualified OkHttpClient + Api (keep it — see §5)
src/test/.../<Name>ContractTest.kt # subclass of the shared contract kit
(--local sources have no net/ — they get a <Name>Reader interface + bound
impl, a 3-line @Binds di/ module, and a plain <Name>SourceTest instead; see
Local-provider sources below.)
Everything compiles immediately; the contract test fails honestly because the stub doesn't talk to the network yet. That red test is your to-do list.
new-source.sh scaffolds an anonymous JSON HTTP source by default. Pass one
mode flag (mutually exclusive) when your backend has a different shape:
| Flag | Backend shape | What changes vs. the default |
|---|---|---|
| (none) | Anonymous JSON HTTP — source-hackernews |
— |
--auth/--oauth |
OAuth / BYOK token HTTP — source-reddit, source-google-drive |
request() threads a Bearer token via an accessToken() seam; the contract test wires a non-blank fake token (#1529) |
--xml/--feed |
XML / Atom / RSS HTTP — source-arxiv, source-rss, source-primegaming |
XML Accept header, no kotlinx-serialization, Atom-bodied contract fixture (#1533) |
--local |
Non-HTTP local provider — source-ocr, source-epub, source-calendar |
a <Name>Reader device-read seam, no OkHttp/serialization, a plain fake-backed test — no HTTP contract kit (#1526) |
An authed XML feed is the one combination the flags don't cover directly:
start from --auth and swap the Accept header (and drop the JSON parse-error
catch), or start from --xml and add the accessToken() seam. See
Local-provider sources below for the --local path in full.
The scaffold ships a request() wrapper that already does the two things every
review checks for: it pins the blocking OkHttp call to Dispatchers.IO (#585)
and maps HTTP status codes to typed FictionResult failures. Copy that shape
for every endpoint — never surface a raw HTTP code, a raw body, or a thrown
exception to the source layer.
Point your endpoints at the real service and add typed request functions
(popular, search, a detail fetch, a chapter fetch). The baseUrl seam is
already open so the contract test can retarget it at a MockWebServer.
Authenticated source (
--auth)? The scaffoldedrequest()reads the bearer token from anopen suspend fun accessToken()seam and short-circuits toAuthRequiredwhen it's blank — wireaccessToken()to your token store. The--authcontract test overridesaccessToken()with a non-blank fake sopopular()actually reaches the wire; without it a blank-token short-circuit issues zero requests and the IO-pin check fails confusingly with "no request reached the probe" (#1529).
Feed / XML source (
--xml)? The scaffoldedrequest()sends an XMLAcceptheader and has no kotlinx-serialization parse-error catch (regex andXmlPullParserdon't throwSerializationException). Parse in yourparselambda; feed sources usually add conditional-GET headers (If-None-Match/If-Modified-Since) here.source-arxivandsource-rssare two real XML precedents (#1533).
Every network call must resolve to exactly one of these. The scaffolded
request() already implements this table — keep it intact as you extend it:
| Upstream | Return |
|---|---|
| 2xx + parseable body | FictionResult.Success(value) |
| 401 / 403 (no CF challenge) | FictionResult.AuthRequired(...) |
| 404 | FictionResult.NotFound(...) |
| 429 | FictionResult.RateLimited(retryAfter, ...) |
| Cloudflare challenge body/headers | FictionResult.Cloudflare(challengeUrl, ...) |
any IOException / 5xx |
FictionResult.NetworkError(msg, cause) |
Check for a Cloudflare challenge before the 401/403 arm — a CF-gated 403
must read as Cloudflare (escalate to the WebView resolver), not "sign in
required." See Ao3Api.requestText and StandardEbooksApi.fetchString for two
real CF-aware implementations.
In <Name>Source.kt, replace each NotFound("not implemented") with a call
that fetches via your Api and maps the result into the core-data models:
- Browse (
popular/latestUpdates/byGenre/search) →ListPage<FictionSummary>(sethasNextso pagination terminates). fictionDetail→FictionDetail(synopsis + chapter list, no bodies).chapter→ChapterContent(cleaned/sanitized text).genres→ the genre labels yourbyGenreaccepts (orSuccess(emptyList())).- Auth-gated (
followsList/setFollowed) → returnAuthRequiredwhen anonymous; only implement if your service has a real account-side follow concept (and setsupportsFollow = true).
Use FictionResult.map { … } to transform a Success while passing failures
through unchanged. source-hackernews (toSummary() / toDetail()) is the
canonical mapping example.
Every FictionSummary.id (and the fictionId in FictionDetail) must be
shaped "<pluginId>:<localId>", where <pluginId> is your source's @SourcePlugin
id (the same string as FictionSource.id). Example: a Gutenberg book 84 becomes
"gutenberg:84"; a Notion page becomes "notion:guides".
Why it's not optional. FictionSourceIdResolver (#981) routes a stored fiction
back to its owning source by the id's colon prefix (id.substringBefore(':')), and
defaults a colon-less id to Royal Road. So a source that returns a bare id (e.g.
"my-doc") has its fictions silently misrouted to Royal Road — detail and reader
never open. It compiles clean and passes green tests; it breaks only at runtime.
As of #1564:
- The contract kit (
FictionSourceContractTest) asserts every id returned bypopular()/search()starts with"<yourId>:"— a bare id fails the test. FictionSourceIdResolverlogs a loud warning when it routes a colon-less, non-numeric id to Royal Road (bare numeric ids are the one legitimate colon-less shape — they're Royal Road's).
Chapter ids are composite too. A Chapter.id must be globally unique across
fictions — build it as "$fictionId::<localChapterId>" (note the ::), so joins on
chapter id stay 1:1 (#1261). source-hackernews shows both conventions.
Open <Name>ContractTest.kt and set the two fixture hooks:
happyListBody()— a trimmed, real response body from your list endpoint (2–3 items is plenty).listPathFragment()— a substring of the path yourpopular()hits, so the kit routes the happy body to it. The scaffold defaults this to a loudREPLACE_WITH_YOUR_LIST_PATHsentinel: the kit asserts the fragment matched a real requested path, so a forgotten edit (or a fragment that isn't actually in your API's path — e.g. the source id, which rarely is) fails loudly instead of silently 404-ing the happy body while the IO-pin check still greens (#1523).
Then run it:
./gradlew :source-<id>:testDebugUnitTest --tests '*ContractTest'The kit enforces the tribal gotchas as executable checks:
- network work leaves the caller thread (the #585
Dispatchers.IOpin), - 401/403 →
AuthRequired, 429 →RateLimited(never raw exceptions), - a Cloudflare challenge body is a failure, never returned as chapter text,
- blank search doesn't crash.
If your source is search-only (no meaningful popular()), set
override val exercisesPopular = false and the popular-based checks skip.
Purely local (non-HTTP) sources don't use this kit at all — scaffold them with
--local and see Local-provider sources below.
The scaffold prints these; they are the only central changes:
// settings.gradle.kts
include(":source-<id>")
// app/build.gradle.kts
implementation(project(":source-<id>"))That's it. @SourcePlugin(id = …) makes KSP emit both Hilt bindings — the
registry descriptor (Browse chip, Settings auto-section) and the
Map<String, FictionSource> routing entry. You do not add a SourceIds
constant (that table is frozen — the annotation id is the source of truth).
Two things sit next to each other here, so keep them straight (#1522):
- KSP emits the
FictionSourcemultibindings — the descriptor@IntoSetand theMap<String, FictionSource>@IntoMap. You never hand-write those. - The scaffold generates
di/<Name>Module.kt— the@Qualifier+@Providesthat supply your source's dedicatedOkHttpClient(with the shared UA interceptor) and itsApi. There is no global unqualifiedOkHttpClient, so this module is required. You don't write it by hand, but do not delete it thinking it's redundant: without it the:appHilt graph can't provide<Name>Apiand the build fails at:app:hiltJavaCompileRelease(CI-only — it compiles fine per-module).--localsources have no HTTP-client module, but they DO get a differentdi/<Name>Module.kt— a 3-line@Bindsthat wires the<Name>Readerinterface to its@ApplicationContext-backed impl (#1562); keep that one too.
Some sources read the device, not a network service — source-ocr (scanned
text), source-epub (SAF-picked books), source-calendar (CalendarContract).
They have no HTTP surface, so the OkHttp request() wrapper, the qualified-client
DI module, and the HTTP contract kit don't apply. Scaffold them with --local:
scripts/new-source.sh --local <id> "<Display Name>"That emits a leaner module:
- no OkHttp / kotlinx-serialization and no
core-source-testkitdep; - a
<Name>Readerseam — an interface with a bound impl<Name>ReaderImpl @Inject constructor(@ApplicationContext context: Context)whose IO-pinneditems()/item()methods you point at your device read (bundled assets viacontext.assets, SAF,ContentResolver, or a DataStore the:app/:featurelayer persists); - a 3-line
di/<Name>Module.ktwith a single@Binds(interface → impl); - a plain
<Name>SourceTestwhoseFakeReaderimplements the interface (thesource-ocrpattern) instead of a contract-kit subclass; - an
AndroidManifest.xmlplaceholder for a permission you may need to declare (e.g.READ_CALENDAR) — delete it if your source needs none.
Why an interface, not a concrete
@Injectclass (#1562). A real local read needs aContext(assets / SAF /ContentResolver). A concrete@Inject constructor(@ApplicationContext Context)reader cannot be fake-subclassed in a pure-JVM test — the fake can't callsuper()without a realContext(that would force Robolectric, which--localavoids). The interface + bound impl keeps the fake trivial. If your provider genuinely needs noContext, drop the impl's constructor arg — the@Bindsstill holds.
Id scheme still applies. Even though a local source skips the HTTP contract
kit (which asserts it), your FictionSummary ids must be
"<pluginId>:<localId>" — a colon-less id silently routes to Royal Road (see
Id scheme above). The --local
test seed already models this ("$ID:1"); keep the prefix when you map real
items (#1564).
The IO pin the HTTP kit enforces automatically is your responsibility in a
local source: keep every blocking device read inside
withContext(Dispatchers.IO) in the impl. source-ocr is the reference
implementation. If you ran the default scaffold before realizing your source is
local, re-run with --local on a fresh id rather than gutting the HTTP scaffold
by hand.
-
:source-<id>:testDebugUnitTest(the contract test) is green. - CI Build APK passes — this is the compile gate and proves your
@SourcePluginbindings resolve in the:appHilt graph. - Every network call is
withContext(Dispatchers.IO)(IO pin). - Status codes map per the decision table (auth/rate/CF are typed, not
NetworkError). - No
SourceIdsedit; the@SourcePluginidis the identity. -
descriptionandsourceUrlare set on@SourcePlugin(they render in the plugin manager card).
Reviewers look for exactly those six things.