From 6e4bcbce3a544d8cb3356d4b439f9b766a632835 Mon Sep 17 00:00:00 2001 From: Kris Bennett <13557788+kbennett2000@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:08:19 -0600 Subject: [PATCH 1/4] fix(F6b): speak songbird's real cookie-session auth + import shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brief's §4 contract was wrong (no bearer-token auth in songbird). Reworked the finalize integration against songbird source @ 89f894e: - Auth: LOGIN-PER-SEND. HttpImportPoster does POST /api/v1/auth/login {username, password} → captures the songbird_session cookie from Set-Cookie → POST /api/v1/import with a Cookie header → best-effort POST /api/v1/auth/logout (net-zero session rows; sessions are 30-day, accumulate per login). Explicit cookie handling, no global CookieManager. Credentials are header/body only, never logged. - Seam: ImportPoster.send(baseUrl, username, password, json) -> SongbirdExchange {login, imported}; PostResult unchanged. - ImportResult.from(SongbirdExchange): UNREACHABLE / LOGIN_REJECTED (login 401) / HTTP_ERROR (login other non-2xx, or any import non-2xx incl. 401/502 after a good login) / SUCCESS parsing the real ImportSummary {annotations,sermon_notes each created/skipped/failed, errors[]}; failed>0 is surfaced prominently with the first error. Tolerant parsing kept. - Settings: base URL + username + masked password (EncryptedSharedPreferences); the dead bearer token pref is gone (no migration — it never worked). canSend + isConfigured now require all three. - UI: SettingsFragment username/password fields; FinalizeFragment passes creds + renders login-rejected / with-failures. Tests reworked against the real shape: ImportResultTest (13) + committed fixture import_summary.json (provenance: songbird tests/import_export_test.py @ 89f894e), FinalizeViewModelTest (4, fake two-step poster), SongbirdSettingsTest (3-field). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../makeacopy/songbird/HttpImportPoster.java | 82 +++++++++-- .../makeacopy/songbird/ImportPoster.java | 18 ++- .../makeacopy/songbird/ImportResult.java | 101 ++++++++++--- .../makeacopy/songbird/SongbirdExchange.java | 32 ++++ .../songbird/SongbirdPrefsHelper.java | 33 +++-- .../makeacopy/songbird/SongbirdSettings.java | 13 +- .../ui/finalize/FinalizeFragment.java | 21 ++- .../ui/finalize/FinalizeViewModel.java | 10 +- .../ui/settings/SettingsFragment.java | 20 ++- app/src/main/res/layout/fragment_settings.xml | 22 ++- app/src/main/res/values/strings.xml | 6 +- .../makeacopy/songbird/ImportResultTest.java | 138 +++++++++++++----- .../songbird/SongbirdSettingsTest.java | 16 +- .../ui/finalize/FinalizeViewModelTest.java | 61 +++++--- .../resources/songbird/import_summary.json | 5 + 15 files changed, 430 insertions(+), 148 deletions(-) create mode 100644 app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdExchange.java create mode 100644 app/src/test/resources/songbird/import_summary.json diff --git a/app/src/main/java/de/schliweb/makeacopy/songbird/HttpImportPoster.java b/app/src/main/java/de/schliweb/makeacopy/songbird/HttpImportPoster.java index a12f8181..5af8d3ad 100644 --- a/app/src/main/java/de/schliweb/makeacopy/songbird/HttpImportPoster.java +++ b/app/src/main/java/de/schliweb/makeacopy/songbird/HttpImportPoster.java @@ -9,6 +9,7 @@ */ package de.schliweb.makeacopy.songbird; +import com.google.gson.JsonObject; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -16,51 +17,106 @@ 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}): * - *

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}. + *

    + *
  1. {@code POST {base}/api/v1/auth/login} {username,password} → capture the {@code songbird_session} + * cookie from {@code Set-Cookie}. + *
  2. {@code POST {base}/api/v1/import} with a {@code Cookie} header + the JSON body. + *
  3. 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. + *
