authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-01 10:51:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 10:51:23-07:00
loge41755cf8a9b07a12fefee5134cadacb514d4ad9
treeed04627f995d2b9ed6ec90d503640fd12e54645c
parent8dc07f199c2e3a4d8ec1a3997a2b9c2edf12457e
signature Signed by PGP key B5690EEEBB952194

feat(mobile): render the Android subscription widget with expo-widgets (#12046)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

23 files changed, 483 insertions(+), 319 deletions(-)

apps/mobile/app.config.ts+47-32
...@@ -129,51 +129,66 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [...@@ -129,51 +129,66 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
129 // Agent activity can update many times an hour; without the129 // Agent activity can update many times an hour; without the
130 // frequent-updates entitlement iOS throttles the update budget sooner.130 // frequent-updates entitlement iOS throttles the update budget sooner.
131 frequentUpdates: true,131 frequentUpdates: true,
132 enableAndroid: true,
132 widgets: [133 widgets: [
133 {134 {
134 name: "SubscriptionUsage",135 name: "SubscriptionUsage",
135 displayName: "Subscription usage",136 displayName: "Subscription usage",
136 description: "Subscription quotas from your connected T3 Code environments.",137 description: "Subscription quotas from your connected T3 Code environments.",
137 configuration: {138 ios: {
138 title: "Subscription usage",139 configuration: {
139 description:140 title: "Subscription usage",
140 "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.",141 description:
141 parameters: {142 "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.",
142 codexPeriod: {143 parameters: {
143 title: "Codex limits",144 codexPeriod: {
144 type: "enum",145 title: "Codex limits",
145 default: "auto",146 type: "enum",
146 values: [147 default: "auto",
147 { name: "Both", value: "auto" },148 values: [
148 { name: "Session", value: "session" },149 { name: "Both", value: "auto" },
149 { name: "Weekly", value: "weekly" },150 { name: "Session", value: "session" },
150 ],151 { name: "Weekly", value: "weekly" },
151 },152 ],
152 claudePeriod: {153 },
153 title: "Claude limits",154 claudePeriod: {
154 type: "enum",155 title: "Claude limits",
155 default: "auto",156 type: "enum",
156 values: [157 default: "auto",
157 { name: "Both", value: "auto" },158 values: [
158 { name: "Session", value: "session" },159 { name: "Both", value: "auto" },
159 { name: "Weekly", value: "weekly" },160 { name: "Session", value: "session" },
160 ],161 { name: "Weekly", value: "weekly" },
162 ],
163 },
161 },164 },
162 },165 },
166 supportedFamilies: [
167 "systemSmall",
168 "systemMedium",
169 "systemLarge",
170 "systemExtraLarge",
171 "accessoryRectangular",
172 ],
173 },
174 android: {
175 minWidth: 250,
176 minHeight: 180,
177 targetCellWidth: 4,
178 targetCellHeight: 3,
179 resizeMode: "both",
180 // Embeds the layout in the APK so the widget renders before the app
181 // has run once; the app replaces it with stored props on publish.
182 initialLayout: "./src/widgets/SubscriptionUsage.android.tsx",
163 },183 },
164 supportedFamilies: [
165 "systemSmall",
166 "systemMedium",
167 "systemLarge",
168 "systemExtraLarge",
169 "accessoryRectangular",
170 ],
171 },184 },
172 {185 {
173 name: "AgentActivity",186 name: "AgentActivity",
174 displayName: "Agent Activity",187 displayName: "Agent Activity",
175 description: "Shows the current state of active T3 Code agents.",188 description: "Shows the current state of active T3 Code agents.",
176 supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"],189 // Live Activity companion; there is no Android presentation for it.
190 android: null,
191 ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] },
177 },192 },
178 ],193 ],
179 },194 },
apps/mobile/modules/t3-subscription-widget/android/build.gradle deleted-18
...@@ -1,18 +0,0 @@
1apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'
3
4group = 'com.t3tools.subscriptionwidget'
5version = '0.0.0'
6
7android {
8 namespace 'expo.modules.t3subscriptionwidget'
9 compileSdk rootProject.ext.compileSdkVersion
10 defaultConfig {
11 minSdkVersion rootProject.ext.minSdkVersion
12 targetSdkVersion rootProject.ext.targetSdkVersion
13 }
14}
15
16dependencies {
17 implementation project(':expo-modules-core')
18}
apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml deleted-8
...@@ -1,8 +0,0 @@
1<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2 <application>
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>
5 <meta-data android:name="android.appwidget.provider" android:resource="@xml/t3_subscription_widget_info" />
6 </receiver>
7 </application>
8</manifest>
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt deleted-174
...@@ -1,174 +0,0 @@
1package expo.modules.t3subscriptionwidget
2
3import android.app.AlarmManager
4import android.app.PendingIntent
5import android.appwidget.AppWidgetManager
6import android.appwidget.AppWidgetProvider
7import android.content.ComponentName
8import android.content.Context
9import android.content.Intent
10import android.net.Uri
11import android.os.Build
12import android.view.View
13import android.widget.RemoteViews
14import org.json.JSONObject
15import java.text.DateFormat
16import java.util.Date
17
18class SubscriptionUsageWidget : AppWidgetProvider() {
19 override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
20 ids.forEach { update(context, manager, it) }
21 }
22
23 override fun onReceive(context: Context, intent: Intent) {
24 super.onReceive(context, intent)
25 if (intent.action == EXPIRE) updateAll(context)
26 }
27
28 override fun onDisabled(context: Context) {
29 context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context))
30 }
31
32 companion object {
33 const val PREFERENCES = "t3_subscription_widget"
34 private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE"
35
36 private fun expiryIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
37 context,
38 0,
39 Intent(context, SubscriptionUsageWidget::class.java).setAction(EXPIRE),
40 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
41 )
42
43 fun updateAll(context: Context) {
44 val manager = AppWidgetManager.getInstance(context)
45 manager.getAppWidgetIds(ComponentName(context, SubscriptionUsageWidget::class.java))
46 .forEach { update(context, manager, it) }
47 }
48
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
52 val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null)
53 val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull()
54 val openApp = openAppIntent(context, id, snapshot)
55 val providers = snapshot?.optJSONArray("providers")
56 val now = System.currentTimeMillis()
57 var nextExpiry = Long.MAX_VALUE
58 val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index ->
59 val provider = providers?.optJSONObject(index) ?: return@mapNotNull null
60 val windows = provider.optJSONArray("windows")
61 val expiresAt = provider.optLong("expiresAt")
62 if (expiresAt > now && windows != null && windows.length() > 0) {
63 nextExpiry = minOf(nextExpiry, expiresAt)
64 (0 until windows.length()).map { provider to windows.optJSONObject(it) }
65 } else {
66 listOf(provider to null)
67 }
68 }
69 // Keep the first quota from each provider near the top of the list.
70 val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index ->
71 groups.mapNotNull { it.getOrNull(index) }
72 }
73 val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget)
74 // Count limits only; "Open app to refresh" placeholders are not entries.
75 val limits = rows.count { (_, window) -> window != null }
76 // Without limits the layout's plain title stays.
77 if (limits > 0) {
78 views.setTextViewText(
79 R.id.t3_widget_title,
80 context.getString(R.string.t3_subscription_widget_title_count, limits)
81 )
82 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) {
103 val formatted = DateFormat.getDateTimeInstance(
104 DateFormat.SHORT,
105 DateFormat.SHORT
106 ).format(Date(checkedAt))
107 context.getString(R.string.t3_subscription_widget_last_checked, formatted)
108 } else {
109 context.getString(R.string.t3_subscription_widget_unknown_check)
110 }
111 views.setTextViewText(R.id.t3_widget_footer, checked)
112 val alarms = context.getSystemService(AlarmManager::class.java)
113 alarms.cancel(expiryIntent(context))
114 // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry.
115 if (nextExpiry != Long.MAX_VALUE) {
116 alarms.set(AlarmManager.RTC, nextExpiry, expiryIntent(context))
117 }
118 manager.updateAppWidget(id, views)
119 }
120
121 private fun openAppIntent(
122 context: Context,
123 id: Int,
124 snapshot: JSONObject?,
125 forCollection: Boolean = false
126 ): PendingIntent? {
127 // Target this variant's launcher so co-installed builds cannot steal the tap.
128 val intent =
129 context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
130 intent.action = Intent.ACTION_VIEW
131 val deepLink = snapshot?.optString("url")?.takeIf { it.isNotBlank() }
132 ?: "t3code://settings/usage?tab=limits"
133 intent.data = Uri.parse(deepLink)
134 intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
135 return PendingIntent.getActivity(
136 context,
137 id * 2 + if (forCollection) 1 else 0,
138 intent,
139 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 }
145 )
146 }
147
148 private fun rowView(context: Context, provider: JSONObject, window: JSONObject?): RemoteViews {
149 val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row)
150 val remaining = window?.optInt("remaining")?.coerceIn(0, 100)
151 val detail = provider.optString("detail")
152 val label = provider.optString("name")
153 val windowLabel = window?.optString("label") ?: detail
154 child.setTextViewText(R.id.t3_widget_label, label)
155 child.setTextViewText(R.id.t3_widget_window, windowLabel)
156 val percent = remaining?.let {
157 context.getString(R.string.t3_subscription_widget_remaining, it)
158 } ?: "—"
159 child.setTextViewText(R.id.t3_widget_percent, percent)
160 val visibility = if (remaining == null) View.GONE else View.VISIBLE
161 child.setViewVisibility(R.id.t3_widget_progress, visibility)
162 if (remaining != null) child.setProgressBar(R.id.t3_widget_progress, 100, remaining, false)
163 val reset = window?.optString("reset")
164 ?: context.getString(R.string.t3_subscription_widget_refresh)
165 child.setTextViewText(R.id.t3_widget_reset, reset)
166 child.setOnClickFillInIntent(R.id.t3_widget_row, Intent())
167 child.setContentDescription(
168 R.id.t3_widget_row,
169 "$label. $windowLabel. $percent. $reset. $detail"
170 )
171 return child
172 }
173 }
174}
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt deleted-18
...@@ -1,18 +0,0 @@
1package expo.modules.t3subscriptionwidget
2
3import expo.modules.kotlin.modules.Module
4import expo.modules.kotlin.modules.ModuleDefinition
5import org.json.JSONObject
6
7class T3SubscriptionWidgetModule : Module() {
8 override fun definition() = ModuleDefinition {
9 Name("T3SubscriptionWidget")
10 Function("updateSnapshot") { snapshot: String ->
11 val context = appContext.reactContext ?: return@Function
12 JSONObject(snapshot) // Reject malformed writes before replacing the saved snapshot.
13 context.getSharedPreferences(SubscriptionUsageWidget.PREFERENCES, 0)
14 .edit().putString("snapshot", snapshot).apply()
15 SubscriptionUsageWidget.updateAll(context)
16 }
17 }
18}
apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml deleted-4
...@@ -1,4 +0,0 @@
1<shape xmlns:android="http://schemas.android.com/apk/res/android">
2 <solid android:color="@color/t3_widget_background" />
3 <corners android:radius="20dp" />
4</shape>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml deleted-10
...@@ -1,10 +0,0 @@
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"
3 android:orientation="vertical" android:padding="16dp" android:background="@drawable/t3_subscription_widget_background">
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 <FrameLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1">
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 <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>
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" />
10</LinearLayout>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml deleted-10
...@@ -1,10 +0,0 @@
1<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
2 android:id="@+id/t3_widget_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingTop="8dp">
3 <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal">
4 <TextView android:id="@+id/t3_widget_label" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:textSize="12sp" android:textStyle="bold" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" />
5 <TextView android:id="@+id/t3_widget_percent" android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingStart="8dp" android:textSize="12sp" android:textColor="@color/t3_widget_foreground" />
6 </LinearLayout>
7 <TextView android:id="@+id/t3_widget_window" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" />
8 <ProgressBar android:id="@+id/t3_widget_progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="match_parent" android:layout_height="6dp" android:max="100" android:progressTint="#0284c7" />
9 <TextView android:id="@+id/t3_widget_reset" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" />
10</LinearLayout>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml deleted-5
...@@ -1,5 +0,0 @@
1<resources>
2 <color name="t3_widget_background">#18181B</color>
3 <color name="t3_widget_foreground">#FAFAFA</color>
4 <color name="t3_widget_secondary">#A1A1AA</color>
5</resources>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml deleted-5
...@@ -1,5 +0,0 @@
1<resources>
2 <color name="t3_widget_background">#FAFAFA</color>
3 <color name="t3_widget_foreground">#18181B</color>
4 <color name="t3_widget_secondary">#52525B</color>
5</resources>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml deleted-15
...@@ -1,15 +0,0 @@
1<resources>
2 <string name="t3_subscription_widget_unknown_check">Last checked unavailable</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>
9 <string name="t3_subscription_widget_description">Saved subscription quotas from your T3 Code environments. Tap to refresh in the app.</string>
10 <string name="t3_subscription_widget_empty">No subscription limits available. Open T3 Code to connect.</string>
11 <string name="t3_subscription_widget_open">Tap to open Usage</string>
12 <string name="t3_subscription_widget_refresh">Open app to refresh</string>
13 <string name="t3_subscription_widget_remaining">%1$d%% remaining</string>
14 <string name="t3_subscription_widget_last_checked">Last checked %1$s</string>
15</resources>
apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml deleted-9
...@@ -1,9 +0,0 @@
1<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
2 android:minWidth="250dp" android:minHeight="180dp"
3 android:minResizeWidth="180dp" android:minResizeHeight="130dp"
4 android:targetCellWidth="4" android:targetCellHeight="3"
5 android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen"
6 android:updatePeriodMillis="1800000"
7 android:description="@string/t3_subscription_widget_description"
8 android:initialLayout="@layout/t3_subscription_widget"
9 android:previewLayout="@layout/t3_subscription_widget" />
apps/mobile/modules/t3-subscription-widget/expo-module.config.json deleted-4
...@@ -1,4 +0,0 @@
1{
2 "platforms": ["android"],
3 "android": { "modules": ["expo.modules.t3subscriptionwidget.T3SubscriptionWidgetModule"] }
4}
apps/mobile/modules/t3-widget-expiry/android/build.gradle created+29
...@@ -0,0 +1,29 @@
1apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'
3
4group = 'com.t3tools.widgetexpiry'
5version = '0.0.0'
6
7android {
8 namespace 'expo.modules.t3widgetexpiry'
9 compileSdk rootProject.ext.compileSdkVersion
10 defaultConfig {
11 minSdkVersion rootProject.ext.minSdkVersion
12 targetSdkVersion rootProject.ext.targetSdkVersion
13 }
14 testOptions {
15 unitTests.includeAndroidResources = true
16 }
17}
18
19dependencies {
20 implementation project(':expo-modules-core')
21 testImplementation 'junit:junit:4.13.2'
22 testImplementation 'org.robolectric:robolectric:4.16.1'
23}
24
25tasks.withType(Test).configureEach {
26 javaLauncher = javaToolchains.launcherFor {
27 languageVersion = JavaLanguageVersion.of(21)
28 }
29}
apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml created+10
...@@ -0,0 +1,10 @@
1<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2 <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
3 <application>
4 <receiver android:name="expo.modules.t3widgetexpiry.WidgetExpiryReceiver" android:exported="false">
5 <intent-filter>
6 <action android:name="android.intent.action.BOOT_COMPLETED" />
7 </intent-filter>
8 </receiver>
9 </application>
10</manifest>
apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/T3WidgetExpiryModule.kt created+14
...@@ -0,0 +1,14 @@
1package expo.modules.t3widgetexpiry
2
3import expo.modules.kotlin.modules.Module
4import expo.modules.kotlin.modules.ModuleDefinition
5
6class T3WidgetExpiryModule : Module() {
7 override fun definition() = ModuleDefinition {
8 Name("T3WidgetExpiry")
9 Function("schedule") { name: String, deadlines: List<Double> ->
10 val context = appContext.reactContext ?: return@Function
11 WidgetExpiryReceiver.schedule(context, name, deadlines.map { it.toLong() }.toLongArray())
12 }
13 }
14}
apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt created+82
...@@ -0,0 +1,82 @@
1package expo.modules.t3widgetexpiry
2
3import android.app.AlarmManager
4import android.app.PendingIntent
5import android.appwidget.AppWidgetManager
6import android.content.BroadcastReceiver
7import android.content.ComponentName
8import android.content.Context
9import android.content.Intent
10
11/**
12 * Re-renders an expo-widgets widget when a snapshot deadline passes while the app is closed.
13 * The generated `<package>.<name>Provider` receiver re-runs the JS layout with the stored
14 * snapshot on ACTION_APPWIDGET_UPDATE, so a deadline only needs to deliver that broadcast.
15 */
16class WidgetExpiryReceiver : BroadcastReceiver() {
17 override fun onReceive(context: Context, intent: Intent) {
18 if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
19 val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
20 for ((name, value) in stored.all) {
21 val deadlines = (value as? String)?.split(',')?.mapNotNull { it.toLongOrNull() }
22 ?.toLongArray() ?: continue
23 refresh(context, name)
24 schedule(context, name, deadlines)
25 }
26 return
27 }
28 val name = intent.getStringExtra(EXTRA_NAME) ?: return
29 val remaining = intent.getLongArrayExtra(EXTRA_DEADLINES) ?: LongArray(0)
30 refresh(context, name)
31 schedule(context, name, remaining)
32 }
33
34 private fun refresh(context: Context, name: String) {
35 val provider = providerComponent(context, name)
36 val ids = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
37 if (ids.isNotEmpty()) {
38 context.sendBroadcast(
39 Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
40 .setComponent(provider)
41 .putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
42 )
43 }
44 }
45
46 companion object {
47 private const val EXTRA_NAME = "expo.modules.t3widgetexpiry.NAME"
48 private const val EXTRA_DEADLINES = "expo.modules.t3widgetexpiry.DEADLINES"
49 private const val PREFERENCES = "expo.modules.t3widgetexpiry.DEADLINES"
50
51 /** Arms one inexact, non-wakeup alarm for the next future deadline and carries the rest. */
52 fun schedule(context: Context, name: String, deadlines: LongArray) {
53 val alarms = context.getSystemService(AlarmManager::class.java)
54 val now = System.currentTimeMillis()
55 val pending = deadlines.filter { it > now }.sorted()
56 val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit()
57 if (pending.isEmpty()) {
58 stored.remove(name)
59 } else {
60 stored.putString(name, pending.joinToString(","))
61 }
62 stored.apply()
63 val intent = Intent(context, WidgetExpiryReceiver::class.java)
64 .setAction("expo.modules.t3widgetexpiry.EXPIRE.$name")
65 .putExtra(EXTRA_NAME, name)
66 .putExtra(EXTRA_DEADLINES, pending.drop(1).toLongArray())
67 val operation = PendingIntent.getBroadcast(
68 context,
69 0,
70 intent,
71 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
72 )
73 alarms.cancel(operation)
74 val next = pending.firstOrNull() ?: return
75 alarms.set(AlarmManager.RTC, next, operation)
76 }
77
78 // Mirrors expo-widgets' widgetProviderComponentName for the generated provider class.
79 private fun providerComponent(context: Context, name: String) =
80 ComponentName(context.packageName, "${context.packageName}.${name}Provider")
81 }
82}
apps/mobile/modules/t3-widget-expiry/android/src/test/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiverTest.kt created+58
...@@ -0,0 +1,58 @@
1package expo.modules.t3widgetexpiry
2
3import android.app.AlarmManager
4import android.content.Context
5import android.content.Intent
6import org.junit.Assert.assertEquals
7import org.junit.Assert.assertTrue
8import org.junit.Test
9import org.junit.runner.RunWith
10import org.robolectric.RobolectricTestRunner
11import org.robolectric.RuntimeEnvironment
12import org.robolectric.Shadows.shadowOf
13import org.robolectric.annotation.Config
14
15@RunWith(RobolectricTestRunner::class)
16@Config(sdk = [24, 36], manifest = Config.NONE)
17class WidgetExpiryReceiverTest {
18 @Test
19 fun bootRestoresTheNextAlarmFromPersistedDeadlines() {
20 val context = RuntimeEnvironment.getApplication()
21 val alarms = context.getSystemService(AlarmManager::class.java)
22 val deadlines = longArrayOf(
23 System.currentTimeMillis() + 60000,
24 System.currentTimeMillis() + 120000
25 )
26 WidgetExpiryReceiver.schedule(context, "SubscriptionUsage", deadlines)
27 val scheduled = shadowOf(alarms).scheduledAlarms.single()
28 alarms.cancel(requireNotNull(scheduled.operation))
29 assertTrue(shadowOf(alarms).scheduledAlarms.isEmpty())
30
31 WidgetExpiryReceiver().onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
32
33 assertEquals(deadlines[0], shadowOf(alarms).scheduledAlarms.single().triggerAtTime)
34 }
35
36 @Test
37 fun bootDoesNotRestoreCancelledDeadlines() {
38 val context = RuntimeEnvironment.getApplication()
39 WidgetExpiryReceiver.schedule(
40 context,
41 "SubscriptionUsage",
42 longArrayOf(System.currentTimeMillis() + 60000)
43 )
44 WidgetExpiryReceiver.schedule(context, "SubscriptionUsage", longArrayOf())
45
46 WidgetExpiryReceiver().onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
47
48 assertTrue(
49 shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty()
50 )
51 assertTrue(
52 context.getSharedPreferences(
53 "expo.modules.t3widgetexpiry.DEADLINES",
54 Context.MODE_PRIVATE
55 ).all.isEmpty()
56 )
57 }
58}
apps/mobile/modules/t3-widget-expiry/expo-module.config.json created+4
...@@ -0,0 +1,4 @@
1{
2 "platforms": ["android"],
3 "android": { "modules": ["expo.modules.t3widgetexpiry.T3WidgetExpiryModule"] }
4}
apps/mobile/src/widgets/SubscriptionUsage.android.test.ts created+93
...@@ -0,0 +1,93 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3vi.mock("@expo/ui/jetpack-compose", () => ({
4 Button: "Button",
5 Column: "Column",
6 LinearProgressIndicator: "LinearProgressIndicator",
7 Text: "Text",
8 getMaterialColors: ({ scheme }: { scheme: string }) => ({
9 surface: `${scheme}-surface`,
10 onSurface: `${scheme}-onSurface`,
11 onSurfaceVariant: `${scheme}-onSurfaceVariant`,
12 surfaceVariant: `${scheme}-surfaceVariant`,
13 primary: `${scheme}-primary`,
14 error: `${scheme}-error`,
15 }),
16}));
17
18vi.mock("@expo/ui/jetpack-compose/modifiers", () => ({
19 fillMaxSize: () => "fillMaxSize",
20 fillMaxWidth: () => "fillMaxWidth",
21 height: (value: number) => ({ height: value }),
22 padding: (...values: number[]) => ({ padding: values }),
23 paddingAll: (value: number) => ({ paddingAll: value }),
24}));
25
26vi.mock("expo-widgets", () => ({
27 createWidget: vi.fn((name: string, layout: unknown) => ({ layout, name })),
28}));
29
30import { SubscriptionUsage } from "./SubscriptionUsage.android";
31import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot";
32
33const now = Date.parse("2026-09-05T12:00:00.000Z");
34const provider = {
35 name: "Codex",
36 detail: "Subscription remaining",
37 windows: [
38 { kind: "session", label: "5 hours", remaining: 60, reset: "Next reset Sep 5, 5:00 PM" },
39 { kind: "weekly", label: "Weekly", remaining: 8, reset: "Next reset Sep 9, 9:00 AM" },
40 ],
41 expiresAt: now + 60_000,
42 totalWindows: 2,
43} satisfies SubscriptionUsageSnapshot["providers"][number];
44const snapshot = {
45 checkedAt: now,
46 url: "t3code-dev://settings/usage?tab=limits",
47 providers: [provider, { ...provider, name: "Claude" }],
48} satisfies SubscriptionUsageSnapshot;
49
50function render(props: SubscriptionUsageSnapshot, colorScheme: "light" | "dark" = "dark") {
51 vi.setSystemTime(now);
52 return JSON.stringify(SubscriptionUsage(props, { colorScheme, configuration: undefined }));
53}
54
55describe("SubscriptionUsage Android layout", () => {
56 it("renders each window with its remaining share, bar, and reset", () => {
57 const tree = render(snapshot);
58 expect(tree).toContain("5 hours · 60% left");
59 expect(tree).toContain('"progress":0.6');
60 expect(tree).toContain("Next reset Sep 5, 5:00 PM");
61 expect(tree).toContain('"progress":0.08');
62 expect(tree).toContain('"color":"dark-error"');
63 expect(tree).toContain("As of ");
64 });
65
66 it("drops the bars and asks for a refresh once the snapshot expires", () => {
67 const tree = render({
68 ...snapshot,
69 providers: [{ ...provider, expiresAt: now - 1 }],
70 });
71 expect(tree).toContain("Open T3 to refresh");
72 expect(tree).not.toContain("LinearProgressIndicator");
73 expect(tree).not.toContain("more in T3");
74 });
75
76 it("counts the quotas that did not fit", () => {
77 const tree = render({ ...snapshot, providers: [{ ...provider, totalWindows: 5 }] });
78 expect(tree).toContain("3 more in T3");
79 });
80
81 it("keeps quotas without an expiry deadline visible", () => {
82 const tree = render({ ...snapshot, providers: [{ ...provider, expiresAt: 0 }] });
83 expect(tree).toContain("5 hours · 60% left");
84 expect(tree).not.toContain("Open T3 to refresh");
85 });
86
87 it("invites connecting when nothing has been checked", () => {
88 const tree = render({ checkedAt: 0, providers: [] }, "light");
89 expect(tree).toContain("Tap to connect in T3");
90 expect(tree).not.toContain("As of ");
91 expect(tree).toContain('"containerColor":"light-surface"');
92 });
93});
apps/mobile/src/widgets/SubscriptionUsage.android.tsx created+114
...@@ -0,0 +1,114 @@
1import {
2 Button,
3 Column,
4 getMaterialColors,
5 LinearProgressIndicator,
6 Text,
7} from "@expo/ui/jetpack-compose";
8import {
9 fillMaxSize,
10 fillMaxWidth,
11 height,
12 padding,
13 paddingAll,
14} from "@expo/ui/jetpack-compose/modifiers";
15import { createWidget, type WidgetEnvironment } from "expo-widgets";
16
17import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot";
18
19export function SubscriptionUsage(props: SubscriptionUsageProps, environment: WidgetEnvironment) {
20 "widget";
21 // The widget runtime evaluates this function without the app's module scope.
22 // Android has no timeline, so freshness is decided on every render; the
23 // expiry alarm and each tap trigger one while the app is closed.
24 const now = Date.now();
25 // The 4x3 default cell fits two quotas per provider with their reset text.
26 const limit = 2;
27 const colors = getMaterialColors({
28 scheme: environment.colorScheme === "dark" ? "dark" : "light",
29 });
30 const muted = colors.onSurfaceVariant;
31 const providers = props.providers ?? [
32 { name: "Codex", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 },
33 { name: "Claude", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 },
34 ];
35 return (
36 // The card is one Button so a tap reaches the app's interaction listener,
37 // which opens props.url. expo-widgets has no Android counterpart to widgetURL.
38 <Button
39 colors={{ containerColor: colors.surface }}
40 modifiers={[fillMaxSize()]}
41 onClick={() => {}}
42 >
43 <Column modifiers={[fillMaxSize(), paddingAll(16)]}>
44 {providers.map((provider, index) => {
45 const stale =
46 provider.windows.length > 0 && provider.expiresAt > 0 && now >= provider.expiresAt;
47 const shown = stale ? [] : provider.windows.slice(0, limit);
48 const hidden = stale ? 0 : (provider.totalWindows ?? provider.windows.length) - limit;
49 return (
50 <Column
51 key={provider.name}
52 modifiers={[fillMaxWidth(), padding(0, index === 0 ? 0 : 10, 0, 0)]}
53 >
54 <Text
55 color={colors.onSurface}
56 maxLines={1}
57 style={{ fontSize: 13, fontWeight: "bold" }}
58 >
59 {provider.name}
60 </Text>
61 {shown.length === 0 ? (
62 <Text color={muted} maxLines={1} style={{ fontSize: 11 }}>
63 {stale ? "Open T3 to refresh" : provider.detail}
64 </Text>
65 ) : null}
66 {shown.map((window) => {
67 const low = window.remaining <= 10;
68 return (
69 <Column key={window.label} modifiers={[fillMaxWidth(), padding(0, 4, 0, 0)]}>
70 <Text
71 color={low ? colors.error : colors.onSurface}
72 maxLines={1}
73 style={{ fontSize: 11 }}
74 >
75 {`${window.label} · ${window.remaining}% left`}
76 </Text>
77 <Column modifiers={[fillMaxWidth(), padding(0, 3, 0, 3)]}>
78 <LinearProgressIndicator
79 progress={window.remaining / 100}
80 color={low ? colors.error : colors.primary}
81 trackColor={colors.surfaceVariant}
82 modifiers={[fillMaxWidth(), height(6)]}
83 />
84 </Column>
85 <Text color={muted} maxLines={1} style={{ fontSize: 10 }}>
86 {window.reset}
87 </Text>
88 </Column>
89 );
90 })}
91 {hidden > 0 ? (
92 <Text color={muted} maxLines={1} style={{ fontSize: 10 }}>
93 {`${hidden} more in T3`}
94 </Text>
95 ) : null}
96 </Column>
97 );
98 })}
99 <Text
100 color={muted}
101 maxLines={1}
102 style={{ fontSize: 10 }}
103 modifiers={[padding(0, 10, 0, 0)]}
104 >
105 {props.checkedAt
106 ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}`
107 : "Tap to connect in T3"}
108 </Text>
109 </Column>
110 </Button>
111 );
112}
113
114export default createWidget("SubscriptionUsage", SubscriptionUsage);
apps/mobile/src/widgets/publishSubscriptionUsage.android.ts+29-4
...@@ -1,8 +1,33 @@...@@ -1,8 +1,33 @@
1import { requireOptionalNativeModule } from "expo";1import { requireOptionalNativeModule } from "expo";
2import * as Linking from "expo-linking";
2import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot";3import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot";
34
4export function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) {5let tapUrl: string | undefined;
5 requireOptionalNativeModule<{ updateSnapshot: (snapshot: string) => void }>(6let listening = false;
6 "T3SubscriptionWidget",7
7 )?.updateSnapshot(JSON.stringify(snapshot));8export async function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) {
9 if (!requireOptionalNativeModule("ExpoWidgets")) return;
10 const [{ default: widget }, { addUserInteractionListener }] = await Promise.all([
11 import("./SubscriptionUsage"),
12 import("expo-widgets"),
13 ]);
14 tapUrl = snapshot.url;
15 if (!listening) {
16 // expo-widgets has no Android counterpart to widgetURL: the card is a
17 // button whose tap is delivered to this process, so opening the app needs
18 // a live JS runtime.
19 listening = true;
20 addUserInteractionListener((event) => {
21 if (event.source === "SubscriptionUsage" && tapUrl) void Linking.openURL(tapUrl);
22 });
23 }
24 widget.updateSnapshot(snapshot);
25 // Android has no timeline; an alarm re-renders the stored snapshot at each
26 // deadline so stale readings flip to "Open T3 to refresh" unattended.
27 requireOptionalNativeModule<{ schedule: (name: string, deadlines: number[]) => void }>(
28 "T3WidgetExpiry",
29 )?.schedule(
30 "SubscriptionUsage",
31 snapshot.providers.map((provider) => provider.expiresAt).filter((at) => at > 0),
32 );
8}33}
docs/user/usage.md+3-3
...@@ -120,9 +120,9 @@ settings section when you no longer need it....@@ -120,9 +120,9 @@ settings section when you no longer need it.
120## Subscription usage widget120## Subscription usage widget
121121
122Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and122Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and
123Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session,123Claude quotas. Tap it to open **Usage → Limits**; on Android this works while T3 is running in
124Weekly, or both for each provider. Reopen T3 to refresh expired readings. The Android widget124the background, otherwise open the app from the launcher. On iOS, use **Edit Widget** to choose
125requires Android 12L or later.125Session, Weekly, or both for each provider. Reopen T3 to refresh expired readings.
126126
127## Keyboard shortcuts127## Keyboard shortcuts
128128