| author | |
| committer | |
| log | e41755cf8a9b07a12fefee5134cadacb514d4ad9 |
| tree | ed04627f995d2b9ed6ec90d503640fd12e54645c |
| parent | 8dc07f199c2e3a4d8ec1a3997a2b9c2edf12457e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 129 | // Agent activity can update many times an hour; without the |
| 130 | 130 | // frequent-updates entitlement iOS throttles the update budget sooner. |
| 131 | 131 | frequentUpdates: true, |
| 132 | enableAndroid: true, | |
| 132 | 133 | widgets: [ |
| 133 | 134 | { |
| 134 | 135 | name: "SubscriptionUsage", |
| 135 | 136 | displayName: "Subscription usage", |
| 136 | 137 | description: "Subscription quotas from your connected T3 Code environments.", |
| 137 | configuration: { | |
| 138 | title: "Subscription usage", | |
| 139 | description: | |
| 140 | "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.", | |
| 141 | parameters: { | |
| 142 | codexPeriod: { | |
| 143 | title: "Codex limits", | |
| 144 | type: "enum", | |
| 145 | default: "auto", | |
| 146 | values: [ | |
| 147 | { name: "Both", value: "auto" }, | |
| 148 | { name: "Session", value: "session" }, | |
| 149 | { name: "Weekly", value: "weekly" }, | |
| 150 | ], | |
| 151 | }, | |
| 152 | claudePeriod: { | |
| 153 | title: "Claude limits", | |
| 154 | type: "enum", | |
| 155 | default: "auto", | |
| 156 | values: [ | |
| 157 | { name: "Both", value: "auto" }, | |
| 158 | { name: "Session", value: "session" }, | |
| 159 | { name: "Weekly", value: "weekly" }, | |
| 160 | ], | |
| 138 | ios: { | |
| 139 | configuration: { | |
| 140 | title: "Subscription usage", | |
| 141 | description: | |
| 142 | "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.", | |
| 143 | parameters: { | |
| 144 | codexPeriod: { | |
| 145 | title: "Codex limits", | |
| 146 | type: "enum", | |
| 147 | default: "auto", | |
| 148 | values: [ | |
| 149 | { name: "Both", value: "auto" }, | |
| 150 | { name: "Session", value: "session" }, | |
| 151 | { name: "Weekly", value: "weekly" }, | |
| 152 | ], | |
| 153 | }, | |
| 154 | claudePeriod: { | |
| 155 | title: "Claude limits", | |
| 156 | type: "enum", | |
| 157 | default: "auto", | |
| 158 | values: [ | |
| 159 | { name: "Both", value: "auto" }, | |
| 160 | { name: "Session", value: "session" }, | |
| 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 | 186 | name: "AgentActivity", |
| 174 | 187 | displayName: "Agent Activity", |
| 175 | 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 @@ |
| 1 | apply plugin: 'com.android.library' | |
| 2 | apply plugin: 'org.jetbrains.kotlin.android' | |
| 3 | ||
| 4 | group = 'com.t3tools.subscriptionwidget' | |
| 5 | version = '0.0.0' | |
| 6 | ||
| 7 | android { | |
| 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 | ||
| 16 | dependencies { | |
| 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 @@ |
| 1 | package expo.modules.t3subscriptionwidget | |
| 2 | ||
| 3 | import android.app.AlarmManager | |
| 4 | import android.app.PendingIntent | |
| 5 | import android.appwidget.AppWidgetManager | |
| 6 | import android.appwidget.AppWidgetProvider | |
| 7 | import android.content.ComponentName | |
| 8 | import android.content.Context | |
| 9 | import android.content.Intent | |
| 10 | import android.net.Uri | |
| 11 | import android.os.Build | |
| 12 | import android.view.View | |
| 13 | import android.widget.RemoteViews | |
| 14 | import org.json.JSONObject | |
| 15 | import java.text.DateFormat | |
| 16 | import java.util.Date | |
| 17 | ||
| 18 | class 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 @@ |
| 1 | package expo.modules.t3subscriptionwidget | |
| 2 | ||
| 3 | import expo.modules.kotlin.modules.Module | |
| 4 | import expo.modules.kotlin.modules.ModuleDefinition | |
| 5 | import org.json.JSONObject | |
| 6 | ||
| 7 | class 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 @@ |
| 1 | apply plugin: 'com.android.library' | |
| 2 | apply plugin: 'org.jetbrains.kotlin.android' | |
| 3 | ||
| 4 | group = 'com.t3tools.widgetexpiry' | |
| 5 | version = '0.0.0' | |
| 6 | ||
| 7 | android { | |
| 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 | ||
| 19 | dependencies { | |
| 20 | implementation project(':expo-modules-core') | |
| 21 | testImplementation 'junit:junit:4.13.2' | |
| 22 | testImplementation 'org.robolectric:robolectric:4.16.1' | |
| 23 | } | |
| 24 | ||
| 25 | tasks.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 @@ |
| 1 | package expo.modules.t3widgetexpiry | |
| 2 | ||
| 3 | import expo.modules.kotlin.modules.Module | |
| 4 | import expo.modules.kotlin.modules.ModuleDefinition | |
| 5 | ||
| 6 | class 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 @@ |
| 1 | package expo.modules.t3widgetexpiry | |
| 2 | ||
| 3 | import android.app.AlarmManager | |
| 4 | import android.app.PendingIntent | |
| 5 | import android.appwidget.AppWidgetManager | |
| 6 | import android.content.BroadcastReceiver | |
| 7 | import android.content.ComponentName | |
| 8 | import android.content.Context | |
| 9 | import 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 | */ | |
| 16 | class 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 @@ |
| 1 | package expo.modules.t3widgetexpiry | |
| 2 | ||
| 3 | import android.app.AlarmManager | |
| 4 | import android.content.Context | |
| 5 | import android.content.Intent | |
| 6 | import org.junit.Assert.assertEquals | |
| 7 | import org.junit.Assert.assertTrue | |
| 8 | import org.junit.Test | |
| 9 | import org.junit.runner.RunWith | |
| 10 | import org.robolectric.RobolectricTestRunner | |
| 11 | import org.robolectric.RuntimeEnvironment | |
| 12 | import org.robolectric.Shadows.shadowOf | |
| 13 | import org.robolectric.annotation.Config | |
| 14 | ||
| 15 | @RunWith(RobolectricTestRunner::class) | |
| 16 | @Config(sdk = [24, 36], manifest = Config.NONE) | |
| 17 | class 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 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | vi.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 | ||
| 18 | vi.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 | ||
| 26 | vi.mock("expo-widgets", () => ({ | |
| 27 | createWidget: vi.fn((name: string, layout: unknown) => ({ layout, name })), | |
| 28 | })); | |
| 29 | ||
| 30 | import { SubscriptionUsage } from "./SubscriptionUsage.android"; | |
| 31 | import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; | |
| 32 | ||
| 33 | const now = Date.parse("2026-09-05T12:00:00.000Z"); | |
| 34 | const 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]; | |
| 44 | const snapshot = { | |
| 45 | checkedAt: now, | |
| 46 | url: "t3code-dev://settings/usage?tab=limits", | |
| 47 | providers: [provider, { ...provider, name: "Claude" }], | |
| 48 | } satisfies SubscriptionUsageSnapshot; | |
| 49 | ||
| 50 | function render(props: SubscriptionUsageSnapshot, colorScheme: "light" | "dark" = "dark") { | |
| 51 | vi.setSystemTime(now); | |
| 52 | return JSON.stringify(SubscriptionUsage(props, { colorScheme, configuration: undefined })); | |
| 53 | } | |
| 54 | ||
| 55 | describe("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 @@ |
| 1 | import { | |
| 2 | Button, | |
| 3 | Column, | |
| 4 | getMaterialColors, | |
| 5 | LinearProgressIndicator, | |
| 6 | Text, | |
| 7 | } from "@expo/ui/jetpack-compose"; | |
| 8 | import { | |
| 9 | fillMaxSize, | |
| 10 | fillMaxWidth, | |
| 11 | height, | |
| 12 | padding, | |
| 13 | paddingAll, | |
| 14 | } from "@expo/ui/jetpack-compose/modifiers"; | |
| 15 | import { createWidget, type WidgetEnvironment } from "expo-widgets"; | |
| 16 | ||
| 17 | import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot"; | |
| 18 | ||
| 19 | export 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 | ||
| 114 | export default createWidget("SubscriptionUsage", SubscriptionUsage); |
apps/mobile/src/widgets/publishSubscriptionUsage.android.ts+29-4| ... | ... | @@ -1,8 +1,33 @@ |
| 1 | 1 | import { requireOptionalNativeModule } from "expo"; |
| 2 | import * as Linking from "expo-linking"; | |
| 2 | 3 | import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; |
| 3 | 4 | |
| 4 | export function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { | |
| 5 | requireOptionalNativeModule<{ updateSnapshot: (snapshot: string) => void }>( | |
| 6 | "T3SubscriptionWidget", | |
| 7 | )?.updateSnapshot(JSON.stringify(snapshot)); | |
| 5 | let tapUrl: string | undefined; | |
| 6 | let listening = false; | |
| 7 | ||
| 8 | export 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 | 120 | ## Subscription usage widget |
| 121 | 121 | |
| 122 | 122 | Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and |
| 123 | Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session, | |
| 124 | Weekly, or both for each provider. Reopen T3 to refresh expired readings. The Android widget | |
| 125 | requires Android 12L or later. | |
| 123 | Claude quotas. Tap it to open **Usage → Limits**; on Android this works while T3 is running in | |
| 124 | the background, otherwise open the app from the launcher. On iOS, use **Edit Widget** to choose | |
| 125 | Session, Weekly, or both for each provider. Reopen T3 to refresh expired readings. | |
| 126 | 126 | |
| 127 | 127 | ## Keyboard shortcuts |
| 128 | 128 |