+ * + * 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> 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) { diff --git a/app/src/main/java/de/schliweb/makeacopy/songbird/ImportPoster.java b/app/src/main/java/de/schliweb/makeacopy/songbird/ImportPoster.java index 24b4a0e0..07a38ad8 100644 --- a/app/src/main/java/de/schliweb/makeacopy/songbird/ImportPoster.java +++ b/app/src/main/java/de/schliweb/makeacopy/songbird/ImportPoster.java @@ -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); } diff --git a/app/src/main/java/de/schliweb/makeacopy/songbird/ImportResult.java b/app/src/main/java/de/schliweb/makeacopy/songbird/ImportResult.java index 03f6d8af..ab7ae20c 100644 --- a/app/src/main/java/de/schliweb/makeacopy/songbird/ImportResult.java +++ b/app/src/main/java/de/schliweb/makeacopy/songbird/ImportResult.java @@ -9,28 +9,40 @@ */ package de.schliweb.makeacopy.songbird; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; /** - * Classified outcome of a songbird import (slice F6), derived purely from a {@link PostResult}. The four - * statuses each map to a distinguishable, actionable message on the finalize screen. Tolerant parsing: - * unknown fields ignored; a 2xx without a usable summary is still success. + * Classified outcome of a songbird login-per-send exchange (slice F6b), derived purely from a {@link + * SongbirdExchange}. Each status maps to a distinguishable, actionable message on the finalize screen. + * + *

Success parses songbird's {@code ImportSummary}: + * {@code {"annotations":{"created","skipped","failed"},"sermon_notes":{…},"errors":[…]}}. Tolerant: + * unknown fields ignored; a 2xx import without a usable summary is still success (no counts). * * @param status the outcome category - * @param created annotations created (SUCCESS only; 0 otherwise) + * @param created annotations created (SUCCESS only) * @param skipped annotations skipped — the idempotency proof (SUCCESS only) - * @param summaryPresent whether the 2xx response carried a parseable created/skipped summary + * @param failed total entries songbird rejected (annotations + sermon_notes; SUCCESS only) + * @param summaryPresent whether the 2xx import response carried a parseable annotations summary * @param httpCode the HTTP status (HTTP_ERROR only; 0 otherwise) - * @param detail a short body snippet for HTTP_ERROR (≤200 chars; never contains the token) + * @param detail HTTP_ERROR body snippet, or the first {@code errors[]} reason when {@code failed>0} + * (≤200 chars; never contains credentials) */ public record ImportResult( - Status status, int created, int skipped, boolean summaryPresent, int httpCode, String detail) { + Status status, + int created, + int skipped, + int failed, + boolean summaryPresent, + int httpCode, + String detail) { public enum Status { SUCCESS, UNREACHABLE, - UNAUTHORIZED, + LOGIN_REJECTED, HTTP_ERROR } @@ -40,33 +52,78 @@ public boolean isSuccess() { return status == Status.SUCCESS; } - /** Classifies a raw {@link PostResult}. Pure. */ - public static ImportResult from(PostResult r) { - if (r == null || r.networkError()) { - return new ImportResult(Status.UNREACHABLE, 0, 0, false, 0, ""); + /** True when the import succeeded but songbird rejected one or more entries. */ + public boolean hasFailures() { + return status == Status.SUCCESS && failed > 0; + } + + /** Classifies a raw {@link SongbirdExchange}. Pure. */ + public static ImportResult from(SongbirdExchange ex) { + if (ex == null || ex.login() == null || ex.login().networkError()) { + return unreachable(); + } + PostResult login = ex.login(); + if (!is2xx(login.status())) { + if (login.status() == 401 || login.status() == 403) { + return new ImportResult(Status.LOGIN_REJECTED, 0, 0, 0, false, login.status(), ""); + } + return httpError(login.status(), login.body()); } - int s = r.status(); - if (s >= 200 && s < 300) { - return parseSuccess(r.body()); + // Login succeeded — classify the import. + PostResult imp = ex.imported(); + if (imp == null || imp.networkError()) { + return unreachable(); } - if (s == 401 || s == 403) { - return new ImportResult(Status.UNAUTHORIZED, 0, 0, false, s, ""); + if (is2xx(imp.status())) { + return parseSummary(imp.body()); } - return new ImportResult(Status.HTTP_ERROR, 0, 0, false, s, snippet(r.body())); + return httpError(imp.status(), imp.body()); } - private static ImportResult parseSuccess(String body) { + private static ImportResult parseSummary(String body) { try { JsonObject root = JsonParser.parseString(body).getAsJsonObject(); JsonObject ann = root.getAsJsonObject("annotations"); if (ann != null && ann.has("created") && ann.has("skipped")) { + int created = ann.get("created").getAsInt(); + int skipped = ann.get("skipped").getAsInt(); + int failed = optInt(ann, "failed") + sermonFailed(root); return new ImportResult( - Status.SUCCESS, ann.get("created").getAsInt(), ann.get("skipped").getAsInt(), true, 0, ""); + Status.SUCCESS, created, skipped, failed, true, 0, failed > 0 ? firstError(root) : ""); } } catch (RuntimeException ignore) { - // 2xx but unparseable/missing summary — still a success. + // 2xx but unparseable/missing summary — still a success, just no counts. + } + return new ImportResult(Status.SUCCESS, 0, 0, 0, false, 0, ""); + } + + private static int sermonFailed(JsonObject root) { + JsonObject sn = root.getAsJsonObject("sermon_notes"); + return sn == null ? 0 : optInt(sn, "failed"); + } + + private static int optInt(JsonObject o, String key) { + return (o != null && o.has(key) && o.get(key).isJsonPrimitive()) ? o.get(key).getAsInt() : 0; + } + + private static String firstError(JsonObject root) { + JsonArray errors = root.getAsJsonArray("errors"); + if (errors != null && errors.size() > 0) { + return snippet(errors.get(0).getAsString()); } - return new ImportResult(Status.SUCCESS, 0, 0, false, 0, ""); + return ""; + } + + private static ImportResult unreachable() { + return new ImportResult(Status.UNREACHABLE, 0, 0, 0, false, 0, ""); + } + + private static ImportResult httpError(int code, String body) { + return new ImportResult(Status.HTTP_ERROR, 0, 0, 0, false, code, snippet(body)); + } + + private static boolean is2xx(int s) { + return s >= 200 && s < 300; } private static String snippet(String body) { diff --git a/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdExchange.java b/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdExchange.java new file mode 100644 index 00000000..e8d35e6c --- /dev/null +++ b/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdExchange.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Christian Kierdorf + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.schliweb.makeacopy.songbird; + +/** + * The raw two-step outcome of a login-per-send exchange with songbird (slice F6b): the login request's + * {@link PostResult} and, when login reached 2xx, the import request's {@link PostResult}. No exceptions + * cross the {@link ImportPoster} seam, so this is trivially fakeable in tests. Carries no credentials. + * + * @param login the login POST outcome (never null) + * @param imported the import POST outcome, or {@code null} when login did not reach 2xx (so import was + * never attempted) + */ +public record SongbirdExchange(PostResult login, PostResult imported) { + + /** Login failed (network error or non-2xx); import not attempted. */ + public static SongbirdExchange loginOnly(PostResult login) { + return new SongbirdExchange(login, null); + } + + /** Login succeeded and import was attempted. */ + public static SongbirdExchange of(PostResult login, PostResult imported) { + return new SongbirdExchange(login, imported); + } +} diff --git a/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdPrefsHelper.java b/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdPrefsHelper.java index 1f2f7687..f12642b5 100644 --- a/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdPrefsHelper.java +++ b/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdPrefsHelper.java @@ -16,16 +16,18 @@ import androidx.security.crypto.MasterKey; /** - * Stores the songbird connection settings (slice F6, decision D1): base URL + bearer token, both at rest - * in {@link EncryptedSharedPreferences}. The token is NEVER written to plaintext prefs, NEVER logged, and - * NEVER echoed — error handling logs only generic messages. + * Stores the songbird connection settings (slice F6b): base URL + username + password, all at rest in + * {@link EncryptedSharedPreferences}. songbird uses cookie-session auth (no token), so the F6 bearer + * token is gone (no migration — it never worked). The password is NEVER written to plaintext prefs, + * NEVER logged, and NEVER echoed — error handling logs only generic messages. */ public final class SongbirdPrefsHelper { private static final String TAG = "SongbirdPrefs"; private static final String FILE = "songbird_secure_prefs"; private static final String KEY_BASE_URL = "songbird_base_url"; - private static final String KEY_TOKEN = "songbird_bearer_token"; + private static final String KEY_USERNAME = "songbird_username"; + private static final String KEY_PASSWORD = "songbird_password"; private SongbirdPrefsHelper() {} @@ -34,9 +36,14 @@ public static String getBaseUrl(Context ctx) { return p == null ? "" : p.getString(KEY_BASE_URL, ""); } - public static String getToken(Context ctx) { + public static String getUsername(Context ctx) { SharedPreferences p = open(ctx); - return p == null ? "" : p.getString(KEY_TOKEN, ""); + return p == null ? "" : p.getString(KEY_USERNAME, ""); + } + + public static String getPassword(Context ctx) { + SharedPreferences p = open(ctx); + return p == null ? "" : p.getString(KEY_PASSWORD, ""); } /** Persists the base URL, normalized (trim + strip trailing slash). */ @@ -45,14 +52,20 @@ public static void setBaseUrl(Context ctx, String value) { if (p != null) p.edit().putString(KEY_BASE_URL, SongbirdSettings.normalizeBaseUrl(value)).apply(); } - /** Persists the bearer token (trimmed). Never logged. */ - public static void setToken(Context ctx, String value) { + /** Persists the username (trimmed). */ + public static void setUsername(Context ctx, String value) { + SharedPreferences p = open(ctx); + if (p != null) p.edit().putString(KEY_USERNAME, value == null ? "" : value.trim()).apply(); + } + + /** Persists the password verbatim (no trim — spaces may be significant). Never logged. */ + public static void setPassword(Context ctx, String value) { SharedPreferences p = open(ctx); - if (p != null) p.edit().putString(KEY_TOKEN, value == null ? "" : value.trim()).apply(); + if (p != null) p.edit().putString(KEY_PASSWORD, value == null ? "" : value).apply(); } public static boolean isConfigured(Context ctx) { - return SongbirdSettings.canSend(getBaseUrl(ctx), getToken(ctx)); + return SongbirdSettings.canSend(getBaseUrl(ctx), getUsername(ctx), getPassword(ctx)); } private static SharedPreferences open(Context ctx) { diff --git a/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdSettings.java b/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdSettings.java index 819db436..f357d0cb 100644 --- a/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdSettings.java +++ b/app/src/main/java/de/schliweb/makeacopy/songbird/SongbirdSettings.java @@ -29,11 +29,12 @@ public static String normalizeBaseUrl(String raw) { return s; } - /** True when both the base URL and the token are present (the Send gate). */ - public static boolean canSend(String baseUrl, String token) { - return baseUrl != null - && !baseUrl.trim().isEmpty() - && token != null - && !token.trim().isEmpty(); + /** True when the base URL, username, and password are all present (the Send gate). */ + public static boolean canSend(String baseUrl, String username, String password) { + return notBlank(baseUrl) && notBlank(username) && notBlank(password); + } + + private static boolean notBlank(String s) { + return s != null && !s.trim().isEmpty(); } } diff --git a/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeFragment.java b/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeFragment.java index 5132cede..42adeb41 100644 --- a/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeFragment.java +++ b/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeFragment.java @@ -98,8 +98,9 @@ private void updateSendGate() { private void onSend() { if (json == null) return; String baseUrl = SongbirdPrefsHelper.getBaseUrl(requireContext()); - String token = SongbirdPrefsHelper.getToken(requireContext()); - viewModel.send(baseUrl, token, json); + String username = SongbirdPrefsHelper.getUsername(requireContext()); + String password = SongbirdPrefsHelper.getPassword(requireContext()); + viewModel.send(baseUrl, username, password, json); } private void onShare() { @@ -144,13 +145,19 @@ private void renderState(FinalizeViewModel.SendUiState state) { private String describe(ImportResult r) { switch (r.status()) { case SUCCESS: - return r.summaryPresent() - ? getString(R.string.finalize_imported, r.created(), r.skipped()) - : getString(R.string.finalize_imported_no_summary); + if (!r.summaryPresent()) { + return getString(R.string.finalize_imported_no_summary); + } + if (r.failed() > 0) { + // songbird rejected entries — surface it prominently with the first reason. + return getString( + R.string.finalize_imported_with_failures, r.created(), r.skipped(), r.failed(), r.detail()); + } + return getString(R.string.finalize_imported, r.created(), r.skipped()); case UNREACHABLE: return getString(R.string.finalize_unreachable); - case UNAUTHORIZED: - return getString(R.string.finalize_unauthorized); + case LOGIN_REJECTED: + return getString(R.string.finalize_login_rejected); case HTTP_ERROR: default: return getString(R.string.finalize_http_error, r.httpCode(), r.detail()); diff --git a/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeViewModel.java b/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeViewModel.java index 74a16900..303ba5c1 100644 --- a/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeViewModel.java +++ b/app/src/main/java/de/schliweb/makeacopy/ui/finalize/FinalizeViewModel.java @@ -15,7 +15,7 @@ import de.schliweb.makeacopy.songbird.HttpImportPoster; import de.schliweb.makeacopy.songbird.ImportPoster; import de.schliweb.makeacopy.songbird.ImportResult; -import de.schliweb.makeacopy.songbird.PostResult; +import de.schliweb.makeacopy.songbird.SongbirdExchange; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -56,13 +56,13 @@ public LiveData getState() { return state; } - /** POSTs the JSON; safe to call again to retry (idempotent). */ - public void send(String baseUrl, String token, String json) { + /** Logs in and sends the JSON; safe to call again to retry (idempotent). */ + public void send(String baseUrl, String username, String password, String json) { state.setValue(new SendUiState(Phase.SENDING, null)); executor.execute( () -> { - PostResult pr = poster.post(baseUrl, token, json); - state.postValue(new SendUiState(Phase.DONE, ImportResult.from(pr))); + SongbirdExchange ex = poster.send(baseUrl, username, password, json); + state.postValue(new SendUiState(Phase.DONE, ImportResult.from(ex))); }); } diff --git a/app/src/main/java/de/schliweb/makeacopy/ui/settings/SettingsFragment.java b/app/src/main/java/de/schliweb/makeacopy/ui/settings/SettingsFragment.java index 3ae795b5..c9c66a0b 100644 --- a/app/src/main/java/de/schliweb/makeacopy/ui/settings/SettingsFragment.java +++ b/app/src/main/java/de/schliweb/makeacopy/ui/settings/SettingsFragment.java @@ -24,10 +24,10 @@ import de.schliweb.makeacopy.utils.ui.UIUtils; /** - * Minimal settings screen (slice F6): the songbird base URL + bearer token. There was no pre-existing - * settings UI in the stripped fork, so this is the surviving settings surface — reached from the finalize - * screen. The token field is masked (password toggle); values persist via the encrypted {@link - * SongbirdPrefsHelper}. The token is never logged or echoed. + * Minimal settings screen (slice F6b): the songbird base URL + username + password. There was no + * pre-existing settings UI in the stripped fork, so this is the surviving settings surface — reached from + * the finalize screen. The password field is masked (password toggle); values persist via the encrypted + * {@link SongbirdPrefsHelper}. The password is never logged or echoed. */ @dagger.hilt.android.AndroidEntryPoint public class SettingsFragment extends Fragment { @@ -45,17 +45,21 @@ public View onCreateView( public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); binding.baseUrlField.setText(SongbirdPrefsHelper.getBaseUrl(requireContext())); - binding.tokenField.setText(SongbirdPrefsHelper.getToken(requireContext())); + binding.usernameField.setText(SongbirdPrefsHelper.getUsername(requireContext())); + binding.passwordField.setText(SongbirdPrefsHelper.getPassword(requireContext())); binding.buttonSaveSettings.setOnClickListener(v -> save()); } private void save() { String baseUrl = binding.baseUrlField.getText() == null ? "" : binding.baseUrlField.getText().toString(); - String token = - binding.tokenField.getText() == null ? "" : binding.tokenField.getText().toString(); + String username = + binding.usernameField.getText() == null ? "" : binding.usernameField.getText().toString(); + String password = + binding.passwordField.getText() == null ? "" : binding.passwordField.getText().toString(); SongbirdPrefsHelper.setBaseUrl(requireContext(), baseUrl); // normalized inside - SongbirdPrefsHelper.setToken(requireContext(), token); + SongbirdPrefsHelper.setUsername(requireContext(), username); + SongbirdPrefsHelper.setPassword(requireContext(), password); UIUtils.showToast(requireContext(), getString(R.string.settings_saved), Toast.LENGTH_SHORT); try { Navigation.findNavController(requireView()).popBackStack(); diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index ca23d722..7012bf72 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -35,16 +35,32 @@ + + + + + Set the songbird URL and token in Settings to enable Send. Sending… Imported: %1$d created, %2$d skipped. + ⚠ Imported: %1$d created, %2$d skipped, %3$d FAILED — %4$s Imported (no summary returned). Couldn\'t reach songbird — check Tailscale/URL. You can Share JSON instead. - songbird rejected the token. Update it in Settings. + songbird rejected the username/password. Update them in Settings. Error %1$d: %2$s Couldn\'t prepare the file to share. songbird connection Base URL (e.g. http://host:8000) - Bearer token + Username + Password Settings saved diff --git a/app/src/test/java/de/schliweb/makeacopy/songbird/ImportResultTest.java b/app/src/test/java/de/schliweb/makeacopy/songbird/ImportResultTest.java index 651d516f..d84acf2c 100644 --- a/app/src/test/java/de/schliweb/makeacopy/songbird/ImportResultTest.java +++ b/app/src/test/java/de/schliweb/makeacopy/songbird/ImportResultTest.java @@ -13,87 +13,151 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import org.junit.Test; -/** Tests for {@link ImportResult#from} classification (slice F6). */ +/** + * Tests for {@link ImportResult#from} (slice F6b) against songbird's REAL {@code ImportSummary} shape and + * the cookie-session login-per-send flow. Response fixtures match songbird source + * (github.com/kbennett2000/songbird @ 89f894e — api/schemas.py ImportSummary, + * tests/import_export_test.py). + */ public class ImportResultTest { + private static PostResult ok2xx(String body) { + return PostResult.http(200, body); + } + + // ---- success (login ok → import 2xx → ImportSummary) ---- + @Test - public void success_withSummary() { - ImportResult r = - ImportResult.from(PostResult.http(200, "{\"annotations\": {\"created\": 1, \"skipped\": 2}}")); + public void firstImport_fromCommittedFixture() throws IOException { + String body = readResource("/songbird/import_summary.json"); + ImportResult r = ImportResult.from(SongbirdExchange.of(ok2xx("{}"), ok2xx(body))); assertEquals(ImportResult.Status.SUCCESS, r.status()); assertTrue(r.summaryPresent()); assertEquals(1, r.created()); - assertEquals(2, r.skipped()); + assertEquals(0, r.skipped()); + assertEquals(0, r.failed()); + assertFalse(r.hasFailures()); } @Test - public void success_idempotentReimport_skippedReported() { - ImportResult r = - ImportResult.from(PostResult.http(200, "{\"annotations\": {\"created\": 0, \"skipped\": 1}}")); - assertTrue(r.isSuccess()); + public void idempotentReimport_reportsSkipped() { + String body = + "{\"annotations\":{\"created\":0,\"skipped\":1,\"failed\":0}," + + "\"sermon_notes\":{\"created\":0,\"skipped\":0,\"failed\":0},\"errors\":[]}"; + ImportResult r = ImportResult.from(SongbirdExchange.of(ok2xx("{}"), ok2xx(body))); + assertEquals(ImportResult.Status.SUCCESS, r.status()); assertEquals(0, r.created()); assertEquals(1, r.skipped()); + assertEquals(0, r.failed()); } @Test - public void success_201_withSummary() { - ImportResult r = - ImportResult.from(PostResult.http(201, "{\"annotations\": {\"created\": 1, \"skipped\": 0}}")); + public void failuresReported_withFirstErrorAsDetail() { + String body = + "{\"annotations\":{\"created\":0,\"skipped\":0,\"failed\":1}," + + "\"sermon_notes\":{\"created\":0,\"skipped\":0,\"failed\":0}," + + "\"errors\":[\"annotation 1SA 25:1: unknown translation(s): XYZ\"]}"; + ImportResult r = ImportResult.from(SongbirdExchange.of(ok2xx("{}"), ok2xx(body))); assertEquals(ImportResult.Status.SUCCESS, r.status()); - assertTrue(r.summaryPresent()); + assertTrue(r.hasFailures()); + assertEquals(1, r.failed()); + assertEquals("annotation 1SA 25:1: unknown translation(s): XYZ", r.detail()); + } + + @Test + public void failedCountsSermonNotesToo() { + String body = + "{\"annotations\":{\"created\":1,\"skipped\":0,\"failed\":0}," + + "\"sermon_notes\":{\"created\":0,\"skipped\":0,\"failed\":2},\"errors\":[\"x\",\"y\"]}"; + ImportResult r = ImportResult.from(SongbirdExchange.of(ok2xx("{}"), ok2xx(body))); + assertEquals(2, r.failed()); + assertTrue(r.hasFailures()); } @Test - public void success_missingSummary_stillSuccess() { - ImportResult r = ImportResult.from(PostResult.http(200, "{\"ok\": true}")); + public void success2xx_missingSummary_stillSuccessNoCounts() { + ImportResult r = ImportResult.from(SongbirdExchange.of(ok2xx("{}"), ok2xx("{\"ok\":true}"))); assertEquals(ImportResult.Status.SUCCESS, r.status()); assertFalse(r.summaryPresent()); } @Test - public void success_malformedBody_stillSuccess() { - ImportResult r = ImportResult.from(PostResult.http(200, "not json at all")); + public void success2xx_malformedBody_stillSuccess() { + ImportResult r = ImportResult.from(SongbirdExchange.of(ok2xx("{}"), ok2xx("not json"))); assertEquals(ImportResult.Status.SUCCESS, r.status()); assertFalse(r.summaryPresent()); } + // ---- login failures ---- + @Test - public void success_ignoresUnknownExtraFields() { - ImportResult r = - ImportResult.from( - PostResult.http( - 200, "{\"annotations\": {\"created\": 3, \"skipped\": 0, \"extra\": 9}, \"x\": 1}")); - assertTrue(r.summaryPresent()); - assertEquals(3, r.created()); + public void loginRejected_401_isLoginRejected() { + PostResult login = PostResult.http(401, "{\"detail\":{\"code\":\"INVALID_CREDENTIALS\"}}"); + ImportResult r = ImportResult.from(SongbirdExchange.loginOnly(login)); + assertEquals(ImportResult.Status.LOGIN_REJECTED, r.status()); } @Test - public void unauthorized_401and403() { - assertEquals(ImportResult.Status.UNAUTHORIZED, ImportResult.from(PostResult.http(401, "nope")).status()); - assertEquals(ImportResult.Status.UNAUTHORIZED, ImportResult.from(PostResult.http(403, "nope")).status()); + public void loginNetworkError_isUnreachable() { + ImportResult r = ImportResult.from(SongbirdExchange.loginOnly(PostResult.unreachable())); + assertEquals(ImportResult.Status.UNREACHABLE, r.status()); } @Test - public void httpError_carriesCodeAndSnippet() { - ImportResult r = ImportResult.from(PostResult.http(422, "validation: book_usfm required")); + public void loginOtherNon2xx_isHttpError() { + ImportResult r = ImportResult.from(SongbirdExchange.loginOnly(PostResult.http(500, "boom"))); assertEquals(ImportResult.Status.HTTP_ERROR, r.status()); - assertEquals(422, r.httpCode()); - assertEquals("validation: book_usfm required", r.detail()); + assertEquals(500, r.httpCode()); + assertEquals("boom", r.detail()); } + // ---- import failures AFTER a successful login ---- + @Test - public void httpError_snippetTruncatedTo200() { + public void importUnauthorizedAfterLogin_isHttpError_notLoginRejected() { + // A 401 at import (cookie didn't stick) is NOT a credentials problem — login already succeeded. + PostResult imp = PostResult.http(401, "{\"detail\":{\"code\":\"NOT_AUTHENTICATED\"}}"); + ImportResult r = ImportResult.from(SongbirdExchange.of(ok2xx("{}"), imp)); + assertEquals(ImportResult.Status.HTTP_ERROR, r.status()); + assertEquals(401, r.httpCode()); + } + + @Test + public void importConcordDown_502_isHttpError() { + PostResult imp = PostResult.http(502, "{\"detail\":{\"code\":\"CONCORD_UNREACHABLE\"}}"); + ImportResult r = ImportResult.from(SongbirdExchange.of(ok2xx("{}"), imp)); + assertEquals(ImportResult.Status.HTTP_ERROR, r.status()); + assertEquals(502, r.httpCode()); + } + + @Test + public void importNetworkErrorAfterLogin_isUnreachable() { + ImportResult r = ImportResult.from(SongbirdExchange.of(ok2xx("{}"), PostResult.unreachable())); + assertEquals(ImportResult.Status.UNREACHABLE, r.status()); + } + + @Test + public void httpErrorSnippet_truncatedTo200() { StringBuilder big = new StringBuilder(); for (int i = 0; i < 500; i++) big.append('x'); - ImportResult r = ImportResult.from(PostResult.http(500, big.toString())); + ImportResult r = ImportResult.from(SongbirdExchange.loginOnly(PostResult.http(500, big.toString()))); assertEquals(200, r.detail().length()); } - @Test - public void networkError_unreachable() { - ImportResult r = ImportResult.from(PostResult.unreachable()); - assertEquals(ImportResult.Status.UNREACHABLE, r.status()); + private static String readResource(String path) throws IOException { + try (InputStream in = ImportResultTest.class.getResourceAsStream(path)) { + if (in == null) throw new IOException("missing test resource: " + path); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) bos.write(buf, 0, n); + return new String(bos.toByteArray(), StandardCharsets.UTF_8); + } } } diff --git a/app/src/test/java/de/schliweb/makeacopy/songbird/SongbirdSettingsTest.java b/app/src/test/java/de/schliweb/makeacopy/songbird/SongbirdSettingsTest.java index b08c48ac..648fe182 100644 --- a/app/src/test/java/de/schliweb/makeacopy/songbird/SongbirdSettingsTest.java +++ b/app/src/test/java/de/schliweb/makeacopy/songbird/SongbirdSettingsTest.java @@ -38,12 +38,14 @@ public void normalize_nullToEmpty() { } @Test - public void canSend_requiresBoth() { - assertTrue(SongbirdSettings.canSend("http://h:8000", "tok")); - assertFalse(SongbirdSettings.canSend("", "tok")); - assertFalse(SongbirdSettings.canSend("http://h:8000", "")); - assertFalse(SongbirdSettings.canSend(" ", "tok")); - assertFalse(SongbirdSettings.canSend("http://h:8000", " ")); - assertFalse(SongbirdSettings.canSend(null, null)); + public void canSend_requiresAllThree() { + assertTrue(SongbirdSettings.canSend("http://h:8000", "kris", "pw")); + assertFalse(SongbirdSettings.canSend("", "kris", "pw")); + assertFalse(SongbirdSettings.canSend("http://h:8000", "", "pw")); + assertFalse(SongbirdSettings.canSend("http://h:8000", "kris", "")); + assertFalse(SongbirdSettings.canSend(" ", "kris", "pw")); + assertFalse(SongbirdSettings.canSend("http://h:8000", " ", "pw")); + assertFalse(SongbirdSettings.canSend("http://h:8000", "kris", " ")); + assertFalse(SongbirdSettings.canSend(null, null, null)); } } diff --git a/app/src/test/java/de/schliweb/makeacopy/ui/finalize/FinalizeViewModelTest.java b/app/src/test/java/de/schliweb/makeacopy/ui/finalize/FinalizeViewModelTest.java index 8c00e711..ca4dd8a8 100644 --- a/app/src/test/java/de/schliweb/makeacopy/ui/finalize/FinalizeViewModelTest.java +++ b/app/src/test/java/de/schliweb/makeacopy/ui/finalize/FinalizeViewModelTest.java @@ -17,26 +17,32 @@ import de.schliweb.makeacopy.songbird.ImportPoster; import de.schliweb.makeacopy.songbird.ImportResult; import de.schliweb.makeacopy.songbird.PostResult; +import de.schliweb.makeacopy.songbird.SongbirdExchange; import de.schliweb.makeacopy.ui.finalize.FinalizeViewModel.Phase; import de.schliweb.makeacopy.ui.finalize.FinalizeViewModel.SendUiState; import org.junit.After; import org.junit.Before; import org.junit.Test; -/** Tests for {@link FinalizeViewModel} via a fake {@link ImportPoster} + inline executor (slice F6). */ +/** + * Tests for {@link FinalizeViewModel} via a fake {@link ImportPoster} + inline executor (slice F6b): + * the login-per-send two-step flow — login-fail, login-ok/import-fail, login-ok/import-ok. + */ public class FinalizeViewModelTest { - /** Records the args it was called with and returns a canned PostResult. */ + /** Records the args it was called with and returns a canned exchange. */ private static final class FakePoster implements ImportPoster { String baseUrl; - String token; + String username; + String password; String json; - PostResult result; + SongbirdExchange result; @Override - public PostResult post(String baseUrl, String token, String json) { + public SongbirdExchange send(String baseUrl, String username, String password, String json) { this.baseUrl = baseUrl; - this.token = token; + this.username = username; + this.password = password; this.json = json; return result; } @@ -74,16 +80,19 @@ private static FinalizeViewModel vm(FakePoster fake) { } @Test - public void send_success_passesArgsAndPublishesDone() { + public void send_loginOkImportOk_passesCredsAndPublishesSuccess() { FakePoster fake = new FakePoster(); - fake.result = PostResult.http(200, "{\"annotations\": {\"created\": 1, \"skipped\": 0}}"); + fake.result = + SongbirdExchange.of( + PostResult.http(200, "{}"), + PostResult.http(200, "{\"annotations\":{\"created\":1,\"skipped\":0,\"failed\":0}}")); FinalizeViewModel vm = vm(fake); - vm.send("http://host:8000", "secret-token", "{json}"); + vm.send("http://host:8077", "kris", "s3cret", "{json}"); - // The poster received exactly what we passed. - assertEquals("http://host:8000", fake.baseUrl); - assertEquals("secret-token", fake.token); + assertEquals("http://host:8077", fake.baseUrl); + assertEquals("kris", fake.username); + assertEquals("s3cret", fake.password); assertEquals("{json}", fake.json); SendUiState s = vm.getState().getValue(); @@ -91,24 +100,34 @@ public void send_success_passesArgsAndPublishesDone() { assertEquals(Phase.DONE, s.phase()); assertEquals(ImportResult.Status.SUCCESS, s.result().status()); assertEquals(1, s.result().created()); - assertEquals(0, s.result().skipped()); } @Test - public void send_unreachable_publishesUnreachable() { + public void send_loginFail_publishesLoginRejected() { FakePoster fake = new FakePoster(); - fake.result = PostResult.unreachable(); + fake.result = SongbirdExchange.loginOnly(PostResult.http(401, "{\"detail\":{}}")); FinalizeViewModel vm = vm(fake); - vm.send("http://host:8000", "t", "{}"); - assertEquals(ImportResult.Status.UNREACHABLE, vm.getState().getValue().result().status()); + vm.send("http://host:8077", "kris", "wrong", "{}"); + assertEquals(ImportResult.Status.LOGIN_REJECTED, vm.getState().getValue().result().status()); + } + + @Test + public void send_loginOkImportFail_publishesHttpError() { + FakePoster fake = new FakePoster(); + fake.result = SongbirdExchange.of(PostResult.http(200, "{}"), PostResult.http(502, "concord down")); + FinalizeViewModel vm = vm(fake); + vm.send("http://host:8077", "kris", "s3cret", "{}"); + ImportResult r = vm.getState().getValue().result(); + assertEquals(ImportResult.Status.HTTP_ERROR, r.status()); + assertEquals(502, r.httpCode()); } @Test - public void send_unauthorized_publishesUnauthorized() { + public void send_loginUnreachable_publishesUnreachable() { FakePoster fake = new FakePoster(); - fake.result = PostResult.http(401, "denied"); + fake.result = SongbirdExchange.loginOnly(PostResult.unreachable()); FinalizeViewModel vm = vm(fake); - vm.send("http://host:8000", "bad", "{}"); - assertEquals(ImportResult.Status.UNAUTHORIZED, vm.getState().getValue().result().status()); + vm.send("http://host:8077", "kris", "s3cret", "{}"); + assertEquals(ImportResult.Status.UNREACHABLE, vm.getState().getValue().result().status()); } } diff --git a/app/src/test/resources/songbird/import_summary.json b/app/src/test/resources/songbird/import_summary.json new file mode 100644 index 00000000..c9299a8d --- /dev/null +++ b/app/src/test/resources/songbird/import_summary.json @@ -0,0 +1,5 @@ +{ + "annotations": {"created": 1, "skipped": 0, "failed": 0}, + "sermon_notes": {"created": 0, "skipped": 0, "failed": 0}, + "errors": [] +} From 21444a3ac588dfe9f579734a700c4fae7a04aec4 Mon Sep 17 00:00:00 2001 From: Kris Bennett <13557788+kbennett2000@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:09:19 -0600 Subject: [PATCH 2/4] docs(F6b): brief errata (cookie-session auth) + slice map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Brief errata under Environment: brief §4's Bearer-token auth and {"annotations":{created,skipped}} response are superseded — songbird is Argon2 cookie-session (login → songbird_session cookie → cookied import → logout) and the import response is ImportSummary (annotations/sermon_notes each created/skipped/failed + errors[]). Verified against songbird source @ 89f894e; the brief stays unedited. Secrets line now: username+password, encrypted. Slice map: annotate F6 as built to a wrong brief auth contract; add F6b. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 80172adb..f6552a35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,10 +117,19 @@ 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 `, - 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.) ## Build & test @@ -206,4 +215,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 --translation --concord-version ` — 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.** \ No newline at end of file +- [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).** \ No newline at end of file From c7a0ac2119b3305e5969080534cfda9f6f6791b8 Mon Sep 17 00:00:00 2001 From: Kris Bennett <13557788+kbennett2000@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:36:22 -0600 Subject: [PATCH 3/4] fix(lint): drop dead locale-only string (MissingDefaultResource) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge gate runs :app:lintPaddleDebug (build-release.yml), which my slice verifications never ran (assemble + unit tests only). Lint aborts on MissingDefaultResource: no_document_to_share_export_first existed in 10 locale files (values-{de,es,fa,hi,hu,it,pl,pt,ro,ru}) with no default — dead cruft left by F1c's incomplete locale sweep (an export/share message; nothing references it). Removed it from all 10 locales; the full gate (compile + testPaddleDebugUnitTest + lintPaddleDebug) is now green. CLAUDE.md: document that lintPaddleDebug is part of the gate (run it, not just assemble), and correct the first-run note to username/password (cookie-session). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 10 ++++++++-- app/src/main/res/values-de/strings.xml | 2 -- app/src/main/res/values-es/strings.xml | 3 --- app/src/main/res/values-fa/strings.xml | 2 -- app/src/main/res/values-hi/strings.xml | 2 -- app/src/main/res/values-hu/strings.xml | 2 -- app/src/main/res/values-it/strings.xml | 2 -- app/src/main/res/values-pl/strings.xml | 2 -- app/src/main/res/values-pt/strings.xml | 2 -- app/src/main/res/values-ro/strings.xml | 3 --- app/src/main/res/values-ru/strings.xml | 2 -- 11 files changed, 8 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f6552a35..41550142 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,8 +176,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 `; 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://: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://: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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c09ae802..5cd08d7b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -256,8 +256,6 @@ - Kein Dokument zum Teilen verfügbar. Bitte zuerst exportieren. - diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 693dfad7..65c6894f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -253,9 +253,6 @@ - No hay documento disponible para compartir. Exporta un documento - primero. - diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 96eae24c..54925b53 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -245,8 +245,6 @@ - هیچ سندی برای اشتراک‌گذاری وجود ندارد. ابتدا یک سند خروجی بگیرید. - diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index b490e16c..504a6a97 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -229,8 +229,6 @@ - शेयर करने के लिए कोई दस्तावेज़ नहीं। पहले निर्यात करें। - diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index c2443da3..a0f37485 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -247,8 +247,6 @@ - Nincs megosztható dokumentum. Először exportálj egy dokumentumot. - diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 6493f6ce..b91aa6dc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -250,8 +250,6 @@ - Nessun documento da condividere. Esporta prima un documento. - diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6a927806..57083048 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -247,8 +247,6 @@ - Brak dokumentu do udostępnienia. Najpierw wyeksportuj dokument. - diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 59ce6c39..93c6178d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -251,8 +251,6 @@ - Brak dokumentu do udostępnienia. Najpierw wyeksportuj dokument. - diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 447d0546..accc0f7d 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -252,9 +252,6 @@ - Niciun document disponibil pentru partajare. Exportați mai întâi un - document. - diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3eda58bc..c7482b1a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -251,8 +251,6 @@ - Нет документа для общего доступа. Сначала экспортируйте документ. - From 00190cc7f621fb50c9aa0fa913d1f61b15be23bd Mon Sep 17 00:00:00 2001 From: Kris Bennett <13557788+kbennett2000@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:52:39 -0600 Subject: [PATCH 4/4] fix(F6b): permit cleartext HTTP to the LAN songbird (was UNREACHABLE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device Send failed with "couldn't reach songbird" though songbird was online and reachable. Root cause: Android (targetSdk 36) blocks cleartext HTTP by default and the manifest set no policy — so the http:// login POST threw IOException → mapped to UNREACHABLE. Not a server/contract issue: live GET /healthz → 200 (songbird 1.6.0) and POST /api/v1/auth/login → 200 + Set-Cookie: songbird_session both succeed with the supplied URL+creds. Add res/xml/network_security_config.xml (cleartextTrafficPermitted=true) and wire it via . The operator points the app at an arbitrary LAN/Tailscale http host (no TLS, no fixed domain), and this is a private non-store sideloaded app — cleartext is the correct policy. Full gate green (compile + testPaddleDebugUnitTest + lintPaddleDebug) + assemble; APK manifest carries networkSecurityConfig; identity unchanged; frozen-core zero-diff. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 5 +++++ app/src/main/AndroidManifest.xml | 1 + app/src/main/res/xml/network_security_config.xml | 10 ++++++++++ 3 files changed, 16 insertions(+) create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/CLAUDE.md b/CLAUDE.md index 41550142..15faf0ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,11 @@ Do not implement against a pending decision — ask first. Update this table whe 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 ``) permits it. Acceptable for a private, + non-store, sideloaded app; revisit if songbird ever fronts with TLS. ## Build & test diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3cedb37f..a54af6e1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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" diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..2a8abe03 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + +