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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Ranked Choices mobile

Phase 1 of the Ranked Choices Expo migration. This app currently provides a
Phase 2 of the Ranked Choices Expo migration is in progress. This app currently provides a
shortcode lookup, ballot preview, and local candidate-ranking controls backed
by the existing PHP API. Anonymous ballots can be submitted through the typed,
idempotent v2 vote endpoint. Released votes are loaded through the public v2
Expand All @@ -9,7 +9,11 @@ TypeScript module. Secure ballots can be submitted with an assigned voter
code. Ballots with voter grouping enabled render and validate their select,
checkbox, and text questions before submission. Ballot and results screens can
open the system share sheet with the canonical RankedChoices.com ballot link.
The app does not authenticate users yet.
The native app can also create a basic guest ballot from a name and candidate
list. The API generates its shortcode and a one-time management credential;
only the credential digest is stored on the server, while the native client
protects the credential with Expo SecureStore. The app does not authenticate
users yet, and advanced ballot creation remains on RankedChoices.com.

## Get started

Expand Down Expand Up @@ -55,6 +59,14 @@ committing the eventual store identity:
APP_VARIANT=development npx expo run:android
```

Basic ballot creation uses a native SecureStore module and is disabled on Expo
web. Rebuild a development client after adding or updating that dependency. If
encrypted storage is unavailable, the client refuses to create a ballot. If
storage fails after the server responds, keep the success screen open and retry
saving access; the app retains the credential only in memory during that
recovery state and never shows it in a URL, share payload, log, or error
message.

The dynamic app config defaults local commands to the development variant.
`RCV_ANDROID_PACKAGE` and `RCV_IOS_BUNDLE_IDENTIFIER` remain available as
explicit local overrides.
Expand Down Expand Up @@ -103,6 +115,21 @@ ADB="$ANDROID_HOME/platform-tools/adb" \
npm run test:android:e2e
```

To create a disposable basic ballot, verify that its management credential was
saved, and open the new ballot in a development build:

```bash
RCV_E2E_CREATE_BALLOT=1 \
RCV_E2E_APP_PACKAGE=com.rankedchoices.dev \
RCV_E2E_INCOMING_URL=rankedchoices:///create \
RCV_E2E_COLD_START=0 \
ADB="$ANDROID_HOME/platform-tools/adb" \
npm run test:android:e2e
```

This scenario creates a real local database row. Remove the generated
shortcode from the disposable development database after the test.

Expo Go is the default target. A development build can exercise the custom
scheme with:

