Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,24 @@ Do not implement against a pending decision — ask first. Update this table whe

- Devices: Samsung Tab A11, Galaxy S23. Sideloaded debug APK. OCR stays on-device — never
move it to a server.
- songbird import: `POST {base_url}/api/v1/import`, header `Authorization: Bearer <token>`,
body = ImportDocument. Response: `{"annotations": {"created": N, "skipped": M}}`. Idempotent.
Reachable over Tailscale. Concord is NOT a runtime dependency.
- Secrets: the bearer token never enters the repo — runtime settings only.
- songbird import: `POST {base_url}/api/v1/import`, body = ImportDocument. Idempotent. Reachable over
Tailscale. Concord is NOT a runtime dependency of this fork (songbird calls it server-side).
- **Brief errata (verified against songbird source @ commit `89f894e`, supersedes brief §4):** songbird
has **NO bearer-token auth** — it is **Argon2 cookie-session**. Auth is login-per-send:
`POST /api/v1/auth/login` `{username,password}` → **200** + `Set-Cookie: songbird_session=…` (bad creds
→ **401** `{"detail":{"code":"INVALID_CREDENTIALS"}}`); then `POST /api/v1/import` carrying that cookie;
then best-effort `POST /api/v1/auth/logout` (sessions are 30-day, accumulate one row per login).
Import **200** response is `ImportSummary` = `{"annotations":{created,skipped,failed},
"sermon_notes":{…},"errors":[…]}` — NOT the brief's `{"annotations":{"created","skipped"}}` (missing
`failed`/`sermon_notes`/`errors`). Import without a valid cookie → 401; Concord down → 502. The brief
stays unedited as the founding document; this errata carries reality.
- Secrets: the songbird **username + password** never enter the repo — runtime settings only, stored in
EncryptedSharedPreferences (the password is never logged or echoed). (F6's bearer token is gone — F6b.)
- **Cleartext HTTP (F6b):** songbird is a LAN/tailnet `http` service (no TLS) at an operator-set host, but
`targetSdk 36` blocks cleartext by default — which surfaced as a false "couldn't reach songbird". A
network-security config (`res/xml/network_security_config.xml`, `cleartextTrafficPermitted="true"`,
wired via `<application android:networkSecurityConfig>`) permits it. Acceptable for a private,
non-store, sideloaded app; revisit if songbird ever fronts with TLS.

## Build & test

Expand Down Expand Up @@ -167,8 +181,14 @@ echo "sdk.dir=$ANDROID_HOME" > local.properties # gitignored
- applicationId `io.github.kbennett2000.sermonscanner` (coexists with stock MakeACopy).
Sideload (device not assumed connected): `adb install -r <apk>`; uninstall:
`adb uninstall io.github.kbennett2000.sermonscanner`.
- **First-run config (F6):** on the finalize screen tap **Settings** and enter the songbird base URL
(e.g. `http://<host>:8000`, over Tailscale) + bearer token before **Send** is enabled (stored encrypted).
- **First-run config (F6/F6b):** on the finalize screen tap **Settings** and enter the songbird base URL
(e.g. `http://<host>:8000`, over Tailscale) + **username + password** before **Send** is enabled
(stored encrypted; songbird is cookie-session — see the Environment errata).
- **Verification gate (run this, not just assemble):** the CI/merge gate is
`./gradlew :app:compilePaddleDebugJavaWithJavac :app:testPaddleDebugUnitTest :app:lintPaddleDebug`
(`.github/workflows/build-release.yml`). **`lintPaddleDebug` is part of the gate** (`abortOnError`
defaults true, no baseline) — assemble + unit tests alone do **not** catch lint errors (e.g.
`MissingDefaultResource`). Always run lint before declaring a slice green.

Notes: paddle is the sole flavor (F1b, D5) — Tesseract removed; there is no `assembleStandardDebug`. The
packaged ONNX runtime carries **DocQuad + PaddleOCR** ops; the on-disk `libonnxruntime.so` is the F0b
Expand Down Expand Up @@ -206,4 +226,5 @@ UI/data (trimmed in F1c per D3).
- [x] F3b — verse-count table + span resolver (D2): `de.schliweb.makeacopy.anchor` — `SpanResolver.resolve(StructuralAnchor, VerseTable)` → `ResolvedSpan` (five Appendix A fields) or typed `SpanResolution` failure (`UNKNOWN_BOOK`/`CHAPTER_OUT_OF_RANGE`). Chapter-only fills `1..table[ch]`; single verse `start=end`; range passes through; verses never validated (§6). `VerseTable` (pure Gson parser) reads the **canon-structural** table at `app/src/main/assets/anchor/verse_counts.json` (counts = canonical structure; `source_translation`/`concord_version` are provenance only). Regenerate offline with `python3 tools/generate_verse_counts/generate_verse_counts.py --base-url <concord> --translation <id> --concord-version <v>` — never a runtime dep. The committed table was generated from **NKJV** (`concord_version v1.2.0`); NKJV is a licensed translation in Concord's `data/private/`, so it's a fine verse-count source (bare counts are canonical structure, not text) but **regeneration requires Kris's private Concord deployment**. Tests: `SpanResolverTest` (8) + `VerseTableTest` (6) on a sample table; `VerseCountsSchemaTest` enforces the real asset (66 keys == BookMap, positive counts). Asset loading is F4 wiring; no UI / no frozen-core edits.
- [x] F4 — edit screen (text, anchor, title, tags): `ui/edit/EditFragment` + fragment-scoped `EditViewModel` (Context-free, injected `VerseTable`). Reached via the hub **Continue** action (gated on ≥1 page with OCR; replaced the F2 TEMP hook). Prefills combined OCR text + runs `AnchorFinder` once (operator owns the anchor after); structured anchor editing (book picker via `anchor/BookNames`, numeric chapter/verse-from/verse-to) with live `PassageLabel` + `SpanResolver` re-resolve (out-of-range/unknown **block**, reversed range **warns**); title (blank warns, not blocks) / date (picker, ISO) / tags. Produces `draft/SermonDraft` (resolved span + label + text/title/date/tags) handed via activity-scoped `SermonDraftViewModel` to TEMP `DraftPreviewFragment`. `VerseTableLoader` is the thin cached asset loader F3b deferred. Tests: `BookNamesTest`, `PassageLabelTest`, `EditViewModelTest` (11). New strings default-locale only. Known limits: stateless between visits; no process-death restore.
- [x] F5 — songbird JSON emitter (deterministic, Appendix A): `de.schliweb.makeacopy.emit` — `NoteMarkdown.build()` (D1 minimal body: `# title` omitted when blank, `passage — date` em-dash line, non-empty edited lines as `- ` items, emphasis passed through never generated) + `ImportJsonEmitter.emit()` (fixed-order StringBuilder walk, 2-space indent, invariants hard-coded, tags trimmed/deduped, reversed range normalized at the wire, no trailing newline). Byte-stability pinned by `app/src/test/resources/emit/golden_import.json` (regenerate consciously). Tests: `NoteMarkdownTest` (10), `ImportJsonEmitterTest` (9, incl. golden byte-equality + present-and-null vs absent), `EmitterFixtureChainTest` (full pure pipeline → `1SA 25:1-44`). Stub `DraftPreviewFragment` now shows the real JSON. No frozen-core edits.
- [x] F6 — finalize: POST / save-share per D4: `ui/finalize/FinalizeFragment` (replaces the F4/F5 TEMP stub) — JSON preview + **Send to songbird** (`songbird/HttpImportPoster`: one `HttpURLConnection` POST to `{base}/api/v1/import`, Bearer header, 5s/15s, no retries/idempotent) + **Share JSON** (FileProvider, cache, `application/json`). `FinalizeViewModel` (injected poster+executor) publishes IDLE→SENDING→DONE; `songbird/ImportResult.from` classifies SUCCESS/UNREACHABLE/UNAUTHORIZED/HTTP_ERROR (success shows created+skipped). `ui/settings/SettingsFragment` + `songbird/SongbirdPrefsHelper` store base URL + token in **EncryptedSharedPreferences** (token never logged/echoed). Added `INTERNET` permission + `androidx.security-crypto`. Tests: `SongbirdSettingsTest`, `ImportResultTest`, `ShareFilenameTest`, `FinalizeViewModelTest` (fake poster). No frozen-core edits. **The brief's slice plan is complete.**
- [x] F6 — finalize: POST / save-share per D4: `ui/finalize/FinalizeFragment` (replaces the F4/F5 TEMP stub) — JSON preview + **Send to songbird** (`songbird/HttpImportPoster`: one `HttpURLConnection` POST to `{base}/api/v1/import`, Bearer header, 5s/15s, no retries/idempotent) + **Share JSON** (FileProvider, cache, `application/json`). `FinalizeViewModel` (injected poster+executor) publishes IDLE→SENDING→DONE; `songbird/ImportResult.from` classifies SUCCESS/UNREACHABLE/UNAUTHORIZED/HTTP_ERROR (success shows created+skipped). `ui/settings/SettingsFragment` + `songbird/SongbirdPrefsHelper` store base URL + token in **EncryptedSharedPreferences** (token never logged/echoed). Added `INTERNET` permission + `androidx.security-crypto`. Tests: `SongbirdSettingsTest`, `ImportResultTest`, `ShareFilenameTest`, `FinalizeViewModelTest` (fake poster). No frozen-core edits. **NOTE: built to a wrong brief auth contract (Bearer token) — corrected in F6b.**
- [x] F6b — speak songbird's real auth (cookie-session): the integration gate found songbird has no bearer auth. Reworked to **login-per-send** (`POST /api/v1/auth/login` → `songbird_session` cookie → cookied `POST /api/v1/import` → best-effort `logout`) via `HttpImportPoster` with explicit cookie handling (no global CookieManager). Settings became base URL + **username + password** (EncryptedSharedPreferences; password never logged); the dead token pref is gone. `ImportPoster.send(...) → SongbirdExchange`; `ImportResult` statuses now `SUCCESS`/`UNREACHABLE`/`LOGIN_REJECTED`/`HTTP_ERROR`, parsing the real `ImportSummary` (created/skipped/**failed** + `errors[]`; `failed>0` surfaced). Verified against songbird source @ `89f894e`; contract in the Environment errata. Tests reworked against the real shape + committed `songbird/import_summary.json` fixture. No frozen-core edits. **The brief's slice plan is complete (F6b corrects the F6 contract).**
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:networkSecurityConfig="@xml/network_security_config"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,58 +9,114 @@
*/
package de.schliweb.makeacopy.songbird;

import com.google.gson.JsonObject;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;

/**
* Real {@link ImportPoster}: a single {@code HttpURLConnection} POST to {@code {base}/api/v1/import}
* (slice F6). Context-free. No retries — the songbird import is idempotent, so the operator simply taps
* Send again to retry. The token is set as the {@code Authorization} header only — never logged.
* Real {@link ImportPoster}: songbird's Argon2 cookie-session flow (slice F6b), login-per-send with
* explicit cookie handling (no global {@code CookieManager}):
*
* <p>Not unit-tested (no MockWebServer in this project, by decision) — validated on-device via the
* {@link ImportPoster} seam, which a fake covers in {@code FinalizeViewModelTest}.
* <ol>
* <li>{@code POST {base}/api/v1/auth/login} {username,password} → capture the {@code songbird_session}
* cookie from {@code Set-Cookie}.
* <li>{@code POST {base}/api/v1/import} with a {@code Cookie} header + the JSON body.
* <li>best-effort {@code POST {base}/api/v1/auth/logout} (ignored outcome) — deletes the session row so
* login-per-send doesn't accumulate 30-day rows.
* </ol>
*
* Context-free. Timeouts connect 5s / read 15s, no retries (idempotent — operator re-taps). Credentials
* live only in the login body + the cookie header — never logged. Not unit-tested (no MockWebServer);
* validated on-device via the {@link ImportPoster} seam, which a fake covers in {@code
* FinalizeViewModelTest}.
*/
public final class HttpImportPoster implements ImportPoster {

private static final int CONNECT_TIMEOUT_MS = 5000;
private static final int READ_TIMEOUT_MS = 15000;
private static final String COOKIE_NAME = "songbird_session";

@Override
public PostResult post(String baseUrl, String token, String json) {
public SongbirdExchange send(String baseUrl, String username, String password, String json) {
String[] cookieOut = new String[1];
PostResult login =
post(baseUrl + "/api/v1/auth/login", loginJson(username, password), null, cookieOut);
if (login.networkError() || login.status() < 200 || login.status() >= 300) {
return SongbirdExchange.loginOnly(login);
}
String cookie = cookieOut[0]; // session token value, or null if no Set-Cookie
PostResult imported = post(baseUrl + "/api/v1/import", json, cookie, null);
if (cookie != null) {
// Best-effort cleanup so the per-send session row doesn't linger 30 days; outcome ignored.
post(baseUrl + "/api/v1/auth/logout", "", cookie, null);
}
return SongbirdExchange.of(login, imported);
}

/** One POST. {@code cookie} (if non-null) is sent as the session header; {@code cookieOut} (if
* non-null) receives the captured {@code songbird_session} value from the response. */
private static PostResult post(String urlStr, String body, String cookie, String[] cookieOut) {
HttpURLConnection conn = null;
try {
URL url = new URL(baseUrl + "/api/v1/import");
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setInstanceFollowRedirects(false);
conn.setRequestMethod("POST");
conn.setRequestProperty("Authorization", "Bearer " + token);
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
conn.setRequestProperty("Accept", "application/json");
if (cookie != null) {
conn.setRequestProperty("Cookie", COOKIE_NAME + "=" + cookie);
}
conn.setDoOutput(true);

byte[] body = json == null ? new byte[0] : json.getBytes(StandardCharsets.UTF_8);
byte[] b = body == null ? new byte[0] : body.getBytes(StandardCharsets.UTF_8);
try (OutputStream os = conn.getOutputStream()) {
os.write(body);
os.write(b);
}

int status = conn.getResponseCode();
if (cookieOut != null) {
cookieOut[0] = extractSessionCookie(conn);
}
InputStream stream =
(status >= 200 && status < 400) ? conn.getInputStream() : conn.getErrorStream();
return PostResult.http(status, readAll(stream));
} catch (IOException e) {
// Host unreachable / timeout / DNS — surfaced as UNREACHABLE (no token in any message).
return PostResult.unreachable();
} finally {
if (conn != null) conn.disconnect();
}
}

/** The {@code songbird_session} value from any {@code Set-Cookie} response header, or null. */
private static String extractSessionCookie(HttpURLConnection conn) {
for (Map.Entry<String, List<String>> e : conn.getHeaderFields().entrySet()) {
if (e.getKey() == null || !"Set-Cookie".equalsIgnoreCase(e.getKey())) continue;
for (String value : e.getValue()) {
if (value != null && value.startsWith(COOKIE_NAME + "=")) {
String rest = value.substring((COOKIE_NAME + "=").length());
int semi = rest.indexOf(';');
return semi >= 0 ? rest.substring(0, semi) : rest;
}
}
}
return null;
}

private static String loginJson(String username, String password) {
JsonObject o = new JsonObject();
o.addProperty("username", username == null ? "" : username);
o.addProperty("password", password == null ? "" : password);
return o.toString();
}

private static String readAll(InputStream in) throws IOException {
if (in == null) return "";
try (InputStream s = in) {
Expand Down
18 changes: 11 additions & 7 deletions app/src/main/java/de/schliweb/makeacopy/songbird/ImportPoster.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,23 @@
package de.schliweb.makeacopy.songbird;

/**
* The network seam for posting the import document to songbird (slice F6). A thin interface so {@link
* de.schliweb.makeacopy.ui.finalize.FinalizeViewModel} can be unit-tested with a fake — the real
* implementation is {@link HttpImportPoster}.
* The network seam for sending an import document to songbird (slice F6b). songbird uses Argon2
* cookie-session auth, so a send is login-per-send: POST login → cookied POST import (→ best-effort
* logout). A thin interface so {@link de.schliweb.makeacopy.ui.finalize.FinalizeViewModel} can be
* unit-tested with a fake — the real implementation is {@link HttpImportPoster}.
*/
public interface ImportPoster {

/**
* POSTs {@code json} to songbird. Implementations must never log or echo {@code token}. Returns a
* {@link PostResult}; connection failures map to {@link PostResult#unreachable()} rather than throwing.
* Logs in with {@code username}/{@code password}, then POSTs {@code json} to the import endpoint with
* the captured session cookie. Implementations must never log or echo the credentials. Returns the raw
* {@link SongbirdExchange}; connection failures map to {@link PostResult#unreachable()} rather than
* throwing.
*
* @param baseUrl normalized base URL (no trailing slash)
* @param token bearer token (header only)
* @param username songbird username (login body only)
* @param password songbird password (login body only)
* @param json the emitted import document
*/
PostResult post(String baseUrl, String token, String json);
SongbirdExchange send(String baseUrl, String username, String password, String json);
}
Loading
Loading