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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,7 @@ app-example

# test coverage
coverage/

# Signing credentials
credentials.json
ios-certs/
58 changes: 58 additions & 0 deletions PRIVACY_POLICY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Privacy Policy

**Last updated:** 27 April 2026

NearForm ("we", "us", or "our") built the CodeMeter app (the "App") as an open-source application. This Privacy Policy explains how we collect, use, and protect your information when you use the App.

## Information We Collect

### Authentication Data

The App uses GitHub OAuth to authenticate you. During sign-in, we receive an OAuth access token and your GitHub username. These are stored locally on your device using secure storage and are never transmitted to any server operated by us.

### Usage Data

The App retrieves your AI coding assistant quota and usage information directly from the GitHub API using your OAuth token. This data is cached locally on your device to improve performance and enable offline access.

### Data We Do Not Collect

- We do not collect personal information such as your name, email address, phone number, or location.
- We do not use analytics, crash reporting, or tracking SDKs.
- We do not collect device identifiers or advertising IDs.

## How We Use Your Information

Your information is used solely to:

- Authenticate you with GitHub
- Display your AI coding usage and quota information

## Data Sharing

We do not sell, trade, or share your data with any third parties. Your data is only transmitted between your device and GitHub's API to retrieve your usage quota information.

## Data Storage and Security

All data is stored locally on your device using platform-provided secure storage mechanisms. No data is stored on any external server operated by us.

## Data Deletion

You can remove all stored data at any time by signing out within the App. This deletes your OAuth token, cached quota data, and all locally stored information. Alternatively, uninstalling the App will remove all associated data from your device.

## Third-Party Services

The App interacts with the following third-party service:

- **GitHub API** ([GitHub Privacy Statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement)) — used to authenticate and retrieve your usage quota data.

## Children's Privacy

The App is not directed at children under the age of 13. We do not knowingly collect personal information from children.

## Changes to This Privacy Policy

We may update this Privacy Policy from time to time. Any changes will be posted in this document with an updated revision date.

## Contact Us

