From 569e3c51a1a32a79fd046f0c519f6d376a0aa9ae Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:41:15 -0700 Subject: [PATCH 1/4] feat(mobile): render the Android subscription widget with expo-widgets expo-widgets 58 runs Android home-screen widgets from the same JS layout model as iOS, so the hand-written RemoteViews module is replaced by a Compose layout in SubscriptionUsage.android.tsx. One TSX implementation per platform now feeds one snapshot pipeline, and the widget follows the device's Material palette. What the RemoteViews module did that expo-widgets does not: - Expiry re-render while the app is closed. Kept as a small t3-widget-expiry module that arms one inexact alarm per deadline and sends ACTION_APPWIDGET_UPDATE to the generated provider, which re-runs the stored snapshot through the widget runtime. - Cold-start tap. expo-widgets delivers taps to the running app's interaction listener; there is no Android counterpart to widgetURL. A tap opens Usage while T3 is alive and otherwise only refreshes the widget. Co-Authored-By: Claude Fable 5 --- apps/mobile/app.config.ts | 79 ++++---- .../android/src/main/AndroidManifest.xml | 8 - .../SubscriptionUsageWidget.kt | 174 ------------------ .../T3SubscriptionWidgetModule.kt | 18 -- .../t3_subscription_widget_background.xml | 4 - .../res/layout/t3_subscription_widget.xml | 10 - .../res/layout/t3_subscription_widget_row.xml | 10 - .../src/main/res/values-night/colors.xml | 5 - .../android/src/main/res/values/colors.xml | 5 - .../android/src/main/res/values/strings.xml | 15 -- .../res/xml/t3_subscription_widget_info.xml | 9 - .../expo-module.config.json | 4 - .../android/build.gradle | 4 +- .../android/src/main/AndroidManifest.xml | 5 + .../t3widgetexpiry/T3WidgetExpiryModule.kt | 14 ++ .../t3widgetexpiry/WidgetExpiryReceiver.kt | 60 ++++++ .../t3-widget-expiry/expo-module.config.json | 4 + .../widgets/SubscriptionUsage.android.test.ts | 87 +++++++++ .../src/widgets/SubscriptionUsage.android.tsx | 109 +++++++++++ .../publishSubscriptionUsage.android.ts | 33 +++- docs/user/usage.md | 6 +- 21 files changed, 360 insertions(+), 303 deletions(-) delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml delete mode 100644 apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml delete mode 100644 apps/mobile/modules/t3-subscription-widget/expo-module.config.json rename apps/mobile/modules/{t3-subscription-widget => t3-widget-expiry}/android/build.gradle (79%) create mode 100644 apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/T3WidgetExpiryModule.kt create mode 100644 apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt create mode 100644 apps/mobile/modules/t3-widget-expiry/expo-module.config.json create mode 100644 apps/mobile/src/widgets/SubscriptionUsage.android.test.ts create mode 100644 apps/mobile/src/widgets/SubscriptionUsage.android.tsx diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 6a992e6c5264..a1ab75b89fc2 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -129,51 +129,66 @@ const widgetsPlugin: NonNullable[number] = [ // Agent activity can update many times an hour; without the // frequent-updates entitlement iOS throttles the update budget sooner. frequentUpdates: true, + enableAndroid: true, widgets: [ { name: "SubscriptionUsage", displayName: "Subscription usage", description: "Subscription quotas from your connected T3 Code environments.", - configuration: { - title: "Subscription usage", - description: - "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.", - parameters: { - codexPeriod: { - title: "Codex limits", - type: "enum", - default: "auto", - values: [ - { name: "Both", value: "auto" }, - { name: "Session", value: "session" }, - { name: "Weekly", value: "weekly" }, - ], - }, - claudePeriod: { - title: "Claude limits", - type: "enum", - default: "auto", - values: [ - { name: "Both", value: "auto" }, - { name: "Session", value: "session" }, - { name: "Weekly", value: "weekly" }, - ], + ios: { + configuration: { + title: "Subscription usage", + description: + "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.", + parameters: { + codexPeriod: { + title: "Codex limits", + type: "enum", + default: "auto", + values: [ + { name: "Both", value: "auto" }, + { name: "Session", value: "session" }, + { name: "Weekly", value: "weekly" }, + ], + }, + claudePeriod: { + title: "Claude limits", + type: "enum", + default: "auto", + values: [ + { name: "Both", value: "auto" }, + { name: "Session", value: "session" }, + { name: "Weekly", value: "weekly" }, + ], + }, }, }, + supportedFamilies: [ + "systemSmall", + "systemMedium", + "systemLarge", + "systemExtraLarge", + "accessoryRectangular", + ], + }, + android: { + minWidth: 250, + minHeight: 180, + targetCellWidth: 4, + targetCellHeight: 3, + resizeMode: "both", + // Embeds the layout in the APK so the widget renders before the app + // has run once; the app replaces it with stored props on publish. + initialLayout: "./src/widgets/SubscriptionUsage.android.tsx", }, - supportedFamilies: [ - "systemSmall", - "systemMedium", - "systemLarge", - "systemExtraLarge", - "accessoryRectangular", - ], }, { name: "AgentActivity", displayName: "Agent Activity", description: "Shows the current state of active T3 Code agents.", - supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], + // Live Activity companion; there is no Android presentation for it. + android: null, + ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] }, }, ], }, diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml deleted file mode 100644 index e98c1ad6b312..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt deleted file mode 100644 index b8a916129ea7..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt +++ /dev/null @@ -1,174 +0,0 @@ -package expo.modules.t3subscriptionwidget - -import android.app.AlarmManager -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.view.View -import android.widget.RemoteViews -import org.json.JSONObject -import java.text.DateFormat -import java.util.Date - -class SubscriptionUsageWidget : AppWidgetProvider() { - override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { - ids.forEach { update(context, manager, it) } - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - if (intent.action == EXPIRE) updateAll(context) - } - - override fun onDisabled(context: Context) { - context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) - } - - companion object { - const val PREFERENCES = "t3_subscription_widget" - private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE" - - private fun expiryIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( - context, - 0, - Intent(context, SubscriptionUsageWidget::class.java).setAction(EXPIRE), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - fun updateAll(context: Context) { - val manager = AppWidgetManager.getInstance(context) - manager.getAppWidgetIds(ComponentName(context, SubscriptionUsageWidget::class.java)) - .forEach { update(context, manager, it) } - } - - private fun update(context: Context, manager: AppWidgetManager, id: Int) { - // The receiver is disabled below 12L (values-v32/bools.xml), but the module still calls in. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S_V2) return - val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) - val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull() - val openApp = openAppIntent(context, id, snapshot) - val providers = snapshot?.optJSONArray("providers") - val now = System.currentTimeMillis() - var nextExpiry = Long.MAX_VALUE - val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index -> - val provider = providers?.optJSONObject(index) ?: return@mapNotNull null - val windows = provider.optJSONArray("windows") - val expiresAt = provider.optLong("expiresAt") - if (expiresAt > now && windows != null && windows.length() > 0) { - nextExpiry = minOf(nextExpiry, expiresAt) - (0 until windows.length()).map { provider to windows.optJSONObject(it) } - } else { - listOf(provider to null) - } - } - // Keep the first quota from each provider near the top of the list. - val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index -> - groups.mapNotNull { it.getOrNull(index) } - } - val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) - // Count limits only; "Open app to refresh" placeholders are not entries. - val limits = rows.count { (_, window) -> window != null } - // Without limits the layout's plain title stays. - if (limits > 0) { - views.setTextViewText( - R.id.t3_widget_title, - context.getString(R.string.t3_subscription_widget_title_count, limits) - ) - views.setContentDescription( - R.id.t3_widget_title, - context.resources.getQuantityString( - R.plurals.t3_subscription_widget_title_description, - limits, - limits - ) - ) - } - openApp?.let { views.setOnClickPendingIntent(R.id.t3_widget_root, it) } - openAppIntent(context, id, snapshot, forCollection = true)?.let { - views.setPendingIntentTemplate(R.id.t3_widget_rows, it) - } - val items = RemoteViews.RemoteCollectionItems.Builder() - rows.forEachIndexed { index, (provider, window) -> - items.addItem(index.toLong(), rowView(context, provider, window)) - } - views.setRemoteAdapter(R.id.t3_widget_rows, items.build()) - views.setEmptyView(R.id.t3_widget_rows, R.id.t3_widget_empty) - val checkedAt = snapshot?.optLong("checkedAt") ?: 0 - val checked = if (checkedAt > 0) { - val formatted = DateFormat.getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT - ).format(Date(checkedAt)) - context.getString(R.string.t3_subscription_widget_last_checked, formatted) - } else { - context.getString(R.string.t3_subscription_widget_unknown_check) - } - views.setTextViewText(R.id.t3_widget_footer, checked) - val alarms = context.getSystemService(AlarmManager::class.java) - alarms.cancel(expiryIntent(context)) - // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry. - if (nextExpiry != Long.MAX_VALUE) { - alarms.set(AlarmManager.RTC, nextExpiry, expiryIntent(context)) - } - manager.updateAppWidget(id, views) - } - - private fun openAppIntent( - context: Context, - id: Int, - snapshot: JSONObject?, - forCollection: Boolean = false - ): PendingIntent? { - // Target this variant's launcher so co-installed builds cannot steal the tap. - val intent = - context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null - intent.action = Intent.ACTION_VIEW - val deepLink = snapshot?.optString("url")?.takeIf { it.isNotBlank() } - ?: "t3code://settings/usage?tab=limits" - intent.data = Uri.parse(deepLink) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - return PendingIntent.getActivity( - context, - id * 2 + if (forCollection) 1 else 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or if (forCollection) { - // Collection rows use fill-in intents with an explicit app target. - PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_IMMUTABLE - } - ) - } - - private fun rowView(context: Context, provider: JSONObject, window: JSONObject?): RemoteViews { - val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row) - val remaining = window?.optInt("remaining")?.coerceIn(0, 100) - val detail = provider.optString("detail") - val label = provider.optString("name") - val windowLabel = window?.optString("label") ?: detail - child.setTextViewText(R.id.t3_widget_label, label) - child.setTextViewText(R.id.t3_widget_window, windowLabel) - val percent = remaining?.let { - context.getString(R.string.t3_subscription_widget_remaining, it) - } ?: "—" - child.setTextViewText(R.id.t3_widget_percent, percent) - val visibility = if (remaining == null) View.GONE else View.VISIBLE - child.setViewVisibility(R.id.t3_widget_progress, visibility) - if (remaining != null) child.setProgressBar(R.id.t3_widget_progress, 100, remaining, false) - val reset = window?.optString("reset") - ?: context.getString(R.string.t3_subscription_widget_refresh) - child.setTextViewText(R.id.t3_widget_reset, reset) - child.setOnClickFillInIntent(R.id.t3_widget_row, Intent()) - child.setContentDescription( - R.id.t3_widget_row, - "$label. $windowLabel. $percent. $reset. $detail" - ) - return child - } - } -} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt deleted file mode 100644 index 6557b70958de..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package expo.modules.t3subscriptionwidget - -import expo.modules.kotlin.modules.Module -import expo.modules.kotlin.modules.ModuleDefinition -import org.json.JSONObject - -class T3SubscriptionWidgetModule : Module() { - override fun definition() = ModuleDefinition { - Name("T3SubscriptionWidget") - Function("updateSnapshot") { snapshot: String -> - val context = appContext.reactContext ?: return@Function - JSONObject(snapshot) // Reject malformed writes before replacing the saved snapshot. - context.getSharedPreferences(SubscriptionUsageWidget.PREFERENCES, 0) - .edit().putString("snapshot", snapshot).apply() - SubscriptionUsageWidget.updateAll(context) - } - } -} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml deleted file mode 100644 index 69ec028ee8af..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml deleted file mode 100644 index bf800d249fe0..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml deleted file mode 100644 index 941f27ef856a..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml deleted file mode 100644 index 0647eb1a8ea7..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml +++ /dev/null @@ -1,5 +0,0 @@ - - #18181B - #FAFAFA - #A1A1AA - diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml deleted file mode 100644 index bf4ca10ecfc4..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml +++ /dev/null @@ -1,5 +0,0 @@ - - #FAFAFA - #18181B - #52525B - diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml deleted file mode 100644 index 586b82595345..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml +++ /dev/null @@ -1,15 +0,0 @@ - - Last checked unavailable - Subscription usage - Subscription usage (%1$d) - - Subscription usage, %1$d entry - Subscription usage, %1$d entries - - Saved subscription quotas from your T3 Code environments. Tap to refresh in the app. - No subscription limits available. Open T3 Code to connect. - Tap to open Usage - Open app to refresh - %1$d%% remaining - Last checked %1$s - diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml deleted file mode 100644 index 4dfdad5b0fec..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/apps/mobile/modules/t3-subscription-widget/expo-module.config.json b/apps/mobile/modules/t3-subscription-widget/expo-module.config.json deleted file mode 100644 index 7ddc9c09e221..000000000000 --- a/apps/mobile/modules/t3-subscription-widget/expo-module.config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "platforms": ["android"], - "android": { "modules": ["expo.modules.t3subscriptionwidget.T3SubscriptionWidgetModule"] } -} diff --git a/apps/mobile/modules/t3-subscription-widget/android/build.gradle b/apps/mobile/modules/t3-widget-expiry/android/build.gradle similarity index 79% rename from apps/mobile/modules/t3-subscription-widget/android/build.gradle rename to apps/mobile/modules/t3-widget-expiry/android/build.gradle index 7de5052417c0..efd9a3d7caa6 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/build.gradle +++ b/apps/mobile/modules/t3-widget-expiry/android/build.gradle @@ -1,11 +1,11 @@ apply plugin: 'com.android.library' apply plugin: 'org.jetbrains.kotlin.android' -group = 'com.t3tools.subscriptionwidget' +group = 'com.t3tools.widgetexpiry' version = '0.0.0' android { - namespace 'expo.modules.t3subscriptionwidget' + namespace 'expo.modules.t3widgetexpiry' compileSdk rootProject.ext.compileSdkVersion defaultConfig { minSdkVersion rootProject.ext.minSdkVersion diff --git a/apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..f0591499218a --- /dev/null +++ b/apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/T3WidgetExpiryModule.kt b/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/T3WidgetExpiryModule.kt new file mode 100644 index 000000000000..c9a2e4a440f1 --- /dev/null +++ b/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/T3WidgetExpiryModule.kt @@ -0,0 +1,14 @@ +package expo.modules.t3widgetexpiry + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3WidgetExpiryModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3WidgetExpiry") + Function("schedule") { name: String, deadlines: List -> + val context = appContext.reactContext ?: return@Function + WidgetExpiryReceiver.schedule(context, name, deadlines.map { it.toLong() }.toLongArray()) + } + } +} diff --git a/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt b/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt new file mode 100644 index 000000000000..1cd2482d06a9 --- /dev/null +++ b/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt @@ -0,0 +1,60 @@ +package expo.modules.t3widgetexpiry + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent + +/** + * Re-renders an expo-widgets widget when a snapshot deadline passes while the app is closed. + * The generated `.Provider` receiver re-runs the JS layout with the stored + * snapshot on ACTION_APPWIDGET_UPDATE, so a deadline only needs to deliver that broadcast. + */ +class WidgetExpiryReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val name = intent.getStringExtra(EXTRA_NAME) ?: return + val remaining = intent.getLongArrayExtra(EXTRA_DEADLINES) ?: LongArray(0) + val provider = providerComponent(context, name) + val ids = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) + if (ids.isNotEmpty()) { + context.sendBroadcast( + Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) + .setComponent(provider) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + ) + } + schedule(context, name, remaining) + } + + companion object { + private const val EXTRA_NAME = "expo.modules.t3widgetexpiry.NAME" + private const val EXTRA_DEADLINES = "expo.modules.t3widgetexpiry.DEADLINES" + + /** Arms one inexact, non-wakeup alarm for the next future deadline and carries the rest. */ + fun schedule(context: Context, name: String, deadlines: LongArray) { + val alarms = context.getSystemService(AlarmManager::class.java) + val now = System.currentTimeMillis() + val pending = deadlines.filter { it > now }.sorted() + val intent = Intent(context, WidgetExpiryReceiver::class.java) + .setAction("expo.modules.t3widgetexpiry.EXPIRE.$name") + .putExtra(EXTRA_NAME, name) + .putExtra(EXTRA_DEADLINES, pending.drop(1).toLongArray()) + val operation = PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarms.cancel(operation) + val next = pending.firstOrNull() ?: return + alarms.set(AlarmManager.RTC, next, operation) + } + + // Mirrors expo-widgets' widgetProviderComponentName for the generated provider class. + private fun providerComponent(context: Context, name: String) = + ComponentName(context.packageName, "${context.packageName}.${name}Provider") + } +} diff --git a/apps/mobile/modules/t3-widget-expiry/expo-module.config.json b/apps/mobile/modules/t3-widget-expiry/expo-module.config.json new file mode 100644 index 000000000000..7bed9ce05433 --- /dev/null +++ b/apps/mobile/modules/t3-widget-expiry/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["android"], + "android": { "modules": ["expo.modules.t3widgetexpiry.T3WidgetExpiryModule"] } +} diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts b/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts new file mode 100644 index 000000000000..0e3809a31b91 --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("@expo/ui/jetpack-compose", () => ({ + Button: "Button", + Column: "Column", + LinearProgressIndicator: "LinearProgressIndicator", + Text: "Text", + getMaterialColors: ({ scheme }: { scheme: string }) => ({ + surface: `${scheme}-surface`, + onSurface: `${scheme}-onSurface`, + onSurfaceVariant: `${scheme}-onSurfaceVariant`, + surfaceVariant: `${scheme}-surfaceVariant`, + primary: `${scheme}-primary`, + error: `${scheme}-error`, + }), +})); + +vi.mock("@expo/ui/jetpack-compose/modifiers", () => ({ + fillMaxSize: () => "fillMaxSize", + fillMaxWidth: () => "fillMaxWidth", + height: (value: number) => ({ height: value }), + padding: (...values: number[]) => ({ padding: values }), + paddingAll: (value: number) => ({ paddingAll: value }), +})); + +vi.mock("expo-widgets", () => ({ + createWidget: vi.fn((name: string, layout: unknown) => ({ layout, name })), +})); + +import { SubscriptionUsage } from "./SubscriptionUsage.android"; +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +const now = Date.parse("2026-09-05T12:00:00.000Z"); +const provider = { + name: "Codex", + detail: "Subscription remaining", + windows: [ + { kind: "session", label: "5 hours", remaining: 60, reset: "Next reset Sep 5, 5:00 PM" }, + { kind: "weekly", label: "Weekly", remaining: 8, reset: "Next reset Sep 9, 9:00 AM" }, + ], + expiresAt: now + 60_000, + totalWindows: 2, +} satisfies SubscriptionUsageSnapshot["providers"][number]; +const snapshot = { + checkedAt: now, + url: "t3code-dev://settings/usage?tab=limits", + providers: [provider, { ...provider, name: "Claude" }], +} satisfies SubscriptionUsageSnapshot; + +function render(props: SubscriptionUsageSnapshot, colorScheme: "light" | "dark" = "dark") { + vi.setSystemTime(now); + return JSON.stringify(SubscriptionUsage(props, { colorScheme, configuration: undefined })); +} + +describe("SubscriptionUsage Android layout", () => { + it("renders each window with its remaining share, bar, and reset", () => { + const tree = render(snapshot); + expect(tree).toContain("5 hours · 60% left"); + expect(tree).toContain('"progress":0.6'); + expect(tree).toContain("Next reset Sep 5, 5:00 PM"); + expect(tree).toContain('"progress":0.08'); + expect(tree).toContain('"color":"dark-error"'); + expect(tree).toContain("As of "); + }); + + it("drops the bars and asks for a refresh once the snapshot expires", () => { + const tree = render({ + ...snapshot, + providers: [{ ...provider, expiresAt: now - 1 }], + }); + expect(tree).toContain("Open T3 to refresh"); + expect(tree).not.toContain("LinearProgressIndicator"); + expect(tree).not.toContain("more in T3"); + }); + + it("counts the quotas that did not fit", () => { + const tree = render({ ...snapshot, providers: [{ ...provider, totalWindows: 5 }] }); + expect(tree).toContain("3 more in T3"); + }); + + it("invites connecting when nothing has been checked", () => { + const tree = render({ checkedAt: 0, providers: [] }, "light"); + expect(tree).toContain("Tap to connect in T3"); + expect(tree).not.toContain("As of "); + expect(tree).toContain('"containerColor":"light-surface"'); + }); +}); diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.tsx b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx new file mode 100644 index 000000000000..bc4f25ea595f --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx @@ -0,0 +1,109 @@ +import { + Button, + Column, + getMaterialColors, + LinearProgressIndicator, + Text, +} from "@expo/ui/jetpack-compose"; +import { + fillMaxSize, + fillMaxWidth, + height, + padding, + paddingAll, +} from "@expo/ui/jetpack-compose/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; + +import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot"; + +export function SubscriptionUsage(props: SubscriptionUsageProps, environment: WidgetEnvironment) { + "widget"; + // The widget runtime evaluates this function without the app's module scope. + // Android has no timeline, so freshness is decided on every render; the + // expiry alarm and each tap trigger one while the app is closed. + const now = Date.now(); + // The 4x3 default cell fits two quotas per provider with their reset text. + const limit = 2; + const colors = getMaterialColors({ + scheme: environment.colorScheme === "dark" ? "dark" : "light", + }); + const muted = colors.onSurfaceVariant; + const providers = props.providers ?? [ + { name: "Codex", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 }, + { name: "Claude", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 }, + ]; + return ( + // The card is one Button so a tap reaches the app's interaction listener, + // which opens props.url. expo-widgets has no Android counterpart to widgetURL. + + ); +} + +export default createWidget("SubscriptionUsage", SubscriptionUsage); diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts index a37b4f61eca3..406772d3d530 100644 --- a/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts @@ -1,8 +1,33 @@ import { requireOptionalNativeModule } from "expo"; +import * as Linking from "expo-linking"; import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; -export function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { - requireOptionalNativeModule<{ updateSnapshot: (snapshot: string) => void }>( - "T3SubscriptionWidget", - )?.updateSnapshot(JSON.stringify(snapshot)); +let tapUrl: string | undefined; +let listening = false; + +export async function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const [{ default: widget }, { addUserInteractionListener }] = await Promise.all([ + import("./SubscriptionUsage"), + import("expo-widgets"), + ]); + tapUrl = snapshot.url; + if (!listening) { + // expo-widgets has no Android counterpart to widgetURL: the card is a + // button whose tap is delivered to this process, so opening the app needs + // a live JS runtime. + listening = true; + addUserInteractionListener((event) => { + if (event.source === "SubscriptionUsage" && tapUrl) void Linking.openURL(tapUrl); + }); + } + widget.updateSnapshot(snapshot); + // Android has no timeline; an alarm re-renders the stored snapshot at each + // deadline so stale readings flip to "Open T3 to refresh" unattended. + requireOptionalNativeModule<{ schedule: (name: string, deadlines: number[]) => void }>( + "T3WidgetExpiry", + )?.schedule( + "SubscriptionUsage", + snapshot.providers.map((provider) => provider.expiresAt).filter((at) => at > 0), + ); } diff --git a/docs/user/usage.md b/docs/user/usage.md index 64f392d6e941..5c52122bf8a2 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -120,9 +120,9 @@ settings section when you no longer need it. ## Subscription usage widget Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and -Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session, -Weekly, or both for each provider. Reopen T3 to refresh expired readings. The Android widget -requires Android 12L or later. +Claude quotas. Tap it to open **Usage → Limits**; on Android this works while T3 is running in +the background, otherwise open the app from the launcher. On iOS, use **Edit Widget** to choose +Session, Weekly, or both for each provider. Reopen T3 to refresh expired readings. ## Keyboard shortcuts From 527a2425b5df56c89286eca1ccfff1eacf38e635 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 1 Oct 2026 10:32:13 -0700 Subject: [PATCH 2/4] fix(mobile): retain Android widget taps and expiry after reboot --- .../t3-widget-expiry/android/build.gradle | 11 +++++ .../android/src/main/AndroidManifest.xml | 7 ++- .../t3widgetexpiry/WidgetExpiryReceiver.kt | 20 ++++++++- .../WidgetExpiryReceiverTest.kt | 44 +++++++++++++++++++ .../widgets/SubscriptionUsage.android.test.ts | 6 +++ .../src/widgets/SubscriptionUsage.android.tsx | 9 +++- 6 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/modules/t3-widget-expiry/android/src/test/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiverTest.kt diff --git a/apps/mobile/modules/t3-widget-expiry/android/build.gradle b/apps/mobile/modules/t3-widget-expiry/android/build.gradle index efd9a3d7caa6..86e73074b5d1 100644 --- a/apps/mobile/modules/t3-widget-expiry/android/build.gradle +++ b/apps/mobile/modules/t3-widget-expiry/android/build.gradle @@ -11,8 +11,19 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion } + testOptions { + unitTests.includeAndroidResources = true + } } dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml index f0591499218a..9ae263ac37b1 100644 --- a/apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml +++ b/apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml @@ -1,5 +1,10 @@ + - + + + + + diff --git a/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt b/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt index 1cd2482d06a9..9482723edfa7 100644 --- a/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt +++ b/apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt @@ -15,8 +15,23 @@ import android.content.Intent */ class WidgetExpiryReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + for ((name, value) in stored.all) { + val deadlines = (value as? String)?.split(',')?.mapNotNull { it.toLongOrNull() } + ?.toLongArray() ?: continue + refresh(context, name) + schedule(context, name, deadlines) + } + return + } val name = intent.getStringExtra(EXTRA_NAME) ?: return val remaining = intent.getLongArrayExtra(EXTRA_DEADLINES) ?: LongArray(0) + refresh(context, name) + schedule(context, name, remaining) + } + + private fun refresh(context: Context, name: String) { val provider = providerComponent(context, name) val ids = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) if (ids.isNotEmpty()) { @@ -26,18 +41,21 @@ class WidgetExpiryReceiver : BroadcastReceiver() { .putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) ) } - schedule(context, name, remaining) } companion object { private const val EXTRA_NAME = "expo.modules.t3widgetexpiry.NAME" private const val EXTRA_DEADLINES = "expo.modules.t3widgetexpiry.DEADLINES" + private const val PREFERENCES = "expo.modules.t3widgetexpiry.DEADLINES" /** Arms one inexact, non-wakeup alarm for the next future deadline and carries the rest. */ fun schedule(context: Context, name: String, deadlines: LongArray) { val alarms = context.getSystemService(AlarmManager::class.java) val now = System.currentTimeMillis() val pending = deadlines.filter { it > now }.sorted() + val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit() + if (pending.isEmpty()) stored.remove(name) else stored.putString(name, pending.joinToString(",")) + stored.apply() val intent = Intent(context, WidgetExpiryReceiver::class.java) .setAction("expo.modules.t3widgetexpiry.EXPIRE.$name") .putExtra(EXTRA_NAME, name) diff --git a/apps/mobile/modules/t3-widget-expiry/android/src/test/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiverTest.kt b/apps/mobile/modules/t3-widget-expiry/android/src/test/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiverTest.kt new file mode 100644 index 000000000000..3b2f75fda7fd --- /dev/null +++ b/apps/mobile/modules/t3-widget-expiry/android/src/test/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiverTest.kt @@ -0,0 +1,44 @@ +package expo.modules.t3widgetexpiry + +import android.app.AlarmManager +import android.content.Context +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24, 36], manifest = Config.NONE) +class WidgetExpiryReceiverTest { + @Test + fun bootRestoresTheNextAlarmFromPersistedDeadlines() { + val context = RuntimeEnvironment.getApplication() + val alarms = context.getSystemService(AlarmManager::class.java) + val deadlines = longArrayOf(System.currentTimeMillis() + 60000, System.currentTimeMillis() + 120000) + WidgetExpiryReceiver.schedule(context, "SubscriptionUsage", deadlines) + val scheduled = shadowOf(alarms).scheduledAlarms.single() + alarms.cancel(scheduled.operation) + assertTrue(shadowOf(alarms).scheduledAlarms.isEmpty()) + + WidgetExpiryReceiver().onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED)) + + assertEquals(deadlines[0], shadowOf(alarms).scheduledAlarms.single().triggerAtTime) + } + + @Test + fun bootDoesNotRestoreCancelledDeadlines() { + val context = RuntimeEnvironment.getApplication() + WidgetExpiryReceiver.schedule(context, "SubscriptionUsage", longArrayOf(System.currentTimeMillis() + 60000)) + WidgetExpiryReceiver.schedule(context, "SubscriptionUsage", longArrayOf()) + + WidgetExpiryReceiver().onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED)) + + assertTrue(shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty()) + assertTrue(context.getSharedPreferences("expo.modules.t3widgetexpiry.DEADLINES", Context.MODE_PRIVATE).all.isEmpty()) + } +} diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts b/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts index 0e3809a31b91..db2efd5da17b 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts +++ b/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts @@ -78,6 +78,12 @@ describe("SubscriptionUsage Android layout", () => { expect(tree).toContain("3 more in T3"); }); + it("keeps quotas without an expiry deadline visible", () => { + const tree = render({ ...snapshot, providers: [{ ...provider, expiresAt: 0 }] }); + expect(tree).toContain("5 hours · 60% left"); + expect(tree).not.toContain("Open T3 to refresh"); + }); + it("invites connecting when nothing has been checked", () => { const tree = render({ checkedAt: 0, providers: [] }, "light"); expect(tree).toContain("Tap to connect in T3"); diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.tsx b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx index bc4f25ea595f..ac47edf8cdd1 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.android.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx @@ -35,10 +35,15 @@ export function SubscriptionUsage(props: SubscriptionUsageProps, environment: Wi return ( // The card is one Button so a tap reaches the app's interaction listener, // which opens props.url. expo-widgets has no Android counterpart to widgetURL. -