Migrate app to native Kotlin
android-release / build (push) Successful in 3m5s

This commit is contained in:
Space-Banane
2026-07-06 13:49:22 +02:00
parent 81e351aa38
commit 3d6548d77d
86 changed files with 2247 additions and 13199 deletions
-5
View File
@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
-22
View File
@@ -1,22 +0,0 @@
name: Build Android APK
on:
workflow_dispatch:
inputs:
profile:
type: choice
description: EAS build profile to use.
default: production
options:
- preview
- production
jobs:
build_android:
name: Build Android APK
type: build
environment: ${{ inputs.profile }}
params:
platform: android
profile: ${{ inputs.profile }}
message: Android ${{ inputs.profile }} APK from ${{ github.ref_name }}
-57
View File
@@ -1,57 +0,0 @@
name: Publish OTA updates
on:
push:
branches: ['main']
paths:
- 'app/**'
- 'components/**'
- 'hooks/**'
- 'lib/**'
- 'types/**'
- 'assets/**'
- 'App.tsx'
- 'index.ts'
- 'global.css'
- 'tailwind.config.js'
- 'babel.config.js'
- 'metro.config.js'
workflow_dispatch:
inputs:
channel:
type: choice
description: Update channel to publish.
default: production
options:
- preview
- production
environment:
type: choice
description: EAS environment to use for the update job.
default: production
options:
- preview
- production
message:
description: Optional update message override.
required: false
jobs:
publish_main:
if: ${{ github.event_name == 'push' }}
name: Publish production update
type: update
environment: production
params:
channel: production
platform: all
publish_manual:
if: ${{ github.event_name == 'workflow_dispatch' }}
name: Publish selected update
type: update
environment: ${{ inputs.environment }}
params:
channel: ${{ inputs.channel }}
message: ${{ inputs.message }}
platform: all
+11
View File
@@ -0,0 +1,11 @@
* text=auto
*.gradle text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.properties text eol=lf
*.xml text eol=lf
*.yml text eol=lf
*.md text eol=lf
gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
+47 -69
View File
@@ -1,29 +1,21 @@
name: CodexBar Mobile Build
name: android-release
on:
push:
branches:
- main
paths:
- "**/*.ts"
- "**/*.tsx"
- "**/*.js"
- "**/*.json"
- ".gitea/workflows/**"
tags:
- 'v*'
workflow_dispatch:
jobs:
build-android:
build:
runs-on: ubuntu-latest
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v2
- name: Checkout
uses: actions/checkout@v4
- name: 🏗 Setup Node
uses: actions/setup-node@v2
with:
node-version: 22
- name: 🏗 Setup Java
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
@@ -46,62 +38,48 @@ jobs:
- name: Set up Gradle cache
uses: gradle/actions/setup-gradle@v3
- name: 🏗 Setup Expo and EAS
uses: expo/expo-github-action@v8
with:
token: ${{ secrets.EXPO_TOKEN }}
eas-version: latest
packager: npm
- name: 📦 Install dependencies
run: npm ci --legacy-peer-deps
- name: 👷 Build app
- name: Write release keystore
env:
CODEXMOBILE_KEYSTORE_BASE64: ${{ secrets.CODEXMOBILE_KEYSTORE_BASE64 }}
CODEXMOBILE_KEYSTORE_PASSWORD: ${{ secrets.CODEXMOBILE_KEYSTORE_PASSWORD }}
CODEXMOBILE_KEY_ALIAS: ${{ secrets.CODEXMOBILE_KEY_ALIAS }}
CODEXMOBILE_KEY_PASSWORD: ${{ secrets.CODEXMOBILE_KEY_PASSWORD }}
run: |
eas build --local \
--non-interactive \
--output=./app-build \
--platform=android \
--profile=preview
if [ -z "$CODEXMOBILE_KEYSTORE_BASE64" ] || [ -z "$CODEXMOBILE_KEYSTORE_PASSWORD" ] || [ -z "$CODEXMOBILE_KEY_ALIAS" ] || [ -z "$CODEXMOBILE_KEY_PASSWORD" ]; then
echo "Missing release signing secrets" >&2
exit 1
fi
echo "$CODEXMOBILE_KEYSTORE_BASE64" | base64 -d > "$HOME/codexmobile-release.jks"
{
echo "CODEXMOBILE_STORE_FILE=$HOME/codexmobile-release.jks"
echo "CODEXMOBILE_STORE_PASSWORD=$CODEXMOBILE_KEYSTORE_PASSWORD"
echo "CODEXMOBILE_KEY_ALIAS=$CODEXMOBILE_KEY_ALIAS"
echo "CODEXMOBILE_KEY_PASSWORD=$CODEXMOBILE_KEY_PASSWORD"
} >> "$GITHUB_ENV"
- name: 📝 Rename build to APK
run: mv app-build codexbar-release.apk
- name: 🗜 Zip APK
run: zip codexbar-release.zip codexbar-release.apk
- name: 📤 Upload build artifact
uses: actions/upload-artifact@v3
with:
name: codexbar-android-preview-build
path: codexbar-release.zip
if-no-files-found: error
release:
runs-on: ubuntu-latest
needs: [build-android]
steps:
- name: 🏗 Setup repo
uses: actions/checkout@v2
- name: 📥 Download Android artifact
uses: actions/download-artifact@v3
with:
name: codexbar-android-preview-build
- name: 🏷 Create tag
- name: Build release APK
run: |
TAG="codexbar-build-$(git rev-parse --short HEAD)"
git tag "$TAG"
git push origin "$TAG"
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
chmod +x gradlew
./gradlew assembleRelease
- name: 🚀 Create release
uses: softprops/action-gh-release@v1
- name: Compute release tag
run: |
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
echo "RELEASE_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
else
echo "RELEASE_TAG=main-${GITHUB_RUN_NUMBER}-${GITHUB_SHA::7}" >> "$GITHUB_ENV"
fi
- name: Publish Gitea release
uses: akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: --experimental-fetch
with:
tag_name: ${{ env.RELEASE_TAG }}
name: ${{ env.RELEASE_TAG }}
files: codexbar-release.zip
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
target_commitish: ${{ github.sha }}
files: |
app/build/outputs/apk/release/*.apk
body: |
CodexMobile release built from ${{ github.ref_name }} at ${{ github.sha }}.
prerelease: false
draft: false
+9 -34
View File
@@ -1,41 +1,16 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.gradle/
build/
app/build/
local.properties
keystore.properties
.kotlin/
*.orig.*
*.jks
*.keystore
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
*.orig.*
.env.ci
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
.DS_Store
-3
View File
@@ -1,3 +0,0 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
-20
View File
@@ -1,20 +0,0 @@
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});
-70
View File
@@ -1,70 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
## Commands
```bash
npx expo start # Start Expo dev server (scan QR with Expo Go)
npx expo start --android
npx expo start --ios
npx expo start --web
eas build --profile preview --platform android # Build APK via EAS
eas build --profile production --platform android
```
There is no test suite or lint script configured in package.json.
## Architecture
This is an Expo SDK 56 app that monitors AI service usage limits for **Codex CLI** and **Claude.ai**. It fetches usage data from their private REST APIs using credentials the user provides.
### Navigation (Expo Router v3)
```
app/index.tsx → checks onboarding state, redirects
app/onboarding/ → stack: welcome → codex → claude → done
app/(tabs)/
_layout.tsx → bottom tab navigator (4 tabs)
index.tsx → Dashboard: both services side-by-side
codex.tsx → Codex CLI detailed view
claude.tsx → Claude.ai detailed view (not yet in git)
settings.tsx → Settings (not yet in git)
```
### Data Flow
Each service follows the same pattern:
1. **Hook** (`hooks/useCodexUsage.ts`, `hooks/useClaudeUsage.ts`) — manages auth + usage state, persists credentials to SecureStore, exposes `refresh()` / `clear()`.
2. **API module** (`lib/api/codexApi.ts`, `lib/api/claudeApi.ts`) — thin fetch wrappers with no side effects.
3. **Screen** — calls the hook, renders `ServiceStatusCard` or the detailed usage UI.
### Credential Storage
All credentials live in `expo-secure-store` (never AsyncStorage). Keys:
- `codex_auth` — serialized `CodexAuth` object (Bearer token + optional account ID), imported from the user's `auth.json` file via `expo-document-picker`.
- `claude_session_key` — cookie value used as `Cookie: sessionKey=…` on claude.ai API calls.
`TOKEN_EXPIRED` errors automatically clear the stored credential and prompt re-entry.
### Styling
NativeWind v4 (Tailwind CSS for React Native). Brand colors:
- Codex: `#10a37f` (teal)
- Claude: `#d97706` (amber)
Use Tailwind utility classes on all components. Dark mode is supported via `dark:` variants.
### Key Types
- `lib/types/codex.ts``CodexAuth`, `CodexUsageResponse`, `UsageWindow`
- `lib/types/claude.ts``ClaudeOrg`, `ClaudeUsageResponse`, `ClaudeWindowUsage`
### Path Alias
`@/` maps to the repo root (configured in `tsconfig.json`). Use `@/components/...`, `@/lib/...`, `@/hooks/...` everywhere.
+20
View File
@@ -0,0 +1,20 @@
# CodexMobile
Native Android app for monitoring Codex CLI and Claude.ai usage windows, reset credits, and local quota reminders.
## Build
```bash
./gradlew assembleDebug
```
Release builds require signing values from environment variables or `keystore.properties`:
```properties
CODEXMOBILE_STORE_FILE=/path/to/codexmobile-release.jks
CODEXMOBILE_STORE_PASSWORD=...
CODEXMOBILE_KEY_ALIAS=codexmobile-release
CODEXMOBILE_KEY_PASSWORD=...
```
CI secrets are generated into `.env.ci` for copying into Gitea.
-87
View File
@@ -1,87 +0,0 @@
{
"expo": {
"name": "Codexbar",
"slug": "codexbar-mobile",
"description": "Monitor Codex CLI and Claude.ai usage limits, resets, and credits at a glance.",
"scheme": "codexbar",
"version": "1.0.0",
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/9196c5ee-4c6e-4229-9a99-54b50afe38e8"
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"backgroundColor": "#090D11",
"primaryColor": "#10A37F",
"userInterfaceStyle": "automatic",
"ios": {
"bundleIdentifier": "dev.reversed.codexbar",
"buildNumber": "1",
"supportsTablet": true,
"config": {
"usesNonExemptEncryption": false
}
},
"android": {
"package": "dev.reversed.codexbar",
"versionCode": 1,
"adaptiveIcon": {
"backgroundColor": "#090D11",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": true
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/splash-icon.png",
"imageWidth": 220,
"resizeMode": "contain",
"backgroundColor": "#090D11",
"dark": {
"image": "./assets/splash-icon.png",
"backgroundColor": "#090D11"
}
}
],
[
"expo-secure-store",
{
"configureAndroidBackup": true,
"faceIDPermission": "Allow Codexbar to access your securely stored credentials."
}
],
"expo-document-picker",
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#10A37F",
"defaultChannel": "usage-alerts"
}
],
"expo-font"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {},
"eas": {
"projectId": "9196c5ee-4c6e-4229-9a99-54b50afe38e8"
}
},
"owner": "spacebanane"
}
}
-53
View File
@@ -1,53 +0,0 @@
import { Tabs } from "expo-router";
import { useColorScheme } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MaterialIcons } from "@expo/vector-icons";
import { COLORS } from "@/lib/constants";
export default function TabLayout() {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const insets = useSafeAreaInsets();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: COLORS.codex,
tabBarInactiveTintColor: isDark ? "#52525b" : "#a1a1aa",
tabBarStyle: {
backgroundColor: isDark ? "#09090b" : "#ffffff",
borderTopColor: isDark ? "#27272a" : "#f4f4f5",
height: 56 + insets.bottom,
paddingBottom: 8 + insets.bottom,
paddingTop: 6,
},
tabBarLabelStyle: {
fontSize: 10,
fontWeight: "600",
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen name="codex" options={{ href: null }} />
<Tabs.Screen name="claude" options={{ href: null }} />
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="settings" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
-230
View File
@@ -1,230 +0,0 @@
import {
ScrollView,
View,
Text,
TouchableOpacity,
TextInput,
ActivityIndicator,
RefreshControl,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { MaterialIcons } from "@expo/vector-icons";
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
import { UsageStat } from "@/components/UsageStat";
import { ErrorMessage } from "@/components/ErrorMessage";
import { LastUpdated } from "@/components/LastUpdated";
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
import { COLORS } from "@/lib/constants";
function ClaudeTabContent() {
const {
auth,
pendingKey,
setPendingKey,
pendingOrg,
setPendingOrg,
usage,
lastFetchedAt,
status,
error,
keyValidationError,
canSaveKey,
saveKey,
refresh,
clearKey,
} = useClaudeUsage();
return (
<SafeAreaView
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
edges={["top", "bottom"]}
>
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 32 }}
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl
refreshing={status === "loading"}
onRefresh={refresh}
tintColor={COLORS.claude}
/>
}
>
{/* Header */}
<View className="flex-row items-center gap-x-3 py-5">
<View
className="w-9 h-9 rounded-xl items-center justify-center"
style={{ backgroundColor: `${COLORS.claude}22` }}
>
<MaterialIcons name="psychology" size={18} color={COLORS.claude} />
</View>
<View>
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
Claude.ai
</Text>
<Text className="text-xs text-neutral-400 mt-0.5">
Usage windows
</Text>
</View>
</View>
{/* Configuration card */}
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 mb-4">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3">
Configuration
</Text>
<ErrorMessage
message={error && (status === "idle" || status === "error") ? error : null}
/>
{auth ? (
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-x-2.5">
<View className="w-2 h-2 rounded-full bg-green-500" />
<Text className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Connected
</Text>
<Text className="text-xs text-neutral-400 dark:text-neutral-500 font-mono">
{auth.sessionKey.slice(0, 10)}
</Text>
</View>
<TouchableOpacity
onPress={clearKey}
className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800"
>
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Clear
</Text>
</TouchableOpacity>
</View>
) : (
<View>
<TextInput
value={pendingKey}
onChangeText={setPendingKey}
placeholder="sk-ant-… (required)"
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-white dark:bg-neutral-800 mb-2"
/>
{keyValidationError && (
<Text selectable className="mb-2 text-xs text-red-500">
{keyValidationError}
</Text>
)}
<TextInput
value={pendingOrg}
onChangeText={setPendingOrg}
placeholder="lastActiveOrg UUID (optional)"
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-white dark:bg-neutral-800 mb-3"
/>
<TouchableOpacity
onPress={saveKey}
disabled={!canSaveKey}
className="py-3.5 px-4 rounded-xl items-center"
style={{
backgroundColor: canSaveKey ? COLORS.claude : COLORS.disabled,
}}
>
<Text
className="font-semibold text-sm"
style={{ color: canSaveKey ? "white" : COLORS.muted }}
>
Save Session Key
</Text>
</TouchableOpacity>
<View className="bg-neutral-50 dark:bg-neutral-800 rounded-xl p-3.5 mt-3 border border-neutral-100 dark:border-neutral-700">
<Text className="text-xs font-semibold text-neutral-400 uppercase tracking-widest mb-1.5">
Where to find it
</Text>
<Text className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
Chrome DevTools Application Cookies claude.ai copy{" "}
<Text className="font-mono text-neutral-600 dark:text-neutral-300">sessionKey</Text>
{" "}and optionally{" "}
<Text className="font-mono text-neutral-600 dark:text-neutral-300">lastActiveOrg</Text>
</Text>
</View>
</View>
)}
</View>
{/* Usage card */}
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3">
Usage
</Text>
{status === "idle" && !auth && (
<View className="items-center py-8">
<MaterialIcons name="insert-chart-outlined" size={32} color="#d4d4d4" />
<Text className="text-sm text-neutral-400 text-center mt-3">
Enter your session key to see usage windows
</Text>
</View>
)}
{status === "loading" && !usage && (
<ActivityIndicator color={COLORS.claude} style={{ marginVertical: 24 }} />
)}
{status === "error" && error && <ErrorMessage message={error} />}
{usage && (
<View>
<UsageStat
label="5-hour window"
percent={usage.five_hour.utilization}
resetAtISO={usage.five_hour.resets_at}
/>
<UsageStat
label="7-day window"
percent={usage.seven_day.utilization}
resetAtISO={usage.seven_day.resets_at}
/>
{(usage.seven_day_sonnet || usage.seven_day_opus) && (
<View className="mt-1 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">
By Model (7-day)
</Text>
{usage.seven_day_sonnet && (
<UsageStat
label="Sonnet"
percent={usage.seven_day_sonnet.utilization}
/>
)}
{usage.seven_day_opus && (
<UsageStat
label="Opus"
percent={usage.seven_day_opus.utilization}
/>
)}
</View>
)}
<View className="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
<LastUpdated timestamp={lastFetchedAt} />
</View>
</View>
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
export default function ClaudeTab() {
return (
<ScreenErrorBoundary screenName="Claude">
<ClaudeTabContent />
</ScreenErrorBoundary>
);
}
-276
View File
@@ -1,276 +0,0 @@
import {
ScrollView,
View,
Text,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { MaterialIcons } from "@expo/vector-icons";
import { useCodexUsage } from "@/hooks/useCodexUsage";
import { UsageStat } from "@/components/UsageStat";
import { ErrorMessage } from "@/components/ErrorMessage";
import { LastUpdated } from "@/components/LastUpdated";
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
import { COLORS } from "@/lib/constants";
import { windowLabel } from "@/lib/timeUtils";
import type { CodexResetCredit } from "@/types/codex";
function formatResetStatus(status?: string) {
if (!status) return "Unknown";
return status.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function ResetCreditRow({ credit }: { credit: CodexResetCredit }) {
return (
<View className="mt-2 rounded-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-3">
<View className="flex-row items-center justify-between mb-1.5">
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
Credit {credit.index}
</Text>
<View className="px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30">
<Text className="text-[11px] font-semibold text-green-700 dark:text-green-300">
{formatResetStatus(credit.status)}
</Text>
</View>
</View>
{credit.timeUntilExpiry && (
<Text className="text-sm text-neutral-700 dark:text-neutral-300">
Expires in {credit.timeUntilExpiry}
</Text>
)}
{credit.expiresAtLocal && (
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
Expires {credit.expiresAtLocal}
</Text>
)}
{credit.grantedAtLocal && (
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
Granted {credit.grantedAtLocal}
</Text>
)}
</View>
);
}
function CodexTabContent() {
const {
auth,
usage,
lastFetchedAt,
status,
error,
importAuthFile,
refresh,
clearAuth,
} =
useCodexUsage();
const resetCoupons = usage?.resetCoupons;
const availableResetCredits =
resetCoupons?.credits?.filter((credit) => credit.status === "available") ?? [];
return (
<SafeAreaView
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
edges={["top", "bottom"]}
>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 32 }}
refreshControl={
<RefreshControl
refreshing={status === "loading"}
onRefresh={refresh}
tintColor={COLORS.codex}
/>
}
>
{/* Header */}
<View className="flex-row items-center gap-x-3 py-5">
<View
className="w-9 h-9 rounded-xl items-center justify-center"
style={{ backgroundColor: `${COLORS.codex}22` }}
>
<MaterialIcons name="auto-awesome" size={18} color={COLORS.codex} />
</View>
<View>
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
Codex CLI
</Text>
<Text className="text-xs text-neutral-400 mt-0.5">
Rate limits & credits
</Text>
</View>
</View>
{/* Configuration card */}
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4 mb-4">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3">
Configuration
</Text>
<ErrorMessage message={error && status === "idle" ? error : null} />
{auth ? (
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-x-2.5">
<View className="w-2 h-2 rounded-full bg-green-500" />
<Text className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Connected
</Text>
{auth.accountId && (
<Text className="text-xs text-neutral-400 dark:text-neutral-500 font-mono">
{auth.accountId.slice(0, 10)}
</Text>
)}
</View>
<TouchableOpacity
onPress={clearAuth}
className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800"
>
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Clear
</Text>
</TouchableOpacity>
</View>
) : (
<View>
<TouchableOpacity
onPress={importAuthFile}
className="flex-row items-center justify-center gap-x-2 py-3.5 px-4 rounded-xl"
style={{ backgroundColor: COLORS.codex }}
>
<MaterialIcons name="upload-file" size={16} color="white" />
<Text className="text-white font-semibold text-sm">
Import auth.json
</Text>
</TouchableOpacity>
<Text className="text-xs text-neutral-400 text-center mt-2">
Located at ~/.codex/auth.json
</Text>
</View>
)}
</View>
{/* Usage card */}
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-4">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3">
Usage
</Text>
{status === "idle" && !auth && (
<View className="items-center py-8">
<MaterialIcons name="insert-chart-outlined" size={32} color="#d4d4d4" />
<Text className="text-sm text-neutral-400 text-center mt-3">
Import auth.json to see your rate limits
</Text>
</View>
)}
{status === "loading" && !usage && (
<ActivityIndicator color={COLORS.codex} style={{ marginVertical: 24 }} />
)}
{status === "error" && error && <ErrorMessage message={error} />}
{usage && (
<View>
{/* Plan badge */}
<View className="flex-row items-center mb-5">
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/40">
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
{usage.planType ?? "codex"}
</Text>
</View>
</View>
{usage.primary && (
<UsageStat
label={`Primary (${windowLabel(usage.primary.windowSeconds)})`}
percent={usage.primary.usedPercent}
resetAtSeconds={usage.primary.resetsAt}
/>
)}
{usage.secondary && (
<UsageStat
label={`Secondary (${windowLabel(usage.secondary.windowSeconds)})`}
percent={usage.secondary.usedPercent}
resetAtSeconds={usage.secondary.resetsAt}
/>
)}
{/* Credits */}
<View className="mt-1 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-2">
Credits
</Text>
{usage.credits.unlimited ? (
<View className="flex-row items-center gap-x-2">
<MaterialIcons name="all-inclusive" size={16} color={COLORS.codex} />
<Text className="text-sm font-semibold text-green-600 dark:text-green-400">
Unlimited
</Text>
</View>
) : usage.credits.hasCredits ? (
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
${Number(usage.credits.balance).toFixed(2)} remaining
</Text>
) : (
<Text className="text-sm text-neutral-400">No credits</Text>
)}
</View>
{resetCoupons && (
<View className="mt-3 p-3.5 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-100 dark:border-neutral-700">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-2">
Reset Credits
</Text>
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
{resetCoupons.availableCount ?? 0} available
</Text>
{typeof resetCoupons.totalEarnedCount === "number" && (
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{resetCoupons.totalEarnedCount} earned total
</Text>
)}
{resetCoupons.nextExpiringCredit?.expiresAtLocal && (
<Text className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
Next expires {resetCoupons.nextExpiringCredit.expiresAtLocal}
</Text>
)}
{resetCoupons.nextExpiringCredit?.timeUntilExpiry && (
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-0.5">
In {resetCoupons.nextExpiringCredit.timeUntilExpiry}
</Text>
)}
{availableResetCredits.length > 0 && (
<View className="mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-1">
Available Now
</Text>
{availableResetCredits.map((credit) => (
<ResetCreditRow key={`${credit.index}-${credit.expiresAt ?? "none"}`} credit={credit} />
))}
</View>
)}
{resetCoupons.source !== "live_api" && (
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
Source: {resetCoupons.sourceDescription}
</Text>
)}
</View>
)}
<View className="mt-4 border-t border-neutral-100 pt-3 dark:border-neutral-800">
<LastUpdated timestamp={lastFetchedAt} />
</View>
</View>
)}
</View>
</ScrollView>
</SafeAreaView>
);
}
export default function CodexTab() {
return (
<ScreenErrorBoundary screenName="Codex">
<CodexTabContent />
</ScreenErrorBoundary>
);
}
-338
View File
@@ -1,338 +0,0 @@
import {
ScrollView,
View,
Text,
TouchableOpacity,
RefreshControl,
ActivityIndicator,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useEffect } from "react";
import { MaterialIcons } from "@expo/vector-icons";
import { useCodexUsage } from "@/hooks/useCodexUsage";
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
import { onUsageDataLoaded } from "@/lib/notifications";
import { ProgressBar } from "@/components/ProgressBar";
import { ResetCountdown } from "@/components/ResetCountdown";
import { LastUpdated } from "@/components/LastUpdated";
import { ErrorMessage } from "@/components/ErrorMessage";
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
import { COLORS, getUsageColor } from "@/lib/constants";
import { windowLabel } from "@/lib/timeUtils";
function UsageRow({
label,
percent,
resetAtSeconds,
resetAtISO,
}: {
label: string;
percent: number;
resetAtSeconds?: number;
resetAtISO?: string;
}) {
const color = getUsageColor(percent);
return (
<View className="mb-4">
<View className="flex-row justify-between items-baseline mb-1.5">
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
{label}
</Text>
<Text className="text-sm font-bold" style={{ color }}>
{Math.round(percent)}% used
</Text>
</View>
<ProgressBar percent={percent} />
<ResetCountdown resetAtSeconds={resetAtSeconds} resetAtISO={resetAtISO} />
</View>
);
}
function SetupPrompt({ label }: { label: string }) {
return (
<View>
<Text className="text-sm text-neutral-400 dark:text-neutral-500 mb-3">
{label}
</Text>
<TouchableOpacity
onPress={() => router.navigate("/(tabs)/settings")}
className="flex-row items-center self-start gap-x-1.5 px-3 py-2 rounded-xl border border-neutral-200 dark:border-neutral-700"
>
<Text className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
Set up in Settings
</Text>
<MaterialIcons name="arrow-forward" size={12} color="#737373" />
</TouchableOpacity>
</View>
);
}
function DashboardTabContent() {
const codex = useCodexUsage();
const claude = useClaudeUsage();
// Fire daily digest reschedule + threshold check whenever fresh data arrives
useEffect(() => {
if (codex.usage || claude.usage) {
void onUsageDataLoaded(
claude.usage,
codex.usage
);
}
}, [codex.lastFetchedAt, claude.lastFetchedAt]);
useFocusEffect(
useCallback(() => {
void codex.reloadCredentials();
void claude.reloadCredentials();
}, [codex.reloadCredentials, claude.reloadCredentials])
);
const isRefreshing =
codex.status === "loading" || claude.status === "loading";
const handleRefresh = () => {
void codex.refresh();
void claude.refresh();
};
const connectedCount = [codex.auth, claude.auth].filter(Boolean).length;
return (
<SafeAreaView
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
edges={["top"]}
>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={COLORS.codex}
/>
}
>
{/* Header */}
<View className="flex-row items-center justify-between pt-6 pb-5">
<View className="flex-row items-center gap-x-3">
<View className="w-10 h-10 rounded-2xl bg-emerald-500 items-center justify-center">
<MaterialIcons name="bar-chart" size={20} color="white" />
</View>
<View>
<Text className="text-xl font-bold text-neutral-900 dark:text-white tracking-tight">
Codexbar
</Text>
<Text className="text-xs text-neutral-400 mt-0.5">
{connectedCount === 0
? "No services connected"
: connectedCount === 2
? "2 services connected"
: "1 of 2 services connected"}
</Text>
</View>
</View>
<TouchableOpacity
onPress={handleRefresh}
className="w-9 h-9 rounded-xl bg-neutral-100 dark:bg-neutral-800 items-center justify-center"
>
<MaterialIcons name="refresh" size={18} color={COLORS.codex} />
</TouchableOpacity>
</View>
{/* Codex card */}
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
<View style={{ height: 3, backgroundColor: COLORS.codex }} />
<View className="p-4">
<View className="flex-row items-center justify-between mb-4">
<View className="flex-row items-center gap-x-2.5">
<View
className="w-8 h-8 rounded-xl items-center justify-center"
style={{ backgroundColor: `${COLORS.codex}18` }}
>
<MaterialIcons name="auto-awesome" size={16} color={COLORS.codex} />
</View>
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
Codex CLI
</Text>
</View>
<View className="flex-row items-center gap-x-2">
{codex.usage?.planType && (
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/30">
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
{codex.usage.planType}
</Text>
</View>
)}
{codex.auth && codex.status === "error" && (
<View className="w-2 h-2 rounded-full bg-red-500" />
)}
{codex.status === "success" && !codex.usage?.planType && (
<View className="w-2 h-2 rounded-full bg-green-500" />
)}
</View>
</View>
{!codex.auth && (
<SetupPrompt label="Import auth.json to see your Codex rate limits." />
)}
{codex.auth && codex.status === "loading" && !codex.usage && (
<ActivityIndicator
color={COLORS.codex}
style={{ marginVertical: 16 }}
/>
)}
{codex.auth && codex.status === "error" && (
<ErrorMessage message={codex.error} />
)}
{codex.usage && (
<View>
{codex.usage.primary && (
<UsageRow
label={`${windowLabel(codex.usage.primary.windowSeconds)} window`}
percent={codex.usage.primary.usedPercent}
resetAtSeconds={codex.usage.primary.resetsAt}
/>
)}
{codex.usage.secondary && (
<UsageRow
label={`${windowLabel(codex.usage.secondary.windowSeconds)} window`}
percent={codex.usage.secondary.usedPercent}
resetAtSeconds={codex.usage.secondary.resetsAt}
/>
)}
{!codex.usage.credits.unlimited &&
codex.usage.credits.hasCredits && (
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
<MaterialIcons name="toll" size={14} color="#a3a3a3" />
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
${Number(codex.usage.credits.balance).toFixed(2)}{" "}
credits remaining
</Text>
</View>
)}
{codex.usage.credits.unlimited && (
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800 flex-row items-center gap-x-2">
<MaterialIcons
name="all-inclusive"
size={14}
color={COLORS.codex}
/>
<Text className="text-xs text-green-600 dark:text-green-400">
Unlimited credits
</Text>
</View>
)}
<View className="mt-3 border-t border-neutral-100 pt-3 dark:border-neutral-800">
{!!codex.usage.resetCoupons && (
<View className="mb-3">
<View className="flex-row items-center gap-x-2">
<MaterialIcons name="restart-alt" size={14} color={COLORS.codex} />
<Text className="text-xs text-neutral-500 dark:text-neutral-400">
{codex.usage.resetCoupons.availableCount ?? 0} reset credits
available
</Text>
</View>
{!!codex.usage.resetCoupons.nextExpiringCredit?.timeUntilExpiry && (
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
Next expires in {codex.usage.resetCoupons.nextExpiringCredit.timeUntilExpiry}
</Text>
)}
</View>
)}
<LastUpdated timestamp={codex.lastFetchedAt} />
</View>
</View>
)}
</View>
</View>
{/* Claude card */}
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 mb-4 overflow-hidden">
<View style={{ height: 3, backgroundColor: COLORS.claude }} />
<View className="p-4">
<View className="flex-row items-center justify-between mb-4">
<View className="flex-row items-center gap-x-2.5">
<View
className="w-8 h-8 rounded-xl items-center justify-center"
style={{ backgroundColor: `${COLORS.claude}18` }}
>
<MaterialIcons name="psychology" size={16} color={COLORS.claude} />
</View>
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
Claude.ai
</Text>
</View>
{claude.auth && claude.status === "error" && (
<View className="w-2 h-2 rounded-full bg-red-500" />
)}
{claude.status === "success" && (
<View className="w-2 h-2 rounded-full bg-green-500" />
)}
</View>
{!claude.auth && (
<SetupPrompt label="Add your session key to see Claude usage windows." />
)}
{claude.auth && claude.status === "loading" && !claude.usage && (
<ActivityIndicator
color={COLORS.claude}
style={{ marginVertical: 16 }}
/>
)}
{claude.auth && claude.status === "error" && (
<ErrorMessage message={claude.error} />
)}
{claude.usage && (
<View>
<UsageRow
label="5-hour window"
percent={claude.usage.five_hour.utilization}
resetAtISO={claude.usage.five_hour.resets_at}
/>
<UsageRow
label="7-day window"
percent={claude.usage.seven_day.utilization}
resetAtISO={claude.usage.seven_day.resets_at}
/>
{(claude.usage.seven_day_sonnet ||
claude.usage.seven_day_opus) && (
<View className="pt-3 border-t border-neutral-100 dark:border-neutral-800">
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">
By Model (7-day)
</Text>
{claude.usage.seven_day_sonnet && (
<UsageRow
label="Sonnet"
percent={claude.usage.seven_day_sonnet.utilization}
/>
)}
{claude.usage.seven_day_opus && (
<UsageRow
label="Opus"
percent={claude.usage.seven_day_opus.utilization}
/>
)}
</View>
)}
<View className="mt-3 border-t border-neutral-100 pt-3 dark:border-neutral-800">
<LastUpdated timestamp={claude.lastFetchedAt} />
</View>
</View>
)}
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
export default function DashboardTab() {
return (
<ScreenErrorBoundary screenName="Dashboard">
<DashboardTabContent />
</ScreenErrorBoundary>
);
}
-580
View File
@@ -1,580 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import {
View,
Text,
TouchableOpacity,
Alert,
ScrollView,
TextInput,
Switch,
KeyboardAvoidingView,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router, useFocusEffect } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons";
import {
loadCodexAuth,
clearCodexAuth,
saveCodexAuth,
loadClaudeSessionKey,
clearClaudeSessionKey,
saveClaudeSessionKey,
saveClaudeLastActiveOrg,
clearClaudeLastActiveOrg,
} from "@/lib/storage";
import { pickAndReadCodexAuth } from "@/lib/fileReader";
import { resetOnboarding } from "@/lib/setupState";
import { useNotificationSettings } from "@/hooks/useNotificationSettings";
import {
isClaudeSessionKeyValid,
validateClaudeSessionKey,
} from "@/lib/claudeCredentials";
import { COLORS } from "@/lib/constants";
import { formatTime, parseTimeInput } from "@/lib/timeUtils";
import { ScreenErrorBoundary } from "@/components/ScreenErrorBoundary";
import type { CodexAuth } from "@/types/codex";
import Constants from "expo-constants";
function humanizeImportError(code: string): string {
if (code === "DOCUMENT_NOT_READABLE") {
return "The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.";
}
return code;
}
function SettingRow({
icon,
label,
value,
onPress,
destructive,
disabled,
}: {
icon: React.ComponentProps<typeof MaterialIcons>["name"];
label: string;
value?: string;
onPress?: () => void;
destructive?: boolean;
disabled?: boolean;
}) {
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled}
activeOpacity={onPress ? 0.7 : 1}
className="flex-row items-center gap-x-3 py-3.5 px-4"
>
<View className="w-8 items-center">
<MaterialIcons
name={icon}
size={20}
color={destructive ? "#ef4444" : disabled ? "#a3a3a3" : "#737373"}
/>
</View>
<View className="flex-1">
<Text
className={`text-sm font-medium ${
destructive
? "text-red-500"
: disabled
? "text-neutral-400 dark:text-neutral-600"
: "text-neutral-800 dark:text-neutral-100"
}`}
>
{label}
</Text>
{value && (
<Text className="text-xs text-neutral-400 dark:text-neutral-500 mt-0.5 font-mono">
{value}
</Text>
)}
</View>
{onPress && !disabled && (
<MaterialIcons
name="chevron-right"
size={18}
color={destructive ? "#ef4444" : "#d4d4d4"}
/>
)}
</TouchableOpacity>
);
}
function SectionHeader({ title }: { title: string }) {
return (
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 px-4 pt-5 pb-1.5">
{title}
</Text>
);
}
function SectionCard({ children }: { children: React.ReactNode }) {
return (
<View className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 mx-4 overflow-hidden">
{children}
</View>
);
}
function Divider() {
return <View className="ml-16 mr-0 h-px bg-neutral-100 dark:bg-neutral-800" />;
}
function SettingsTabContent() {
const [codexAuth, setCodexAuth] = useState<CodexAuth | null>(null);
const [claudeKey, setClaudeKey] = useState<string | null>(null);
const [pendingKey, setPendingKey] = useState("");
const [pendingOrg, setPendingOrg] = useState("");
const [claudeError, setClaudeError] = useState<string | null>(null);
const notif = useNotificationSettings();
const [timeInput, setTimeInput] = useState("");
const [timeError, setTimeError] = useState<string | null>(null);
const [thresholdInput, setThresholdInput] = useState("");
// Sync local text inputs when settings load
useEffect(() => {
if (notif.loaded) {
setTimeInput(formatTime(notif.settings.dailyHour, notif.settings.dailyMinute));
setThresholdInput(String(notif.settings.thresholdPct));
}
}, [notif.loaded]);
const reload = useCallback(() => {
Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then(
([codex, claude]) => {
setCodexAuth(codex);
setClaudeKey(claude);
}
);
}, []);
useEffect(() => {
reload();
}, [reload]);
useFocusEffect(
useCallback(() => {
reload();
}, [reload])
);
const handleImportCodex = async () => {
try {
const auth = await pickAndReadCodexAuth();
await saveCodexAuth(auth);
setCodexAuth(auth);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
if (msg !== "PICKER_CANCELLED") {
Alert.alert("Import failed", humanizeImportError(msg));
}
}
};
const handleSaveClaude = async () => {
const trimmed = pendingKey.trim();
const validationError = validateClaudeSessionKey(trimmed);
if (validationError) {
setClaudeError(validationError);
return;
}
const orgTrimmed = pendingOrg.trim() || undefined;
await saveClaudeSessionKey(trimmed);
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
else await clearClaudeLastActiveOrg();
setClaudeKey(trimmed);
setPendingKey("");
setPendingOrg("");
setClaudeError(null);
};
const handleClearCodex = () => {
Alert.alert(
"Clear Codex credentials",
"This will remove your stored auth.json data. You can re-import it anytime.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Clear",
style: "destructive",
onPress: async () => {
await clearCodexAuth();
setCodexAuth(null);
},
},
]
);
};
const handleClearClaude = () => {
Alert.alert(
"Clear Claude session key",
"Your session key will be removed. You can re-enter it anytime.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Clear",
style: "destructive",
onPress: async () => {
await clearClaudeSessionKey();
setClaudeKey(null);
},
},
]
);
};
const handleResetSetup = () => {
Alert.alert(
"Re-run Setup Wizard",
"This will clear all credentials and restart the setup flow.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Reset & Re-run",
style: "destructive",
onPress: async () => {
await Promise.all([
clearCodexAuth(),
clearClaudeSessionKey(),
resetOnboarding(),
]);
router.replace("/onboarding");
},
},
]
);
};
const handleTimeBlur = useCallback(() => {
const parsed = parseTimeInput(timeInput);
if (parsed) {
void notif.update({ dailyHour: parsed.hour, dailyMinute: parsed.minute });
setTimeInput(formatTime(parsed.hour, parsed.minute));
setTimeError(null);
} else {
setTimeError("Use HH:MM format, from 00:00 to 23:59.");
}
}, [timeInput, notif]);
const handleThresholdBlur = useCallback(() => {
const n = parseInt(thresholdInput, 10);
if (!isNaN(n) && n >= 1 && n <= 99) {
void notif.update({ thresholdPct: n });
} else {
setThresholdInput(String(notif.settings.thresholdPct));
}
}, [thresholdInput, notif]);
const canSaveClaude = isClaudeSessionKeyValid(pendingKey);
const inlineClaudeError =
claudeError ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null);
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
return (
<SafeAreaView
className="flex-1 bg-neutral-50 dark:bg-neutral-950"
edges={["top", "bottom"]}
>
<KeyboardAvoidingView
className="flex-1"
behavior={process.env.EXPO_OS === "ios" ? "padding" : "height"}
>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 48 }}
keyboardShouldPersistTaps="handled"
>
{/* Header */}
<View className="px-4 py-5">
<Text className="text-2xl font-bold text-neutral-900 dark:text-white tracking-tight">
Settings
</Text>
</View>
{/* Codex CLI section */}
<SectionHeader title="Codex CLI" />
<SectionCard>
{codexAuth ? (
<>
<SettingRow
icon="check-circle"
label="Connected"
value={
codexAuth.accountId
? `${codexAuth.accountId.slice(0, 16)}`
: undefined
}
disabled
/>
<Divider />
<SettingRow
icon="delete-outline"
label="Clear credentials"
onPress={handleClearCodex}
destructive
/>
</>
) : (
<SettingRow
icon="upload-file"
label="Import auth.json"
onPress={handleImportCodex}
/>
)}
</SectionCard>
{/* Claude.ai section */}
<SectionHeader title="Claude.ai" />
<SectionCard>
{claudeKey ? (
<>
<SettingRow
icon="check-circle"
label="Connected"
value={`${claudeKey.slice(0, 16)}`}
disabled
/>
<Divider />
<SettingRow
icon="delete-outline"
label="Clear session key"
onPress={handleClearClaude}
destructive
/>
</>
) : (
<View className="p-4">
{inlineClaudeError && (
<Text selectable className="text-xs text-red-500 mb-2">
{inlineClaudeError}
</Text>
)}
<TextInput
value={pendingKey}
onChangeText={(t) => {
setPendingKey(t);
setClaudeError(null);
}}
placeholder="sk-ant-… (required)"
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-neutral-50 dark:bg-neutral-800 mb-2"
/>
<TextInput
value={pendingOrg}
onChangeText={setPendingOrg}
placeholder="lastActiveOrg UUID (optional)"
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-3 text-sm text-neutral-900 dark:text-white bg-neutral-50 dark:bg-neutral-800 mb-3"
/>
<TouchableOpacity
onPress={handleSaveClaude}
disabled={!canSaveClaude}
className="py-3 px-4 rounded-xl items-center"
style={{
backgroundColor: canSaveClaude
? COLORS.claude
: COLORS.disabled,
}}
>
<Text
className="font-semibold text-sm"
style={{ color: canSaveClaude ? "white" : COLORS.muted }}
>
Save Session Key
</Text>
</TouchableOpacity>
<Text className="text-xs text-neutral-400 text-center mt-3 leading-relaxed">
Chrome DevTools Application Cookies claude.ai {" "}
<Text className="font-mono text-neutral-500">sessionKey</Text>
</Text>
</View>
)}
</SectionCard>
{/* Notifications section */}
<SectionHeader title="Notifications" />
<SectionCard>
{notif.permissionStatus !== "granted" ? (
<TouchableOpacity
onPress={async () => {
const granted = await notif.askPermissions();
if (!granted && notif.permissionStatus === "denied") {
Alert.alert(
"Notifications blocked",
"Go to Settings → CodexBar → Notifications to enable them."
);
}
}}
className="flex-row items-center gap-x-3 py-3.5 px-4"
>
<View className="w-8 items-center">
<MaterialIcons name="notifications-off" size={20} color="#737373" />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Enable notifications
</Text>
<Text className="text-xs text-neutral-400 mt-0.5">
{notif.permissionStatus === "denied"
? "Blocked — open system Settings to allow"
: "Required to receive alerts"}
</Text>
</View>
{notif.permissionStatus !== "denied" && (
<View className="px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30">
<Text className="text-xs font-semibold text-amber-700 dark:text-amber-300">
Allow
</Text>
</View>
)}
</TouchableOpacity>
) : (
<>
{/* Daily digest */}
<View className="flex-row items-center gap-x-3 py-3.5 px-4">
<View className="w-8 items-center">
<MaterialIcons name="alarm" size={20} color="#737373" />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Daily digest
</Text>
<Text className="text-xs text-neutral-400 mt-0.5">
Daily reminder with last-known usage
</Text>
</View>
<Switch
value={notif.settings.dailyEnabled}
onValueChange={(v) => void notif.update({ dailyEnabled: v })}
trackColor={{ false: COLORS.disabled, true: COLORS.codex }}
thumbColor="white"
/>
</View>
{notif.settings.dailyEnabled && (
<>
<Divider />
<View className="px-4 py-3">
<View className="flex-row items-center gap-x-3">
<View className="w-8 items-center">
<MaterialIcons name="schedule" size={20} color="#737373" />
</View>
<Text className="flex-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
Time
</Text>
<TextInput
value={timeInput}
onChangeText={(value) => {
setTimeInput(value);
setTimeError(null);
}}
onBlur={handleTimeBlur}
placeholder="09:00"
placeholderTextColor={COLORS.muted}
keyboardType="numbers-and-punctuation"
returnKeyType="done"
maxLength={5}
className="text-sm font-mono text-neutral-600 dark:text-neutral-300 text-right"
style={{ minWidth: 52 }}
/>
</View>
{timeError && (
<Text selectable className="ml-11 mt-1.5 text-xs text-red-500">
{timeError}
</Text>
)}
</View>
</>
)}
<Divider />
{/* Threshold alert */}
<View className="flex-row items-center gap-x-3 py-3.5 px-4">
<View className="w-8 items-center">
<MaterialIcons name="warning" size={20} color="#737373" />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Low quota alert
</Text>
<Text className="text-xs text-neutral-400 mt-0.5">
Alert when weekly quota runs low
</Text>
</View>
<Switch
value={notif.settings.thresholdEnabled}
onValueChange={(v) => void notif.update({ thresholdEnabled: v })}
trackColor={{ false: COLORS.disabled, true: COLORS.claude }}
thumbColor="white"
/>
</View>
{notif.settings.thresholdEnabled && (
<>
<Divider />
<View className="flex-row items-center gap-x-3 py-3 px-4">
<View className="w-8 items-center">
<MaterialIcons name="battery-alert" size={20} color="#737373" />
</View>
<Text className="flex-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
Alert below
</Text>
<View className="flex-row items-center gap-x-1">
<TextInput
value={thresholdInput}
onChangeText={setThresholdInput}
onBlur={handleThresholdBlur}
keyboardType="numeric"
returnKeyType="done"
maxLength={2}
className="text-sm font-mono text-neutral-600 dark:text-neutral-300 text-right"
style={{ minWidth: 28 }}
/>
<Text className="text-sm text-neutral-400 dark:text-neutral-500">
% remaining
</Text>
</View>
</View>
</>
)}
</>
)}
</SectionCard>
{/* App section */}
<SectionHeader title="App" />
<SectionCard>
<SettingRow
icon="info-outline"
label="Version"
value={appVersion}
disabled
/>
<Divider />
<SettingRow
icon="restart-alt"
label="Re-run Setup Wizard"
onPress={handleResetSetup}
destructive
/>
</SectionCard>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
export default function SettingsTab() {
return (
<ScreenErrorBoundary screenName="Settings">
<SettingsTabContent />
</ScreenErrorBoundary>
);
}
-14
View File
@@ -1,14 +0,0 @@
import "../global.css";
import { Stack } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import "@/lib/notifications"; // registers setNotificationHandler on app start
export default function RootLayout() {
return (
<SafeAreaProvider>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaProvider>
);
}
+140
View File
@@ -0,0 +1,140 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
def getVersionCode = {
def stdout = new ByteArrayOutputStream()
try {
exec {
commandLine 'git', 'rev-list', '--count', 'HEAD'
standardOutput = stdout
errorOutput = new ByteArrayOutputStream()
ignoreExitValue = true
}
def count = stdout.toString().trim()
return count.isInteger() ? count.toInteger() : 1
} catch (ignored) {
return 1
}
}
def signingPropertiesFile = rootProject.file('keystore.properties')
def signingProperties = new Properties()
if (signingPropertiesFile.exists()) {
signingPropertiesFile.withInputStream(signingProperties.&load)
}
def readSigningValue = { String... keys ->
keys.collect { key -> System.getenv(key) ?: signingProperties.getProperty(key) }
.find { value -> value != null && !value.trim().isEmpty() }
}
def releaseKeystorePath = readSigningValue('CODEXMOBILE_STORE_FILE', 'CODEXMOBILE_KEYSTORE_FILE')
def releaseKeystorePassword = readSigningValue('CODEXMOBILE_STORE_PASSWORD', 'CODEXMOBILE_KEYSTORE_PASSWORD')
def releaseKeyAlias = readSigningValue('CODEXMOBILE_KEY_ALIAS')
def releaseKeyPassword = readSigningValue('CODEXMOBILE_KEY_PASSWORD')
def hasReleaseSigning = [releaseKeystorePath, releaseKeystorePassword, releaseKeyAlias, releaseKeyPassword].every { it }
def releaseSigningHelp = '''Release signing is not configured.
Provide these values either as environment variables or in keystore.properties:
- CODEXMOBILE_STORE_FILE or CODEXMOBILE_KEYSTORE_FILE
- CODEXMOBILE_STORE_PASSWORD or CODEXMOBILE_KEYSTORE_PASSWORD
- CODEXMOBILE_KEY_ALIAS
- CODEXMOBILE_KEY_PASSWORD
'''
android {
namespace 'dev.reversed.codexbarmobile'
compileSdk 34
signingConfigs {
if (hasReleaseSigning) {
release {
storeFile rootProject.file(releaseKeystorePath)
storePassword releaseKeystorePassword
keyAlias releaseKeyAlias
keyPassword releaseKeyPassword
}
}
}
defaultConfig {
applicationId 'dev.reversed.codexbarmobile'
minSdk 26
targetSdk 34
versionCode getVersionCode()
versionName '1.0.0'
vectorDrawables.useSupportLibrary = true
}
buildTypes {
debug {
applicationIdSuffix '.debug'
versionNameSuffix '-debug'
}
release {
if (hasReleaseSigning) {
signingConfig signingConfigs.release
}
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.14'
}
packaging {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
gradle.taskGraph.whenReady { graph ->
def needsSignedRelease = graph.allTasks.any { task ->
task.project == project &&
task.name.toLowerCase().contains('release') &&
!task.name.toLowerCase().contains('lint') &&
!task.name.toLowerCase().contains('unittest') &&
!task.name.toLowerCase().contains('signingreport')
}
if (needsSignedRelease && !hasReleaseSigning) {
throw new GradleException(releaseSigningHelp)
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4'
implementation 'androidx.activity:activity-compose:1.9.2'
implementation platform('androidx.compose:compose-bom:2024.06.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'com.google.errorprone:error_prone_annotations:2.28.0'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
debugImplementation 'androidx.compose.ui:ui-tooling'
}
-19
View File
@@ -1,19 +0,0 @@
import { useEffect } from "react";
import { View, ActivityIndicator } from "react-native";
import { router } from "expo-router";
import { hasCompletedOnboarding } from "@/lib/setupState";
import { COLORS } from "@/lib/constants";
export default function Index() {
useEffect(() => {
hasCompletedOnboarding().then((done) => {
router.replace(done ? "/(tabs)" : "/onboarding");
});
}, []);
return (
<View className="flex-1 bg-neutral-950 items-center justify-center">
<ActivityIndicator color={COLORS.codex} size="large" />
</View>
);
}
-12
View File
@@ -1,12 +0,0 @@
import { Stack } from "expo-router";
export default function OnboardingLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: "slide_from_right",
}}
/>
);
}
-236
View File
@@ -1,236 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import {
View,
Text,
TouchableOpacity,
TextInput,
KeyboardAvoidingView,
ScrollView,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons";
import {
saveClaudeSessionKey,
saveClaudeLastActiveOrg,
clearClaudeLastActiveOrg,
loadClaudeSessionKey,
loadClaudeLastActiveOrg,
} from "@/lib/storage";
import {
isClaudeSessionKeyValid,
validateClaudeSessionKey,
} from "@/lib/claudeCredentials";
import { COLORS } from "@/lib/constants";
export default function OnboardingClaudeScreen() {
const [savedKey, setSavedKey] = useState<string | null>(null);
const [savedOrg, setSavedOrg] = useState<string | null>(null);
const [pendingKey, setPendingKey] = useState("");
const [pendingOrg, setPendingOrg] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
Promise.all([loadClaudeSessionKey(), loadClaudeLastActiveOrg()]).then(
([k, o]) => {
if (k) setSavedKey(k);
if (o) setSavedOrg(o);
}
);
}, []);
const canSave = isClaudeSessionKeyValid(pendingKey);
const inlineError =
error ?? (pendingKey ? validateClaudeSessionKey(pendingKey) : null);
const handleSave = useCallback(async () => {
const trimmed = pendingKey.trim();
const validationError = validateClaudeSessionKey(trimmed);
if (validationError) {
setError(validationError);
return;
}
const orgTrimmed = pendingOrg.trim() || undefined;
await saveClaudeSessionKey(trimmed);
if (orgTrimmed) await saveClaudeLastActiveOrg(orgTrimmed);
else await clearClaudeLastActiveOrg();
setSavedKey(trimmed);
setSavedOrg(orgTrimmed ?? null);
setPendingKey("");
setPendingOrg("");
setError(null);
}, [pendingKey, pendingOrg]);
return (
<SafeAreaView className="flex-1 bg-neutral-950">
<KeyboardAvoidingView
className="flex-1"
behavior={process.env.EXPO_OS === "ios" ? "padding" : "height"}
>
<ScrollView
className="flex-1"
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
>
<View className="flex-1 px-8 justify-between py-8">
{/* Top: back + step indicator */}
<View className="flex-row items-center justify-between">
<TouchableOpacity
onPress={() => router.back()}
className="w-9 h-9 rounded-xl bg-neutral-900 items-center justify-center"
>
<MaterialIcons name="arrow-back" size={18} color="#a3a3a3" />
</TouchableOpacity>
<View className="flex-row items-center gap-x-2">
<View
className="w-6 h-6 rounded-full items-center justify-center"
style={{ backgroundColor: COLORS.codex }}
>
<MaterialIcons name="check" size={14} color="white" />
</View>
<View className="w-12 h-0.5" style={{ backgroundColor: COLORS.claude }} />
<View
className="w-6 h-6 rounded-full items-center justify-center"
style={{ backgroundColor: COLORS.claude }}
>
<Text className="text-white text-xs font-bold">2</Text>
</View>
</View>
<View className="w-9" />
</View>
{/* Content */}
<View>
<View
className="w-16 h-16 rounded-2xl items-center justify-center mb-6"
style={{ backgroundColor: `${COLORS.claude}22` }}
>
<MaterialIcons name="psychology" size={32} color={COLORS.claude} />
</View>
<Text className="text-3xl font-bold text-white mb-3">
Connect Claude.ai
</Text>
<Text className="text-neutral-400 text-base leading-relaxed mb-8">
Enter your session key to monitor your Claude.ai usage windows.
</Text>
{savedKey ? (
<View className="bg-neutral-900 rounded-2xl border border-neutral-800 mb-4 overflow-hidden">
<View className="flex-row items-center gap-x-3 p-4">
<View
className="w-10 h-10 rounded-xl items-center justify-center"
style={{ backgroundColor: `${COLORS.claude}22` }}
>
<MaterialIcons name="check-circle" size={22} color={COLORS.claude} />
</View>
<View className="flex-1">
<Text className="text-white font-semibold">Connected</Text>
<Text className="text-neutral-500 text-sm font-mono">
{savedKey.slice(0, 16)}
</Text>
</View>
<TouchableOpacity
onPress={() => {
setSavedKey(null);
setSavedOrg(null);
setPendingKey("");
setPendingOrg("");
}}
>
<Text className="text-neutral-400 text-xs">Change</Text>
</TouchableOpacity>
</View>
{savedOrg && (
<View className="border-t border-neutral-800 px-4 py-3">
<Text className="text-neutral-500 text-xs font-mono">
org: {savedOrg.slice(0, 8)}
</Text>
</View>
)}
</View>
) : (
<View>
{inlineError && (
<View className="bg-red-950 border border-red-900 rounded-xl px-4 py-3 mb-4">
<Text selectable className="text-red-400 text-sm">{inlineError}</Text>
</View>
)}
<TextInput
value={pendingKey}
onChangeText={(t) => {
setPendingKey(t);
setError(null);
}}
placeholder="sk-ant-… (required)"
placeholderTextColor="#525252"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3"
/>
<TextInput
value={pendingOrg}
onChangeText={setPendingOrg}
placeholder="lastActiveOrg UUID (optional)"
placeholderTextColor="#525252"
autoCapitalize="none"
autoCorrect={false}
className="border border-neutral-800 rounded-xl px-4 py-3.5 text-sm text-white bg-neutral-900 mb-3"
/>
<TouchableOpacity
onPress={handleSave}
disabled={!canSave}
className="rounded-2xl py-4 items-center mb-4"
style={{
backgroundColor: canSave ? COLORS.claude : "#1a1a1a",
}}
>
<Text
className="font-bold text-base"
style={{ color: canSave ? "white" : "#525252" }}
>
Save Session Key
</Text>
</TouchableOpacity>
<View className="bg-neutral-900 rounded-xl p-4 border border-neutral-800">
<Text className="text-neutral-400 text-xs font-semibold uppercase tracking-widest mb-2">
How to find your credentials
</Text>
<Text className="text-neutral-500 text-sm leading-relaxed">
Open Claude.ai in Chrome DevTools (F12) Application Cookies claude.ai{"\n"}
Copy <Text className="text-neutral-300 font-mono">sessionKey</Text> and optionally <Text className="text-neutral-300 font-mono">lastActiveOrg</Text>
</Text>
</View>
</View>
)}
</View>
{/* Bottom actions */}
<View className="gap-y-3">
<TouchableOpacity
onPress={() => router.push("/onboarding/done")}
className="rounded-2xl py-4 items-center"
style={{
backgroundColor: savedKey ? COLORS.claude : "#1a1a1a",
}}
>
<Text
className="font-bold text-base"
style={{ color: savedKey ? "white" : "#a3a3a3" }}
>
Continue
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => router.push("/onboarding/done")}
className="py-3 items-center"
>
<Text className="text-neutral-600 text-sm">Skip for now</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
-162
View File
@@ -1,162 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons";
import { pickAndReadCodexAuth } from "@/lib/fileReader";
import { saveCodexAuth, loadCodexAuth } from "@/lib/storage";
import type { CodexAuth } from "@/types/codex";
import { COLORS } from "@/lib/constants";
function humanizeImportError(code: string): string {
if (code === "DOCUMENT_NOT_READABLE") {
return "The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.";
}
return code;
}
export default function OnboardingCodexScreen() {
const [auth, setAuth] = useState<CodexAuth | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadCodexAuth().then((stored) => {
if (stored) setAuth(stored);
});
}, []);
const importFile = useCallback(async () => {
setLoading(true);
setError(null);
try {
const parsed = await pickAndReadCodexAuth();
await saveCodexAuth(parsed);
setAuth(parsed);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
if (msg !== "PICKER_CANCELLED") setError(humanizeImportError(msg));
} finally {
setLoading(false);
}
}, []);
return (
<SafeAreaView className="flex-1 bg-neutral-950">
<View className="flex-1 px-8 justify-between py-8">
{/* Top: back + step indicator */}
<View className="flex-row items-center justify-between">
<TouchableOpacity
onPress={() => router.back()}
className="w-9 h-9 rounded-xl bg-neutral-900 items-center justify-center"
>
<MaterialIcons name="arrow-back" size={18} color="#a3a3a3" />
</TouchableOpacity>
<View className="flex-row items-center gap-x-2">
<View className="w-6 h-6 rounded-full items-center justify-center" style={{ backgroundColor: COLORS.codex }}>
<Text className="text-white text-xs font-bold">1</Text>
</View>
<View className="w-12 h-0.5 bg-neutral-800" />
<View className="w-6 h-6 rounded-full bg-neutral-800 items-center justify-center">
<Text className="text-neutral-500 text-xs font-bold">2</Text>
</View>
</View>
<View className="w-9" />
</View>
{/* Content */}
<View>
<View
className="w-16 h-16 rounded-2xl items-center justify-center mb-6"
style={{ backgroundColor: `${COLORS.codex}22` }}
>
<MaterialIcons name="auto-awesome" size={32} color={COLORS.codex} />
</View>
<Text className="text-3xl font-bold text-white mb-3">
Connect Codex CLI
</Text>
<Text className="text-neutral-400 text-base leading-relaxed mb-8">
Import your{" "}
<Text className="text-neutral-200 font-mono text-sm">
~/.codex/auth.json
</Text>{" "}
file to track your rate limits and credits.
</Text>
{/* Status / action */}
{auth ? (
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800 mb-4">
<View
className="w-10 h-10 rounded-xl items-center justify-center"
style={{ backgroundColor: `${COLORS.codex}22` }}
>
<MaterialIcons name="check-circle" size={22} color={COLORS.codex} />
</View>
<View className="flex-1">
<Text className="text-white font-semibold">Imported</Text>
{auth.accountId && (
<Text className="text-neutral-500 text-sm font-mono">
{auth.accountId.slice(0, 12)}
</Text>
)}
</View>
<TouchableOpacity onPress={importFile}>
<Text className="text-neutral-400 text-xs">Re-import</Text>
</TouchableOpacity>
</View>
) : (
<View>
{error && (
<View className="bg-red-950 border border-red-900 rounded-xl px-4 py-3 mb-4">
<Text className="text-red-400 text-sm">{error}</Text>
</View>
)}
<TouchableOpacity
onPress={importFile}
disabled={loading}
className="rounded-2xl py-4 items-center mb-3"
style={{ backgroundColor: loading ? "#1a1a1a" : COLORS.codex }}
>
{loading ? (
<ActivityIndicator color={COLORS.codex} />
) : (
<View className="flex-row items-center gap-x-2">
<MaterialIcons name="upload-file" size={18} color="white" />
<Text className="text-white font-bold text-base">
Import auth.json
</Text>
</View>
)}
</TouchableOpacity>
<Text className="text-neutral-600 text-xs text-center">
Located at ~/.codex/auth.json on your Mac
</Text>
</View>
)}
</View>
{/* Bottom actions */}
<View className="gap-y-3">
<TouchableOpacity
onPress={() => router.push("/onboarding/claude")}
className="rounded-2xl py-4 items-center"
style={{ backgroundColor: auth ? COLORS.codex : "#1a1a1a" }}
>
<Text
className="font-bold text-base"
style={{ color: auth ? "white" : "#a3a3a3" }}
>
Continue
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => router.push("/onboarding/claude")}
className="py-3 items-center"
>
<Text className="text-neutral-600 text-sm">Skip for now</Text>
</TouchableOpacity>
</View>
</View>
</SafeAreaView>
);
}
-125
View File
@@ -1,125 +0,0 @@
import { useEffect, useState } from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons";
import { markOnboardingComplete } from "@/lib/setupState";
import { loadCodexAuth, loadClaudeSessionKey } from "@/lib/storage";
import { COLORS } from "@/lib/constants";
export default function OnboardingDoneScreen() {
const [codexConfigured, setCodexConfigured] = useState(false);
const [claudeConfigured, setClaudeConfigured] = useState(false);
useEffect(() => {
Promise.all([loadCodexAuth(), loadClaudeSessionKey()]).then(
([codex, claude]) => {
setCodexConfigured(!!codex);
setClaudeConfigured(!!claude);
}
);
}, []);
const handleEnterApp = async () => {
await markOnboardingComplete();
router.replace("/(tabs)");
};
const noneConfigured = !codexConfigured && !claudeConfigured;
return (
<SafeAreaView className="flex-1 bg-neutral-950">
<View className="flex-1 px-8 items-center justify-center">
{/* Icon */}
<View
className="w-20 h-20 rounded-3xl items-center justify-center mb-8"
style={{ backgroundColor: `${COLORS.codex}22` }}
>
<MaterialIcons
name={noneConfigured ? "info-outline" : "check-circle"}
size={40}
color={noneConfigured ? COLORS.muted : COLORS.codex}
/>
</View>
{/* Title */}
<Text className="text-4xl font-bold text-white mb-3 text-center">
{noneConfigured ? "Almost there" : "You're all set!"}
</Text>
<Text className="text-neutral-400 text-base text-center leading-relaxed mb-10">
{noneConfigured
? "No services configured yet. You can set them up anytime in Settings."
: "Your services are connected and ready to monitor."}
</Text>
{/* Summary cards */}
<View className="w-full gap-y-3 mb-10">
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
<View
className="w-10 h-10 rounded-xl items-center justify-center"
style={{
backgroundColor: codexConfigured
? `${COLORS.codex}22`
: "#27272a",
}}
>
<MaterialIcons
name={codexConfigured ? "check-circle" : "radio-button-unchecked"}
size={22}
color={codexConfigured ? COLORS.codex : "#525252"}
/>
</View>
<View className="flex-1">
<Text
className="font-semibold"
style={{ color: codexConfigured ? "white" : "#525252" }}
>
Codex CLI
</Text>
<Text className="text-neutral-600 text-sm">
{codexConfigured ? "Auth imported" : "Not configured"}
</Text>
</View>
</View>
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
<View
className="w-10 h-10 rounded-xl items-center justify-center"
style={{
backgroundColor: claudeConfigured
? `${COLORS.claude}22`
: "#27272a",
}}
>
<MaterialIcons
name={claudeConfigured ? "check-circle" : "radio-button-unchecked"}
size={22}
color={claudeConfigured ? COLORS.claude : "#525252"}
/>
</View>
<View className="flex-1">
<Text
className="font-semibold"
style={{ color: claudeConfigured ? "white" : "#525252" }}
>
Claude.ai
</Text>
<Text className="text-neutral-600 text-sm">
{claudeConfigured ? "Session saved" : "Not configured"}
</Text>
</View>
</View>
</View>
{/* CTA */}
<TouchableOpacity
onPress={handleEnterApp}
className="w-full rounded-2xl py-4 items-center"
style={{ backgroundColor: COLORS.codex }}
>
<Text className="text-white font-bold text-base">Open App</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
-83
View File
@@ -1,83 +0,0 @@
import { View, Text, TouchableOpacity } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { router } from "expo-router";
import { MaterialIcons } from "@expo/vector-icons";
import { COLORS } from "@/lib/constants";
export default function WelcomeScreen() {
return (
<SafeAreaView className="flex-1 bg-neutral-950">
<View className="flex-1 px-8 justify-between py-12">
{/* Logo + title */}
<View className="items-center mt-8">
<View className="flex-row items-center justify-center mb-8">
<View
className="w-16 h-16 rounded-2xl items-center justify-center"
style={{ backgroundColor: COLORS.codex }}
>
<MaterialIcons name="auto-awesome" size={28} color="white" />
</View>
<View
className="w-16 h-16 rounded-2xl items-center justify-center -ml-5"
style={{ backgroundColor: COLORS.claude }}
>
<MaterialIcons name="psychology" size={28} color="white" />
</View>
</View>
<Text className="text-5xl font-bold text-white mb-4 tracking-tight">
Codexbar
</Text>
<Text className="text-neutral-400 text-base text-center leading-relaxed">
Monitor your Codex CLI and Claude.ai{"\n"}usage limits at a glance.
</Text>
</View>
{/* Feature pills */}
<View className="gap-y-3">
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
<View
className="w-10 h-10 rounded-xl items-center justify-center"
style={{ backgroundColor: `${COLORS.codex}22` }}
>
<MaterialIcons name="auto-awesome" size={20} color={COLORS.codex} />
</View>
<View className="flex-1">
<Text className="text-white font-semibold mb-0.5">Codex CLI</Text>
<Text className="text-neutral-500 text-sm">
Rate limits · Credits · Windows
</Text>
</View>
</View>
<View className="flex-row items-center gap-x-3 bg-neutral-900 rounded-2xl p-4 border border-neutral-800">
<View
className="w-10 h-10 rounded-xl items-center justify-center"
style={{ backgroundColor: `${COLORS.claude}22` }}
>
<MaterialIcons name="psychology" size={20} color={COLORS.claude} />
</View>
<View className="flex-1">
<Text className="text-white font-semibold mb-0.5">Claude.ai</Text>
<Text className="text-neutral-500 text-sm">
5-hour · 7-day · Model usage
</Text>
</View>
</View>
</View>
{/* CTA */}
<View>
<TouchableOpacity
onPress={() => router.push("/onboarding/codex")}
className="rounded-2xl py-4 items-center"
style={{ backgroundColor: COLORS.codex }}
>
<Text className="text-white font-bold text-base">Get Started</Text>
</TouchableOpacity>
<Text className="text-neutral-600 text-xs text-center mt-4">
You can configure services at any time in Settings
</Text>
</View>
</View>
</SafeAreaView>
);
}
+2
View File
@@ -0,0 +1,2 @@
-dontwarn okhttp3.**
-dontwarn okio.**
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".CodexMobileApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="CodexMobile"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CodexMobile">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.CodexMobile">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver android:name=".DailyDigestReceiver" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,282 @@
package dev.reversed.codexbarmobile
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONArray
import org.json.JSONObject
class AppStorage(context: Context) {
private val prefs: SharedPreferences
init {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs = EncryptedSharedPreferences.create(
context,
"codexmobile_secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun hasCompletedOnboarding(): Boolean = prefs.getBoolean(KEY_ONBOARDING_DONE, false)
fun markOnboardingComplete() {
prefs.edit().putBoolean(KEY_ONBOARDING_DONE, true).apply()
}
fun resetOnboarding() {
prefs.edit().remove(KEY_ONBOARDING_DONE).apply()
}
fun saveCodexAuth(auth: CodexAuth) {
prefs.edit().putString(KEY_CODEX_AUTH, JSONObject().apply {
put("accessToken", auth.accessToken)
putNullable("accountId", auth.accountId)
}.toString()).apply()
}
fun loadCodexAuth(): CodexAuth? = prefs.getString(KEY_CODEX_AUTH, null)?.let { raw ->
runCatching {
val json = JSONObject(raw)
CodexAuth(
accessToken = json.getString("accessToken"),
accountId = json.optStringOrNull("accountId"),
)
}.getOrNull()
}
fun clearCodexAuth() {
prefs.edit().remove(KEY_CODEX_AUTH).remove(KEY_CODEX_USAGE_CACHE).apply()
}
fun saveClaudeAuth(auth: ClaudeAuth) {
prefs.edit().putString(KEY_CLAUDE_AUTH, JSONObject().apply {
put("sessionKey", auth.sessionKey)
putNullable("lastActiveOrg", auth.lastActiveOrg)
}.toString()).apply()
}
fun loadClaudeAuth(): ClaudeAuth? = prefs.getString(KEY_CLAUDE_AUTH, null)?.let { raw ->
runCatching {
val json = JSONObject(raw)
ClaudeAuth(
sessionKey = json.getString("sessionKey"),
lastActiveOrg = json.optStringOrNull("lastActiveOrg"),
)
}.getOrNull()
}
fun clearClaudeAuth() {
prefs.edit().remove(KEY_CLAUDE_AUTH).remove(KEY_CLAUDE_USAGE_CACHE).apply()
}
fun saveCodexUsageCache(cache: UsageCache<CodexUsage>) {
prefs.edit().putString(KEY_CODEX_USAGE_CACHE, JSONObject().apply {
put("lastFetchedAt", cache.lastFetchedAt)
put("data", cache.data.toJson())
}.toString()).apply()
}
fun loadCodexUsageCache(): UsageCache<CodexUsage>? = prefs.getString(KEY_CODEX_USAGE_CACHE, null)?.let { raw ->
runCatching {
val json = JSONObject(raw)
UsageCache(
data = json.getJSONObject("data").toCodexUsage(),
lastFetchedAt = json.getLong("lastFetchedAt"),
)
}.getOrNull()
}
fun saveClaudeUsageCache(cache: UsageCache<ClaudeUsage>) {
prefs.edit().putString(KEY_CLAUDE_USAGE_CACHE, JSONObject().apply {
put("lastFetchedAt", cache.lastFetchedAt)
put("data", cache.data.toJson())
}.toString()).apply()
}
fun loadClaudeUsageCache(): UsageCache<ClaudeUsage>? = prefs.getString(KEY_CLAUDE_USAGE_CACHE, null)?.let { raw ->
runCatching {
val json = JSONObject(raw)
UsageCache(
data = json.getJSONObject("data").toClaudeUsage(),
lastFetchedAt = json.getLong("lastFetchedAt"),
)
}.getOrNull()
}
fun loadNotificationSettings(): NotificationSettings = NotificationSettings(
dailyEnabled = prefs.getBoolean(KEY_DAILY_ENABLED, false),
dailyHour = prefs.getInt(KEY_DAILY_HOUR, 9),
dailyMinute = prefs.getInt(KEY_DAILY_MINUTE, 0),
thresholdEnabled = prefs.getBoolean(KEY_THRESHOLD_ENABLED, false),
thresholdPct = prefs.getInt(KEY_THRESHOLD_PCT, 20),
)
fun saveNotificationSettings(settings: NotificationSettings) {
prefs.edit()
.putBoolean(KEY_DAILY_ENABLED, settings.dailyEnabled)
.putInt(KEY_DAILY_HOUR, settings.dailyHour)
.putInt(KEY_DAILY_MINUTE, settings.dailyMinute)
.putBoolean(KEY_THRESHOLD_ENABLED, settings.thresholdEnabled)
.putInt(KEY_THRESHOLD_PCT, settings.thresholdPct)
.apply()
}
fun loadThresholdLastFired(): String? = prefs.getString(KEY_THRESHOLD_LAST_FIRED, null)
fun saveThresholdLastFired(date: String) {
prefs.edit().putString(KEY_THRESHOLD_LAST_FIRED, date).apply()
}
companion object {
private const val KEY_ONBOARDING_DONE = "codexmobile_onboarding_v1"
private const val KEY_CODEX_AUTH = "codexmobile_codex_auth"
private const val KEY_CLAUDE_AUTH = "codexmobile_claude_auth"
private const val KEY_CODEX_USAGE_CACHE = "codexmobile_codex_usage_cache"
private const val KEY_CLAUDE_USAGE_CACHE = "codexmobile_claude_usage_cache"
private const val KEY_DAILY_ENABLED = "codexmobile_daily_enabled"
private const val KEY_DAILY_HOUR = "codexmobile_daily_hour"
private const val KEY_DAILY_MINUTE = "codexmobile_daily_minute"
private const val KEY_THRESHOLD_ENABLED = "codexmobile_threshold_enabled"
private const val KEY_THRESHOLD_PCT = "codexmobile_threshold_pct"
private const val KEY_THRESHOLD_LAST_FIRED = "codexmobile_threshold_last_fired"
fun parseCodexAuthJson(text: String): CodexAuth {
val json = JSONObject(text)
val tokens = json.optJSONObject("tokens")
val accessToken = tokens?.optStringOrNull("access_token")
?: json.optStringOrNull("accessToken")
?: throw IllegalArgumentException("MISSING_ACCESS_TOKEN")
val accountId = tokens?.optStringOrNull("account_id")
?: json.optStringOrNull("accountId")
?: json.optStringOrNull("account_id")
return CodexAuth(accessToken = accessToken, accountId = accountId)
}
}
}
fun JSONObject.optStringOrNull(name: String): String? = if (has(name) && !isNull(name)) {
optString(name).takeIf { it.isNotBlank() }
} else null
fun JSONObject.optDoubleOrNull(name: String): Double? = if (has(name) && !isNull(name)) optDouble(name) else null
fun JSONObject.optLongOrNull(name: String): Long? = if (has(name) && !isNull(name)) optLong(name) else null
fun JSONObject.optIntOrNull(name: String): Int? = if (has(name) && !isNull(name)) optInt(name) else null
fun JSONObject.putNullable(name: String, value: Any?) {
if (value == null) put(name, JSONObject.NULL) else put(name, value)
}
fun UsageWindow.toJson(): JSONObject = JSONObject().apply {
put("usedPercent", usedPercent)
putNullable("resetsAt", resetsAt)
putNullable("resetsAtIso", resetsAtIso)
putNullable("windowSeconds", windowSeconds)
}
fun JSONObject.toUsageWindow(): UsageWindow = UsageWindow(
usedPercent = getDouble("usedPercent"),
resetsAt = optLongOrNull("resetsAt"),
resetsAtIso = optStringOrNull("resetsAtIso"),
windowSeconds = optLongOrNull("windowSeconds"),
)
fun CodexUsage.toJson(): JSONObject = JSONObject().apply {
putNullable("planType", planType)
putNullable("rateLimitReachedType", rateLimitReachedType)
putNullable("primary", primary?.toJson())
putNullable("secondary", secondary?.toJson())
put("credits", JSONObject().apply {
put("hasCredits", credits.hasCredits)
put("unlimited", credits.unlimited)
put("balance", credits.balance)
})
putNullable("resetCoupons", resetCoupons?.toJson())
}
fun JSONObject.toCodexUsage(): CodexUsage {
val creditsJson = getJSONObject("credits")
return CodexUsage(
planType = optStringOrNull("planType"),
rateLimitReachedType = optStringOrNull("rateLimitReachedType"),
primary = optJSONObject("primary")?.toUsageWindow(),
secondary = optJSONObject("secondary")?.toUsageWindow(),
credits = CodexCredits(
hasCredits = creditsJson.optBoolean("hasCredits"),
unlimited = creditsJson.optBoolean("unlimited"),
balance = creditsJson.optDouble("balance", 0.0),
),
resetCoupons = optJSONObject("resetCoupons")?.toResetCoupons(),
)
}
fun ResetCoupons.toJson(): JSONObject = JSONObject().apply {
put("source", source)
put("sourceDescription", sourceDescription)
putNullable("availableCount", availableCount)
putNullable("totalEarnedCount", totalEarnedCount)
put("credits", JSONArray().also { arr -> credits.forEach { arr.put(it.toJson()) } })
putNullable("nextExpiringCredit", nextExpiringCredit?.toJson())
putNullable("error", error)
}
fun ResetCredit.toJson(): JSONObject = JSONObject().apply {
put("index", index)
putNullable("status", status)
putNullable("grantedAt", grantedAt)
putNullable("expiresAt", expiresAt)
putNullable("timeUntilExpiry", timeUntilExpiry)
}
fun JSONObject.toResetCoupons(): ResetCoupons {
val arr = optJSONArray("credits") ?: JSONArray()
val credits = (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.toResetCredit() }
return ResetCoupons(
source = optString("source", "unavailable"),
sourceDescription = optString("sourceDescription", "Reset-credit endpoint unavailable"),
availableCount = optIntOrNull("availableCount"),
totalEarnedCount = optIntOrNull("totalEarnedCount"),
credits = credits,
nextExpiringCredit = optJSONObject("nextExpiringCredit")?.toResetCredit(),
error = optStringOrNull("error"),
)
}
fun JSONObject.toResetCredit(): ResetCredit = ResetCredit(
index = optInt("index"),
status = optStringOrNull("status"),
grantedAt = optStringOrNull("grantedAt"),
expiresAt = optStringOrNull("expiresAt"),
timeUntilExpiry = optStringOrNull("timeUntilExpiry"),
)
fun ClaudeUsage.toJson(): JSONObject = JSONObject().apply {
put("fiveHour", fiveHour.toJson())
put("sevenDay", sevenDay.toJson())
putNullable("sevenDaySonnet", sevenDaySonnet?.toJson())
putNullable("sevenDayOpus", sevenDayOpus?.toJson())
}
fun ClaudeWindowUsage.toJson(): JSONObject = JSONObject().apply {
put("utilization", utilization)
putNullable("resetsAt", resetsAt)
}
fun JSONObject.toClaudeUsage(): ClaudeUsage = ClaudeUsage(
fiveHour = getJSONObject("fiveHour").toClaudeWindowUsage(),
sevenDay = getJSONObject("sevenDay").toClaudeWindowUsage(),
sevenDaySonnet = optJSONObject("sevenDaySonnet")?.toClaudeWindowUsage(),
sevenDayOpus = optJSONObject("sevenDayOpus")?.toClaudeWindowUsage(),
)
fun JSONObject.toClaudeWindowUsage(): ClaudeWindowUsage = ClaudeWindowUsage(
utilization = getDouble("utilization"),
resetsAt = optStringOrNull("resetsAt"),
)
@@ -0,0 +1,256 @@
package dev.reversed.codexbarmobile
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class AppViewModel(application: Application) : AndroidViewModel(application) {
private val storage = AppStorage(application)
private val api = UsageApi()
var screen by androidx.compose.runtime.mutableStateOf(AppScreen.Splash)
private set
var tab by androidx.compose.runtime.mutableStateOf(MainTab.Dashboard)
private set
var codexAuth by androidx.compose.runtime.mutableStateOf<CodexAuth?>(null)
private set
var codexUsage by androidx.compose.runtime.mutableStateOf<CodexUsage?>(null)
private set
var codexLastFetchedAt by androidx.compose.runtime.mutableStateOf<Long?>(null)
private set
var codexStatus by androidx.compose.runtime.mutableStateOf(LoadStatus.Idle)
private set
var codexError by androidx.compose.runtime.mutableStateOf<String?>(null)
private set
var claudeAuth by androidx.compose.runtime.mutableStateOf<ClaudeAuth?>(null)
private set
var claudeUsage by androidx.compose.runtime.mutableStateOf<ClaudeUsage?>(null)
private set
var claudeLastFetchedAt by androidx.compose.runtime.mutableStateOf<Long?>(null)
private set
var claudeStatus by androidx.compose.runtime.mutableStateOf(LoadStatus.Idle)
private set
var claudeError by androidx.compose.runtime.mutableStateOf<String?>(null)
private set
var notificationSettings by androidx.compose.runtime.mutableStateOf(NotificationSettings())
private set
init {
loadInitialState()
}
fun selectTab(next: MainTab) {
tab = next
}
fun goTo(next: AppScreen) {
screen = next
}
fun openMain() {
storage.markOnboardingComplete()
screen = AppScreen.Main
}
fun resetSetup() {
storage.clearCodexAuth()
storage.clearClaudeAuth()
storage.resetOnboarding()
codexAuth = null
codexUsage = null
codexLastFetchedAt = null
codexStatus = LoadStatus.Idle
codexError = null
claudeAuth = null
claudeUsage = null
claudeLastFetchedAt = null
claudeStatus = LoadStatus.Idle
claudeError = null
screen = AppScreen.Welcome
tab = MainTab.Dashboard
}
fun importCodexAuthText(text: String) {
viewModelScope.launch {
runCatching { AppStorage.parseCodexAuthJson(text) }
.onSuccess { auth ->
storage.saveCodexAuth(auth)
codexAuth = auth
codexError = null
refreshCodex()
}
.onFailure { codexError = humanizeImportError(it.message ?: "INVALID_JSON") }
}
}
fun saveClaude(sessionKey: String, lastActiveOrg: String?) {
val trimmed = sessionKey.trim()
val validationError = validateClaudeSessionKey(trimmed)
if (validationError != null) {
claudeError = validationError
return
}
val auth = ClaudeAuth(trimmed, lastActiveOrg?.trim()?.takeIf { it.isNotBlank() })
storage.saveClaudeAuth(auth)
claudeAuth = auth
claudeError = null
refreshClaude()
}
fun clearCodex() {
storage.clearCodexAuth()
codexAuth = null
codexUsage = null
codexLastFetchedAt = null
codexStatus = LoadStatus.Idle
codexError = null
}
fun clearClaude() {
storage.clearClaudeAuth()
claudeAuth = null
claudeUsage = null
claudeLastFetchedAt = null
claudeStatus = LoadStatus.Idle
claudeError = null
}
fun refreshAll() {
refreshCodex()
refreshClaude()
}
fun refreshCodex() {
val auth = codexAuth ?: return
viewModelScope.launch {
codexStatus = LoadStatus.Loading
codexError = null
runCatchingWithRetry { api.fetchCodexUsage(auth) }
.onSuccess { usage ->
val fetchedAt = System.currentTimeMillis()
codexUsage = usage
codexLastFetchedAt = fetchedAt
codexStatus = LoadStatus.Success
storage.saveCodexUsageCache(UsageCache(usage, fetchedAt))
syncNotifications()
}
.onFailure { error ->
if (error.message == "TOKEN_EXPIRED") clearCodex()
codexStatus = LoadStatus.Error
codexError = humanizeError(error)
}
}
}
fun refreshClaude() {
val auth = claudeAuth ?: return
viewModelScope.launch {
claudeStatus = LoadStatus.Loading
claudeError = null
runCatchingWithRetry { api.fetchClaudeUsage(auth) }
.onSuccess { usage ->
val fetchedAt = System.currentTimeMillis()
claudeUsage = usage
claudeLastFetchedAt = fetchedAt
claudeStatus = LoadStatus.Success
storage.saveClaudeUsageCache(UsageCache(usage, fetchedAt))
syncNotifications()
}
.onFailure { error ->
if (error.message == "TOKEN_EXPIRED") clearClaude()
claudeStatus = LoadStatus.Error
claudeError = humanizeError(error)
}
}
}
fun updateNotificationSettings(settings: NotificationSettings) {
notificationSettings = settings
storage.saveNotificationSettings(settings)
syncNotifications()
}
private fun loadInitialState() {
codexAuth = storage.loadCodexAuth()
claudeAuth = storage.loadClaudeAuth()
notificationSettings = storage.loadNotificationSettings()
storage.loadCodexUsageCache()?.let {
codexUsage = it.data
codexLastFetchedAt = it.lastFetchedAt
codexStatus = LoadStatus.Success
}
storage.loadClaudeUsageCache()?.let {
claudeUsage = it.data
claudeLastFetchedAt = it.lastFetchedAt
claudeStatus = LoadStatus.Success
}
screen = if (storage.hasCompletedOnboarding()) AppScreen.Main else AppScreen.Welcome
refreshAll()
}
private fun syncNotifications() {
val context = getApplication<Application>()
if (notificationSettings.dailyEnabled) {
NotificationScheduler.scheduleDailyDigest(
context = context,
hour = notificationSettings.dailyHour,
minute = notificationSettings.dailyMinute,
body = buildDailyDigestBody(claudeUsage, codexUsage),
)
} else {
NotificationScheduler.cancelDailyDigest(context)
}
maybeFireThresholdAlert(context, storage, notificationSettings, claudeUsage, codexUsage)
}
private suspend fun <T> runCatchingWithRetry(block: suspend () -> T): Result<T> {
return runCatching { block() }.recoverCatching { first ->
if (!shouldRetry(first)) throw first
delay(RETRY_DELAY_MS)
block()
}
}
private fun shouldRetry(error: Throwable): Boolean = when (error.message) {
"TOKEN_EXPIRED", "NO_ORGS_FOUND", "INVALID_CODEX_RESPONSE", "INVALID_CLAUDE_RESPONSE" -> false
else -> true
}
private fun humanizeImportError(code: String): String = when (code) {
"MISSING_ACCESS_TOKEN" -> "That auth.json does not contain a Codex access token."
"INVALID_JSON" -> "That file is not valid JSON."
else -> "The selected file could not be read. Try another copy of auth.json."
}
private fun humanizeError(error: Throwable): String = when (val message = error.message) {
"TOKEN_EXPIRED" -> "Credentials expired. Reconnect this service."
"NO_ORGS_FOUND" -> "No Claude organizations were found for this session."
null -> "Something went wrong. Pull to retry."
else -> if (message.startsWith("HTTP_ERROR_")) "Service request failed (${message.removePrefix("HTTP_ERROR_")}). Pull to retry." else message
}
companion object {
private const val RETRY_DELAY_MS = 5_000L
}
}
private val claudeSessionPattern = Regex("^sk-ant-[A-Za-z0-9_-]+$")
fun validateClaudeSessionKey(value: String): String? {
val key = value.trim()
return when {
!key.startsWith("sk-ant-") -> "Session key must start with sk-ant-."
key.length <= 40 -> "Session key looks too short. Paste the complete cookie value."
!claudeSessionPattern.matches(key) -> "Session key contains unexpected characters."
else -> null
}
}
fun isClaudeSessionKeyValid(value: String): Boolean = validateClaudeSessionKey(value) == null
@@ -0,0 +1,855 @@
package dev.reversed.codexbarmobile
import android.Manifest
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.AutoAwesome
import androidx.compose.material.icons.rounded.BarChart
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Psychology
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.UploadFile
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CodexMobileTheme {
val vm: AppViewModel = viewModel()
CodexMobileApp(vm)
}
}
}
}
@Composable
private fun CodexMobileApp(vm: AppViewModel) {
val context = LocalContext.current
val pickCodexAuth = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
if (uri != null) {
val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
if (text != null) vm.importCodexAuthText(text)
}
}
val requestNotifications = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
val pickAuth = { pickCodexAuth.launch(arrayOf("application/json", "text/*", "*/*")) }
val askNotifications = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
when (vm.screen) {
AppScreen.Splash -> SplashScreen()
AppScreen.Welcome -> WelcomeScreen(vm)
AppScreen.OnboardingCodex -> OnboardingCodexScreen(vm, pickAuth)
AppScreen.OnboardingClaude -> OnboardingClaudeScreen(vm)
AppScreen.OnboardingDone -> OnboardingDoneScreen(vm)
AppScreen.Main -> MainScreen(vm, pickAuth, askNotifications)
}
}
@Composable
private fun SplashScreen() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = CodexGreen)
}
}
@Composable
private fun WelcomeScreen(vm: AppViewModel) {
OnboardingShell {
Spacer(Modifier.height(32.dp))
AppMark(size = 72)
Spacer(Modifier.height(28.dp))
Text("CodexMobile", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black)
Text(
"Monitor Codex CLI and Claude.ai usage limits without a web stack.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
Spacer(Modifier.weight(1f))
FeaturePill(Icons.Rounded.AutoAwesome, "Codex CLI", "Rate limits, credits, reset coupons", CodexGreen)
Spacer(Modifier.height(12.dp))
FeaturePill(Icons.Rounded.Psychology, "Claude.ai", "5-hour, 7-day, and model windows", ClaudeAmber)
Spacer(Modifier.weight(1f))
PrimaryButton("Get started", CodexGreen) { vm.goTo(AppScreen.OnboardingCodex) }
Text(
"You can configure services later in Settings.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
}
@Composable
private fun OnboardingCodexScreen(vm: AppViewModel, pickAuth: () -> Unit) {
OnboardingShell {
StepHeader(1, 2)
Spacer(Modifier.height(36.dp))
ServiceIcon(Icons.Rounded.AutoAwesome, CodexGreen)
Text("Connect Codex CLI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
Text(
"Import your ~/.codex/auth.json file to track rate limits, credits, and reset coupons.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
Spacer(Modifier.height(24.dp))
vm.codexError?.let { ErrorCard(it) }
if (vm.codexAuth != null) {
ConnectedCard("Imported", vm.codexAuth?.accountId?.take(16), CodexGreen)
TextButton(onClick = pickAuth) { Text("Re-import auth.json") }
} else {
PrimaryButton("Import auth.json", CodexGreen, icon = Icons.Rounded.UploadFile, onClick = pickAuth)
Text("File picker accepts the Codex auth JSON export.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.weight(1f))
PrimaryButton("Continue", if (vm.codexAuth != null) CodexGreen else MutedButton) { vm.goTo(AppScreen.OnboardingClaude) }
TextButton(onClick = { vm.goTo(AppScreen.OnboardingClaude) }) { Text("Skip for now") }
}
}
@Composable
private fun OnboardingClaudeScreen(vm: AppViewModel) {
var key by remember { mutableStateOf("") }
var org by remember { mutableStateOf("") }
val inlineError = if (key.isBlank()) null else validateClaudeSessionKey(key)
OnboardingShell {
StepHeader(2, 2)
Spacer(Modifier.height(36.dp))
ServiceIcon(Icons.Rounded.Psychology, ClaudeAmber)
Text("Connect Claude.ai", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
Text(
"Paste your sessionKey cookie and optionally lastActiveOrg to monitor Claude usage windows.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
Spacer(Modifier.height(24.dp))
if (vm.claudeAuth != null) {
ConnectedCard("Connected", vm.claudeAuth?.sessionKey?.take(16), ClaudeAmber)
TextButton(onClick = { vm.clearClaude() }) { Text("Change session key") }
} else {
(vm.claudeError ?: inlineError)?.let { ErrorCard(it) }
OutlinedTextField(
value = key,
onValueChange = { key = it },
label = { Text("sessionKey") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = org,
onValueChange = { org = it },
label = { Text("lastActiveOrg (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
PrimaryButton("Save session key", if (isClaudeSessionKeyValid(key)) ClaudeAmber else MutedButton) {
vm.saveClaude(key, org)
}
HelpCard("Find it in Chrome DevTools > Application > Cookies > claude.ai > sessionKey.")
}
Spacer(Modifier.weight(1f))
PrimaryButton("Continue", if (vm.claudeAuth != null) ClaudeAmber else MutedButton) { vm.goTo(AppScreen.OnboardingDone) }
TextButton(onClick = { vm.goTo(AppScreen.OnboardingDone) }) { Text("Skip for now") }
}
}
@Composable
private fun OnboardingDoneScreen(vm: AppViewModel) {
OnboardingShell(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.weight(1f))
ServiceIcon(if (vm.codexAuth != null || vm.claudeAuth != null) Icons.Rounded.CheckCircle else Icons.Rounded.Info, CodexGreen, 84)
Text(
if (vm.codexAuth == null && vm.claudeAuth == null) "Almost there" else "You're all set",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Black,
)
Text(
if (vm.codexAuth == null && vm.claudeAuth == null) "No services are configured yet. Settings can finish setup anytime." else "Your connected services are ready to monitor.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
Spacer(Modifier.height(28.dp))
SummaryRow("Codex CLI", if (vm.codexAuth != null) "Auth imported" else "Not configured", vm.codexAuth != null, CodexGreen)
SummaryRow("Claude.ai", if (vm.claudeAuth != null) "Session saved" else "Not configured", vm.claudeAuth != null, ClaudeAmber)
Spacer(Modifier.weight(1f))
PrimaryButton("Open app", CodexGreen) { vm.openMain() }
}
}
@Composable
private fun MainScreen(vm: AppViewModel, pickAuth: () -> Unit, askNotifications: () -> Unit) {
Scaffold(
bottomBar = {
NavigationBar {
MainTab.entries.forEach { item ->
NavigationBarItem(
selected = vm.tab == item,
onClick = { vm.selectTab(item) },
icon = { Icon(item.icon, null) },
label = { Text(item.label) },
)
}
}
},
) { padding ->
Box(Modifier.padding(padding)) {
when (vm.tab) {
MainTab.Dashboard -> DashboardScreen(vm)
MainTab.Codex -> CodexScreen(vm, pickAuth)
MainTab.Claude -> ClaudeScreen(vm)
MainTab.Settings -> SettingsScreen(vm, pickAuth, askNotifications)
}
}
}
}
@Composable
private fun DashboardScreen(vm: AppViewModel) {
ScreenColumn {
Header("CodexMobile", connectedSummary(vm), Icons.Rounded.BarChart, CodexGreen) { vm.refreshAll() }
ServiceCard("Codex CLI", Icons.Rounded.AutoAwesome, CodexGreen, vm.codexStatus, vm.codexError, vm.codexAuth != null) {
vm.codexUsage?.let { CodexUsageContent(it, vm.codexLastFetchedAt, compact = true) }
?: EmptyState("Import auth.json in Settings to see Codex rate limits.") { vm.selectTab(MainTab.Settings) }
}
ServiceCard("Claude.ai", Icons.Rounded.Psychology, ClaudeAmber, vm.claudeStatus, vm.claudeError, vm.claudeAuth != null) {
vm.claudeUsage?.let { ClaudeUsageContent(it, vm.claudeLastFetchedAt) }
?: EmptyState("Add a session key in Settings to see Claude usage.") { vm.selectTab(MainTab.Settings) }
}
}
}
@Composable
private fun CodexScreen(vm: AppViewModel, pickAuth: () -> Unit) {
ScreenColumn {
Header("Codex CLI", "Rate limits and reset credits", Icons.Rounded.AutoAwesome, CodexGreen) { vm.refreshCodex() }
ConfigCard(
connected = vm.codexAuth != null,
label = vm.codexAuth?.accountId?.take(16),
color = CodexGreen,
connectLabel = "Import auth.json",
onConnect = pickAuth,
onClear = vm::clearCodex,
)
vm.codexError?.let { ErrorCard(it) }
UsageSurface(vm.codexStatus) {
vm.codexUsage?.let { CodexUsageContent(it, vm.codexLastFetchedAt, compact = false) }
?: EmptyState("Import Codex auth.json to load usage windows.") { pickAuth() }
}
}
}
@Composable
private fun ClaudeScreen(vm: AppViewModel) {
var key by remember { mutableStateOf("") }
var org by remember { mutableStateOf("") }
ScreenColumn {
Header("Claude.ai", "Usage windows", Icons.Rounded.Psychology, ClaudeAmber) { vm.refreshClaude() }
if (vm.claudeAuth != null) {
ConfigCard(
connected = true,
label = vm.claudeAuth?.sessionKey?.take(16),
color = ClaudeAmber,
connectLabel = "Save session key",
onConnect = {},
onClear = vm::clearClaude,
)
} else {
ClaudeCredentialCard(key, org, vm.claudeError, onKey = { key = it }, onOrg = { org = it }) { vm.saveClaude(key, org) }
}
UsageSurface(vm.claudeStatus) {
vm.claudeUsage?.let { ClaudeUsageContent(it, vm.claudeLastFetchedAt) }
?: EmptyState("Enter a Claude session key to load usage windows.")
}
}
}
@Composable
private fun SettingsScreen(vm: AppViewModel, pickAuth: () -> Unit, askNotifications: () -> Unit) {
var key by remember { mutableStateOf("") }
var org by remember { mutableStateOf("") }
var showResetDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
ScreenColumn {
Text("Settings", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Black)
SectionTitle("Codex CLI")
ConfigCard(vm.codexAuth != null, vm.codexAuth?.accountId?.take(16), CodexGreen, "Import auth.json", pickAuth, vm::clearCodex)
SectionTitle("Claude.ai")
if (vm.claudeAuth != null) {
ConfigCard(true, vm.claudeAuth?.sessionKey?.take(16), ClaudeAmber, "Save session key", {}, vm::clearClaude)
} else {
ClaudeCredentialCard(key, org, vm.claudeError, onKey = { key = it }, onOrg = { org = it }) { vm.saveClaude(key, org) }
}
SectionTitle("Notifications")
SettingsCard {
val hasPermission = NotificationScheduler.hasNotificationPermission(context)
if (!hasPermission) {
RowItem(Icons.Rounded.Notifications, "Enable notifications", "Required for digests and quota alerts") {
OutlinedButton(onClick = askNotifications) { Text("Allow") }
}
HorizontalDivider()
}
SwitchRow("Daily digest", "Last-known usage at a chosen time", vm.notificationSettings.dailyEnabled) {
vm.updateNotificationSettings(vm.notificationSettings.copy(dailyEnabled = it))
}
if (vm.notificationSettings.dailyEnabled) {
TimeSetting(vm.notificationSettings) { hour, minute ->
vm.updateNotificationSettings(vm.notificationSettings.copy(dailyHour = hour, dailyMinute = minute))
}
}
HorizontalDivider()
SwitchRow("Low quota alert", "Warn when weekly quota runs low", vm.notificationSettings.thresholdEnabled) {
vm.updateNotificationSettings(vm.notificationSettings.copy(thresholdEnabled = it))
}
if (vm.notificationSettings.thresholdEnabled) {
ThresholdSetting(vm.notificationSettings) {
vm.updateNotificationSettings(vm.notificationSettings.copy(thresholdPct = it))
}
}
}
SectionTitle("App")
SettingsCard {
RowItem(Icons.Rounded.Info, "Version", "1.0.0") {}
HorizontalDivider()
RowItem(Icons.Rounded.RestartAlt, "Re-run setup wizard", "Clears credentials and onboarding", danger = true) {
TextButton(onClick = { showResetDialog = true }) { Text("Reset") }
}
}
}
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset setup?") },
text = { Text("This clears stored credentials and restarts onboarding.") },
confirmButton = { TextButton(onClick = { showResetDialog = false; vm.resetSetup() }) { Text("Reset") } },
dismissButton = { TextButton(onClick = { showResetDialog = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun CodexUsageContent(usage: CodexUsage, lastFetchedAt: Long?, compact: Boolean) {
usage.planType?.let { AssistChip(onClick = {}, label = { Text(it.replaceFirstChar(Char::uppercase)) }) }
usage.primary?.let { UsageRow("${windowLabel(it.windowSeconds)} window", it, CodexGreen) }
usage.secondary?.let { UsageRow("${windowLabel(it.windowSeconds)} window", it, CodexGreen) }
CreditCard(usage)
if (!compact) ResetCreditsCard(usage.resetCoupons)
LastUpdated(lastFetchedAt)
}
@Composable
private fun ClaudeUsageContent(usage: ClaudeUsage, lastFetchedAt: Long?) {
UsageRow("5-hour window", UsageWindow(usage.fiveHour.utilization, resetsAtIso = usage.fiveHour.resetsAt), ClaudeAmber)
UsageRow("7-day window", UsageWindow(usage.sevenDay.utilization, resetsAtIso = usage.sevenDay.resetsAt), ClaudeAmber)
if (usage.sevenDaySonnet != null || usage.sevenDayOpus != null) {
CardBlock {
Text("By model (7-day)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
usage.sevenDaySonnet?.let { UsageRow("Sonnet", UsageWindow(it.utilization), ClaudeAmber) }
usage.sevenDayOpus?.let { UsageRow("Opus", UsageWindow(it.utilization), ClaudeAmber) }
}
}
LastUpdated(lastFetchedAt)
}
@Composable
private fun UsageRow(label: String, window: UsageWindow, accent: Color) {
val percent = window.usedPercent.roundToInt().coerceIn(0, 100)
Column(Modifier.padding(vertical = 10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom) {
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("$percent% used", style = MaterialTheme.typography.titleSmall, color = usageColor(percent, accent), fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { percent / 100f },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = usageColor(percent, accent),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
resetText(window)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp)) }
}
}
@Composable
private fun CreditCard(usage: CodexUsage) {
CardBlock {
Text("Credits", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
val text = when {
usage.credits.unlimited -> "Unlimited credits"
usage.credits.hasCredits -> "\$${"%.2f".format(usage.credits.balance)} remaining"
else -> "No credits"
}
Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
usage.resetCoupons?.let {
Text("${it.availableCount ?: 0} reset credits available", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
it.nextExpiringCredit?.timeUntilExpiry?.let { expiry -> Text("Next expires in $expiry", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
@Composable
private fun ResetCreditsCard(resetCoupons: ResetCoupons?) {
if (resetCoupons == null) return
CardBlock {
Text("Reset credits", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("${resetCoupons.availableCount ?: 0} available", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
resetCoupons.totalEarnedCount?.let { Text("$it earned total", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
resetCoupons.credits.filter { it.status == "available" }.forEach { credit ->
Text("Credit ${credit.index}: expires ${credit.timeUntilExpiry ?: credit.expiresAt ?: "unknown"}", style = MaterialTheme.typography.bodySmall)
}
if (resetCoupons.source != "live_api") Text("Source: ${resetCoupons.sourceDescription}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun ServiceCard(title: String, icon: ImageVector, color: Color, status: LoadStatus, error: String?, connected: Boolean, content: @Composable () -> Unit) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(28.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
ServiceIcon(icon, color, 40)
Spacer(Modifier.width(12.dp))
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
StatusDot(status, connected)
}
Spacer(Modifier.height(12.dp))
if (status == LoadStatus.Loading && !connected) CircularProgressIndicator(color = color)
if (status == LoadStatus.Error && error != null) ErrorCard(error)
content()
}
}
}
@Composable
private fun UsageSurface(status: LoadStatus, content: @Composable () -> Unit) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(28.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp)) {
if (status == LoadStatus.Loading) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(20.dp))
Spacer(Modifier.width(10.dp))
Text("Refreshing usage…", style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(12.dp))
}
content()
}
}
}
@Composable
private fun ConfigCard(connected: Boolean, label: String?, color: Color, connectLabel: String, onConnect: () -> Unit, onClear: () -> Unit) {
SettingsCard {
if (connected) {
RowItem(Icons.Rounded.CheckCircle, "Connected", label ?: "Credentials saved") {
TextButton(onClick = onClear) { Text("Clear", color = MaterialTheme.colorScheme.error) }
}
} else {
RowItem(Icons.Rounded.UploadFile, connectLabel, "Credentials stay encrypted on this device") {
Button(onClick = onConnect, colors = ButtonDefaults.buttonColors(containerColor = color)) { Text("Connect") }
}
}
}
}
@Composable
private fun ClaudeCredentialCard(key: String, org: String, error: String?, onKey: (String) -> Unit, onOrg: (String) -> Unit, onSave: () -> Unit) {
SettingsCard {
Column(Modifier.padding(16.dp)) {
(error ?: if (key.isBlank()) null else validateClaudeSessionKey(key))?.let { ErrorCard(it) }
OutlinedTextField(key, onKey, Modifier.fillMaxWidth(), label = { Text("sessionKey") }, singleLine = true, visualTransformation = PasswordVisualTransformation())
Spacer(Modifier.height(10.dp))
OutlinedTextField(org, onOrg, Modifier.fillMaxWidth(), label = { Text("lastActiveOrg (optional)") }, singleLine = true)
Spacer(Modifier.height(12.dp))
PrimaryButton("Save session key", if (isClaudeSessionKeyValid(key)) ClaudeAmber else MutedButton, onClick = onSave)
HelpCard("Chrome DevTools > Application > Cookies > claude.ai > sessionKey")
}
}
}
@Composable
private fun ScreenColumn(content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
@Composable
private fun OnboardingShell(horizontalAlignment: Alignment.Horizontal = Alignment.Start, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(28.dp),
horizontalAlignment = horizontalAlignment,
content = content,
)
}
@Composable
private fun Header(title: String, subtitle: String, icon: ImageVector, color: Color, onRefresh: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
ServiceIcon(icon, color, 48)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onRefresh) { Icon(Icons.Rounded.Refresh, "Refresh") }
}
}
@Composable
private fun PrimaryButton(text: String, color: Color, icon: ImageVector? = null, onClick: () -> Unit) {
Button(onClick = onClick, colors = ButtonDefaults.buttonColors(containerColor = color), shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth().height(54.dp)) {
if (icon != null) { Icon(icon, null); Spacer(Modifier.width(8.dp)) }
Text(text, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun ServiceIcon(icon: ImageVector, color: Color, size: Int = 56) {
Surface(shape = RoundedCornerShape((size / 3).dp), color = color.copy(alpha = 0.16f), modifier = Modifier.size(size.dp)) {
Box(contentAlignment = Alignment.Center) { Icon(icon, null, tint = color, modifier = Modifier.size((size / 2).dp)) }
}
}
@Composable
private fun AppMark(size: Int) {
Row(horizontalArrangement = Arrangement.Center) {
ServiceIcon(Icons.Rounded.AutoAwesome, CodexGreen, size)
Spacer(Modifier.width(8.dp))
ServiceIcon(Icons.Rounded.Psychology, ClaudeAmber, size)
}
}
@Composable
private fun StepHeader(current: Int, total: Int) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
(1..total).forEach { step ->
Surface(
shape = CircleShape,
color = if (step <= current) CodexGreen else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(28.dp),
) {
Box(contentAlignment = Alignment.Center) {
Text(
step.toString(),
style = MaterialTheme.typography.labelMedium,
color = if (step <= current) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
)
}
}
if (step < total) {
Spacer(Modifier.width(8.dp))
HorizontalDivider(Modifier.width(42.dp), color = if (step < current) CodexGreen else MaterialTheme.colorScheme.surfaceVariant)
Spacer(Modifier.width(8.dp))
}
}
}
}
@Composable
private fun FeaturePill(icon: ImageVector, title: String, subtitle: String, color: Color) {
SettingsCard {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
ServiceIcon(icon, color, 42)
Spacer(Modifier.width(12.dp))
Column {
Text(title, fontWeight = FontWeight.Bold)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun ConnectedCard(title: String, label: String?, color: Color) {
SettingsCard {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.CheckCircle, null, tint = color)
Spacer(Modifier.width(12.dp))
Column {
Text(title, fontWeight = FontWeight.Bold)
label?.let { Text("$it", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
}
@Composable
private fun SummaryRow(title: String, subtitle: String, ok: Boolean, color: Color) {
SettingsCard {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = CircleShape, color = if (ok) color else MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(10.dp)) {}
Spacer(Modifier.width(12.dp))
Column { Text(title, fontWeight = FontWeight.Bold); Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
Spacer(Modifier.height(10.dp))
}
@Composable
private fun ErrorCard(text: String) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth()) {
Text(text, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(14.dp))
}
}
@Composable
private fun HelpCard(text: String) {
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
}
@Composable
private fun CardBlock(content: @Composable ColumnScope.() -> Unit) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)), shape = RoundedCornerShape(20.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Column(Modifier.padding(14.dp), content = content)
}
}
@Composable
private fun EmptyState(text: String, onAction: (() -> Unit)? = null) {
Column(Modifier.fillMaxWidth().padding(vertical = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (onAction != null) TextButton(onClick = onAction) { Text("Open Settings") }
}
}
@Composable
private fun LastUpdated(timestamp: Long?) {
Text("Updated ${timestamp?.let(::formatTimestamp) ?: "never"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
}
@Composable
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth()) {
Column(content = content)
}
}
@Composable
private fun SectionTitle(title: String) {
Text(title.uppercase(), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
}
@Composable
private fun RowItem(icon: ImageVector, title: String, subtitle: String, danger: Boolean = false, action: @Composable () -> Unit) {
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(title, fontWeight = FontWeight.SemiBold, color = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
action()
}
}
@Composable
private fun SwitchRow(title: String, subtitle: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
RowItem(Icons.Rounded.Alarm, title, subtitle) { Switch(checked = checked, onCheckedChange = onChecked) }
}
@Composable
private fun TimeSetting(settings: NotificationSettings, onChange: (Int, Int) -> Unit) {
var value by remember(settings.dailyHour, settings.dailyMinute) { mutableStateOf("%02d:%02d".format(settings.dailyHour, settings.dailyMinute)) }
OutlinedTextField(
value = value,
onValueChange = {
value = it.take(5)
parseTime(value)?.let { parsed -> onChange(parsed.first, parsed.second) }
},
label = { Text("Digest time") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
supportingText = { Text("Use 24-hour HH:MM format.") },
)
}
@Composable
private fun ThresholdSetting(settings: NotificationSettings, onChange: (Int) -> Unit) {
var value by remember(settings.thresholdPct) { mutableStateOf(settings.thresholdPct.toString()) }
OutlinedTextField(
value = value,
onValueChange = { next -> value = next.filter(Char::isDigit).take(2); value.toIntOrNull()?.takeIf { it in 1..99 }?.let(onChange) },
label = { Text("Alert below % remaining") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable
private fun StatusDot(status: LoadStatus, connected: Boolean) {
val color = when {
status == LoadStatus.Error -> MaterialTheme.colorScheme.error
connected -> CodexGreen
else -> MaterialTheme.colorScheme.outline
}
Surface(shape = CircleShape, color = color, modifier = Modifier.size(10.dp)) {}
}
private fun connectedSummary(vm: AppViewModel): String = when (listOf(vm.codexAuth, vm.claudeAuth).count { it != null }) {
0 -> "No services connected"
1 -> "1 of 2 services connected"
else -> "2 services connected"
}
private val MainTab.icon: ImageVector get() = when (this) {
MainTab.Dashboard -> Icons.Rounded.Home
MainTab.Codex -> Icons.Rounded.AutoAwesome
MainTab.Claude -> Icons.Rounded.Psychology
MainTab.Settings -> Icons.Rounded.Settings
}
private fun usageColor(percent: Int, fallback: Color): Color = when {
percent >= 85 -> DangerRed
percent >= 60 -> WarningAmber
else -> fallback
}
private fun resetText(window: UsageWindow): String? = when {
window.resetsAt != null -> "Resets ${formatTimestamp(window.resetsAt * 1000L)}"
window.resetsAtIso != null -> runCatching { "Resets ${formatTimestamp(Instant.parse(window.resetsAtIso).toEpochMilli())}" }.getOrNull()
else -> null
}
private fun windowLabel(seconds: Long?): String = when {
seconds == null || seconds <= 0L -> "Usage"
seconds < 3_600L -> "${seconds / 60L}m"
seconds < 86_400L -> "${seconds / 3_600L}h"
else -> "${seconds / 86_400L}d"
}
private fun formatTimestamp(value: Long): String = DateTimeFormatter.ofPattern("MMM d, HH:mm")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(value))
private fun parseTime(value: String): Pair<Int, Int>? {
val parts = value.split(":")
if (parts.size != 2) return null
val hour = parts[0].toIntOrNull() ?: return null
val minute = parts[1].toIntOrNull() ?: return null
return if (hour in 0..23 && minute in 0..59) hour to minute else null
}
private val CodexGreen = Color(0xFF10A37F)
private val ClaudeAmber = Color(0xFFD97706)
private val WarningAmber = Color(0xFFF59E0B)
private val DangerRed = Color(0xFFEF4444)
private val MutedButton = Color(0xFF52525B)
@Composable
private fun CodexMobileTheme(content: @Composable () -> Unit) {
val dark = darkColorScheme(
primary = CodexGreen,
secondary = ClaudeAmber,
background = Color(0xFF090D11),
surface = Color(0xFF10161B),
surfaceVariant = Color(0xFF1A2229),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFFE7ECEF),
onSurface = Color(0xFFE7ECEF),
onSurfaceVariant = Color(0xFF9CA3AF),
)
val light = lightColorScheme(
primary = CodexGreen,
secondary = ClaudeAmber,
background = Color(0xFFF6F8F7),
surface = Color.White,
surfaceVariant = Color(0xFFECEFED),
onPrimary = Color.White,
onSecondary = Color.White,
)
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) dark else light, content = {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, content = content)
})
}
@@ -0,0 +1,104 @@
package dev.reversed.codexbarmobile
data class CodexAuth(
val accessToken: String,
val accountId: String? = null,
)
data class UsageWindow(
val usedPercent: Double,
val resetsAt: Long? = null,
val resetsAtIso: String? = null,
val windowSeconds: Long? = null,
)
data class CodexCredits(
val hasCredits: Boolean,
val unlimited: Boolean,
val balance: Double,
)
data class ResetCredit(
val index: Int,
val status: String?,
val grantedAt: String?,
val expiresAt: String?,
val timeUntilExpiry: String?,
)
data class ResetCoupons(
val source: String,
val sourceDescription: String,
val availableCount: Int?,
val totalEarnedCount: Int?,
val credits: List<ResetCredit>,
val nextExpiringCredit: ResetCredit?,
val error: String? = null,
)
data class CodexUsage(
val planType: String?,
val rateLimitReachedType: String?,
val primary: UsageWindow?,
val secondary: UsageWindow?,
val credits: CodexCredits,
val resetCoupons: ResetCoupons?,
)
data class ClaudeAuth(
val sessionKey: String,
val lastActiveOrg: String? = null,
)
data class ClaudeOrg(
val uuid: String,
val name: String,
)
data class ClaudeWindowUsage(
val utilization: Double,
val resetsAt: String? = null,
)
data class ClaudeUsage(
val fiveHour: ClaudeWindowUsage,
val sevenDay: ClaudeWindowUsage,
val sevenDaySonnet: ClaudeWindowUsage? = null,
val sevenDayOpus: ClaudeWindowUsage? = null,
)
data class UsageCache<T>(
val data: T,
val lastFetchedAt: Long,
)
data class NotificationSettings(
val dailyEnabled: Boolean = false,
val dailyHour: Int = 9,
val dailyMinute: Int = 0,
val thresholdEnabled: Boolean = false,
val thresholdPct: Int = 20,
)
enum class LoadStatus {
Idle,
Loading,
Success,
Error,
}
enum class AppScreen {
Splash,
Welcome,
OnboardingCodex,
OnboardingClaude,
OnboardingDone,
Main,
}
enum class MainTab(val label: String) {
Dashboard("Home"),
Codex("Codex"),
Claude("Claude"),
Settings("Settings"),
}
@@ -0,0 +1,168 @@
package dev.reversed.codexbarmobile
import android.Manifest
import android.app.AlarmManager
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import java.time.LocalDate
import java.util.Calendar
import kotlin.math.roundToInt
class CodexMobileApp : Application() {
override fun onCreate() {
super.onCreate()
NotificationScheduler.ensureChannel(this)
}
}
object NotificationScheduler {
private const val CHANNEL_ID = "usage-alerts"
private const val CHANNEL_NAME = "Usage alerts"
private const val DAILY_REQUEST_CODE = 1001
private const val EXTRA_BODY = "body"
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "CodexMobile usage digests and quota warnings"
}
manager.createNotificationChannel(channel)
}
}
fun hasNotificationPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
fun scheduleDailyDigest(context: Context, hour: Int, minute: Int, body: String) {
if (!hasNotificationPermission(context)) return
ensureChannel(context)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, DailyDigestReceiver::class.java).putExtra(EXTRA_BODY, body)
val pending = PendingIntent.getBroadcast(
context,
DAILY_REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val firstRun = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
if (timeInMillis <= System.currentTimeMillis()) add(Calendar.DAY_OF_YEAR, 1)
}
alarmManager.setInexactRepeating(
AlarmManager.RTC_WAKEUP,
firstRun.timeInMillis,
AlarmManager.INTERVAL_DAY,
pending,
)
}
fun cancelDailyDigest(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val pending = PendingIntent.getBroadcast(
context,
DAILY_REQUEST_CODE,
Intent(context, DailyDigestReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
alarmManager.cancel(pending)
pending.cancel()
}
fun showQuotaAlert(context: Context, body: String) {
if (!hasNotificationPermission(context)) return
showNotification(
context = context,
id = 2001,
title = "Low quota warning",
body = body,
)
}
fun showDailyDigest(context: Context, body: String) {
showNotification(
context = context,
id = 1002,
title = "Usage digest",
body = body.ifBlank { "Open CodexMobile to check your usage limits." },
)
}
private fun showNotification(context: Context, id: Int, title: String, body: String) {
if (!hasNotificationPermission(context)) return
ensureChannel(context)
val openIntent = PendingIntent.getActivity(
context,
id,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(openIntent)
.setAutoCancel(true)
.setColor(0xFF10A37F.toInt())
.build()
NotificationManagerCompat.from(context).notify(id, notification)
}
}
class DailyDigestReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
NotificationScheduler.showDailyDigest(context, intent.getStringExtra("body").orEmpty())
}
}
fun maybeFireThresholdAlert(
context: Context,
storage: AppStorage,
settings: NotificationSettings,
claudeUsage: ClaudeUsage?,
codexUsage: CodexUsage?,
) {
if (!settings.thresholdEnabled) return
val today = LocalDate.now().toString()
if (storage.loadThresholdLastFired() == today) return
val alerts = buildList {
claudeUsage?.let {
val remaining = 100.0 - it.sevenDay.utilization
if (remaining <= settings.thresholdPct) add("Claude 7-day: ${remaining.roundPercent()}% remaining")
}
codexUsage?.let {
val remaining = 100.0 - (it.secondary?.usedPercent ?: 0.0)
if (remaining <= settings.thresholdPct) add("Codex weekly: ${remaining.roundPercent()}% remaining")
}
}
if (alerts.isNotEmpty()) {
NotificationScheduler.showQuotaAlert(context, alerts.joinToString("\n"))
storage.saveThresholdLastFired(today)
}
}
fun buildDailyDigestBody(claudeUsage: ClaudeUsage?, codexUsage: CodexUsage?): String {
val parts = buildList {
claudeUsage?.let { add("Claude 7d: ${it.sevenDay.utilization.roundPercent()}% used") }
codexUsage?.secondary?.let { add("Codex weekly: ${it.usedPercent.roundPercent()}% used") }
}
return if (parts.isEmpty()) "Open CodexMobile to check your usage limits." else parts.joinToString(" · ")
}
fun Double.roundPercent(): Int = roundToInt().coerceIn(0, 100)
@@ -0,0 +1,196 @@
package dev.reversed.codexbarmobile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.time.Instant
import java.util.concurrent.TimeUnit
class UsageApi {
private val client = OkHttpClient.Builder()
.connectTimeout(25, TimeUnit.SECONDS)
.readTimeout(25, TimeUnit.SECONDS)
.build()
suspend fun fetchCodexUsage(auth: CodexAuth): CodexUsage = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(CODEX_USAGE_URL)
.applyCodexHeaders(auth)
.get()
.build()
val payload = client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) throw IllegalStateException("TOKEN_EXPIRED")
if (!response.isSuccessful) throw IllegalStateException("HTTP_ERROR_${response.code}")
JSONObject(response.body?.string().orEmpty())
}
val resetCoupons = fetchResetCoupons(auth, payload)
CodexUsage(
planType = payload.optStringOrNull("plan_type"),
rateLimitReachedType = payload.optStringOrNull("rate_limit_reached_type"),
primary = payload.optJSONObject("rate_limit")?.optJSONObject("primary_window")?.toCodexWindow(),
secondary = payload.optJSONObject("rate_limit")?.optJSONObject("secondary_window")?.toCodexWindow(),
credits = payload.optJSONObject("credits").let { credits ->
CodexCredits(
hasCredits = credits?.optBoolean("has_credits") ?: false,
unlimited = credits?.optBoolean("unlimited") ?: false,
balance = credits?.optDouble("balance", 0.0) ?: 0.0,
)
},
resetCoupons = resetCoupons,
)
}
private fun fetchResetCoupons(auth: CodexAuth, usagePayload: JSONObject): ResetCoupons {
val request = Request.Builder()
.url(CODEX_RESET_CREDITS_URL)
.applyCodexHeaders(auth)
.header("OpenAI-Beta", "codex-1")
.header("originator", "Codex Desktop")
.get()
.build()
return runCatching {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IllegalStateException("HTTP_ERROR_${response.code}")
val payload = JSONObject(response.body?.string().orEmpty())
val rawCredits = payload.optJSONArray("credits") ?: JSONArray()
val credits = (0 until rawCredits.length())
.mapNotNull { index -> rawCredits.optJSONObject(index)?.toResetCredit(index + 1) }
.sortedBy { credit -> credit.expiresAt?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: Instant.MAX }
val available = credits.filter { it.status == "available" }
ResetCoupons(
source = "live_api",
sourceDescription = "Live Codex reset-credit endpoint",
availableCount = payload.optIntOrNull("available_count")
?: usagePayload.optJSONObject("rate_limit_reset_credits")?.optIntOrNull("available_count"),
totalEarnedCount = payload.optIntOrNull("total_earned_count"),
credits = credits,
nextExpiringCredit = available.firstOrNull() ?: credits.firstOrNull(),
)
}
}.getOrElse { error ->
ResetCoupons(
source = "unavailable",
sourceDescription = "Reset-credit endpoint unavailable",
availableCount = usagePayload.optJSONObject("rate_limit_reset_credits")?.optIntOrNull("available_count"),
totalEarnedCount = null,
credits = emptyList(),
nextExpiringCredit = null,
error = error.message ?: "UNKNOWN_ERROR",
)
}
}
suspend fun fetchClaudeUsage(auth: ClaudeAuth): ClaudeUsage = withContext(Dispatchers.IO) {
val orgId = auth.lastActiveOrg?.takeIf { it.isNotBlank() } ?: fetchClaudeOrgs(auth).firstOrNull()?.uuid
?: throw IllegalStateException("NO_ORGS_FOUND")
val request = Request.Builder()
.url("$CLAUDE_BASE_URL/organizations/$orgId/usage")
.applyClaudeHeaders(auth)
.get()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) throw IllegalStateException("TOKEN_EXPIRED")
if (!response.isSuccessful) throw IllegalStateException("HTTP_ERROR_${response.code}")
JSONObject(response.body?.string().orEmpty()).toClaudeUsageFromApi()
}
}
private fun fetchClaudeOrgs(auth: ClaudeAuth): List<ClaudeOrg> {
val request = Request.Builder()
.url("$CLAUDE_BASE_URL/organizations")
.applyClaudeHeaders(auth)
.get()
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) throw IllegalStateException("TOKEN_EXPIRED")
if (!response.isSuccessful) throw IllegalStateException("HTTP_ERROR_${response.code}")
val arr = JSONArray(response.body?.string().orEmpty())
(0 until arr.length()).mapNotNull { index ->
arr.optJSONObject(index)?.let { ClaudeOrg(it.getString("uuid"), it.optString("name")) }
}
}
}
private fun Request.Builder.applyCodexHeaders(auth: CodexAuth): Request.Builder = apply {
header("Authorization", "Bearer ${auth.accessToken}")
header("Content-Type", "application/json")
auth.accountId?.takeIf { it.isNotBlank() }?.let { header("ChatGPT-Account-Id", it) }
}
private fun Request.Builder.applyClaudeHeaders(auth: ClaudeAuth): Request.Builder = apply {
val cookies = buildList {
add("sessionKey=${auth.sessionKey}")
auth.lastActiveOrg?.takeIf { it.isNotBlank() }?.let { add("lastActiveOrg=$it") }
}
header("Cookie", cookies.joinToString("; "))
header("Accept", "application/json, text/plain, */*")
header("Accept-Language", "en-US,en;q=0.9")
header("User-Agent", BROWSER_USER_AGENT)
header("Referer", "https://claude.ai/")
header("Origin", "https://claude.ai")
header("anthropic-client-platform", "web_claude_to_cc_migration")
}
companion object {
private const val CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
private const val CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
private const val CLAUDE_BASE_URL = "https://claude.ai/api"
private const val BROWSER_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36"
}
}
private fun JSONObject.toCodexWindow(): UsageWindow {
val seconds = optLong("limit_window_seconds", 0L)
return UsageWindow(
usedPercent = optDouble("used_percent", 0.0),
resetsAt = optLong("reset_at", 0L).takeIf { it > 0L },
windowSeconds = seconds.takeIf { it > 0L },
)
}
private fun JSONObject.toResetCredit(index: Int): ResetCredit = ResetCredit(
index = index,
status = optStringOrNull("status"),
grantedAt = optStringOrNull("granted_at"),
expiresAt = optStringOrNull("expires_at"),
timeUntilExpiry = formatDurationUntil(optStringOrNull("expires_at")),
)
private fun JSONObject.toClaudeUsageFromApi(): ClaudeUsage = ClaudeUsage(
fiveHour = getJSONObject("five_hour").toClaudeWindowFromApi(),
sevenDay = getJSONObject("seven_day").toClaudeWindowFromApi(),
sevenDaySonnet = optJSONObject("seven_day_sonnet")?.toClaudeWindowFromApi(),
sevenDayOpus = optJSONObject("seven_day_opus")?.toClaudeWindowFromApi(),
)
private fun JSONObject.toClaudeWindowFromApi(): ClaudeWindowUsage = ClaudeWindowUsage(
utilization = optDouble("utilization", 0.0),
resetsAt = optStringOrNull("resets_at"),
)
fun formatDurationUntil(isoString: String?): String? {
if (isoString.isNullOrBlank()) return null
val target = runCatching { Instant.parse(isoString).toEpochMilli() }.getOrNull() ?: return null
var remaining = ((target - System.currentTimeMillis()).coerceAtLeast(0L) / 1000L)
val days = remaining / 86_400L
remaining -= days * 86_400L
val hours = remaining / 3_600L
remaining -= hours * 3_600L
val minutes = remaining / 60L
remaining -= minutes * 60L
return buildList {
if (days > 0) add("${days}d")
if (hours > 0) add("${hours}h")
if (minutes > 0) add("${minutes}m")
if (remaining > 0 || isEmpty()) add("${remaining}s")
}.joinToString(" ")
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#10A37F" android:pathData="M23,22h40a12,12 0,0 1,12 12v40a12,12 0,0 1,-12 12h-40a12,12 0,0 1,-12 -12v-40a12,12 0,0 1,12 -12z" />
<path android:fillColor="#D97706" android:pathData="M48,22h37a12,12 0,0 1,12 12v40a12,12 0,0 1,-12 12h-37a12,12 0,0 1,-12 -12v-40a12,12 0,0 1,12 -12z" />
<path android:fillColor="#FFFFFF" android:pathData="M55,31l5.8,15.2L76,52l-15.2,5.8L55,73l-5.8,-15.2L34,52l15.2,-5.8z" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M12,2l2.4,6.4L21,11l-6.6,2.6L12,20l-2.4,-6.4L3,11l6.6,-2.6z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="codexmobile_seed">#10A37F</color>
<color name="ic_launcher_background">#071311</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">CodexMobile</string>
</resources>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.CodexMobile" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:navigationBarColor">#090D11</item>
<item name="android:statusBarColor">#090D11</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true" />
<device-transfer />
</data-extraction-rules>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

-10
View File
@@ -1,10 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
plugins: ["react-native-reanimated/plugin"],
};
};
+4
View File
@@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '8.5.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.24' apply false
}
-49
View File
@@ -1,49 +0,0 @@
import { View, Text } from "react-native";
const ERROR_MESSAGES: Record<string, string> = {
TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.",
MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.",
INVALID_JSON: "The selected file is not valid JSON.",
DOCUMENT_NOT_READABLE:
"The selected file could not be read. Try copying auth.json into Files or Downloads and import it again.",
NO_ORGS_FOUND: "No Claude organizations found for this session key.",
INVALID_CODEX_RESPONSE:
"Codex returned an unexpected response. The app was kept safe from invalid data.",
INVALID_CLAUDE_RESPONSE:
"Claude returned an unexpected usage response. The app was kept safe from invalid data.",
INVALID_CLAUDE_ORGS_RESPONSE:
"Claude returned an unexpected organizations response.",
UNKNOWN_ERROR: "An unknown error occurred. Try refreshing.",
};
function humanize(code: string): string {
if (code.startsWith("HTTP_ERROR_")) {
const status = code.replace("HTTP_ERROR_", "");
if (status === "429") return "Rate limited by the server. Try again shortly.";
if (status === "500" || status === "502" || status === "503")
return `Server error (${status}). The service may be down.`;
return `Unexpected server response (HTTP ${status}).`;
}
if (
code === "Network request failed" ||
code.includes("fetch") ||
code.includes("network")
) {
return "Network error. Check your internet connection and try again.";
}
return ERROR_MESSAGES[code] ?? `Unexpected error: ${code}`;
}
interface ErrorMessageProps {
message: string | null;
}
export function ErrorMessage({ message }: ErrorMessageProps) {
if (!message) return null;
return (
<View className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl px-3 py-2.5 mb-3">
<Text className="text-sm text-red-700 dark:text-red-300">{humanize(message)}</Text>
</View>
);
}
-21
View File
@@ -1,21 +0,0 @@
import { useEffect, useState } from "react";
import { Text } from "react-native";
import { COUNTDOWN_INTERVAL_MS } from "@/lib/constants";
import { formatLastUpdated } from "@/lib/timeUtils";
export function LastUpdated({ timestamp }: { timestamp: number | null }) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), COUNTDOWN_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
if (!timestamp) return null;
return (
<Text selectable className="text-xs text-neutral-400 dark:text-neutral-500">
{formatLastUpdated(timestamp, now)}
</Text>
);
}
-24
View File
@@ -1,24 +0,0 @@
import { View } from "react-native";
import { PROGRESS_THRESHOLDS } from "@/lib/constants";
interface ProgressBarProps {
percent: number;
}
function getBarColor(percent: number): string {
if (percent >= PROGRESS_THRESHOLDS.danger) return "bg-red-500";
if (percent >= PROGRESS_THRESHOLDS.warning) return "bg-yellow-400";
return "bg-green-500";
}
export function ProgressBar({ percent }: ProgressBarProps) {
const clamped = Math.max(0, Math.min(100, percent));
return (
<View className="h-2.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-700">
<View
className={`h-2.5 rounded-full ${getBarColor(clamped)}`}
style={{ width: `${clamped}%` }}
/>
</View>
);
}
-41
View File
@@ -1,41 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Text } from "react-native";
import { COUNTDOWN_INTERVAL_MS } from "@/lib/constants";
import { formatResetCountdown, formatResetCountdownISO } from "@/lib/timeUtils";
interface ResetCountdownProps {
resetAtSeconds?: number;
resetAtISO?: string;
}
export function ResetCountdown({ resetAtSeconds, resetAtISO }: ResetCountdownProps) {
const latest = useRef({ resetAtSeconds, resetAtISO });
latest.current = { resetAtSeconds, resetAtISO };
const getLabel = () => {
if (latest.current.resetAtSeconds !== undefined) {
return formatResetCountdown(latest.current.resetAtSeconds);
}
if (latest.current.resetAtISO) return formatResetCountdownISO(latest.current.resetAtISO);
return "";
};
const [label, setLabel] = useState(getLabel);
useEffect(() => {
const update = () => setLabel(getLabel());
update();
const interval = setInterval(update, COUNTDOWN_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
useEffect(() => {
setLabel(getLabel());
}, [resetAtSeconds, resetAtISO]);
if (!label) return null;
return (
<Text className="text-xs text-neutral-500 dark:text-neutral-400">{label}</Text>
);
}
-45
View File
@@ -1,45 +0,0 @@
import React from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { COLORS } from "@/lib/constants";
interface Props {
children: React.ReactNode;
screenName: string;
}
interface State {
error: Error | null;
}
export class ScreenErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
render() {
if (!this.state.error) return this.props.children;
return (
<View className="flex-1 items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-8">
<Text selectable className="text-xl font-bold text-neutral-900 dark:text-white">
{this.props.screenName} hit a snag
</Text>
<Text
selectable
className="mt-2 text-center text-sm text-neutral-500 dark:text-neutral-400"
>
This screen could not be rendered. The rest of Codexbar is still available.
</Text>
<TouchableOpacity
onPress={() => this.setState({ error: null })}
className="mt-5 rounded-xl px-4 py-3"
style={{ backgroundColor: COLORS.codex }}
>
<Text className="text-sm font-semibold text-white">Try again</Text>
</TouchableOpacity>
</View>
);
}
}
-20
View File
@@ -1,20 +0,0 @@
import { View, Text } from "react-native";
interface SectionCardProps {
title: string;
children: React.ReactNode;
accentClass?: string;
}
export function SectionCard({ title, children, accentClass }: SectionCardProps) {
return (
<View
className={`rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-4 mb-4 ${accentClass ?? ""}`}
>
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-500 dark:text-neutral-400 mb-3">
{title}
</Text>
{children}
</View>
);
}
-124
View File
@@ -1,124 +0,0 @@
import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native";
import { MaterialIcons } from "@expo/vector-icons";
import { ProgressBar } from "./ProgressBar";
import { ResetCountdown } from "./ResetCountdown";
interface UsageRow {
label: string;
percent: number;
resetAtSeconds?: number;
resetAtISO?: string;
}
interface ServiceStatusCardProps {
title: string;
icon: React.ComponentProps<typeof MaterialIcons>["name"];
accentColor: string;
status: "idle" | "loading" | "success" | "error";
badge?: string;
rows?: UsageRow[];
footer?: React.ReactNode;
unconfiguredLabel?: string;
onConfigurePress?: () => void;
}
export function ServiceStatusCard({
title,
icon,
accentColor,
status,
badge,
rows,
footer,
unconfiguredLabel,
onConfigurePress,
}: ServiceStatusCardProps) {
return (
<View className="rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 p-4 mb-4">
{/* Header */}
<View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center gap-x-2.5">
<View
className="w-8 h-8 rounded-xl items-center justify-center"
style={{ backgroundColor: `${accentColor}20` }}
>
<MaterialIcons name={icon} size={16} color={accentColor} />
</View>
<Text className="text-sm font-semibold text-neutral-800 dark:text-white">
{title}
</Text>
</View>
{badge && (
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/40">
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
{badge}
</Text>
</View>
)}
{status === "success" && !badge && (
<View className="w-2 h-2 rounded-full bg-green-500" />
)}
{status === "error" && (
<View className="w-2 h-2 rounded-full bg-red-500" />
)}
</View>
{/* Body */}
{status === "loading" && (
<ActivityIndicator color={accentColor} style={{ marginVertical: 12 }} />
)}
{status === "idle" && (
<View className="py-2 items-start">
<Text className="text-sm text-neutral-400 dark:text-neutral-500 mb-3">
{unconfiguredLabel ?? "Not configured"}
</Text>
{onConfigurePress && (
<TouchableOpacity
onPress={onConfigurePress}
className="px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700"
>
<Text className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
Configure
</Text>
</TouchableOpacity>
)}
</View>
)}
{status === "error" && (
<Text className="text-sm text-red-500 dark:text-red-400 py-2">
Failed to load usage data
</Text>
)}
{status === "success" && rows && (
<View>
{rows.map((row, i) => (
<View key={i} className={`gap-y-1 ${i < rows.length - 1 ? "mb-3" : ""}`}>
<View className="flex-row justify-between items-center">
<Text className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
{row.label}
</Text>
<Text className="text-xs font-semibold text-neutral-700 dark:text-neutral-300">
{Math.round(row.percent)}%
</Text>
</View>
<ProgressBar percent={row.percent} />
<ResetCountdown
resetAtSeconds={row.resetAtSeconds}
resetAtISO={row.resetAtISO}
/>
</View>
))}
</View>
)}
{footer && (
<View className="mt-3 pt-3 border-t border-neutral-100 dark:border-neutral-800">
{footer}
</View>
)}
</View>
);
}
-27
View File
@@ -1,27 +0,0 @@
import { View, Text } from "react-native";
import { ProgressBar } from "./ProgressBar";
import { ResetCountdown } from "./ResetCountdown";
interface UsageStatProps {
label: string;
percent: number;
resetAtSeconds?: number;
resetAtISO?: string;
}
export function UsageStat({ label, percent, resetAtSeconds, resetAtISO }: UsageStatProps) {
return (
<View className="gap-y-1.5 mb-4">
<View className="flex-row justify-between items-center">
<Text className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{label}
</Text>
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
{Math.round(percent)}%
</Text>
</View>
<ProgressBar percent={percent} />
<ResetCountdown resetAtSeconds={resetAtSeconds} resetAtISO={resetAtISO} />
</View>
);
}
-25
View File
@@ -1,25 +0,0 @@
{
"cli": {
"version": ">= 16.0.0",
"appVersionSource": "remote"
},
"build": {
"preview": {
"channel": "preview",
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"channel": "production",
"distribution": "internal",
"android": {
"buildType": "apk"
}
}
},
"submit": {
"production": {}
}
}
-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Copyright 2015-2021 the original authors.
# Licensed under the Apache License, Version 2.0.
APP_HOME=${0%/*}
[ "$APP_HOME" = "$0" ] && APP_HOME=.
APP_HOME=$(cd "$APP_HOME" >/dev/null && pwd -P) || exit
APP_BASE_NAME=${0##*/}
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ]; then
JAVACMD=$JAVA_HOME/bin/java
else
JAVACMD=java
fi
if ! command -v "$JAVACMD" >/dev/null 2>&1 && [ ! -x "$JAVACMD" ]; then
echo "ERROR: Java is not available. Set JAVA_HOME to a JDK 17 installation." >&2
exit 1
fi
exec "$JAVACMD" "-Xmx64m" "-Xms64m" "-Dorg.gradle.appname=$APP_BASE_NAME" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
Vendored
+23
View File
@@ -0,0 +1,23 @@
@if "%DEBUG%"=="" @echo off
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo ERROR: JAVA_HOME is not set and no java command could be found. 1>&2
exit /b 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
exit /b 1
:execute
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
exit /b %ERRORLEVEL%
-245
View File
@@ -1,245 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchClaudeOrgs,
fetchClaudeUsage,
parseClaudeUsageResponse,
} from "@/lib/api/claudeApi";
import {
isClaudeSessionKeyValid,
validateClaudeSessionKey,
} from "@/lib/claudeCredentials";
import { RETRY_DELAY_MS } from "@/lib/constants";
import {
clearClaudeLastActiveOrg,
clearClaudeSessionKey,
clearClaudeUsageCache,
loadClaudeLastActiveOrg,
loadClaudeSessionKey,
loadClaudeUsageCache,
saveClaudeLastActiveOrg,
saveClaudeSessionKey,
saveClaudeUsageCache,
} from "@/lib/storage";
import type { ClaudeAuth, ClaudeUsageResponse } from "@/types/claude";
type Status = "idle" | "loading" | "success" | "error";
function shouldRetry(error: unknown): boolean {
const message = error instanceof Error ? error.message : "";
return ![
"TOKEN_EXPIRED",
"NO_ORGS_FOUND",
"INVALID_CLAUDE_RESPONSE",
"INVALID_CLAUDE_ORGS_RESPONSE",
].includes(message);
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function requestClaudeUsage(auth: ClaudeAuth): Promise<ClaudeUsageResponse> {
let orgUuid = auth.lastActiveOrg?.trim();
if (!orgUuid) {
const orgs = await fetchClaudeOrgs(auth);
if (!orgs.length) throw new Error("NO_ORGS_FOUND");
orgUuid = orgs[0].uuid;
await saveClaudeLastActiveOrg(orgUuid);
}
return fetchClaudeUsage(auth, orgUuid);
}
export function useClaudeUsage() {
const [auth, setAuth] = useState<ClaudeAuth | null>(null);
const [pendingKey, setPendingKey] = useState("");
const [pendingOrg, setPendingOrg] = useState("");
const [usage, setUsage] = useState<ClaudeUsageResponse | null>(null);
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const mounted = useRef(true);
const inFlight = useRef<{
key: string;
promise: Promise<void>;
} | null>(null);
const requestVersion = useRef(0);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const fetchUsage = useCallback((credentials: ClaudeAuth): Promise<void> => {
const sanitized = {
sessionKey: credentials.sessionKey.trim(),
lastActiveOrg: credentials.lastActiveOrg?.trim() || undefined,
};
const requestKey = `${sanitized.lastActiveOrg ?? ""}:${sanitized.sessionKey}`;
if (inFlight.current?.key === requestKey) return inFlight.current.promise;
const version = ++requestVersion.current;
const promise = (async () => {
if (mounted.current) {
setStatus("loading");
setError(null);
}
try {
let data: ClaudeUsageResponse;
try {
data = await requestClaudeUsage(sanitized);
} catch (firstError) {
if (!shouldRetry(firstError)) throw firstError;
await wait(RETRY_DELAY_MS);
if (version !== requestVersion.current) return;
data = await requestClaudeUsage(sanitized);
}
if (version !== requestVersion.current) return;
const fetchedAt = Date.now();
await saveClaudeUsageCache({ data, lastFetchedAt: fetchedAt });
if (mounted.current && version === requestVersion.current) {
setUsage(data);
setLastFetchedAt(fetchedAt);
setStatus("success");
}
} catch (caught: unknown) {
if (version !== requestVersion.current) return;
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
if (message === "TOKEN_EXPIRED") {
await clearClaudeSessionKey();
if (mounted.current) {
setAuth(null);
setUsage(null);
setLastFetchedAt(null);
}
}
if (mounted.current) {
setError(message);
setStatus("error");
}
}
})().finally(() => {
if (inFlight.current?.promise === promise) inFlight.current = null;
});
inFlight.current = { key: requestKey, promise };
return promise;
}, []);
useEffect(() => {
let active = true;
void Promise.all([
loadClaudeSessionKey(),
loadClaudeLastActiveOrg(),
loadClaudeUsageCache(),
]).then(async ([key, org, cache]) => {
if (!active) return;
const storedAuth = key
? { sessionKey: key.trim(), lastActiveOrg: org?.trim() || undefined }
: null;
if (storedAuth && cache) {
try {
if (!Number.isFinite(cache.lastFetchedAt)) {
throw new Error("INVALID_CACHE_TIMESTAMP");
}
const cachedUsage = parseClaudeUsageResponse(cache.data);
setUsage(cachedUsage);
setLastFetchedAt(cache.lastFetchedAt);
setStatus("success");
} catch {
await clearClaudeUsageCache();
}
}
setAuth(storedAuth);
if (storedAuth) await fetchUsage(storedAuth);
});
return () => {
active = false;
};
}, [fetchUsage]);
const saveKey = useCallback(async () => {
const trimmedKey = pendingKey.trim();
const validationError = validateClaudeSessionKey(trimmedKey);
if (validationError) {
setError(validationError);
return;
}
const trimmedOrg = pendingOrg.trim() || undefined;
await saveClaudeSessionKey(trimmedKey);
if (trimmedOrg) await saveClaudeLastActiveOrg(trimmedOrg);
else await clearClaudeLastActiveOrg();
const nextAuth = { sessionKey: trimmedKey, lastActiveOrg: trimmedOrg };
setAuth(nextAuth);
setPendingKey("");
setPendingOrg("");
setError(null);
await fetchUsage(nextAuth);
}, [fetchUsage, pendingKey, pendingOrg]);
const refresh = useCallback(async () => {
if (auth) await fetchUsage(auth);
}, [auth, fetchUsage]);
const reloadCredentials = useCallback(async () => {
const [key, org] = await Promise.all([
loadClaudeSessionKey(),
loadClaudeLastActiveOrg(),
]);
if (!key) {
requestVersion.current += 1;
setAuth(null);
setUsage(null);
setLastFetchedAt(null);
setStatus("idle");
return;
}
const stored = {
sessionKey: key.trim(),
lastActiveOrg: org?.trim() || undefined,
};
setAuth(stored);
await fetchUsage(stored);
}, [fetchUsage]);
const clearKey = useCallback(async () => {
requestVersion.current += 1;
await clearClaudeSessionKey();
setAuth(null);
setUsage(null);
setLastFetchedAt(null);
setStatus("idle");
setError(null);
}, []);
return {
auth,
pendingKey,
setPendingKey,
pendingOrg,
setPendingOrg,
usage,
lastFetchedAt,
status,
error,
keyValidationError: pendingKey
? validateClaudeSessionKey(pendingKey)
: null,
canSaveKey: isClaudeSessionKeyValid(pendingKey),
saveKey,
refresh,
reloadCredentials,
clearKey,
};
}
-178
View File
@@ -1,178 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchCodexUsage,
parseCodexUsageResponse,
} from "@/lib/api/codexApi";
import { RETRY_DELAY_MS } from "@/lib/constants";
import { pickAndReadCodexAuth } from "@/lib/fileReader";
import {
clearCodexAuth,
clearCodexUsageCache,
loadCodexAuth,
loadCodexUsageCache,
saveCodexAuth,
saveCodexUsageCache,
} from "@/lib/storage";
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
type Status = "idle" | "loading" | "success" | "error";
function shouldRetry(error: unknown): boolean {
const message = error instanceof Error ? error.message : "";
return message !== "TOKEN_EXPIRED" && message !== "INVALID_CODEX_RESPONSE";
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function useCodexUsage() {
const [auth, setAuth] = useState<CodexAuth | null>(null);
const [usage, setUsage] = useState<CodexUsageResponse | null>(null);
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const mounted = useRef(true);
const inFlight = useRef<{
key: string;
promise: Promise<void>;
} | null>(null);
const requestVersion = useRef(0);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const fetchUsage = useCallback((credentials: CodexAuth): Promise<void> => {
const requestKey = `${credentials.accountId ?? ""}:${credentials.accessToken}`;
if (inFlight.current?.key === requestKey) return inFlight.current.promise;
const version = ++requestVersion.current;
const promise = (async () => {
if (mounted.current) {
setStatus("loading");
setError(null);
}
try {
let data: CodexUsageResponse;
try {
data = await fetchCodexUsage(credentials);
} catch (firstError) {
if (!shouldRetry(firstError)) throw firstError;
await wait(RETRY_DELAY_MS);
if (version !== requestVersion.current) return;
data = await fetchCodexUsage(credentials);
}
if (version !== requestVersion.current) return;
const fetchedAt = Date.now();
await saveCodexUsageCache({ data, lastFetchedAt: fetchedAt });
if (mounted.current && version === requestVersion.current) {
setUsage(data);
setLastFetchedAt(fetchedAt);
setStatus("success");
}
} catch (caught: unknown) {
if (version !== requestVersion.current) return;
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
if (mounted.current) {
setError(message);
setStatus("error");
}
}
})().finally(() => {
if (inFlight.current?.promise === promise) inFlight.current = null;
});
inFlight.current = { key: requestKey, promise };
return promise;
}, []);
useEffect(() => {
let active = true;
void Promise.all([loadCodexAuth(), loadCodexUsageCache()]).then(
async ([storedAuth, cache]) => {
if (!active) return;
if (storedAuth && cache) {
try {
if (!Number.isFinite(cache.lastFetchedAt)) {
throw new Error("INVALID_CACHE_TIMESTAMP");
}
const cachedUsage = parseCodexUsageResponse(cache.data);
setUsage(cachedUsage);
setLastFetchedAt(cache.lastFetchedAt);
setStatus("success");
} catch {
await clearCodexUsageCache();
}
}
setAuth(storedAuth);
if (storedAuth) await fetchUsage(storedAuth);
}
);
return () => {
active = false;
};
}, [fetchUsage]);
const importAuthFile = useCallback(async () => {
try {
const parsed = await pickAndReadCodexAuth();
await saveCodexAuth(parsed);
setAuth(parsed);
setError(null);
await fetchUsage(parsed);
} catch (caught: unknown) {
const message = caught instanceof Error ? caught.message : "UNKNOWN_ERROR";
if (message !== "PICKER_CANCELLED") setError(message);
}
}, [fetchUsage]);
const refresh = useCallback(async () => {
if (auth) await fetchUsage(auth);
}, [auth, fetchUsage]);
const reloadCredentials = useCallback(async () => {
const stored = await loadCodexAuth();
if (!stored) {
requestVersion.current += 1;
setAuth(null);
setUsage(null);
setLastFetchedAt(null);
setStatus("idle");
return;
}
setAuth(stored);
await fetchUsage(stored);
}, [fetchUsage]);
const clearAuth = useCallback(async () => {
requestVersion.current += 1;
await clearCodexAuth();
setAuth(null);
setUsage(null);
setLastFetchedAt(null);
setStatus("idle");
setError(null);
}, []);
return {
auth,
usage,
lastFetchedAt,
status,
error,
importAuthFile,
refresh,
reloadCredentials,
clearAuth,
};
}
-64
View File
@@ -1,64 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import {
loadNotifSettings,
saveNotifSettings,
type NotifSettings,
} from "@/lib/storage";
import {
requestPermissions,
getPermissionStatus,
scheduleDailyDigest,
cancelDailyDigest,
} from "@/lib/notifications";
export function useNotificationSettings() {
const [settings, setSettings] = useState<NotifSettings>({
dailyEnabled: false,
dailyHour: 9,
dailyMinute: 0,
thresholdEnabled: false,
thresholdPct: 20,
});
const [permissionStatus, setPermissionStatus] = useState("undetermined");
const [loaded, setLoaded] = useState(false);
useEffect(() => {
Promise.all([loadNotifSettings(), getPermissionStatus()]).then(
([stored, status]) => {
setSettings(stored);
setPermissionStatus(status);
setLoaded(true);
}
);
}, []);
const askPermissions = useCallback(async (): Promise<boolean> => {
const granted = await requestPermissions();
setPermissionStatus(granted ? "granted" : "denied");
return granted;
}, []);
const update = useCallback(
async (patch: Partial<NotifSettings>) => {
const next = { ...settings, ...patch };
setSettings(next);
await saveNotifSettings(next);
// Keep scheduled notification in sync when toggling or changing time
const dailyChanged =
"dailyEnabled" in patch ||
"dailyHour" in patch ||
"dailyMinute" in patch;
if (dailyChanged) {
if (next.dailyEnabled) {
await scheduleDailyDigest(next.dailyHour, next.dailyMinute, null, null);
} else {
await cancelDailyDigest();
}
}
},
[settings]
);
return { settings, permissionStatus, loaded, askPermissions, update };
}
-8
View File
@@ -1,8 +0,0 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
-127
View File
@@ -1,127 +0,0 @@
import type { ClaudeAuth, ClaudeOrg, ClaudeUsageResponse } from "@/types/claude";
const BASE_URL = "https://claude.ai/api";
const BROWSER_UA =
"Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36";
function buildHeaders(auth: ClaudeAuth): Record<string, string> {
const cookies = [`sessionKey=${auth.sessionKey}`];
if (auth.lastActiveOrg) cookies.push(`lastActiveOrg=${auth.lastActiveOrg}`);
return {
Cookie: cookies.join("; "),
Accept: "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": BROWSER_UA,
Referer: "https://claude.ai/",
Origin: "https://claude.ai",
"anthropic-client-platform": "web_claude_to_cc_migration",
};
}
function isClaudeWindow(value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const window = value as Record<string, unknown>;
return (
typeof window.utilization === "number" &&
Number.isFinite(window.utilization) &&
(window.resets_at === undefined ||
window.resets_at === null ||
typeof window.resets_at === "string")
);
}
export function parseClaudeOrgsResponse(data: unknown): ClaudeOrg[] {
if (
!Array.isArray(data) ||
data.some(
(org) =>
!org ||
typeof org !== "object" ||
typeof (org as Record<string, unknown>).uuid !== "string" ||
typeof (org as Record<string, unknown>).name !== "string"
)
) {
throw new Error("INVALID_CLAUDE_ORGS_RESPONSE");
}
return data.map((org) => {
const value = org as Record<string, unknown>;
return { uuid: value.uuid as string, name: value.name as string };
});
}
export function parseClaudeUsageResponse(data: unknown): ClaudeUsageResponse {
if (!data || typeof data !== "object") {
throw new Error("INVALID_CLAUDE_RESPONSE");
}
const value = data as Record<string, unknown>;
if (
!isClaudeWindow(value.five_hour) ||
!isClaudeWindow(value.seven_day) ||
(value.seven_day_sonnet !== undefined &&
value.seven_day_sonnet !== null &&
!isClaudeWindow(value.seven_day_sonnet)) ||
(value.seven_day_opus !== undefined &&
value.seven_day_opus !== null &&
!isClaudeWindow(value.seven_day_opus))
) {
throw new Error("INVALID_CLAUDE_RESPONSE");
}
const toWindow = (window: unknown) => {
const item = window as Record<string, unknown>;
return {
utilization: item.utilization as number,
...(typeof item.resets_at === "string"
? { resets_at: item.resets_at }
: {}),
};
};
return {
five_hour: toWindow(value.five_hour),
seven_day: toWindow(value.seven_day),
...(value.seven_day_sonnet
? { seven_day_sonnet: toWindow(value.seven_day_sonnet) }
: {}),
...(value.seven_day_opus
? { seven_day_opus: toWindow(value.seven_day_opus) }
: {}),
};
}
export async function fetchClaudeOrgs(auth: ClaudeAuth): Promise<ClaudeOrg[]> {
const response = await fetch(`${BASE_URL}/organizations`, {
headers: buildHeaders(auth),
credentials: "omit",
});
if (!response.ok) {
await response.text().catch(() => null);
if (response.status === 401 || response.status === 403) throw new Error("TOKEN_EXPIRED");
throw new Error(`HTTP_ERROR_${response.status}`);
}
const data: unknown = await response.json();
return parseClaudeOrgsResponse(data);
}
export async function fetchClaudeUsage(
auth: ClaudeAuth,
orgUuid: string
): Promise<ClaudeUsageResponse> {
const response = await fetch(`${BASE_URL}/organizations/${orgUuid}/usage`, {
headers: buildHeaders(auth),
credentials: "omit",
});
if (!response.ok) {
await response.text().catch(() => null);
if (response.status === 401 || response.status === 403) throw new Error("TOKEN_EXPIRED");
throw new Error(`HTTP_ERROR_${response.status}`);
}
const data: unknown = await response.json();
return parseClaudeUsageResponse(data);
}
-215
View File
@@ -1,215 +0,0 @@
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
const RESET_CREDITS_URL =
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
interface RawUsageWindow {
used_percent?: number;
limit_window_seconds?: number;
reset_at?: number;
}
interface RawUsageResponse {
plan_type?: string | null;
rate_limit?: {
primary_window?: RawUsageWindow | null;
secondary_window?: RawUsageWindow | null;
} | null;
credits?: {
has_credits?: boolean;
unlimited?: boolean;
balance?: string | number;
} | null;
rate_limit_reached_type?: string | null;
rate_limit_reset_credits?: {
available_count?: number | null;
} | null;
}
interface RawResetCredit {
status?: string;
granted_at?: string | null;
expires_at?: string | null;
}
interface RawResetCreditsResponse {
available_count?: number | null;
total_earned_count?: number | null;
credits?: RawResetCredit[] | null;
}
function isNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function formatDurationUntil(isoString: string | null | undefined): string | null {
if (!isoString) return null;
const targetMs = new Date(isoString).getTime();
if (Number.isNaN(targetMs)) return null;
let remainingSeconds = Math.max(Math.round((targetMs - Date.now()) / 1000), 0);
const days = Math.floor(remainingSeconds / 86400);
remainingSeconds -= days * 86400;
const hours = Math.floor(remainingSeconds / 3600);
remainingSeconds -= hours * 3600;
const minutes = Math.floor(remainingSeconds / 60);
remainingSeconds -= minutes * 60;
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
return parts.join(" ");
}
function normalizeWindow(window: RawUsageWindow | null | undefined) {
if (!window) return null;
const windowSeconds =
typeof window.limit_window_seconds === "number" ? window.limit_window_seconds : 0;
const resetsAt = typeof window.reset_at === "number" ? window.reset_at : 0;
return {
usedPercent: typeof window.used_percent === "number" ? window.used_percent : 0,
resetsAt,
windowMinutes: Math.round(windowSeconds / 60),
windowSeconds,
};
}
function buildBaseHeaders(auth: CodexAuth): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.accessToken}`,
"Content-Type": "application/json",
};
if (auth.accountId) {
headers["ChatGPT-Account-Id"] = auth.accountId;
}
return headers;
}
async function fetchResetCoupons(auth: CodexAuth, usage: RawUsageResponse) {
const headers = {
...buildBaseHeaders(auth),
"OpenAI-Beta": "codex-1",
originator: "Codex Desktop",
};
try {
const response = await fetch(RESET_CREDITS_URL, { headers });
if (!response.ok) {
throw new Error(`HTTP_ERROR_${response.status}`);
}
const payload = (await response.json()) as RawResetCreditsResponse;
const credits = Array.isArray(payload.credits) ? payload.credits : [];
const normalizedCredits = credits
.map((credit, index) => ({
index: index + 1,
status: credit.status,
grantedAt: credit.granted_at ?? null,
grantedAtLocal: credit.granted_at
? new Date(credit.granted_at).toLocaleString()
: null,
expiresAt: credit.expires_at ?? null,
expiresAtLocal: credit.expires_at
? new Date(credit.expires_at).toLocaleString()
: null,
timeUntilExpiry: formatDurationUntil(credit.expires_at),
}))
.sort((a, b) => {
const left = a.expiresAt ? new Date(a.expiresAt).getTime() : Number.MAX_SAFE_INTEGER;
const right = b.expiresAt ? new Date(b.expiresAt).getTime() : Number.MAX_SAFE_INTEGER;
return left - right;
});
const availableCredits = normalizedCredits.filter(
(credit) => credit.status === "available"
);
return {
source: "live_api" as const,
sourceDescription: "Live Codex reset-credit endpoint",
availableCount:
payload.available_count ?? usage.rate_limit_reset_credits?.available_count ?? null,
totalEarnedCount: payload.total_earned_count ?? null,
credits: normalizedCredits,
nextExpiringCredit: availableCredits[0] ?? normalizedCredits[0] ?? null,
};
} catch (error) {
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
return {
source: "unavailable" as const,
sourceDescription: "Reset-credit endpoint unavailable",
availableCount: usage.rate_limit_reset_credits?.available_count ?? null,
fallbackReason: message,
error: message,
};
}
}
export function parseCodexUsageResponse(data: unknown): CodexUsageResponse {
if (!data || typeof data !== "object") {
throw new Error("INVALID_CODEX_RESPONSE");
}
const value = data as Record<string, unknown>;
const primary = value.primary as Record<string, unknown> | null | undefined;
const secondary = value.secondary as Record<string, unknown> | null | undefined;
const credits = value.credits as Record<string, unknown> | undefined;
const validWindow = (window: Record<string, unknown> | null | undefined) =>
window === null ||
window === undefined ||
(isNumber(window.usedPercent) &&
isNumber(window.resetsAt) &&
isNumber(window.windowMinutes) &&
isNumber(window.windowSeconds));
if (
!validWindow(primary) ||
!validWindow(secondary) ||
!credits ||
typeof credits.hasCredits !== "boolean" ||
typeof credits.unlimited !== "boolean" ||
!isNumber(credits.balance)
) {
throw new Error("INVALID_CODEX_RESPONSE");
}
return value as unknown as CodexUsageResponse;
}
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
const headers = buildBaseHeaders(auth);
const response = await fetch(USAGE_URL, { headers });
if (response.status === 401 || response.status === 403) {
throw new Error("TOKEN_EXPIRED");
}
if (!response.ok) {
await response.text().catch(() => null);
throw new Error(`HTTP_ERROR_${response.status}`);
}
const payload = (await response.json()) as RawUsageResponse;
const resetCoupons = await fetchResetCoupons(auth, payload);
return {
planType: payload.plan_type ?? null,
rateLimitReachedType: payload.rate_limit_reached_type ?? null,
primary: normalizeWindow(payload.rate_limit?.primary_window),
secondary: normalizeWindow(payload.rate_limit?.secondary_window),
credits: {
hasCredits: Boolean(payload.credits?.has_credits),
unlimited: Boolean(payload.credits?.unlimited),
balance: Number(payload.credits?.balance ?? 0),
},
resetCoupons,
};
}
-19
View File
@@ -1,19 +0,0 @@
const CLAUDE_SESSION_KEY_PATTERN = /^sk-ant-[A-Za-z0-9_-]+$/;
export function validateClaudeSessionKey(value: string): string | null {
const key = value.trim();
if (!key.startsWith("sk-ant-")) {
return "Session key must start with sk-ant-";
}
if (key.length <= 40) {
return "Session key looks too short. Paste the complete cookie value.";
}
if (!CLAUDE_SESSION_KEY_PATTERN.test(key)) {
return "Session key contains unexpected characters. Check the pasted value.";
}
return null;
}
export function isClaudeSessionKeyValid(value: string): boolean {
return validateClaudeSessionKey(value) === null;
}
-23
View File
@@ -1,23 +0,0 @@
export const COLORS = {
codex: "#10a37f",
claude: "#d97706",
danger: "#ef4444",
warning: "#f59e0b",
disabled: "#e5e5e5",
muted: "#a3a3a3",
appBackground: "#090d11",
} as const;
export const PROGRESS_THRESHOLDS = {
warning: 60,
danger: 85,
} as const;
export const COUNTDOWN_INTERVAL_MS = 60_000;
export const RETRY_DELAY_MS = 5_000;
export function getUsageColor(percent: number): string {
if (percent >= PROGRESS_THRESHOLDS.danger) return COLORS.danger;
if (percent >= PROGRESS_THRESHOLDS.warning) return COLORS.warning;
return COLORS.codex;
}
-70
View File
@@ -1,70 +0,0 @@
import * as DocumentPicker from "expo-document-picker";
import * as LegacyFileSystem from "expo-file-system/legacy";
import type { CodexAuth } from "@/types/codex";
function sanitizeFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
}
async function readPickedTextFile(uri: string, fileName?: string): Promise<string> {
try {
const response = await fetch(uri);
if (!response.ok) {
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.text();
} catch {
try {
return await LegacyFileSystem.readAsStringAsync(uri);
} catch {
const cacheDirectory = LegacyFileSystem.cacheDirectory;
if (!cacheDirectory) {
throw new Error("DOCUMENT_NOT_READABLE");
}
const destination = `${cacheDirectory}${Date.now()}-${sanitizeFileName(
fileName ?? "import.json"
)}`;
try {
await LegacyFileSystem.copyAsync({ from: uri, to: destination });
return await LegacyFileSystem.readAsStringAsync(destination);
} catch {
throw new Error("DOCUMENT_NOT_READABLE");
}
}
}
}
export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
const result = await DocumentPicker.getDocumentAsync({
type: "application/json",
copyToCacheDirectory: true,
});
if (result.canceled) throw new Error("PICKER_CANCELLED");
const { uri, name } = result.assets[0];
const text = await readPickedTextFile(uri, name);
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("INVALID_JSON");
}
const tokens = parsed["tokens"] as Record<string, unknown> | undefined;
const accessToken = (tokens?.["access_token"] ?? parsed["accessToken"]) as string | undefined;
const accountId = (
tokens?.["account_id"] ??
parsed["accountId"] ??
parsed["account_id"]
) as string | undefined;
if (!accessToken || typeof accessToken !== "string") {
throw new Error("MISSING_ACCESS_TOKEN");
}
return { accessToken, accountId };
}
-160
View File
@@ -1,160 +0,0 @@
import type * as NotificationsType from "expo-notifications";
import {
loadNotifSettings,
loadNotifThresholdLastFired,
saveNotifThresholdLastFired,
} from "@/lib/storage";
import type { ClaudeUsageResponse } from "@/types/claude";
import type { CodexUsageResponse } from "@/types/codex";
// expo-notifications push notifications were removed from Expo Go in SDK 53.
// Use require() so the initialization error is caught gracefully in Expo Go.
let Notifications: typeof NotificationsType | null = null;
try {
Notifications = require("expo-notifications") as typeof NotificationsType;
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
} catch {
// Running in Expo Go or native module unavailable — notifications disabled.
}
export async function requestPermissions(): Promise<boolean> {
if (!Notifications) return false;
const { status } = await Notifications.requestPermissionsAsync();
return status === "granted";
}
export async function getPermissionStatus(): Promise<string> {
if (!Notifications) return "undetermined";
const { status } = await Notifications.getPermissionsAsync();
return status;
}
const DAILY_ID = "codexbar-daily-digest";
export async function scheduleDailyDigest(
hour: number,
minute: number,
claudeUsage: ClaudeUsageResponse | null,
codexUsage: CodexUsageResponse | null
): Promise<void> {
if (!Notifications) return;
try {
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
} catch {}
const parts: string[] = [];
if (claudeUsage) {
parts.push(`Claude 7d: ${Math.round(claudeUsage.seven_day.utilization)}% used`);
}
if (codexUsage) {
parts.push(
`Codex: ${Math.round(codexUsage.secondary?.usedPercent ?? 0)}% used`
);
}
await Notifications.scheduleNotificationAsync({
identifier: DAILY_ID,
content: {
title: "Usage Digest",
body:
parts.length > 0
? parts.join(" · ")
: "Open to check your usage limits",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.CALENDAR,
hour,
minute,
repeats: true,
},
});
}
export async function cancelDailyDigest(): Promise<void> {
if (!Notifications) return;
try {
await Notifications.cancelScheduledNotificationAsync(DAILY_ID);
} catch {}
}
interface ThresholdAlert {
service: string;
window: string;
remaining: number;
}
function buildThresholdAlerts(
thresholdPct: number,
claudeUsage: ClaudeUsageResponse | null,
codexUsage: CodexUsageResponse | null
): ThresholdAlert[] {
const alerts: ThresholdAlert[] = [];
if (claudeUsage) {
const remaining = 100 - claudeUsage.seven_day.utilization;
if (remaining <= thresholdPct) {
alerts.push({ service: "Claude", window: "7-day", remaining });
}
}
if (codexUsage) {
const remaining = 100 - (codexUsage.secondary?.usedPercent ?? 0);
if (remaining <= thresholdPct) {
alerts.push({ service: "Codex", window: "weekly", remaining });
}
}
return alerts;
}
/** Call this after every successful usage fetch. Reschedules daily digest and fires threshold alerts. */
export async function onUsageDataLoaded(
claudeUsage: ClaudeUsageResponse | null,
codexUsage: CodexUsageResponse | null
): Promise<void> {
const settings = await loadNotifSettings();
if (settings.dailyEnabled) {
await scheduleDailyDigest(
settings.dailyHour,
settings.dailyMinute,
claudeUsage,
codexUsage
);
}
if (settings.thresholdEnabled) {
const today = new Date().toISOString().slice(0, 10);
const lastFired = await loadNotifThresholdLastFired();
if (lastFired !== today) {
const alerts = buildThresholdAlerts(
settings.thresholdPct,
claudeUsage,
codexUsage
);
if (alerts.length > 0 && Notifications) {
await Notifications.scheduleNotificationAsync({
content: {
title: "Low quota warning",
body: alerts
.map(
(a) =>
`${a.service} ${a.window}: ${Math.round(a.remaining)}% remaining`
)
.join("\n"),
},
trigger: null,
});
await saveNotifThresholdLastFired(today);
}
}
}
}
-16
View File
@@ -1,16 +0,0 @@
import * as SecureStore from "expo-secure-store";
const KEY = "codexbar_onboarding_v1";
export async function hasCompletedOnboarding(): Promise<boolean> {
const val = await SecureStore.getItemAsync(KEY);
return val === "done";
}
export async function markOnboardingComplete(): Promise<void> {
await SecureStore.setItemAsync(KEY, "done");
}
export async function resetOnboarding(): Promise<void> {
await SecureStore.deleteItemAsync(KEY);
}
-159
View File
@@ -1,159 +0,0 @@
import * as SecureStore from "expo-secure-store";
import type { ClaudeUsageResponse } from "@/types/claude";
import type { CodexUsageResponse } from "@/types/codex";
const KEYS = {
CODEX_AUTH: "codexbar_codex_auth",
CLAUDE_SESSION_KEY: "codexbar_claude_session_key",
CLAUDE_LAST_ACTIVE_ORG: "codexbar_claude_last_active_org",
CODEX_USAGE_CACHE: "codexbar_codex_usage_cache",
CLAUDE_USAGE_CACHE: "codexbar_claude_usage_cache",
NOTIF_DAILY_ENABLED: "notifDailyEnabled",
NOTIF_DAILY_HOUR: "notifDailyHour",
NOTIF_DAILY_MINUTE: "notifDailyMinute",
NOTIF_THRESHOLD_ENABLED: "notifThresholdEnabled",
NOTIF_THRESHOLD_PCT: "notifThresholdPct",
NOTIF_THRESHOLD_LAST_FIRED: "notifThresholdLastFired",
} as const;
export interface UsageCache<T> {
data: T;
lastFetchedAt: number;
}
async function loadJson<T>(key: string): Promise<T | null> {
const raw = await SecureStore.getItemAsync(key);
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
await SecureStore.deleteItemAsync(key);
return null;
}
}
async function saveJson(key: string, value: unknown): Promise<void> {
await SecureStore.setItemAsync(key, JSON.stringify(value));
}
export interface NotifSettings {
dailyEnabled: boolean;
dailyHour: number;
dailyMinute: number;
thresholdEnabled: boolean;
/** Alert when remaining quota falls below this % (e.g. 20 = fire when < 20% left) */
thresholdPct: number;
}
export async function loadNotifSettings(): Promise<NotifSettings> {
const [de, dh, dm, te, tp] = await Promise.all([
SecureStore.getItemAsync(KEYS.NOTIF_DAILY_ENABLED),
SecureStore.getItemAsync(KEYS.NOTIF_DAILY_HOUR),
SecureStore.getItemAsync(KEYS.NOTIF_DAILY_MINUTE),
SecureStore.getItemAsync(KEYS.NOTIF_THRESHOLD_ENABLED),
SecureStore.getItemAsync(KEYS.NOTIF_THRESHOLD_PCT),
]);
return {
dailyEnabled: de === "true",
dailyHour: dh !== null ? parseInt(dh, 10) : 9,
dailyMinute: dm !== null ? parseInt(dm, 10) : 0,
thresholdEnabled: te === "true",
thresholdPct: tp !== null ? parseInt(tp, 10) : 20,
};
}
export async function saveNotifSettings(s: NotifSettings): Promise<void> {
await Promise.all([
SecureStore.setItemAsync(KEYS.NOTIF_DAILY_ENABLED, String(s.dailyEnabled)),
SecureStore.setItemAsync(KEYS.NOTIF_DAILY_HOUR, String(s.dailyHour)),
SecureStore.setItemAsync(KEYS.NOTIF_DAILY_MINUTE, String(s.dailyMinute)),
SecureStore.setItemAsync(KEYS.NOTIF_THRESHOLD_ENABLED, String(s.thresholdEnabled)),
SecureStore.setItemAsync(KEYS.NOTIF_THRESHOLD_PCT, String(s.thresholdPct)),
]);
}
export async function loadNotifThresholdLastFired(): Promise<string | null> {
return SecureStore.getItemAsync(KEYS.NOTIF_THRESHOLD_LAST_FIRED);
}
export async function saveNotifThresholdLastFired(date: string): Promise<void> {
await SecureStore.setItemAsync(KEYS.NOTIF_THRESHOLD_LAST_FIRED, date);
}
export async function saveCodexAuth(auth: {
accessToken: string;
accountId?: string;
}): Promise<void> {
await saveJson(KEYS.CODEX_AUTH, auth);
}
export async function loadCodexAuth(): Promise<{
accessToken: string;
accountId?: string;
} | null> {
return loadJson(KEYS.CODEX_AUTH);
}
export function loadCodexUsageCache(): Promise<UsageCache<CodexUsageResponse> | null> {
return loadJson(KEYS.CODEX_USAGE_CACHE);
}
export function saveCodexUsageCache(
cache: UsageCache<CodexUsageResponse>
): Promise<void> {
return saveJson(KEYS.CODEX_USAGE_CACHE, cache);
}
export function clearCodexUsageCache(): Promise<void> {
return SecureStore.deleteItemAsync(KEYS.CODEX_USAGE_CACHE);
}
export function loadClaudeUsageCache(): Promise<UsageCache<ClaudeUsageResponse> | null> {
return loadJson(KEYS.CLAUDE_USAGE_CACHE);
}
export function saveClaudeUsageCache(
cache: UsageCache<ClaudeUsageResponse>
): Promise<void> {
return saveJson(KEYS.CLAUDE_USAGE_CACHE, cache);
}
export function clearClaudeUsageCache(): Promise<void> {
return SecureStore.deleteItemAsync(KEYS.CLAUDE_USAGE_CACHE);
}
export async function saveClaudeSessionKey(key: string): Promise<void> {
await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key.trim());
}
export async function loadClaudeSessionKey(): Promise<string | null> {
return SecureStore.getItemAsync(KEYS.CLAUDE_SESSION_KEY);
}
export async function saveClaudeLastActiveOrg(orgId: string): Promise<void> {
await SecureStore.setItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG, orgId.trim());
}
export async function loadClaudeLastActiveOrg(): Promise<string | null> {
return SecureStore.getItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
}
export async function clearClaudeLastActiveOrg(): Promise<void> {
await SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG);
}
export async function clearCodexAuth(): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(KEYS.CODEX_AUTH),
clearCodexUsageCache(),
]);
}
export async function clearClaudeSessionKey(): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY),
SecureStore.deleteItemAsync(KEYS.CLAUDE_LAST_ACTIVE_ORG),
clearClaudeUsageCache(),
]);
}
-66
View File
@@ -1,66 +0,0 @@
export function formatResetCountdown(resetAtSeconds: number): string {
const nowSeconds = Math.floor(Date.now() / 1000);
const diffSeconds = resetAtSeconds - nowSeconds;
if (diffSeconds <= 0) return "resetting now";
if (diffSeconds < 60) return "resets in <1m";
const totalHours = Math.floor(diffSeconds / 3600);
const minutes = Math.floor((diffSeconds % 3600) / 60);
if (totalHours >= 24) {
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
if (hours > 0) return `resets in ${days}d ${hours}h`;
return `resets in ${days}d`;
}
if (totalHours > 0 && minutes > 0) return `resets in ${totalHours}h ${minutes}m`;
if (totalHours > 0) return `resets in ${totalHours}h`;
return `resets in ${minutes}m`;
}
export function parseTimeInput(
value: string
): { hour: number; minute: number } | null {
const match = value.trim().match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (hour > 23 || minute > 59) return null;
return { hour, minute };
}
export function formatTime(hour: number, minute: number): string {
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
export function formatLastUpdated(timestamp: number, now = Date.now()): string {
const elapsedSeconds = Math.max(0, Math.floor((now - timestamp) / 1000));
if (elapsedSeconds < 60) return "Updated just now";
const minutes = Math.floor(elapsedSeconds / 60);
if (minutes < 60) return `Updated ${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `Updated ${hours} hr${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
return `Updated ${days} day${days === 1 ? "" : "s"} ago`;
}
export function formatResetCountdownISO(isoString: string): string {
const resetAtSeconds = Math.floor(new Date(isoString).getTime() / 1000);
return formatResetCountdown(resetAtSeconds);
}
export function windowLabel(limitWindowSeconds: number): string {
const hours = limitWindowSeconds / 3600;
if (hours < 24) return `${Math.round(hours)}h`;
const days = hours / 24;
const wholeDays = Math.floor(days);
const remainderHours = Math.round(hours - wholeDays * 24);
if (remainderHours === 0) return `${wholeDays}d`;
return `${wholeDays}d ${remainderHours}h`;
}
-6
View File
@@ -1,6 +0,0 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css" });
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="nativewind/types" />
declare module "*.css" {
const content: unknown;
export default content;
}
-8792
View File
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
{
"name": "codexbar.mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"dependencies": {
"@expo/metro-runtime": "~56.0.15",
"@expo/vector-icons": "^15.1.1",
"babel-preset-expo": "~56.0.0",
"expo": "~56.0.14",
"expo-constants": "~56.0.20",
"expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.7",
"expo-linking": "~56.0.15",
"expo-notifications": "~56.0.19",
"expo-router": "~56.2.13",
"expo-secure-store": "~56.0.4",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"expo-system-ui": "~56.0.5",
"expo-updates": "~56.0.21",
"nativewind": "^4.2.6",
"react": "19.2.3",
"react-dom": "^19.2.3",
"react-native": "0.85.3",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-web": "^0.21.2",
"react-native-worklets": "^0.8.3"
},
"devDependencies": {
"@types/react": "~19.2.2",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"update:preview": "eas update --channel preview --environment preview",
"update:production": "eas update --channel production --environment production",
"build:android:preview": "eas build --platform android --profile preview --non-interactive",
"build:android:production": "eas build --platform android --profile production --non-interactive"
},
"private": true
}
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'CodexMobile'
include ':app'
-20
View File
@@ -1,20 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./components/**/*.{js,jsx,ts,tsx}",
"./hooks/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
brand: {
codex: "#10a37f",
claude: "#d97706",
},
},
},
},
plugins: [],
};
-15
View File
@@ -1,15 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts",
"nativewind-env.d.ts"
]
}
-21
View File
@@ -1,21 +0,0 @@
export interface ClaudeAuth {
sessionKey: string;
lastActiveOrg?: string;
}
export interface ClaudeOrg {
uuid: string;
name: string;
}
export interface ClaudeWindowUsage {
utilization: number;
resets_at?: string;
}
export interface ClaudeUsageResponse {
five_hour: ClaudeWindowUsage;
seven_day: ClaudeWindowUsage;
seven_day_sonnet?: ClaudeWindowUsage;
seven_day_opus?: ClaudeWindowUsage;
}
-57
View File
@@ -1,57 +0,0 @@
export interface CodexAuth {
accessToken: string;
accountId?: string;
}
export interface CodexUsageWindow {
usedPercent: number;
resetsAt: number;
windowMinutes: number;
windowSeconds: number;
}
export interface CodexCreditsSummary {
hasCredits: boolean;
unlimited: boolean;
balance: number;
}
export interface CodexResetCredit {
index: number;
status?: string;
grantedAt?: string | null;
grantedAtLocal?: string | null;
expiresAt?: string | null;
expiresAtLocal?: string | null;
timeUntilExpiry?: string | null;
}
export interface CodexResetCoupons {
source: "live_api" | "local_state_fallback" | "unavailable";
sourceDescription: string;
availableCount?: number | null;
totalEarnedCount?: number | null;
credits?: CodexResetCredit[];
nextExpiringCredit?: CodexResetCredit | null;
dismissedAtLocal?: string | null;
latestPossibleExpiryLocal?: string | null;
latestPossibleExpiryNote?: string | null;
fallbackReason?: string;
error?: string;
}
export interface CodexDesktopSnapshot {
sessionFile?: string;
threadId?: string | null;
snapshotTimestamp?: string | null;
limitId?: string | null;
planType?: string | null;
rateLimitReachedType?: string | null;
primary: CodexUsageWindow | null;
secondary: CodexUsageWindow | null;
}
export interface CodexUsageResponse extends CodexDesktopSnapshot {
credits: CodexCreditsSummary;
resetCoupons?: CodexResetCoupons | null;
}