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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/e2e/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ def __init__(self, name):
# Froyo's open items and the chat messages sent to its thread.
self.froyo = froyo_items()
self.chats = []
# True while a test wants Froyo's dashboard read to fail (the widget keeps its list).
self.froyo_down = False

def now_ts(self):
# Strictly increasing timestamps, so two notes pushed back to back never tie.
Expand Down Expand Up @@ -297,11 +299,14 @@ def do_GET(self):
state.gw_update = {"latest": q.get("latest", ""), "status": q.get("status", "succeeded")}
return self.send(200, "ok")
if p == "/test/froyo":
# mode=reset (the made-up list), many (8 items, more than fit) or empty.
# 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).
mode = q.get("mode")
with state.lock:
mode = q.get("mode")
state.froyo = [] if mode == "empty" else froyo_items(many=mode == "many")
state.chats = []
state.froyo_down = mode == "down"
if mode != "down":
state.froyo = [] if mode == "empty" else froyo_items(many=mode == "many")
state.chats = []
return self.send(200, "ok")
if p == "/test/chats":
with state.lock:
Expand All @@ -312,7 +317,9 @@ 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 = list(state.froyo)
items, down = list(state.froyo), state.froyo_down
if down:
return self.send(503, json.dumps({"error": "down"}), "application/json")
return self.send(200, froyo_dashboard(items), "application/json")
if p == "/api/update/check":
if not self.signed_in():
Expand Down
34 changes: 34 additions & 0 deletions .github/e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,40 @@ f_froyo_widget() {
widget_home
wait_text "7 open" 30
check "the count goes down to 7"

# The refresh button reads the list now; the header says when.
curl -s "$GW/test/froyo?mode=reset" >/dev/null
widget_home
tap_now "Refresh"
wait_text "5 open · Updated" 30
local now shown
now=$(adb shell date +%H:%M | tr -d '\r')
dump
shown=$(grep -oE 'Updated [0-9]{2}:[0-9]{2}' "$OUT/ui.xml" | head -1 | cut -d' ' -f2)
[[ -n $shown ]] || fail "no 'Updated HH:mm' (24 h) in the header"
python3 - "$shown" "$now" <<'PY' || fail "Updated $shown is not the phone's time now ($now)"
import sys
m = lambda t: int(t[:2]) * 60 + int(t[3:])
d = (m(sys.argv[2]) - m(sys.argv[1])) % 1440
sys.exit(0 if d <= 2 else 1)
PY
check "the refresh button reads the list now and says 'Updated HH:mm' (24 h)"
rows=$(widget_rows) || fail "rows overlap with the Updated note: $rows"
((rows == 5)) || fail "the Updated note cost a row: $rows of 5 show"
check "the note shares the header: all 5 rows still show"
adb exec-out screencap -p > "$OUT/shots/froyo-widget-refresh.png" 2>/dev/null || true

# A failed read keeps the rows and says "as of HH:mm".
curl -s "$GW/test/froyo?mode=down" >/dev/null
tap_now "Refresh"
wait_text "5 open · as of" 30
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'"
curl -s "$GW/test/froyo?mode=reset" >/dev/null
tap_now "Refresh"
wait_text "5 open · Updated" 30
check "the next good read says Updated again"
# Ends on the home screen for the screenshot.
}

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ 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: <title>` 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, and after a Done. 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 the server cannot be reached, the last list stays, with **as of HH:mm**.
- 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.
Expand Down
62 changes: 53 additions & 9 deletions app/src/main/java/io/github/rayvader/kirocrew/FroyoWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ import androidx.work.WorkerParameters
import androidx.work.workDataOf
import io.github.rayvader.kirocrew.core.DueState
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 java.io.IOException
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.concurrent.TimeUnit

Expand All @@ -52,6 +52,14 @@ class FroyoWidgetProvider : AppWidgetProvider() {
override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, id: Int, options: Bundle) {
FroyoWidgets.render(context)
}

override fun onReceive(context: Context, intent: Intent) {
if (intent.action == FroyoWidgets.ACTION_REFRESH) {
FroyoWidgets.refreshNow(context)
return
}
super.onReceive(context, intent)
}
}

/** The widget's saved list, what it shows, and when it refreshes. Android calls only at run time. */
Expand All @@ -61,6 +69,10 @@ object FroyoWidgets {
private const val SP_ITEMS = "froyo_items"
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"

/** 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"
private const val SP_HIDDEN = "froyo_hidden"
private const val SP_SLOT = "froyo_slot"
private const val SP_MEMBER = "froyo_member"
Expand Down Expand Up @@ -91,6 +103,21 @@ object FroyoWidgets {
WorkManager.getInstance(ctx).enqueueUniqueWork(REFRESH_NOW, ExistingWorkPolicy.REPLACE, request)
}

/** The refresh button: show "Updating…" at once, then read the list now. */
fun refreshNow(ctx: Context) {
sp(ctx).edit { putLong(SP_REFRESHING_AT, System.currentTimeMillis()) }
render(ctx)
// No network wait: offline, the read fails at once and the header says "as of HH:mm"
// instead of spinning until the network comes back.
val request = OneTimeWorkRequestBuilder<FroyoRefreshWorker>().build()
WorkManager.getInstance(ctx).enqueueUniqueWork(REFRESH_NOW, ExistingWorkPolicy.REPLACE, request)
}

/** A read finished, good or not: stop showing "Updating…". */
fun refreshDone(ctx: Context) {
sp(ctx).edit { remove(SP_REFRESHING_AT) }
}

/** From the app's onResume: only when a widget is on the home screen. */
fun refreshIfShown(ctx: Context) {
if (hasWidgets(ctx)) refresh(ctx)
Expand Down Expand Up @@ -228,15 +255,23 @@ object FroyoWidgets {
val profile = profile(ctx)
v.setOnClickPendingIntent(R.id.froyo_root, openApp(ctx, profile, Upcoming.chatPagePath(slotKey(ctx)), 0))

// Header: name and count, plus "as of HH:mm" when the last read failed.
var count = ctx.getString(R.string.froyo_open, items.size)
if (error != Error.NONE && loaded && at > 0) {
val time = java.time.Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT))
count = ctx.getString(R.string.froyo_as_of, count, time)
// Header: name and count, then "Updated HH:mm" after a good read, "as of HH:mm" after a
// failed one, or "Updating…" while the refresh button's read runs. It shares the header
// 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 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.UPDATING -> ctx.getString(R.string.froyo_updating_count, open)
SyncNote.NONE -> open
}
v.setViewVisibility(R.id.froyo_header, if (fit.header) View.VISIBLE else View.GONE)
v.setTextViewText(R.id.froyo_count, count)
v.setViewVisibility(R.id.froyo_refresh, if (refreshing) View.GONE else View.VISIBLE)
v.setViewVisibility(R.id.froyo_spinner, if (refreshing) View.VISIBLE else View.GONE)
v.setOnClickPendingIntent(R.id.froyo_refresh, refreshIntent(ctx))

val message = when {
loaded && items.isEmpty() -> ctx.getString(R.string.froyo_empty)
Expand Down Expand Up @@ -336,6 +371,11 @@ object FroyoWidgets {

private const val PI_FLAGS = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE

private fun refreshIntent(ctx: Context): PendingIntent {
val intent = Intent(ctx, FroyoWidgetProvider::class.java).setAction(ACTION_REFRESH)
return PendingIntent.getBroadcast(ctx, 7200, intent, PI_FLAGS)
}

/** Settings > Add Froyo widget: asks the launcher to place it. False when the launcher cannot. */
fun requestPin(ctx: Context): Boolean {
val manager = AppWidgetManager.getInstance(ctx)
Expand All @@ -347,8 +387,12 @@ object FroyoWidgets {
/** Reads Froyo's list and redraws the widgets. */
class FroyoRefreshWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
override fun doWork(): Result {
FroyoWidgets.fetch(applicationContext)
FroyoWidgets.render(applicationContext)
try {
FroyoWidgets.fetch(applicationContext)
} finally {
FroyoWidgets.refreshDone(applicationContext)
FroyoWidgets.render(applicationContext)
}
return Result.success()
}
}
Expand Down
26 changes: 26 additions & 0 deletions app/src/main/java/io/github/rayvader/kirocrew/core/Upcoming.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ enum class DueState { NONE, LATER, SOON, LATE }
*/
data class WidgetFit(val rows: Int, val header: Boolean, val listHeightDp: Float)

/** What the header says after the count: when the list was read, or that it is being read. */
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)

/** 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)

Expand Down Expand Up @@ -236,6 +242,26 @@ object Upcoming {
fun used(fit: WidgetFit, fontScale: Float): Float =
PAD_DP + (if (fit.header) headerHeight(fontScale) else 0f) + fit.listHeightDp

/** A tap on the refresh button counts as running for at most this long, so a lost run never spins forever. */
const val REFRESH_SPIN_MS = 2L * 60 * 1000

/** True while a refresh the person started is still running: started, and not older than [REFRESH_SPIN_MS]. */
fun isRefreshing(startedMs: Long, nowMs: Long): Boolean =
startedMs > 0 && nowMs >= startedMs && nowMs - startedMs < REFRESH_SPIN_MS

/**
* The header's sync note. While a refresh runs: "Updating…". After a good read: "Updated HH:mm".
* After a failed read with a saved list: "as of HH:mm", the time of the list still shown.
* 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 {
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)
}

/**
* 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).
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/res/layout/widget_froyo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@
android:maxLines="1"
android:textColor="@color/widget_muted"
android:textSize="12sp" />

<!-- Refresh now. A spinner takes its place while the read runs. -->
<TextView
android:id="@+id/froyo_refresh"
android:layout_width="22sp"
android:layout_height="22sp"
android:layout_marginStart="4dp"
android:contentDescription="@string/froyo_refresh"
android:gravity="center"
android:text="⟳"
android:textColor="@color/widget_text"
android:textSize="15sp" />

<ProgressBar
android:id="@+id/froyo_spinner"
style="?android:attr/progressBarStyleSmall"
android:layout_width="16sp"
android:layout_height="16sp"
android:layout_marginStart="7dp"
android:layout_marginEnd="3dp"
android:contentDescription="@string/froyo_updating"
android:indeterminate="true"
android:visibility="gone" />
</LinearLayout>

<TextView
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ 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_updated">%1$s · Updated %2$s</string>
<string name="froyo_updating_count">%1$s · Updating…</string>
<string name="froyo_updating">Updating…</string>
<string name="froyo_refresh">Refresh</string>
<string name="froyo_empty">Nothing coming up</string>
<string name="froyo_loading">Loading…</string>
<string name="froyo_no_server">Add a server in the app first</string>
Expand Down
49 changes: 49 additions & 0 deletions app/src/test/java/io/github/rayvader/kirocrew/core/UpcomingTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,55 @@ class UpcomingTest {
assertEquals("•", Upcoming.icon("something-new"))
}

@Test
fun aGoodReadSaysUpdatedInLocal24HourTime() {
val at = java.time.ZonedDateTime.of(2026, 11, 5, 14, 7, 0, 0, zone).toInstant().toEpochMilli()
assertEquals(SyncLine(SyncNote.UPDATED, "14:07"), Upcoming.syncLine(false, false, true, at, zone))
// Another zone, same instant: local time of that zone.
assertEquals(SyncLine(SyncNote.UPDATED, "06:07"), Upcoming.syncLine(false, false, true, at, ZoneId.of("UTC")))
val night = java.time.ZonedDateTime.of(2026, 11, 5, 23, 5, 0, 0, zone).toInstant().toEpochMilli()
assertEquals("23:05", Upcoming.syncLine(false, false, true, night, zone).time)
}

@Test
fun aFailedReadKeepsTheListAndSaysAsOf() {
val at = java.time.ZonedDateTime.of(2026, 11, 5, 9, 30, 0, 0, zone).toInstant().toEpochMilli()
assertEquals(SyncLine(SyncNote.AS_OF, "09:30"), Upcoming.syncLine(false, true, true, at, zone))
}

@Test
fun nothingReadYetSaysNothing() {
assertEquals(SyncNote.NONE, Upcoming.syncLine(false, false, false, 0L, zone).note)
assertEquals(SyncNote.NONE, Upcoming.syncLine(false, true, false, 0L, zone).note)
assertEquals(SyncNote.NONE, Upcoming.syncLine(false, false, true, 0L, zone).note)
}

@Test
fun aRunningRefreshSaysUpdating() {
assertEquals(SyncLine(SyncNote.UPDATING, ""), Upcoming.syncLine(true, false, true, 1_000L, zone))
assertEquals(SyncNote.UPDATING, Upcoming.syncLine(true, true, false, 0L, zone).note)
}

@Test
fun refreshSpinnerStopsWhenDoneOrStale() {
val t = 10_000_000L
assertTrue(Upcoming.isRefreshing(t, t))
assertTrue(Upcoming.isRefreshing(t, t + 30_000))
// A run that never reported back stops spinning after two minutes.
assertFalse(Upcoming.isRefreshing(t, t + Upcoming.REFRESH_SPIN_MS))
// Cleared (0) or a clock that went back: not refreshing.
assertFalse(Upcoming.isRefreshing(0L, t))
assertFalse(Upcoming.isRefreshing(t, t - 1))
}

@Test
fun theSyncNoteSitsInTheHeaderSoNoRowIsLost() {
// Rows per height are unchanged by the note: it shares the header line.
assertEquals(3, Upcoming.fit(139f, 1f).rows)
assertEquals(3, Upcoming.fit(139f, 1.15f).rows)
assertEquals(2, Upcoming.fit(110f, 1.15f).rows)
}

@Test
fun listShowsWholeRowsAndScrollsTheRest() {
// Raymond's Wide on Smart Launcher is about 139 dp; the emulator's 4x2 is about 223 dp.
Expand Down
Loading