If you have any questions about this Privacy Policy, please open an issue on our [GitHub repository](https://github.com/nearform/copilot-status).
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Copilot Status
# CodeMeter

A mobile app to monitor your GitHub Copilot usage quota with native home screen widgets for iOS and Android.
A mobile app to monitor your AI coding assistant usage quota with native home screen widgets for iOS and Android.

## Features

Expand Down
2 changes: 1 addition & 1 deletion __tests__/hooks/useUserData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jest.mock('@/stores/secureStorage', () => ({

jest.mock('@/services/api', () => ({
fetchGitHubUser: jest.fn(),
fetchCopilotQuota: jest.fn(),
fetchQuota: jest.fn(),
}));

const mockedUseQuery = useQuery as jest.Mock;
Expand Down
52 changes: 26 additions & 26 deletions __tests__/services/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fetchCopilotQuota, fetchGitHubUser } from '@/services/api';
import type { GitHubCopilotResponse } from '@/types/api';
import { fetchQuota, fetchGitHubUser } from '@/services/api';
import type { GitHubQuotaResponse } from '@/types/api';
import type { AllQuotas, PaidQuotas } from '@/types/quota';
import { Octokit } from '@octokit/rest';

Expand Down Expand Up @@ -54,7 +54,7 @@ describe('services/api', () => {
});
});

describe('fetchCopilotQuota', () => {
describe('fetchQuota', () => {
const createMockQuotaSnapshot = <T extends string>(
quotaId: T,
entitlement: number,
Expand All @@ -73,7 +73,7 @@ describe('services/api', () => {
unlimited,
});

const mockCopilotResponse: GitHubCopilotResponse = {
const mockQuotaResponse: GitHubQuotaResponse = {
access_type_sku: 'plus_yearly_subscriber_quota',
analytics_tracking_id: 'test-tracking-id',
assigned_date: '2024-01-01T00:00:00Z',
Expand All @@ -98,10 +98,10 @@ describe('services/api', () => {
},
};

it('should fetch and parse copilot quota data', async () => {
it('should fetch and parse quota data', async () => {
const lastUpdated = new Date('2024-01-15T12:00:00');
jest.useFakeTimers().setSystemTime(lastUpdated);
const mockRequest = jest.fn().mockResolvedValue({ data: mockCopilotResponse });
const mockRequest = jest.fn().mockResolvedValue({ data: mockQuotaResponse });

const MockedOctokit = jest.mocked(Octokit);
MockedOctokit.mockImplementation(
Expand All @@ -111,7 +111,7 @@ describe('services/api', () => {
}) as unknown as InstanceType<typeof Octokit>
);

const result = await fetchCopilotQuota('test-token');
const result = await fetchQuota('test-token');

jest.useRealTimers();

Expand Down Expand Up @@ -159,8 +159,8 @@ describe('services/api', () => {
});

it('should calculate used quota correctly', async () => {
const mockResponse: GitHubCopilotResponse = {
...mockCopilotResponse,
const mockResponse: GitHubQuotaResponse = {
...mockQuotaResponse,
quota_snapshots: {
premium_interactions: createMockQuotaSnapshot('premium_interactions', 2000, 500, 25),
chat: createMockQuotaSnapshot('chat', 0, 0, 100, true),
Expand All @@ -178,7 +178,7 @@ describe('services/api', () => {
}) as unknown as InstanceType<typeof Octokit>
);

const result = await fetchCopilotQuota('test-token');
const result = await fetchQuota('test-token');

expect(result.hasSubscription).toBe(true);

Expand All @@ -191,8 +191,8 @@ describe('services/api', () => {
});

it('should clamp percent values when usage exceeds quota', async () => {
const mockResponse: GitHubCopilotResponse = {
...mockCopilotResponse,
const mockResponse: GitHubQuotaResponse = {
...mockQuotaResponse,
quota_snapshots: {
premium_interactions: createMockQuotaSnapshot('premium_interactions', 1000, -500, -50),
chat: createMockQuotaSnapshot('chat', 500, -100, -20),
Expand All @@ -210,7 +210,7 @@ describe('services/api', () => {
}) as unknown as InstanceType<typeof Octokit>
);

const result = await fetchCopilotQuota('test-token');
const result = await fetchQuota('test-token');
const withQuotas = result as PaidQuotas;

expect(withQuotas.premium_interactions.remainingPercent).toBe(0);
Expand All @@ -235,12 +235,12 @@ describe('services/api', () => {
}) as unknown as InstanceType<typeof Octokit>
);

await expect(fetchCopilotQuota('test-token')).rejects.toThrow('API Error');
await expect(fetchQuota('test-token')).rejects.toThrow('API Error');
});

it('should return hasSubscription false for no_access users', async () => {
const mockResponse: GitHubCopilotResponse = {
...mockCopilotResponse,
const mockResponse: GitHubQuotaResponse = {
...mockQuotaResponse,
access_type_sku: 'no_access',
};

Expand All @@ -254,16 +254,16 @@ describe('services/api', () => {
}) as unknown as InstanceType<typeof Octokit>
);

const result = await fetchCopilotQuota('test-token');
const result = await fetchQuota('test-token');
expect(result.hasSubscription).toBe(false);
});

it('should parse free tier response with limited_user_quotas', async () => {
const lastUpdated = new Date('2024-01-15T12:00:00');
jest.useFakeTimers().setSystemTime(lastUpdated);

const mockResponse: GitHubCopilotResponse = {
...mockCopilotResponse,
const mockResponse: GitHubQuotaResponse = {
...mockQuotaResponse,
access_type_sku: 'free_limited_copilot',
quota_snapshots: undefined,
quota_reset_date: undefined,
Expand All @@ -283,7 +283,7 @@ describe('services/api', () => {
}) as unknown as InstanceType<typeof Octokit>
);

const result = await fetchCopilotQuota('test-token');
const result = await fetchQuota('test-token');
jest.useRealTimers();

expect(result.hasSubscription).toBe(true);
Expand Down Expand Up @@ -322,8 +322,8 @@ describe('services/api', () => {
const lastUpdated = new Date('2024-01-15T12:00:00');
jest.useFakeTimers().setSystemTime(lastUpdated);

const mockResponse: GitHubCopilotResponse = {
...mockCopilotResponse,
const mockResponse: GitHubQuotaResponse = {
...mockQuotaResponse,
access_type_sku: 'free_limited_copilot',
quota_snapshots: undefined,
quota_reset_date: undefined,
Expand All @@ -342,7 +342,7 @@ describe('services/api', () => {
}) as unknown as InstanceType<typeof Octokit>
);

const result = await fetchCopilotQuota('test-token');
const result = await fetchQuota('test-token');
jest.useRealTimers();

expect(result.hasSubscription).toBe(true);
Expand All @@ -361,8 +361,8 @@ describe('services/api', () => {
});

it('should return hasSubscription false when response has no quota data', async () => {
const mockResponse: GitHubCopilotResponse = {
...mockCopilotResponse,
const mockResponse: GitHubQuotaResponse = {
...mockQuotaResponse,
access_type_sku: 'free_limited_copilot',
quota_snapshots: undefined,
monthly_quotas: undefined,
Expand All @@ -378,7 +378,7 @@ describe('services/api', () => {
}) as unknown as InstanceType<typeof Octokit>
);

const result = await fetchCopilotQuota('test-token');
const result = await fetchQuota('test-token');
expect(result.hasSubscription).toBe(false);
});
});
Expand Down
8 changes: 4 additions & 4 deletions __tests__/stores/quota.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { queryClient } from '@/services/queryClient';
import { useQuotaStore } from '@/stores/quota';
import { clearCopilotWidget } from '@/widgets/voltraWidgetService';
import { clearAppWidget } from '@/widgets/voltraWidgetService';

jest.mock('@/services/queryClient', () => ({
queryClient: {
Expand All @@ -9,7 +9,7 @@ jest.mock('@/services/queryClient', () => ({
}));

jest.mock('@/widgets/voltraWidgetService', () => ({
clearCopilotWidget: jest.fn(),
clearAppWidget: jest.fn(),
}));

describe('stores/quota', () => {
Expand All @@ -25,11 +25,11 @@ describe('stores/quota', () => {
expect(queryClient.clear).toHaveBeenCalled();
});

it('should clear copilot widget when clearQuota is called', () => {
it('should clear widget when clearQuota is called', () => {
const store = useQuotaStore();
store.clearQuota();

expect(clearCopilotWidget).toHaveBeenCalled();
expect(clearAppWidget).toHaveBeenCalled();
});
});
});
8 changes: 4 additions & 4 deletions __tests__/stores/theme.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useThemeStore } from '@/stores/theme';
import { updateCopilotWidget } from '@/widgets/voltraWidgetService';
import { updateAppWidget } from '@/widgets/voltraWidgetService';
import { UnistylesRuntime } from 'react-native-unistyles';

jest.mock('@/services/storage', () => ({
Expand Down Expand Up @@ -34,7 +34,7 @@ describe('stores/theme', () => {
expect(useThemeStore.getState().themePreference).toBe('light');
expect(UnistylesRuntime.setAdaptiveThemes).toHaveBeenCalledWith(false);
expect(UnistylesRuntime.setTheme).toHaveBeenCalledWith('light');
expect(updateCopilotWidget).toHaveBeenCalled();
expect(updateAppWidget).toHaveBeenCalled();
});

it('should set dark theme and update storage', () => {
Expand All @@ -44,7 +44,7 @@ describe('stores/theme', () => {
expect(useThemeStore.getState().themePreference).toBe('dark');
expect(UnistylesRuntime.setAdaptiveThemes).toHaveBeenCalledWith(false);
expect(UnistylesRuntime.setTheme).toHaveBeenCalledWith('dark');
expect(updateCopilotWidget).toHaveBeenCalled();
expect(updateAppWidget).toHaveBeenCalled();
});

it('should set system theme with adaptive mode', () => {
Expand All @@ -54,7 +54,7 @@ describe('stores/theme', () => {
expect(useThemeStore.getState().themePreference).toBe('system');
expect(UnistylesRuntime.setAdaptiveThemes).toHaveBeenCalledWith(true);
expect(UnistylesRuntime.setTheme).not.toHaveBeenCalled();
expect(updateCopilotWidget).toHaveBeenCalled();
expect(updateAppWidget).toHaveBeenCalled();
});
});
});
3 changes: 3 additions & 0 deletions app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ export default ({ config }) => ({
extra: {
githubClientId: process.env.GITHUB_CLIENT_ID,
githubClientSecret: process.env.GITHUB_CLIENT_SECRET,
eas: {
projectId: 'b516d3da-fe35-4f76-9e27-5cee5d915cfd',
},
},
});
27 changes: 16 additions & 11 deletions app.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"expo": {
"name": "Copilot Status",
"slug": "copilot-status",
"name": "CodeMeter",
"slug": "codemeter",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
Expand All @@ -11,7 +11,9 @@
"supportsTablet": true,
"bundleIdentifier": "com.nearform.copilotstatus",
"entitlements": {
"com.apple.security.application-groups": ["group.com.nearform.copilotstatus"]
"com.apple.security.application-groups": [
"group.com.nearform.copilotstatus"
]
}
},
"android": {
Expand All @@ -21,7 +23,7 @@
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.nearform.copilotstatus"
"package": "com.nearform.copilotstatusapp"
},
"plugins": [
"expo-router",
Expand Down Expand Up @@ -53,18 +55,21 @@
"groupIdentifier": "group.com.nearform.copilotstatus",
"widgets": [
{
"id": "copilot_status",
"displayName": "Copilot Status",
"description": "Monitor your GitHub Copilot usage quota",
"supportedFamilies": ["systemSmall", "systemMedium"]
"id": "code_meter",
"displayName": "CodeMeter",
"description": "Monitor your AI coding usage quota",
"supportedFamilies": [
"systemSmall",
"systemMedium"
]
}
],
"android": {
"widgets": [
{
"id": "copilot_status",
"displayName": "Copilot Status",
"description": "Monitor your GitHub Copilot usage quota",
"id": "code_meter",
"displayName": "CodeMeter",
"description": "Monitor your AI coding usage quota",
"targetCellWidth": 4,
"targetCellHeight": 1,
"minWidth": 250,
Expand Down
Binary file added assets/images/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion assets/images/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/images/play-store-feature-graphic.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading