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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:exported="false" android:label="@string/t3_subscription_widget_name">
<receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:enabled="@bool/t3_subscription_widget_enabled" android:exported="false" android:label="@string/t3_subscription_widget_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/t3_subscription_widget_info" />
</receiver>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Build
import android.view.View
import android.widget.RemoteViews
import org.json.JSONObject
Expand All @@ -29,15 +29,6 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context))
}

override fun onAppWidgetOptionsChanged(
context: Context,
manager: AppWidgetManager,
id: Int,
options: Bundle
) {
update(context, manager, id)
}

companion object {
const val PREFERENCES = "t3_subscription_widget"
private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE"
Expand All @@ -56,59 +47,68 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
}

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 views = RemoteViews(context.packageName, R.layout.t3_subscription_widget)
openAppIntent(context, id, snapshot)?.let {
views.setOnClickPendingIntent(R.id.t3_widget_root, it)
}
val openApp = openAppIntent(context, id, snapshot)
val providers = snapshot?.optJSONArray("providers")
val now = System.currentTimeMillis()
var nextExpiry = Long.MAX_VALUE
var totalRows = 0
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)
totalRows += provider.optInt("totalWindows", windows.length())
(0 until windows.length()).map { provider to windows.optJSONObject(it) }
} else {
totalRows++
listOf(provider to null)
}
}
// Show each provider before filling spare space with its other windows.
// 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) }
}
if (rows.isNotEmpty()) {
views.removeAllViews(R.id.t3_widget_rows)
val options = manager.getAppWidgetOptions(id)
val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180)
val count = ((height - 64) / 66).coerceIn(1, 12).coerceAtMost(rows.size)
for ((provider, window) in rows.take(count)) {
views.addView(R.id.t3_widget_rows, rowView(context, provider, window))
}
val remaining = totalRows - count
val checkedAt = snapshot?.optLong("checkedAt") ?: 0
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))
val more = if (remaining > 0) {
context.getString(R.string.t3_subscription_widget_more, remaining)
} else {
""
}
val checked = if (checkedAt > 0) {
context.getString(R.string.t3_subscription_widget_as_of, formatted)
} else {
context.getString(R.string.t3_subscription_widget_unknown_check)
}
views.setTextViewText(R.id.t3_widget_footer, checked + more)
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.
Expand All @@ -118,7 +118,12 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
manager.updateAppWidget(id, views)
}

private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? {
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
Expand All @@ -129,9 +134,14 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
return PendingIntent.getActivity(
context,
id,
id * 2 + if (forCollection) 1 else 0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
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
}
)
}

