diff --git a/.github/e2e/gateway.py b/.github/e2e/gateway.py index e5c3f37..da5a62b 100644 --- a/.github/e2e/gateway.py +++ b/.github/e2e/gateway.py @@ -144,6 +144,8 @@ def __init__(self, name): self.chats = [] # True while a test wants Froyo's dashboard read to fail (the widget keeps its list). self.froyo_down = False + # True while a test wants the dashboard to answer 200 with no readable list. + self.froyo_bad = False def now_ts(self): # Strictly increasing timestamps, so two notes pushed back to back never tie. @@ -186,13 +188,20 @@ def froyo_items(many=False): def froyo_dashboard(items): - """Like GET /api/members/{slug}/dashboard: the page's values sit in a JSON data island.""" - island = json.dumps({"fields": {"open_items": items, "items_as_of": "now"}, "agentic": ["open_items"]}) + """Like GET /api/members/{slug}/dashboard for Froyo's page v4: the values sit in a JSON data + island. open_items_live (the sync job's list, an object) is current; the agentic open_items + lags behind and carries one stale row, which the widget must never show.""" + stale = items + [{"n": 99, "title": "E2E stale row", "due": None, "kind": "deadline", "flag": None}] + island = json.dumps({ + "fields": {"open_items": stale, "open_items_live": {"items": items, "as_of": "now"}, "items_as_of": "now"}, + "agentic": ["open_items", "items_as_of"], + }) island = island.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") html = ('Froyo' '' '
') - return json.dumps({"state": "live", "template": "e2e", "rendered_html": html}) + return json.dumps({"instance_version": 4, "dashboard": "e2e", "state": "live", "scripts": True, + "fields": ["items_as_of", "open_items", "open_items_live"], "rendered_html": html}) def make_handler(state): @@ -300,11 +309,13 @@ def do_GET(self): return self.send(200, "ok") if p == "/test/froyo": # mode=reset (the made-up list), many (8 items, more than fit), empty, - # or down (reads fail, the list is kept; any other mode brings it back up). + # down (503) or bad (200, no list): reads fail and the list is kept; any other + # mode brings it back up. mode = q.get("mode") with state.lock: state.froyo_down = mode == "down" - if mode != "down": + state.froyo_bad = mode == "bad" + if mode not in ("down", "bad"): state.froyo = [] if mode == "empty" else froyo_items(many=mode == "many") state.chats = [] return self.send(200, "ok") @@ -317,9 +328,11 @@ def do_GET(self): if q.get("member") != FROYO_MEMBER: return self.send(400, json.dumps({"code": "missing_member"}), "application/json") with state.lock: - items, down = list(state.froyo), state.froyo_down + items, down, bad = list(state.froyo), state.froyo_down, state.froyo_bad if down: return self.send(503, json.dumps({"error": "down"}), "application/json") + if bad: + return self.send(200, json.dumps({"state": "empty"}), "application/json") return self.send(200, froyo_dashboard(items), "application/json") if p == "/api/update/check": if not self.signed_in(): diff --git a/.github/e2e/run.sh b/.github/e2e/run.sh index 91426d2..e6c25fa 100755 --- a/.github/e2e/run.sh +++ b/.github/e2e/run.sh @@ -1103,10 +1103,22 @@ PY has_text "E2E test parcel" || fail "a failed read hid the rows" ! has_text "Updated" || fail "a failed read still says Updated" check "a failed refresh keeps the rows and says 'as of HH:mm'" + has_text "· network" || fail "the header does not say why the read failed (503 -> network)" + check "the header says why: 'as of HH:mm · network'" + + # A 200 with no readable list is a format problem, and says so. + curl -s "$GW/test/froyo?mode=bad" >/dev/null + tap_now "Refresh" + wait_text "· format" 30 + has_text "E2E test parcel" || fail "a format failure hid the rows" + check "a page with no list says 'as of HH:mm · format' and keeps the rows" curl -s "$GW/test/froyo?mode=reset" >/dev/null tap_now "Refresh" wait_text "5 open · Updated" 30 + ! has_text "· network" || fail "the reason stayed after a good read" check "the next good read says Updated again" + ! has_text "E2E stale row" || fail "the lagging open_items field was shown instead of open_items_live" + check "the live list (open_items_live) wins over the lagging open_items" # Ends on the home screen for the screenshot. } diff --git a/README.md b/README.md index 2d7c059..87e5b8e 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,10 @@ A home-screen widget, **Froyo · Upcoming**, lists the open items of the crewmat - Due within 24 hours is orange. Overdue is red and says **late**. - Tap a row to open Froyo's chat in the app. - Tap **Done** on a row. The app asks first. Say yes and the row goes, with **Undo** for 5 seconds. Then the app sends `Done: ` to Froyo's chat, and Froyo marks it done. Undo after that sends `Undo done: <title>`. With app lock on, Done asks you to unlock first. -- It refreshes about every 30 minutes, when you open the app, after a Done, and when you tap **⟳** in the header (it shows **Updating…** while it reads). The header says **Updated HH:mm** after a good read. Nothing open shows **Nothing coming up**. When the server cannot be reached, the last list stays, with **as of HH:mm**. +- It refreshes about every 30 minutes, when you open the app, after a Done, and when you tap **⟳** in the header (it shows **Updating…** while it reads). The header says **Updated HH:mm** after a good read. Nothing open shows **Nothing coming up**. When a read fails, the last list stays, with **as of HH:mm** and why: **sign-in** (open the app to sign in), **network**, **timeout**, **server** or **format** (the page held no list the widget can read). - Resize it from 4×1 to 4×4. A taller widget shows more rows at once; the list shows whole rows and scrolls to the rest. Rows are never squeezed or overlapped. -How it works: the app reads Froyo's thread with `POST /api/members/froyo-personal-butler/thread` and its open items from `GET /api/members/froyo-personal-butler/dashboard` (the `open_items` field of Froyo's dashboard), with the app's own login cookie. Done is a chat message sent with `POST /api/chat`, the same call the dashboard's chat box makes. +How it works: the app reads Froyo's thread with `POST /api/members/froyo-personal-butler/thread` and its open items from `GET /api/members/froyo-personal-butler/dashboard` (Froyo's `open_items_live` field, kept current by its sync job, else `open_items`), with the app's own login cookie. Done is a chat message sent with `POST /api/chat`, the same call the dashboard's chat box makes. The widget shows item titles on the home screen, also while app lock is on. diff --git a/app/src/main/java/io/github/rayvader/kirocrew/AlertsClient.kt b/app/src/main/java/io/github/rayvader/kirocrew/AlertsClient.kt index 1d90656..35614c4 100644 --- a/app/src/main/java/io/github/rayvader/kirocrew/AlertsClient.kt +++ b/app/src/main/java/io/github/rayvader/kirocrew/AlertsClient.kt @@ -97,14 +97,21 @@ class AlertsClient(private val ctx: Context) { * the dashboard's Origin, which the gateway's CSRF check asks for, and [json] as its body. * Throws IOException on network errors. */ - fun call(profile: Profile, method: String, path: String, json: String? = null): Response { - var res = callOnce(profile, method, path, json) - if (res.authRequired && refresh(profile)) res = callOnce(profile, method, path, json) + fun call( + profile: Profile, + method: String, + path: String, + json: String? = null, + readTimeoutMs: Int = READ_TIMEOUT_MS, + ): Response { + var res = callOnce(profile, method, path, json, readTimeoutMs) + if (res.authRequired && refresh(profile)) res = callOnce(profile, method, path, json, readTimeoutMs) return res } - private fun callOnce(profile: Profile, method: String, path: String, json: String?): Response { + private fun callOnce(profile: Profile, method: String, path: String, json: String?, readTimeoutMs: Int): Response { val conn = open(profile, method, path) + conn.readTimeout = readTimeoutMs try { conn.setRequestProperty("Accept", "application/json") if (method != "GET") { diff --git a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt index fff8edd..b61a899 100644 --- a/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt +++ b/app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt @@ -27,11 +27,14 @@ import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf import io.github.rayvader.kirocrew.core.DueState +import io.github.rayvader.kirocrew.core.FailReason import io.github.rayvader.kirocrew.core.Profile import io.github.rayvader.kirocrew.core.SyncNote import io.github.rayvader.kirocrew.core.Upcoming import io.github.rayvader.kirocrew.core.UpcomingItem +import android.util.Log import java.io.IOException +import java.net.SocketTimeoutException import java.time.LocalDateTime import java.time.ZoneId import java.util.Locale @@ -70,6 +73,7 @@ object FroyoWidgets { private const val SP_ITEMS_AT = "froyo_items_at" private const val SP_ERROR = "froyo_error" private const val SP_REFRESHING_AT = "froyo_refreshing_at" + private const val SP_REASON = "froyo_fail_reason" /** The header's refresh button. Only this app can send it: the button's intent is explicit. */ const val ACTION_REFRESH = "io.github.rayvader.kirocrew.FROYO_REFRESH" @@ -133,33 +137,69 @@ object FroyoWidgets { /** Reads Froyo's thread and open items from the active server. Off the main thread. */ fun fetch(ctx: Context): Error { - val profile = profile(ctx) ?: return saveError(ctx, Error.NO_SERVER) + val profile = profile(ctx) ?: return saveError(ctx, Error.NO_SERVER, null, "no server") val client = AlertsClient(ctx) return try { - val thread = thread(ctx, client, profile) ?: return saveError(ctx, lastAuth ?: Error.FAILED) - val res = client.call(profile, "GET", Upcoming.dashboardPath(thread.member)) - if (res.authRequired) return saveError(ctx, Error.SIGNED_OUT) - if (res.code != 200) return saveError(ctx, Error.FAILED) - val items = Upcoming.extractItems(res.body) ?: return saveError(ctx, Error.FAILED) + var thread = thread(ctx, client, profile) + ?: return failThread(ctx) + var res = client.call(profile, "GET", Upcoming.dashboardPath(thread.member), readTimeoutMs = DASHBOARD_READ_TIMEOUT_MS) + if (!res.authRequired && !Upcoming.isHtml(res.contentType) && Upcoming.threadMayBeStale(res.code)) { + // The remembered member or slot may be gone: ask for the thread again, once. + forgetThread(ctx) + thread = thread(ctx, client, profile) ?: return failThread(ctx) + res = client.call(profile, "GET", Upcoming.dashboardPath(thread.member), readTimeoutMs = DASHBOARD_READ_TIMEOUT_MS) + } + val items = if (res.code == 200 && !res.authRequired && !Upcoming.isHtml(res.contentType)) Upcoming.extractItems(res.body) else null + val reason = Upcoming.failReason(res.code, res.authRequired, items != null, res.contentType) + if (reason != null || items == null) { + val error = if (reason == FailReason.SIGN_IN) Error.SIGNED_OUT else Error.FAILED + return saveError(ctx, error, reason ?: FailReason.FORMAT, "HTTP ${res.code}") + } sp(ctx).edit { putString(SP_ITEMS, items) putLong(SP_ITEMS_AT, System.currentTimeMillis()) putString(SP_ERROR, Error.NONE.name) + remove(SP_REASON) } Error.NONE + } catch (e: SocketTimeoutException) { + saveError(ctx, Error.FAILED, FailReason.TIMEOUT, e.javaClass.simpleName) } catch (e: IOException) { - saveError(ctx, Error.FAILED) + saveError(ctx, Error.FAILED, FailReason.NETWORK, e.javaClass.simpleName) } catch (e: RuntimeException) { // CookieManager throws while the WebView package is being updated. - saveError(ctx, Error.FAILED) + saveError(ctx, Error.FAILED, FailReason.NETWORK, e.javaClass.simpleName) + } + } + + private fun failThread(ctx: Context): Error { + val auth = lastAuth + return if (auth == Error.SIGNED_OUT) { + saveError(ctx, Error.SIGNED_OUT, FailReason.SIGN_IN, "thread: sign-in") + } else { + saveError(ctx, Error.FAILED, lastThreadReason ?: FailReason.SERVER, "thread") + } + } + + private fun forgetThread(ctx: Context) { + sp(ctx).edit { + remove(SP_SLOT) + remove(SP_MEMBER) + remove(SP_PROFILE) } } + /** The dashboard composes Froyo's whole page per read, which can be slow over a far link. */ + private const val DASHBOARD_READ_TIMEOUT_MS = 45_000 + + @Volatile private var lastThreadReason: FailReason? = null + @Volatile private var lastAuth: Error? = null /** Froyo's pinned chat, asked once per server and then remembered. */ fun thread(ctx: Context, client: AlertsClient, profile: Profile): io.github.rayvader.kirocrew.core.FroyoThread? { lastAuth = null + lastThreadReason = null val sp = sp(ctx) val slot = sp.getString(SP_SLOT, null) val member = sp.getString(SP_MEMBER, null) @@ -168,8 +208,16 @@ object FroyoWidgets { } val res = client.call(profile, "POST", Upcoming.THREAD_PATH) if (res.authRequired) lastAuth = Error.SIGNED_OUT - if (res.code != 200) return null - val thread = Upcoming.parseThread(res.body) ?: return null + if (res.code != 200 || Upcoming.isHtml(res.contentType)) { + lastThreadReason = Upcoming.failReason(res.code, res.authRequired, false, res.contentType) + if (Upcoming.isHtml(res.contentType)) lastAuth = null + return null + } + val thread = Upcoming.parseThread(res.body) + if (thread == null) { + lastThreadReason = FailReason.FORMAT + return null + } sp.edit { putString(SP_SLOT, thread.slotKey) putString(SP_MEMBER, thread.member) @@ -188,15 +236,26 @@ object FroyoWidgets { remove(SP_MEMBER) remove(SP_PROFILE) putString(SP_ERROR, Error.SIGNED_OUT.name) + putString(SP_REASON, FailReason.SIGN_IN.name) } render(ctx) } - private fun saveError(ctx: Context, error: Error): Error { - sp(ctx).edit { putString(SP_ERROR, error.name) } + private fun saveError(ctx: Context, error: Error, reason: FailReason? = null, detail: String = ""): Error { + // In logcat too, so a stuck widget can be traced: adb logcat -s FroyoWidget + Log.w(TAG, "read failed: ${error.name} ${reason?.word ?: "-"} $detail") + sp(ctx).edit { + putString(SP_ERROR, error.name) + if (reason != null) putString(SP_REASON, reason.name) else remove(SP_REASON) + } return error } + private const val TAG = "FroyoWidget" + + private fun savedReason(ctx: Context): FailReason? = + sp(ctx).getString(SP_REASON, null)?.let { runCatching { FailReason.valueOf(it) }.getOrNull() } + fun hide(ctx: Context, title: String) { val hidden = Upcoming.decodeHidden(sp(ctx).getString(SP_HIDDEN, null)).toMutableMap() hidden[title] = System.currentTimeMillis() @@ -260,10 +319,11 @@ object FroyoWidgets { // line, so no row is given up for it. val open = ctx.getString(R.string.froyo_open, items.size) val refreshing = Upcoming.isRefreshing(sp(ctx).getLong(SP_REFRESHING_AT, 0L), System.currentTimeMillis()) - val line = Upcoming.syncLine(refreshing, error != Error.NONE, loaded, at, ZoneId.systemDefault()) + val line = Upcoming.syncLine(refreshing, error != Error.NONE, loaded, at, ZoneId.systemDefault(), savedReason(ctx)) val count = when (line.note) { SyncNote.UPDATED -> ctx.getString(R.string.froyo_updated, open, line.time) - SyncNote.AS_OF -> ctx.getString(R.string.froyo_as_of, open, line.time) + SyncNote.AS_OF -> line.reason?.let { ctx.getString(R.string.froyo_as_of_why, open, line.time, it.word) } + ?: ctx.getString(R.string.froyo_as_of, open, line.time) SyncNote.UPDATING -> ctx.getString(R.string.froyo_updating_count, open) SyncNote.NONE -> open } @@ -278,7 +338,8 @@ object FroyoWidgets { loaded -> null error == Error.NO_SERVER -> ctx.getString(R.string.froyo_no_server) error == Error.SIGNED_OUT -> ctx.getString(R.string.froyo_signed_out) - error == Error.FAILED -> ctx.getString(R.string.froyo_failed) + error == Error.FAILED -> savedReason(ctx)?.let { ctx.getString(R.string.froyo_failed_why, it.word) } + ?: ctx.getString(R.string.froyo_failed) else -> ctx.getString(R.string.froyo_loading) } v.setViewVisibility(R.id.froyo_message, if (message != null) View.VISIBLE else View.GONE) diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/Profile.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/Profile.kt index 0022b07..1c73c6b 100644 --- a/app/src/main/java/io/github/rayvader/kirocrew/core/Profile.kt +++ b/app/src/main/java/io/github/rayvader/kirocrew/core/Profile.kt @@ -21,7 +21,10 @@ object ProfileCodec { val id = dec(parts[0]) ?: return@mapNotNull null val name = dec(parts[1]) ?: return@mapNotNull null val url = dec(parts[2]) ?: return@mapNotNull null - if (id.isBlank() || url.isBlank()) null else Profile(id, name.ifBlank { url }, url) + if (id.isBlank() || url.isBlank()) return@mapNotNull null + // An older save may hold a page path ("…/chat/x"); API calls need the server base. + val base = UrlTools.serverBase(url) + Profile(id, name.ifBlank { base }, base) } } diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt index 9289667..4f0cde4 100644 --- a/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt +++ b/app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt @@ -38,7 +38,21 @@ data class WidgetFit(val rows: Int, val header: Boolean, val listHeightDp: Float enum class SyncNote { NONE, UPDATED, AS_OF, UPDATING } /** [note] plus the 24 h "HH:mm" it names (empty for [SyncNote.NONE] and [SyncNote.UPDATING]). */ -data class SyncLine(val note: SyncNote, val time: String) +data class SyncLine(val note: SyncNote, val time: String, val reason: FailReason? = null) + +/** Why the last read failed, in a word the header can show next to "as of HH:mm". */ +enum class FailReason(val word: String) { + /** The login ran out and could not be renewed: open the app to sign in. */ + SIGN_IN("sign-in"), + /** No connection, or the server could not be reached. */ + NETWORK("network"), + /** The server took too long to answer. */ + TIMEOUT("timeout"), + /** The server answered with an error code. */ + SERVER("server"), + /** The server answered, but not with a list the widget can read. */ + FORMAT("format"), +} /** Froyo's pinned chat: the slot to send to and the member name the dashboard read needs. */ data class FroyoThread(val slotKey: String, val member: String) @@ -55,6 +69,12 @@ object Upcoming { /** The dashboard field Froyo writes its open items to. */ const val FIELD = "open_items" + + /** + * The field Froyo's sync job keeps current every 15 min: an object whose `items` is the + * same rows as [FIELD]. Preferred when it holds a list; [FIELD] is the fallback. + */ + const val LIVE_FIELD = "open_items_live" private const val MAX_TITLE = 200 private const val DATA_ID = "kirocrew-dashboard-data" @@ -106,7 +126,8 @@ object Upcoming { val html = JSONObject(body).optString("rendered_html") val island = island(html) ?: return null val fields = JSONObject(island).optJSONObject("fields") ?: return null - fields.optJSONArray(FIELD)?.toString() + val live = fields.optJSONObject(LIVE_FIELD)?.optJSONArray("items") + (live ?: fields.optJSONArray(FIELD))?.toString() } catch (e: JSONException) { null } @@ -255,13 +276,47 @@ object Upcoming { * Nothing when no list was ever read. [atMs] is when the shown list was read; the time is * 24 h in [zone]. */ - fun syncLine(refreshing: Boolean, failed: Boolean, loaded: Boolean, atMs: Long, zone: ZoneId): SyncLine { + fun syncLine( + refreshing: Boolean, + failed: Boolean, + loaded: Boolean, + atMs: Long, + zone: ZoneId, + reason: FailReason? = null, + ): SyncLine { if (refreshing) return SyncLine(SyncNote.UPDATING, "") if (!loaded || atMs <= 0) return SyncLine(SyncNote.NONE, "") val time = java.time.Instant.ofEpochMilli(atMs).atZone(zone).format(DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)) - return SyncLine(if (failed) SyncNote.AS_OF else SyncNote.UPDATED, time) + return if (failed) SyncLine(SyncNote.AS_OF, time, reason) else SyncLine(SyncNote.UPDATED, time) + } + + /** + * Why a read failed, from what the server answered. [authRequired] is the login check + * after one renewal try; [code] is the HTTP status; [parsed] is false when a 200 held no + * readable list. Null when the read worked. + */ + fun failReason(code: Int, authRequired: Boolean, parsed: Boolean, contentType: String? = null): FailReason? = when { + // A web page instead of JSON: the request reached a dashboard page, not the API (for + // example a server address that kept a page path). Never a login problem. + isHtml(contentType) -> FailReason.FORMAT + authRequired -> FailReason.SIGN_IN + code == 200 && parsed -> null + code == 200 -> FailReason.FORMAT + code == 408 || code == 504 -> FailReason.TIMEOUT + code == 502 || code == 503 -> FailReason.NETWORK + else -> FailReason.SERVER } + /** True for a `text/html` answer, which an API call never gets on purpose. */ + fun isHtml(contentType: String?): Boolean = + contentType?.trim()?.lowercase(Locale.ROOT)?.startsWith("text/html") == true + + /** + * True when the remembered thread may be the reason a read failed: the server no longer + * knows that member name or slot (400 / 404 / 409). The widget then asks for the thread again. + */ + fun threadMayBeStale(code: Int): Boolean = code == 400 || code == 404 || code == 409 + /** * Items marked done on the phone stay hidden until Froyo's list drops them, or for * [HIDE_MS] if it never does (then the row comes back, which shows the done did not land). diff --git a/app/src/main/java/io/github/rayvader/kirocrew/core/UrlTools.kt b/app/src/main/java/io/github/rayvader/kirocrew/core/UrlTools.kt index 566c21b..23ab090 100644 --- a/app/src/main/java/io/github/rayvader/kirocrew/core/UrlTools.kt +++ b/app/src/main/java/io/github/rayvader/kirocrew/core/UrlTools.kt @@ -51,7 +51,37 @@ object UrlTools { if (p.scheme != "http" && p.scheme != "https") return null if (p.host.isEmpty()) return null val port = p.port?.let { ":$it" } ?: "" - return "${p.scheme}://${p.host}$port${p.path.trimEnd('/')}" + return "${p.scheme}://${p.host}$port${basePath(p.path)}" + } + + /** + * First path segments that are dashboard pages or its API, never part of where the + * gateway is served. A server URL copied from the browser ("…/chat/my-chat") keeps + * that page in its path, and every API call joined onto it then reaches the page's + * catch-all instead of /api: the widget and alerts silently stop updating. + */ + private val PAGE_ROOTS = setOf( + "chat", "schedule", "artifacts", "notifications", "capabilities", "apps", + "settings", "logs", "developer", "members", "api", + ) + + /** [path] cut before its first dashboard page segment, without a trailing slash. */ + fun basePath(path: String): String { + val segments = path.split('/').filter { it.isNotEmpty() } + val cut = segments.indexOfFirst { it.lowercase() in PAGE_ROOTS } + val kept = if (cut < 0) segments else segments.take(cut) + return if (kept.isEmpty()) "" else kept.joinToString("/", prefix = "/") + } + + /** + * A saved server URL with any dashboard page path dropped, so a profile saved before + * [normalizeServerUrl] cut it heals on read. Unparseable input comes back unchanged. + */ + fun serverBase(url: String): String { + val p = parse(url) ?: return url + if (p.scheme != "http" && p.scheme != "https") return url + val port = p.port?.let { ":$it" } ?: "" + return "${p.scheme}://${p.host}$port${basePath(p.path)}" } /** No scheme typed: an explicit port, localhost, an IP or .local means plain HTTP (e.g. over Tailscale). */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ffb6fb3..2ef7adf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -184,6 +184,8 @@ The app saves the server and signs you in.<br><br> <string name="froyo_widget_description">Froyo\'s open items, soonest first. Tap Done to mark one done.</string> <string name="froyo_open">%1$d open</string> <string name="froyo_as_of">%1$s · as of %2$s</string> + <string name="froyo_as_of_why">%1$s · as of %2$s · %3$s</string> + <string name="froyo_failed_why">Couldn\'t update · %1$s</string> <string name="froyo_updated">%1$s · Updated %2$s</string> <string name="froyo_updating_count">%1$s · Updating…</string> <string name="froyo_updating">Updating…</string> diff --git a/app/src/test/java/io/github/rayvader/kirocrew/core/CodecsTest.kt b/app/src/test/java/io/github/rayvader/kirocrew/core/CodecsTest.kt index 64cd2a5..7a7ab55 100644 --- a/app/src/test/java/io/github/rayvader/kirocrew/core/CodecsTest.kt +++ b/app/src/test/java/io/github/rayvader/kirocrew/core/CodecsTest.kt @@ -25,6 +25,16 @@ class CodecsTest { assertEquals(listOf(Profile("id1", "http://h:5476", "http://h:5476")), decoded) } + @Test + fun profiles_savedPagePathHealsOnRead() { + // A server saved from a copied chat link: the widget's API calls hit the page, not /api. + val saved = ProfileCodec.encode(listOf(Profile("p", "Home", "https://h.ts.net/chat/android-travel-planner-app"))) + val p = ProfileCodec.decode(saved).single() + assertEquals("https://h.ts.net", p.baseUrl) + assertEquals("p", p.id) + assertEquals("https://h.ts.net/api/members/froyo-personal-butler/thread", UrlTools.buildUrl(p.baseUrl, Upcoming.THREAD_PATH)) + } + @Test fun dataUrl_base64() { val d = DataUrl.parse("data:text/csv;base64,YSxiCjEsMg==")!! diff --git a/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt b/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt index 599dd87..912f569 100644 --- a/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt +++ b/app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt @@ -105,6 +105,98 @@ class UpcomingTest { assertEquals("•", Upcoming.icon("something-new")) } + /** Like Froyo's dashboard from page v4: open_items_live (sync job, object) beside open_items. */ + private fun v4Dashboard(live: String?, items: String): String { + val liveField = live?.let { ",\"open_items_live\":$it" } ?: "" + val island = """{"fields":{"cleared_items":[],"coming_up":[],"items_as_of":"Thu 5 Nov, 17:01 SGT",""" + + """"milestones":[],"open_items":$items$liveField,"waiting_count":0,"waiting_items":[]},""" + + """"agentic":["cleared_items","coming_up","items_as_of","open_items"],"seq":42,"missing":[],""" + + """"written_at":{"open_items":"2026-11-05T09:00:37Z"},"locale":"en","subject":"crewmate","last_user_at":""}""" + val html = "<!doctype html><html><head><style>.x{}</style></head><body>" + + "<script type=\"application/json\" id=\"kirocrew-dashboard-data\">$island</script>" + + "<script>/* header time drawn by script */ document.getElementById('kirocrew-dashboard-data');</script>" + + "</body></html>" + return JSONObject() + .put("instance_version", 4) + .put("template", JSONObject().put("id", "froyo-dashboard").put("version", 4)) + .put("dashboard", "froyo-dashboard") + .put("state", "live") + .put("fields", org.json.JSONArray(listOf("open_items", "open_items_live"))) + .put("scripts", true) + .put("rendered_html", html) + .toString() + } + + @Test + fun aV4PageIsReadAndPrefersTheLiveList() { + val old = """[{"n":1,"id":"a","title":"Old item","due":"2026-11-04","kind":"booking","flag":null}]""" + val live = """{"items":[{"n":1,"id":"b","title":"Test dinner 18:30","due":"2026-11-05","kind":"booking"},""" + + """{"n":2,"id":"c","title":"Test flight","due":"2026-11-09","kind":"flight"}],"as_of":"Thu 5 Nov, 17:01 SGT · 2 open"}""" + val items = Upcoming.parseItems(Upcoming.extractItems(v4Dashboard(live, old))!!, zone) + assertEquals(listOf("Test dinner 18:30", "Test flight"), items.map { it.title }) + } + + @Test + fun aV4PageWithoutTheLiveListFallsBackToOpenItems() { + val old = """[{"n":1,"title":"Only item","due":null,"kind":"bill","flag":"x"}]""" + assertEquals(listOf("Only item"), Upcoming.parseItems(Upcoming.extractItems(v4Dashboard(null, old))!!, zone).map { it.title }) + // A live field that is not the expected object falls back too. + assertEquals(listOf("Only item"), Upcoming.parseItems(Upcoming.extractItems(v4Dashboard("\"soon\"", old))!!, zone).map { it.title }) + assertEquals(listOf("Only item"), Upcoming.parseItems(Upcoming.extractItems(v4Dashboard("{\"as_of\":\"x\"}", old))!!, zone).map { it.title }) + // An empty live list is a real answer: nothing open. + assertEquals("[]", Upcoming.extractItems(v4Dashboard("{\"items\":[]}", old))) + } + + @Test + fun failureReasons() { + assertEquals(null, Upcoming.failReason(200, false, true)) + assertEquals(FailReason.FORMAT, Upcoming.failReason(200, false, false)) + assertEquals(FailReason.SIGN_IN, Upcoming.failReason(401, true, false)) + assertEquals(FailReason.SIGN_IN, Upcoming.failReason(403, true, false)) + assertEquals(FailReason.SERVER, Upcoming.failReason(403, false, false)) + assertEquals(FailReason.SERVER, Upcoming.failReason(500, false, false)) + assertEquals(FailReason.SERVER, Upcoming.failReason(409, false, false)) + assertEquals(FailReason.NETWORK, Upcoming.failReason(502, false, false)) + assertEquals(FailReason.TIMEOUT, Upcoming.failReason(504, false, false)) + assertEquals("sign-in", FailReason.SIGN_IN.word) + assertEquals("network", FailReason.NETWORK.word) + assertEquals("format", FailReason.FORMAT.word) + } + + @Test + fun aWebPageInsteadOfJsonIsAFormatProblemNotSignIn() { + // A server address that kept a page path (/chat/<slug>) gets the page's HTML back. + assertEquals(FailReason.FORMAT, Upcoming.failReason(200, false, false, "text/html; charset=utf-8")) + assertEquals(FailReason.FORMAT, Upcoming.failReason(403, true, false, "text/html")) + assertEquals(FailReason.FORMAT, Upcoming.failReason(403, false, false, "Text/HTML")) + assertEquals(FailReason.SIGN_IN, Upcoming.failReason(401, true, false, "application/json")) + assertEquals(FailReason.SERVER, Upcoming.failReason(403, false, false, "application/json")) + assertTrue(Upcoming.isHtml("text/html")) + assertFalse(Upcoming.isHtml("application/json")) + assertFalse(Upcoming.isHtml(null)) + // The page's HTML has no dashboard data island: no list. + assertNull(Upcoming.extractItems("<!doctype html><html><body><div id=\"root\"></div></body></html>")) + } + + @Test + fun aStaleThreadIsAskedForAgain() { + assertTrue(Upcoming.threadMayBeStale(400)) + assertTrue(Upcoming.threadMayBeStale(404)) + assertTrue(Upcoming.threadMayBeStale(409)) + assertFalse(Upcoming.threadMayBeStale(200)) + assertFalse(Upcoming.threadMayBeStale(401)) + assertFalse(Upcoming.threadMayBeStale(500)) + } + + @Test + fun aFailedReadSaysWhyNextToAsOf() { + val at = java.time.ZonedDateTime.of(2026, 11, 5, 5, 48, 0, 0, ZoneId.of("Indian/Maldives")).toInstant().toEpochMilli() + val line = Upcoming.syncLine(false, true, true, at, ZoneId.of("Indian/Maldives"), FailReason.NETWORK) + assertEquals(SyncLine(SyncNote.AS_OF, "05:48", FailReason.NETWORK), line) + // A good read carries no reason, even if an old one was saved. + assertEquals(null, Upcoming.syncLine(false, false, true, at, zone, FailReason.NETWORK).reason) + } + @Test fun aGoodReadSaysUpdatedInLocal24HourTime() { val at = java.time.ZonedDateTime.of(2026, 11, 5, 14, 7, 0, 0, zone).toInstant().toEpochMilli() diff --git a/app/src/test/java/io/github/rayvader/kirocrew/core/UrlToolsTest.kt b/app/src/test/java/io/github/rayvader/kirocrew/core/UrlToolsTest.kt index 16f93c6..835514b 100644 --- a/app/src/test/java/io/github/rayvader/kirocrew/core/UrlToolsTest.kt +++ b/app/src/test/java/io/github/rayvader/kirocrew/core/UrlToolsTest.kt @@ -82,4 +82,23 @@ class UrlToolsTest { assertNull(UrlTools.parseQr("hello world")) assertNull(UrlTools.parseQr("WIFI:S:net;T:WPA;P:x;;")) } + + @Test + fun normalize_dropsDashboardPagePath() { + assertEquals("https://h.ts.net", UrlTools.normalizeServerUrl("https://h.ts.net/chat/android-travel-planner-app")) + assertEquals("https://h.ts.net", UrlTools.normalizeServerUrl("https://h.ts.net/chat?sid=x")) + assertEquals("http://h:5476", UrlTools.normalizeServerUrl("h:5476/settings/overview")) + assertEquals("https://h.ts.net", UrlTools.normalizeServerUrl("https://h.ts.net/api/notifications")) + // A real sub-path the gateway is served under is kept, up to the page. + assertEquals("https://k.example.com/base", UrlTools.normalizeServerUrl("https://k.example.com/base/members/froyo")) + assertEquals("https://k.example.com/base", UrlTools.normalizeServerUrl("https://k.example.com/base")) + } + + @Test + fun serverBase_healsSavedUrls() { + assertEquals("https://h.ts.net", UrlTools.serverBase("https://h.ts.net/chat/android-travel-planner-app")) + assertEquals("http://h.ts.net:5476", UrlTools.serverBase("http://h.ts.net:5476")) + assertEquals("https://k.example.com/base", UrlTools.serverBase("https://k.example.com/base")) + assertEquals("not a url", UrlTools.serverBase("not a url")) + } }