authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-09-25 01:45:41+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 17:45:41-07:00
log59abcd67ac2c54bd63a422e92fa2e31b2c667b6d
treea461ca2c4643bac7b015ff8bfb98b54863b82b32
parent72447f23d9d9102f647f95f548aadf92edd5b400
signature Signed by PGP key B5690EEEBB952194

fix(mobile): make Android subscription usage widgets scrollable (#13474)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

11 files changed, 241 insertions(+), 115 deletions(-)

apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1<manifest xmlns:android="http://schemas.android.com/apk/res/android">1<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2 <application>2 <application>
3 <receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:exported="false" android:label="@string/t3_subscription_widget_name">3 <receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:enabled="@bool/t3_subscription_widget_enabled" android:exported="false" android:label="@string/t3_subscription_widget_name">
4 <intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>4 <intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
5 <meta-data android:name="android.appwidget.provider" android:resource="@xml/t3_subscription_widget_info" />5 <meta-data android:name="android.appwidget.provider" android:resource="@xml/t3_subscription_widget_info" />
6 </receiver>6 </receiver>
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt+53-42
...@@ -8,7 +8,7 @@ import android.content.ComponentName...@@ -8,7 +8,7 @@ import android.content.ComponentName
8import android.content.Context8import android.content.Context
9import android.content.Intent9import android.content.Intent
10import android.net.Uri10import android.net.Uri
11import android.os.Bundle11import android.os.Build
12import android.view.View12import android.view.View
13import android.widget.RemoteViews13import android.widget.RemoteViews
14import org.json.JSONObject14import org.json.JSONObject
...@@ -29,15 +29,6 @@ class SubscriptionUsageWidget : AppWidgetProvider() {...@@ -29,15 +29,6 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
29 context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context))29 context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context))
30 }30 }
3131
32 override fun onAppWidgetOptionsChanged(
33 context: Context,
34 manager: AppWidgetManager,
35 id: Int,
36 options: Bundle
37 ) {
38 update(context, manager, id)
39 }
40
41 companion object {32 companion object {
42 const val PREFERENCES = "t3_subscription_widget"33 const val PREFERENCES = "t3_subscription_widget"
43 private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE"34 private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE"
...@@ -56,59 +47,68 @@ class SubscriptionUsageWidget : AppWidgetProvider() {...@@ -56,59 +47,68 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
56 }47 }
5748
58 private fun update(context: Context, manager: AppWidgetManager, id: Int) {49 private fun update(context: Context, manager: AppWidgetManager, id: Int) {
50 // The receiver is disabled below 12L (values-v32/bools.xml), but the module still calls in.
51 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S_V2) return
59 val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null)52 val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null)
60 val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull()53 val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull()
61 val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget)54 val openApp = openAppIntent(context, id, snapshot)
62 openAppIntent(context, id, snapshot)?.let {
63 views.setOnClickPendingIntent(R.id.t3_widget_root, it)
64 }
65 val providers = snapshot?.optJSONArray("providers")55 val providers = snapshot?.optJSONArray("providers")
66 val now = System.currentTimeMillis()56 val now = System.currentTimeMillis()
67 var nextExpiry = Long.MAX_VALUE57 var nextExpiry = Long.MAX_VALUE
68 var totalRows = 0
69 val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index ->58 val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index ->
70 val provider = providers?.optJSONObject(index) ?: return@mapNotNull null59 val provider = providers?.optJSONObject(index) ?: return@mapNotNull null
71 val windows = provider.optJSONArray("windows")60 val windows = provider.optJSONArray("windows")
72 val expiresAt = provider.optLong("expiresAt")61 val expiresAt = provider.optLong("expiresAt")
73 if (expiresAt > now && windows != null && windows.length() > 0) {62 if (expiresAt > now && windows != null && windows.length() > 0) {
74 nextExpiry = minOf(nextExpiry, expiresAt)63 nextExpiry = minOf(nextExpiry, expiresAt)
75 totalRows += provider.optInt("totalWindows", windows.length())
76 (0 until windows.length()).map { provider to windows.optJSONObject(it) }64 (0 until windows.length()).map { provider to windows.optJSONObject(it) }
77 } else {65 } else {
78 totalRows++
79 listOf(provider to null)66 listOf(provider to null)
80 }67 }
81 }68 }
82 // Show each provider before filling spare space with its other windows.69 // Keep the first quota from each provider near the top of the list.
83 val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index ->70 val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index ->
84 groups.mapNotNull { it.getOrNull(index) }71 groups.mapNotNull { it.getOrNull(index) }
85 }72 }
86 if (rows.isNotEmpty()) {73 val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget)
87 views.removeAllViews(R.id.t3_widget_rows)74 // Count limits only; "Open app to refresh" placeholders are not entries.
88 val options = manager.getAppWidgetOptions(id)75 val limits = rows.count { (_, window) -> window != null }
89 val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180)76 // Without limits the layout's plain title stays.
90 val count = ((height - 64) / 66).coerceIn(1, 12).coerceAtMost(rows.size)77 if (limits > 0) {
91 for ((provider, window) in rows.take(count)) {78 views.setTextViewText(
92 views.addView(R.id.t3_widget_rows, rowView(context, provider, window))79 R.id.t3_widget_title,
93 }80 context.getString(R.string.t3_subscription_widget_title_count, limits)
94 val remaining = totalRows - count81 )
95 val checkedAt = snapshot?.optLong("checkedAt") ?: 082 views.setContentDescription(
83 R.id.t3_widget_title,
84 context.resources.getQuantityString(
85 R.plurals.t3_subscription_widget_title_description,
86 limits,
87 limits
88 )
89 )
90 }
91 openApp?.let { views.setOnClickPendingIntent(R.id.t3_widget_root, it) }
92 openAppIntent(context, id, snapshot, forCollection = true)?.let {
93 views.setPendingIntentTemplate(R.id.t3_widget_rows, it)
94 }
95 val items = RemoteViews.RemoteCollectionItems.Builder()
96 rows.forEachIndexed { index, (provider, window) ->
97 items.addItem(index.toLong(), rowView(context, provider, window))
98 }
99 views.setRemoteAdapter(R.id.t3_widget_rows, items.build())
100 views.setEmptyView(R.id.t3_widget_rows, R.id.t3_widget_empty)
101 val checkedAt = snapshot?.optLong("checkedAt") ?: 0
102 val checked = if (checkedAt > 0) {
96 val formatted = DateFormat.getDateTimeInstance(103 val formatted = DateFormat.getDateTimeInstance(
97 DateFormat.SHORT,104 DateFormat.SHORT,
98 DateFormat.SHORT105 DateFormat.SHORT
99 ).format(Date(checkedAt))106 ).format(Date(checkedAt))
100 val more = if (remaining > 0) {107 context.getString(R.string.t3_subscription_widget_last_checked, formatted)
101 context.getString(R.string.t3_subscription_widget_more, remaining)108 } else {
102 } else {109 context.getString(R.string.t3_subscription_widget_unknown_check)
103 ""
104 }
105 val checked = if (checkedAt > 0) {
106 context.getString(R.string.t3_subscription_widget_as_of, formatted)
107 } else {
108 context.getString(R.string.t3_subscription_widget_unknown_check)
109 }
110 views.setTextViewText(R.id.t3_widget_footer, checked + more)
111 }110 }
111 views.setTextViewText(R.id.t3_widget_footer, checked)
112 val alarms = context.getSystemService(AlarmManager::class.java)112 val alarms = context.getSystemService(AlarmManager::class.java)
113 alarms.cancel(expiryIntent(context))113 alarms.cancel(expiryIntent(context))
114 // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry.114 // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry.
...@@ -118,7 +118,12 @@ class SubscriptionUsageWidget : AppWidgetProvider() {...@@ -118,7 +118,12 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
118 manager.updateAppWidget(id, views)118 manager.updateAppWidget(id, views)
119 }119 }
120120
121 private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? {121 private fun openAppIntent(
122 context: Context,
123 id: Int,
124 snapshot: JSONObject?,
125 forCollection: Boolean = false
126 ): PendingIntent? {
122 // Target this variant's launcher so co-installed builds cannot steal the tap.127 // Target this variant's launcher so co-installed builds cannot steal the tap.
123 val intent =128 val intent =
124 context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null129 context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
...@@ -129,9 +134,14 @@ class SubscriptionUsageWidget : AppWidgetProvider() {...@@ -129,9 +134,14 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
129 intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP134 intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
130 return PendingIntent.getActivity(135 return PendingIntent.getActivity(
131 context,136 context,
132 id,137 id * 2 + if (forCollection) 1 else 0,
133 intent,138 intent,
134 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE139 PendingIntent.FLAG_UPDATE_CURRENT or if (forCollection) {
140 // Collection rows use fill-in intents with an explicit app target.
141 PendingIntent.FLAG_MUTABLE
142 } else {
143 PendingIntent.FLAG_IMMUTABLE
144 }
135 )145 )
136 }146 }
137147
...@@ -153,6 +163,7 @@ class SubscriptionUsageWidget : AppWidgetProvider() {...@@ -153,6 +163,7 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
153 val reset = window?.optString("reset")163 val reset = window?.optString("reset")
154 ?: context.getString(R.string.t3_subscription_widget_refresh)164 ?: context.getString(R.string.t3_subscription_widget_refresh)
155 child.setTextViewText(R.id.t3_widget_reset, reset)165 child.setTextViewText(R.id.t3_widget_reset, reset)
166 child.setOnClickFillInIntent(R.id.t3_widget_row, Intent())
156 child.setContentDescription(167 child.setContentDescription(
157 R.id.t3_widget_row,168 R.id.t3_widget_row,
158 "$label. $windowLabel. $percent. $reset. $detail"169 "$label. $windowLabel. $percent. $reset. $detail"
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml+5-4
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"1<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
2 android:id="@+id/t3_widget_root" android:layout_width="match_parent" android:layout_height="match_parent"2 android:id="@+id/t3_widget_root" android:layout_width="match_parent" android:layout_height="match_parent"
3 android:orientation="vertical" android:padding="16dp" android:background="@drawable/t3_subscription_widget_background">3 android:orientation="vertical" android:padding="16dp" android:background="@drawable/t3_subscription_widget_background">
4 <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_name" android:textStyle="bold" android:textSize="16sp" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" />4 <TextView android:id="@+id/t3_widget_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_name" android:textStyle="bold" android:textSize="16sp" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" />
5 <LinearLayout android:id="@+id/t3_widget_rows" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="vertical">5 <FrameLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1">
6 <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/t3_subscription_widget_empty" android:textSize="13sp" android:textColor="@color/t3_widget_secondary" />6 <ListView android:id="@+id/t3_widget_rows" android:layout_width="match_parent" android:layout_height="match_parent" android:divider="@null" android:scrollbars="vertical" />
7 </LinearLayout>7 <TextView android:id="@+id/t3_widget_empty" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/t3_subscription_widget_empty" android:textSize="13sp" android:textColor="@color/t3_widget_secondary" />
8 </FrameLayout>
8 <TextView android:id="@+id/t3_widget_footer" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_open" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" />9 <TextView android:id="@+id/t3_widget_footer" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_open" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" />
9</LinearLayout>10</LinearLayout>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-v32/bools.xml created+4
...@@ -0,0 +1,4 @@
1<resources>
2 <!-- Android 12L is the first release whose collection widgets need no compat service. -->
3 <bool name="t3_subscription_widget_enabled">true</bool>
4</resources>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/bools.xml created+3
...@@ -0,0 +1,3 @@
1<resources>
2 <bool name="t3_subscription_widget_enabled">false</bool>
3</resources>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml+7-3
...@@ -1,11 +1,15 @@...@@ -1,11 +1,15 @@
1<resources>1<resources>
2 <string name="t3_subscription_widget_unknown_check">Last checked unavailable</string>2 <string name="t3_subscription_widget_unknown_check">Last checked unavailable</string>
3 <string name="t3_subscription_widget_name">Subscription usage</string>3 <string name="t3_subscription_widget_name">Subscription usage</string>
4 <string name="t3_subscription_widget_title_count">Subscription usage (%1$d)</string>
5 <plurals name="t3_subscription_widget_title_description">
6 <item quantity="one">Subscription usage, %1$d entry</item>
7 <item quantity="other">Subscription usage, %1$d entries</item>
8 </plurals>
4 <string name="t3_subscription_widget_description">Saved subscription quotas from your T3 Code environments. Tap to refresh in the app.</string>9 <string name="t3_subscription_widget_description">Saved subscription quotas from your T3 Code environments. Tap to refresh in the app.</string>
5 <string name="t3_subscription_widget_empty">Open T3 Code and connect an environment to see limits.</string>10 <string name="t3_subscription_widget_empty">No subscription limits available. Open T3 Code to connect.</string>
6 <string name="t3_subscription_widget_open">Tap to open Usage</string>11 <string name="t3_subscription_widget_open">Tap to open Usage</string>
7 <string name="t3_subscription_widget_refresh">Open app to refresh</string>12 <string name="t3_subscription_widget_refresh">Open app to refresh</string>
8 <string name="t3_subscription_widget_remaining">%1$d%% remaining</string>13 <string name="t3_subscription_widget_remaining">%1$d%% remaining</string>
9 <string name="t3_subscription_widget_as_of">As of %1$s</string>14 <string name="t3_subscription_widget_last_checked">Last checked %1$s</string>
10 <string name="t3_subscription_widget_more"> · +%1$d more</string>
11</resources>15</resources>
apps/mobile/src/widgets/SubscriptionUsage.tsx+5-1
...@@ -235,7 +235,11 @@ function SubscriptionUsage(...@@ -235,7 +235,11 @@ function SubscriptionUsage(
235 spacing={accessory || dense ? 2 : 6}235 spacing={accessory || dense ? 2 : 6}
236 modifiers={props.url ? [widgetURL(props.url)] : []}236 modifiers={props.url ? [widgetURL(props.url)] : []}
237 >237 >
238 {compact ? (238 {providers.length === 0 ? (
239 <Text modifiers={[font({ textStyle: "caption" }), foregroundStyle("secondary")]}>
240 No subscription limits available.
241 </Text>
242 ) : compact ? (
239 <VStack alignment="leading" spacing={accessory || dense ? 4 : 8}>243 <VStack alignment="leading" spacing={accessory || dense ? 4 : 8}>
240 {columns}244 {columns}
241 </VStack>245 </VStack>
apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx+3
...@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
2import { Atom } from "effect/unstable/reactivity";2import { Atom } from "effect/unstable/reactivity";
3import * as Linking from "expo-linking";3import * as Linking from "expo-linking";
4import { useEffect } from "react";4import { useEffect } from "react";
5import { Platform } from "react-native";
5import { environmentCatalog } from "../connection/catalog";6import { environmentCatalog } from "../connection/catalog";
6import { environmentPresentations } from "../state/presentation";7import { environmentPresentations } from "../state/presentation";
7import { publishSubscriptionUsage } from "./publishSubscriptionUsage";8import { publishSubscriptionUsage } from "./publishSubscriptionUsage";
...@@ -13,6 +14,8 @@ const snapshotAtom = Atom.make((get) =>...@@ -13,6 +14,8 @@ const snapshotAtom = Atom.make((get) =>
13 buildSubscriptionUsageSnapshot(14 buildSubscriptionUsageSnapshot(
14 get(environmentPresentations.presentationsAtom),15 get(environmentPresentations.presentationsAtom),
15 Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }),16 Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }),
17 // Android scrolls the full list; iOS stores a bounded widget timeline.
18 Platform.OS === "android" ? Infinity : 6,
16 ),19 ),
17).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b)));20).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b)));
1821
apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts+71-11
...@@ -65,18 +65,63 @@ describe("subscription widget snapshots", () => {...@@ -65,18 +65,63 @@ describe("subscription widget snapshots", () => {
65 expect(snapshot.url).toBe(deepLink);65 expect(snapshot.url).toBe(deepLink);
66 expect(JSON.stringify(snapshot)).not.toContain("private@example.com");66 expect(JSON.stringify(snapshot)).not.toContain("private@example.com");
67 });67 });
68 it("clears data after removing environments and hides disabled providers", () => {68 it("clears data after removing environments", () => {
69 expect(69 expect(buildSubscriptionUsageSnapshot(new Map(), deepLink).providers).toEqual([]);
70 buildSubscriptionUsageSnapshot(new Map(), deepLink).providers.every(70 });
71 (p) => p.windows.length === 0,71 it.each<{ name: string; overrides: Partial<ServerProvider> }>([
72 ),72 { name: "disabled", overrides: { enabled: false } },
73 ).toBe(true);73 {
74 name: "missing",
75 overrides: { installed: false, status: "error", usageLimits: undefined },
76 },
77 {
78 name: "API-key",
79 overrides: {
80 usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } },
81 },
82 },
83 ])("hides $name providers", ({ overrides }) => {
74 expect(84 expect(
75 buildSubscriptionUsageSnapshot(85 buildSubscriptionUsageSnapshot(presentations([provider(overrides)]), deepLink).providers,
76 presentations([provider({ enabled: false })]),86 ).toEqual([]);
77 deepLink,87 });
78 ).providers.every((p) => p.windows.length === 0),88 it.each([
79 ).toBe(true);89 { name: "Codex", driver: "codex" },
90 { name: "Claude", driver: "claudeAgent" },
91 ])("only shows $name when $name and OpenCode are configured", ({ name, driver }) => {
92 const snapshot = buildSubscriptionUsageSnapshot(
93 presentations([
94 provider({
95 instanceId: ProviderInstanceId.make(driver),
96 driver: ProviderDriverKind.make(driver),
97 }),
98 provider({
99 instanceId: ProviderInstanceId.make("opencode"),
100 driver: ProviderDriverKind.make("opencode"),
101 usageLimits: undefined,
102 }),
103 ]),
104 deepLink,
105 );
106 expect(snapshot.providers).toHaveLength(1);
107 expect(snapshot.providers[0]).toMatchObject({
108 name,
109 totalWindows: 1,
110 windows: [{ remaining: 60 }],
111 });
112 expect(subscriptionUsageTimeline(snapshot, now + 15 * 60_000)[0]?.props.providers).toEqual([
113 expect.objectContaining({ name, totalWindows: 0, windows: [] }),
114 ]);
115 });
116 it("keeps an enabled provider visible before its first usage read", () => {
117 const snapshot = buildSubscriptionUsageSnapshot(
118 presentations([provider({ usageLimits: undefined })]),
119 deepLink,
120 );
121 expect(snapshot.checkedAt).toBe(0);
122 expect(snapshot.providers).toEqual([
123 { name: "Codex", detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 },
124 ]);
80 });125 });
81 it("uses upstream deduplication for a native account also present in a proxy hub", () => {126 it("uses upstream deduplication for a native account also present in a proxy hub", () => {
82 const input = new Map([127 const input = new Map([
...@@ -151,6 +196,21 @@ describe("subscription widget snapshots", () => {...@@ -151,6 +196,21 @@ describe("subscription widget snapshots", () => {
151 expect(snapshot.providers[0]?.totalWindows).toBe(20);196 expect(snapshot.providers[0]?.totalWindows).toBe(20);
152 expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5);197 expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5);
153 });198 });
199 it("includes every limit for the scrollable Android widget", () => {
200 const windows = Array.from({ length: 20 }, (_, index) => ({
201 ...window,
202 id: `${index}`,
203 usedPercent: index * 5,
204 }));
205 const snapshot = buildSubscriptionUsageSnapshot(
206 presentations([provider({ usageLimits: { checkedAt, windows } })]),
207 deepLink,
208 Infinity,
209 );
210 expect(snapshot.providers[0]?.windows.map((window) => window.remaining)).toEqual(
211 Array.from({ length: 20 }, (_, index) => 5 + index * 5),
212 );
213 });
154 it("marks unknown or distant reset times stale after fifteen minutes", () => {214 it("marks unknown or distant reset times stale after fifteen minutes", () => {
155 const snapshot = buildSubscriptionUsageSnapshot(215 const snapshot = buildSubscriptionUsageSnapshot(
156 presentations([216 presentations([
apps/mobile/src/widgets/subscriptionUsageSnapshot.ts+87-52
...@@ -46,6 +46,8 @@ export function createWidgetRefresher<Id>(refresh: (id: Id) => Promise<unknown>)...@@ -46,6 +46,8 @@ export function createWidgetRefresher<Id>(refresh: (id: Id) => Promise<unknown>)
46function subscriptionUsageProps(46function subscriptionUsageProps(
47 accounts: readonly LimitAccount[],47 accounts: readonly LimitAccount[],
48 now: number,48 now: number,
49 configuredDrivers: ReadonlySet<string>,
50 maxWindowsPerProvider: number,
49): SubscriptionUsageSnapshot {51): SubscriptionUsageSnapshot {
50 const pools = collectLimitPools(accounts, now);52 const pools = collectLimitPools(accounts, now);
51 const checked = accounts53 const checked = accounts
...@@ -53,57 +55,68 @@ function subscriptionUsageProps(...@@ -53,57 +55,68 @@ function subscriptionUsageProps(
53 .map((account) => Date.parse(account.limits.checkedAt));55 .map((account) => Date.parse(account.limits.checkedAt));
54 return {56 return {
55 checkedAt: checked.length > 0 && checked.every(Number.isFinite) ? Math.min(...checked) : 0,57 checkedAt: checked.length > 0 && checked.every(Number.isFinite) ? Math.min(...checked) : 0,
56 providers: (["codex", "claudeAgent"] as const).map((driver) => {58 providers: (["codex", "claudeAgent"] as const)
57 const pool = pools.find((candidate) => candidate.driver === driver);59 .filter(
58 const name = driver === "codex" ? "Codex" : "Claude";60 (driver) =>
59 if (!pool)61 configuredDrivers.has(driver) || accounts.some((account) => account.driver === driver),
60 return { name, detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 };62 )
61 const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt)));63 .map((driver) => {
62 const expiresAt = Math.min(64 const pool = pools.find((candidate) => candidate.driver === driver);
63 checkedAt + SNAPSHOT_MAX_AGE,65 const name = driver === "codex" ? "Codex" : "Claude";
64 ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)),66 if (!pool)
65 );67 return {
66 const fresh = Number.isFinite(expiresAt) && expiresAt > now;68 name,
67 const sortedWindows = [...pool.windows].sort(69 detail: "No limits available",
68 (a, b) => a.remainingPercent - b.remainingPercent,70 windows: [],
69 );71 expiresAt: 0,
70 // Keep a session and weekly limit when scoped limits fill the storage budget.72 totalWindows: 0,
71 const selectedWindows = [73 };
72 ...new Set([74 const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt)));
73 sortedWindows.find((window) => window.kind === "session"),75 const expiresAt = Math.min(
74 sortedWindows.find((window) => window.kind === "weekly"),76 checkedAt + SNAPSHOT_MAX_AGE,
75 ...sortedWindows,77 ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)),
76 ]),78 );
77 ]79 const fresh = Number.isFinite(expiresAt) && expiresAt > now;
78 .filter((window) => window !== undefined)80 const sortedWindows = [...pool.windows].sort(
79 .slice(0, 6)81 (a, b) => a.remainingPercent - b.remainingPercent,
80 .sort((a, b) => a.remainingPercent - b.remainingPercent);82 );
81 return {83 // Keep a session and weekly limit when scoped limits fill the storage budget.
82 name,84 const selectedWindows = [
83 detail: !fresh85 ...new Set([
84 ? "Open T3 to refresh"86 sortedWindows.find((window) => window.kind === "session"),
85 : pool.accounts.length > 187 sortedWindows.find((window) => window.kind === "weekly"),
86 ? `${pool.accounts.length} accounts · pooled`88 ...sortedWindows,
87 : "Subscription remaining",89 ]),
88 expiresAt: fresh ? expiresAt : 0,90 ]
89 totalWindows: fresh ? pool.windows.length : 0,91 .filter((window) => window !== undefined)
90 windows: fresh92 .slice(0, maxWindowsPerProvider)
91 ? selectedWindows.map((window) => ({93 .sort((a, b) => a.remainingPercent - b.remainingPercent);
92 kind: window.kind,94 return {
93 label: window.label,95 name,
94 remaining: Math.round(window.remainingPercent),96 detail: !fresh
95 reset: window.resets[0]97 ? "Open T3 to refresh"
96 ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, {98 : pool.accounts.length > 1
97 month: "short",99 ? `${pool.accounts.length} accounts · pooled`
98 day: "numeric",100 : "Subscription remaining",
99 hour: "numeric",101 expiresAt: fresh ? expiresAt : 0,
100 minute: "2-digit",102 totalWindows: fresh ? pool.windows.length : 0,
101 })}`103 windows: fresh
102 : "Reset time unavailable",104 ? selectedWindows.map((window) => ({
103 }))105 kind: window.kind,
104 : [],106 label: window.label,
105 };107 remaining: Math.round(window.remainingPercent),
106 }),108 reset: window.resets[0]
109 ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, {
110 month: "short",
111 day: "numeric",
112 hour: "numeric",
113 minute: "2-digit",
114 })}`
115 : "Reset time unavailable",
116 }))
117 : [],
118 };
119 }),
107 };120 };
108}121}
109122
...@@ -111,9 +124,31 @@ function subscriptionUsageProps(...@@ -111,9 +124,31 @@ function subscriptionUsageProps(
111export function buildSubscriptionUsageSnapshot(124export function buildSubscriptionUsageSnapshot(
112 presentations: LimitPresentations,125 presentations: LimitPresentations,
113 url: string,126 url: string,
127 maxWindowsPerProvider = 6,
114): SubscriptionUsageSnapshot {128): SubscriptionUsageSnapshot {
115 // Freshness is evaluated at publication/render time, not on unrelated config emissions.129 // Freshness is evaluated at publication/render time, not on unrelated config emissions.
116 return { ...subscriptionUsageProps(collectLimitAccounts(presentations), 0), url };130 const configuredDrivers = new Set(
131 [...presentations.values()].flatMap((presentation) =>
132 (presentation.serverConfig?.providers ?? [])
133 // Servers report default-enabled drivers even when their CLI is missing.
134 .filter(
135 (provider) =>
136 provider.enabled &&
137 provider.installed &&
138 provider.usageLimits?.unavailable?.reason !== "unsupported",
139 )
140 .map((provider) => provider.driver),
141 ),
142 );
143 return {
144 ...subscriptionUsageProps(
145 collectLimitAccounts(presentations),
146 0,
147 configuredDrivers,
148 maxWindowsPerProvider,
149 ),
150 url,
151 };
117}152}
118153
119export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) {154export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) {
docs/user/usage.md+2-1
...@@ -102,4 +102,5 @@ settings section when you no longer need it....@@ -102,4 +102,5 @@ settings section when you no longer need it.
102102
103Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and103Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and
104Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session,104Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session,
105Weekly, or both for each provider. Reopen T3 to refresh expired readings.105Weekly, or both for each provider. Reopen T3 to refresh expired readings. The Android widget
106requires Android 12L or later.