Expand Down Expand Up @@ -181,6 +208,8 @@ request, or user data.
- accessible select, checkbox, and text grouping questions with client and
server validation
- canonical ballot-link sharing through the native system share sheet
- basic guest ballot creation with server-generated shortcodes and encrypted,
device-local management credentials
- local winner and round-by-round result rendering after an accepted vote
- loading, closed, not-found, malformed-response, and network-error handling

Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-splash-screen",
{
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"expo-dev-client": "~57.0.18",
"expo-linking": "~57.0.9",
"expo-router": "~57.0.19",
"expo-secure-store": "~57.0.3",
"expo-splash-screen": "~57.0.8",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
Expand Down
41 changes: 39 additions & 2 deletions apps/mobile/scripts/android-phase1-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ const ballotKey = process.env.RCV_E2E_BALLOT_KEY ?? 'pizza';
const voterCode = process.env.RCV_E2E_VOTER_CODE?.trim();
const groupOptionLabel = process.env.RCV_E2E_GROUP_OPTION_LABEL?.trim();
const shareOnly = process.env.RCV_E2E_SHARE_ONLY === '1';
const createBallot = process.env.RCV_E2E_CREATE_BALLOT === '1';
const appPackage = process.env.RCV_E2E_APP_PACKAGE ?? 'host.exp.exponent';
const incomingUrl =
process.env.RCV_E2E_INCOMING_URL ??
`exp://127.0.0.1:8081/--/ballot/${encodeURIComponent(ballotKey)}`;
(createBallot
? 'exp://127.0.0.1:8081/--/create'
: `exp://127.0.0.1:8081/--/ballot/${encodeURIComponent(ballotKey)}`);
const coldStart = process.env.RCV_E2E_COLD_START !== '0';

if (voterCode && !/^[A-Za-z0-9]{6}$/.test(voterCode)) {
Expand Down Expand Up @@ -78,7 +81,41 @@ const startArguments = [
];
run(...startArguments);

let xml = waitFor(/text="Shortcode: [^"]+"/, 'the incoming ballot link');
let xml;

if (createBallot) {
const suffix = String(Date.now()).slice(-6);
const fields = [
['Ballot name', `E2E-${suffix}`],
['Candidate 1', `Alpha-${suffix}`],
['Candidate 2', `Beta-${suffix}`],
];

xml = waitFor(/content-desc="Ballot name"/, 'the basic ballot form');
for (const [label, value] of fields) {
const pattern = new RegExp(`content-desc="${escapeRegExp(label)}"[^>]*bounds="([^"]+)"`);
xml = scrollUntil(pattern, `${label} field`);
tapMatching(xml, pattern, `${label} field`);
run('shell', 'input', 'text', value);
run('shell', 'input', 'keyevent', '4');
}

xml = scrollUntil(/content-desc="Create ballot"[^>]*bounds="([^"]+)"/, 'the create button');
tapMatching(xml, /content-desc="Create ballot"[^>]*bounds="([^"]+)"/, 'the create button');
xml = waitFor(/text="BALLOT CREATED"/, 'the created-ballot state');
waitFor(/text="Management access is protected on this device\."/, 'encrypted credential storage');

const shortcode = xml.match(/text="([a-f0-9]{8})"/)?.[1];
if (!shortcode) throw new Error('Could not read the created ballot shortcode.');

xml = scrollUntil(/content-desc="Open ballot"[^>]*bounds="([^"]+)"/, 'the open-ballot button');
tapMatching(xml, /content-desc="Open ballot"[^>]*bounds="([^"]+)"/, 'the open-ballot button');
waitFor(new RegExp(`text="Shortcode: ${shortcode}"`), 'the newly created ballot');
console.log(`Android guest-ballot creation E2E passed for shortcode ${shortcode}`);
process.exit(0);
}

xml = waitFor(/text="Shortcode: [^"]+"/, 'the incoming ballot link');

if (shareOnly) {
tapMatching(
Expand Down
83 changes: 83 additions & 0 deletions apps/mobile/src/api/v2-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,89 @@ const request = {
voterCode: 'abcooi',
};

const createdBallot = {
status: 'created',
ballot: { id: 42, key: '12ab34cd', name: 'Lunch', positions: 1 },
candidates: [
{ id: 100, name: 'Tacos' },
{ id: 101, name: 'Salad' },
],
managementToken: 'a'.repeat(43),
};

describe('V2ApiClient.createBallot', () => {
it('creates a basic ballot and accepts the one-time management credential', async () => {
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ data: createdBallot, error: null }), { status: 201 }),
);
const client = new V2ApiClient({ baseUrl: 'https://example.test/api/', fetchImpl });

await expect(
client.createBallot({ name: 'Lunch', candidates: ['Tacos', 'Salad'] }),
).resolves.toEqual(createdBallot);
expect(fetchImpl).toHaveBeenCalledWith('https://example.test/api/v2/ballots.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Lunch', candidates: ['Tacos', 'Salad'] }),
signal: undefined,
});
});

it('preserves server field errors for the creation form', async () => {
const client = new V2ApiClient({
baseUrl: 'https://example.test/api',
fetchImpl: async () =>
new Response(
JSON.stringify({
data: null,
error: {
code: 'validation_failed',
message: 'Check the ballot details.',
fields: { name: 'Enter a name.', 'candidates.1': 'Candidate names must be unique.' },
},
}),
{ status: 422 },
),
});

await expect(client.createBallot({ name: '', candidates: ['A', 'A'] })).rejects.toMatchObject({
code: 'validation_failed',
fields: { name: 'Enter a name.', 'candidates.1': 'Candidate names must be unique.' },
retryable: false,
status: 422,
});
});

it('treats a lost creation response as uncertain and unsafe to retry', async () => {
const client = new V2ApiClient({
baseUrl: 'https://example.test/api',
fetchImpl: async () => {
throw new Error('connection lost');
},
});

await expect(client.createBallot({ name: 'Lunch', candidates: ['A', 'B'] })).rejects.toMatchObject({
code: 'creation_unknown',
retryable: false,
});
});

it('rejects success data without a valid management credential', async () => {
const client = new V2ApiClient({
baseUrl: 'https://example.test/api',
fetchImpl: async () =>
new Response(
JSON.stringify({ data: { ...createdBallot, managementToken: 'short' }, error: null }),
{ status: 201 },
),
});

await expect(client.createBallot({ name: 'Lunch', candidates: ['A', 'B'] })).rejects.toMatchObject({
code: 'malformed_response',
});
});
});

describe('V2ApiClient.submitVote', () => {
it('submits typed rankings and returns the accepted response', async () => {
const fetchImpl = vi.fn(async () =>
Expand Down
88 changes: 88 additions & 0 deletions apps/mobile/src/api/v2-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ export type SubmitVoteResult = {
replayed: boolean;
};

export type CreateBallotRequest = {
name: string;
candidates: string[];
};

export type CreatedBallot = {
status: 'created';
ballot: {
id: number;
key: string;
name: string;
positions: number;
};
candidates: { id: number; name: string }[];
managementToken: string;
};

export type ElectionResults = {
ballot: {
key: string;
Expand All @@ -25,6 +42,7 @@ export type ElectionResults = {
};

export type V2ApiErrorCode =
| 'invalid_json'
| 'validation_failed'
| 'ballot_not_found'
| 'results_not_released'
Expand All @@ -40,6 +58,7 @@ export type V2ApiErrorCode =
| 'invalid_ranking'
| 'server_error'
| 'network'
| 'creation_unknown'
| 'http'
| 'malformed_response';

Expand All @@ -49,6 +68,7 @@ export class V2ApiError extends Error {
message: string,
public readonly retryable = false,
public readonly status?: number,
public readonly fields?: Record<string, string>,
) {
super(message);
this.name = 'V2ApiError';
Expand All @@ -71,6 +91,7 @@ function isKnownErrorCode(value: unknown): value is V2ApiErrorCode {
typeof value === 'string' &&
[
'validation_failed',
'invalid_json',
'ballot_not_found',
'results_not_released',
'idempotency_conflict',
Expand All @@ -97,6 +118,34 @@ export class V2ApiClient {
this.fetchImpl = fetchImpl;
}

async createBallot(
request: CreateBallotRequest,
signal?: AbortSignal,
): Promise<CreatedBallot> {
let response: Response;
try {
response = await this.fetchImpl(`${this.baseUrl}/v2/ballots.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal,
});
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') throw error;
throw new V2ApiError(
'creation_unknown',
'The connection ended before creation could be confirmed. The ballot may have been created, so do not submit it again yet.',
);
}

const envelope = await this.parseEnvelope(response);
if (envelope.error !== null) throw this.normalizeError(envelope.error, response.status);
if (!response.ok || !isCreatedBallot(envelope.data)) {
throw new V2ApiError('malformed_response', 'The ballot server returned invalid success data.');
}
return envelope.data;
}

async getResults(key: string, signal?: AbortSignal): Promise<ElectionResults> {
let response: Response;
try {
Expand Down Expand Up @@ -180,10 +229,49 @@ export class V2ApiClient {
error.message,
error.code === 'server_error' || status >= 500,
status,
normalizeErrorFields(error.fields),
);
}
}

function normalizeErrorFields(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const entries = Object.entries(value);
if (!entries.every(([, message]) => typeof message === 'string')) return undefined;
return Object.fromEntries(entries) as Record<string, string>;
}

function isCreatedBallot(value: unknown): value is CreatedBallot {
if (
!isRecord(value) ||
value.status !== 'created' ||
!isRecord(value.ballot) ||
!Array.isArray(value.candidates) ||
typeof value.managementToken !== 'string'
) {
return false;
}

const ballot = value.ballot;
return (
Number.isInteger(ballot.id) &&
(ballot.id as number) > 0 &&
typeof ballot.key === 'string' &&
/^[a-f0-9]{8}$/.test(ballot.key) &&
typeof ballot.name === 'string' &&
ballot.positions === 1 &&
value.candidates.length >= 2 &&
value.candidates.every(
(candidate) =>
isRecord(candidate) &&
Number.isInteger(candidate.id) &&
(candidate.id as number) > 0 &&
typeof candidate.name === 'string',
) &&
/^[A-Za-z0-9_-]{43}$/.test(value.managementToken)
);
}

function isElectionResults(value: unknown): value is ElectionResults {
if (!isRecord(value) || !isRecord(value.ballot) || !Array.isArray(value.candidates) || !Array.isArray(value.votes)) {
return false;
Expand Down
Loading
Loading