Expand All @@ -153,6 +163,7 @@ class SubscriptionUsageWidget : AppWidgetProvider() {
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"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/t3_widget_root" android:layout_width="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:padding="16dp" android:background="@drawable/t3_subscription_widget_background">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_name" android:textStyle="bold" android:textSize="16sp" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" />
<LinearLayout android:id="@+id/t3_widget_rows" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="vertical">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/t3_subscription_widget_empty" android:textSize="13sp" android:textColor="@color/t3_widget_secondary" />
</LinearLayout>
<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" />
<FrameLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1">
<ListView android:id="@+id/t3_widget_rows" android:layout_width="match_parent" android:layout_height="match_parent" android:divider="@null" android:scrollbars="vertical" />
<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" />
</FrameLayout>
<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" />
</LinearLayout>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<resources>
<!-- Android 12L is the first release whose collection widgets need no compat service. -->
<bool name="t3_subscription_widget_enabled">true</bool>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<resources>
<bool name="t3_subscription_widget_enabled">false</bool>
</resources>
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
<resources>
<string name="t3_subscription_widget_unknown_check">Last checked unavailable</string>
<string name="t3_subscription_widget_name">Subscription usage</string>
<string name="t3_subscription_widget_title_count">Subscription usage (%1$d)</string>
<plurals name="t3_subscription_widget_title_description">
<item quantity="one">Subscription usage, %1$d entry</item>
<item quantity="other">Subscription usage, %1$d entries</item>
</plurals>
<string name="t3_subscription_widget_description">Saved subscription quotas from your T3 Code environments. Tap to refresh in the app.</string>
<string name="t3_subscription_widget_empty">Open T3 Code and connect an environment to see limits.</string>
<string name="t3_subscription_widget_empty">No subscription limits available. Open T3 Code to connect.</string>
<string name="t3_subscription_widget_open">Tap to open Usage</string>
<string name="t3_subscription_widget_refresh">Open app to refresh</string>
<string name="t3_subscription_widget_remaining">%1$d%% remaining</string>
<string name="t3_subscription_widget_as_of">As of %1$s</string>
<string name="t3_subscription_widget_more"> · +%1$d more</string>
<string name="t3_subscription_widget_last_checked">Last checked %1$s</string>
</resources>
6 changes: 5 additions & 1 deletion apps/mobile/src/widgets/SubscriptionUsage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,11 @@ function SubscriptionUsage(
spacing={accessory || dense ? 2 : 6}
modifiers={props.url ? [widgetURL(props.url)] : []}
>
{compact ? (
{providers.length === 0 ? (
<Text modifiers={[font({ textStyle: "caption" }), foregroundStyle("secondary")]}>
No subscription limits available.
</Text>
) : compact ? (
<VStack alignment="leading" spacing={accessory || dense ? 4 : 8}>
{columns}
</VStack>
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/unstable/reactivity";
import * as Linking from "expo-linking";
import { useEffect } from "react";
import { Platform } from "react-native";
import { environmentCatalog } from "../connection/catalog";
import { environmentPresentations } from "../state/presentation";
import { publishSubscriptionUsage } from "./publishSubscriptionUsage";
Expand All @@ -13,6 +14,8 @@ const snapshotAtom = Atom.make((get) =>
buildSubscriptionUsageSnapshot(
get(environmentPresentations.presentationsAtom),
Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }),
// Android scrolls the full list; iOS stores a bounded widget timeline.
Platform.OS === "android" ? Infinity : 6,
),
).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b)));

Expand Down
82 changes: 71 additions & 11 deletions apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,63 @@ describe("subscription widget snapshots", () => {
expect(snapshot.url).toBe(deepLink);
expect(JSON.stringify(snapshot)).not.toContain("private@example.com");
});
it("clears data after removing environments and hides disabled providers", () => {
expect(
buildSubscriptionUsageSnapshot(new Map(), deepLink).providers.every(
(p) => p.windows.length === 0,
),
).toBe(true);
it("clears data after removing environments", () => {
expect(buildSubscriptionUsageSnapshot(new Map(), deepLink).providers).toEqual([]);
});
it.each<{ name: string; overrides: Partial<ServerProvider> }>([
{ name: "disabled", overrides: { enabled: false } },
{
name: "missing",
overrides: { installed: false, status: "error", usageLimits: undefined },
},
{
name: "API-key",
overrides: {
usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } },
},
},
])("hides $name providers", ({ overrides }) => {
expect(
buildSubscriptionUsageSnapshot(
presentations([provider({ enabled: false })]),
deepLink,
).providers.every((p) => p.windows.length === 0),
).toBe(true);
buildSubscriptionUsageSnapshot(presentations([provider(overrides)]), deepLink).providers,
).toEqual([]);
});
it.each([
{ name: "Codex", driver: "codex" },
{ name: "Claude", driver: "claudeAgent" },
])("only shows $name when $name and OpenCode are configured", ({ name, driver }) => {
const snapshot = buildSubscriptionUsageSnapshot(
presentations([
provider({
instanceId: ProviderInstanceId.make(driver),
driver: ProviderDriverKind.make(driver),
}),
provider({
instanceId: ProviderInstanceId.make("opencode"),
driver: ProviderDriverKind.make("opencode"),
usageLimits: undefined,
}),
]),
deepLink,
);
expect(snapshot.providers).toHaveLength(1);
expect(snapshot.providers[0]).toMatchObject({
name,
totalWindows: 1,
windows: [{ remaining: 60 }],
});
expect(subscriptionUsageTimeline(snapshot, now + 15 * 60_000)[0]?.props.providers).toEqual([
expect.objectContaining({ name, totalWindows: 0, windows: [] }),
]);
});
it("keeps an enabled provider visible before its first usage read", () => {
const snapshot = buildSubscriptionUsageSnapshot(
presentations([provider({ usageLimits: undefined })]),
deepLink,
);
expect(snapshot.checkedAt).toBe(0);
expect(snapshot.providers).toEqual([
{ name: "Codex", detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 },
]);
});
it("uses upstream deduplication for a native account also present in a proxy hub", () => {
const input = new Map([
Expand Down Expand Up @@ -151,6 +196,21 @@ describe("subscription widget snapshots", () => {
expect(snapshot.providers[0]?.totalWindows).toBe(20);
expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5);
});
it("includes every limit for the scrollable Android widget", () => {
const windows = Array.from({ length: 20 }, (_, index) => ({
...window,
id: `${index}`,
usedPercent: index * 5,
}));
const snapshot = buildSubscriptionUsageSnapshot(
presentations([provider({ usageLimits: { checkedAt, windows } })]),
deepLink,
Infinity,
);
expect(snapshot.providers[0]?.windows.map((window) => window.remaining)).toEqual(
Array.from({ length: 20 }, (_, index) => 5 + index * 5),
);
});
it("marks unknown or distant reset times stale after fifteen minutes", () => {
const snapshot = buildSubscriptionUsageSnapshot(
presentations([
Expand Down
Loading
Loading