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
2 changes: 1 addition & 1 deletion docs/CODEBASE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ inside a root stack, with a concept-detail modal above them.
| `WeeklyQuizScreen.tsx` | Optional server-backed weekly quiz: eligibility progress, seven reviewed questions, icon-marked accessible answer selection, result feedback and reattempts. |
| `SubtopicQuizzesScreen.tsx` / `SubtopicQuizScreen.tsx` | Profile-linked optional quizzes for completed subtopics, frozen reviewed questions, icon-marked accessible answer selection, retries, and prior-score history. |
| `ProfileScreen.tsx` | Task-grouped account hub: profile/privacy, learning, topics, preferences, library and support. It keeps clear reminder prerequisites and immediate per-control queued reminder writes. |
| `EditProfileScreen.tsx` / `services/profilePhotoPicker.ts` / `services/profilePhotoPreparation.ts` | Name and bio editing with temporary Android keyboard scroll room, camera/library photo preview and explicit save, account-bound picker recovery, bounded JPEG preparation with a native-compatible header read, and confirmed avatar writes. |
| `EditProfileScreen.tsx` / `services/profilePhotoPicker.ts` / `services/profilePhotoPreparation.ts` | Name and bio editing with temporary Android keyboard scroll room, camera/library photo preview and explicit save, account-bound picker recovery, bounded JPEG bytes from the picker or converter with a signature check, and confirmed avatar writes. |
| `ProfileSharingScreen.tsx` / `ProfilePublishReviewSheet.tsx` / `PublicProfileLink.tsx` | Opt-in, independently selected sharing fields, a private review-before-publish step, explicit unpublish confirmation, native share/local QR, and uncached incoming public-profile view. |
| `PersonalizationScreen.tsx` | Server topic catalog and follow controls through `useTopics`. |
| `SavedScreen.tsx` | Recent/cached saved concepts, older metadata pagination, search/category filters, and detail navigation. |
Expand Down
7 changes: 7 additions & 0 deletions docs/WORK_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ claims as completed work.

## Current status

### Profile photo preparation follow-up — 2026-11-02

- The owner confirmed v1.10.17 fixed the Android keyboard but both gallery and camera photos still fail with the same local preparation message. [PR #477](https://github.com/Coding-Moves/one-concept/pull/477) targets `develop` from `codex/fix-native-photo-upload`; no database or backend changes are planned.
- `4caebc2` requests the picker's JPEG bytes and saves a bounded result directly; oversized or non-JPEG output uses Expo ImageManipulator's encoded JPEG instead. Both paths check the 5 MB limit and JPEG signature and send binary bytes directly, avoiding the Android local-file fetch and Blob/FileReader bridge. A failed preparation leaves the existing avatar untouched.
- Validation: mobile TypeScript, focused preparation tests (3 passed), Expo web export, and the browser gallery/camera/upload-recovery/profile smoke test in light and dark themes passed. The full local mobile suite was blocked in its existing `publicConfig` subprocess test because this sandbox denies `spawnSync` with `EPERM`; hosted CI must resolve the suite result. Native Android behavior remains unverified until the owner tests the preview update.
- Handoff: review PR #477 and its hosted checks, merge only with the owner's PR-specific confirmation, then verify gallery and camera on the preview phone before preparing another production release.

### v1.10.17 release preparation — 2026-11-02

- The owner asked for a production release PR to test the profile fixes on the regular phone. [PR #473](https://github.com/Coding-Moves/one-concept/pull/473) merged into `develop` at `32b7f97` after all four hosted checks passed; [preview OTA run 41444180393](https://github.com/Coding-Moves/one-concept/actions/runs/41444180393) succeeded. No physical-device result for the repaired gallery/camera or keyboard paths has been reported yet.
Expand Down
1 change: 1 addition & 0 deletions 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 mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@react-navigation/native": "^7.4.1",
"@react-navigation/native-stack": "^7.21.0",
"@supabase/supabase-js": "^2.118.0",
"base64-js": "1.5.1",
"expo": "~57.0.31",
"expo-camera": "~57.0.6",
"expo-constants": "~57.0.21",
Expand Down
4 changes: 2 additions & 2 deletions mobile/src/context/ProgressContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface ProgressContextValue {
loading: boolean;
refresh: () => Promise<void>;
updateProfile: (input: { displayName?: string; bio?: string; avatarPreset?: string }) => Promise<void>;
uploadAvatar: (jpeg: Blob) => Promise<void>;
uploadAvatar: (jpeg: Uint8Array<ArrayBuffer>) => Promise<void>;
removeAvatar: () => Promise<void>;
pausedSyncCount: number;
retrySync: () => Promise<void>;
Expand Down Expand Up @@ -285,7 +285,7 @@ export function ProgressProvider({ children, repository: override }: Props) {
if (!userId || !repository.updateProfile) throw new Error('Sign in to edit your profile');
await confirmProfileWrite(() => repository.updateProfile!(input, userId));
}, [confirmProfileWrite, repository, userId]);
const uploadAvatar = useCallback(async (jpeg: Blob) => {
const uploadAvatar = useCallback(async (jpeg: Uint8Array<ArrayBuffer>) => {
if (!userId || !repository.uploadAvatar) throw new Error('Sign in to add a photo');
await confirmProfileWrite(() => repository.uploadAvatar!(jpeg, userId));
}, [confirmProfileWrite, repository, userId]);
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/screens/EditProfileScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export function EditProfileScreen() {
}
await beginProfilePhotoPick(userId);
const options: ImagePicker.ImagePickerOptions = {
mediaTypes: ['images'], allowsEditing: false, quality: 0.85,
mediaTypes: ['images'], allowsEditing: false, quality: 0.85, base64: true,
preferredAssetRepresentationMode: ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Compatible,
};
const result = camera ? await ImagePicker.launchCameraAsync(options) : await ImagePicker.launchImageLibraryAsync(options);
Expand Down
53 changes: 28 additions & 25 deletions mobile/src/services/profilePhotoPreparation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ImagePickerAsset } from 'expo-image-picker';
import { byteLength, toByteArray } from 'base64-js';

const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ATTEMPTS = [
Expand All @@ -14,33 +15,38 @@ export class ProfilePhotoPreparationError extends Error {
}
}

/** React Native's Blob does not implement arrayBuffer(); FileReader does. */
export async function hasJpegSignature(blob: Blob): Promise<boolean> {
if (blob.size < 3) return false;
const prefix = await new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => reader.result instanceof ArrayBuffer
? resolve(reader.result)
: reject(new Error('photo header is unreadable'));
reader.onerror = () => reject(reader.error ?? new Error('photo header is unreadable'));
reader.onabort = () => reject(new Error('photo header read was cancelled'));
reader.readAsArrayBuffer(blob.slice(0, 3));
});
const signature = new Uint8Array(prefix);
return signature[0] === 0xff && signature[1] === 0xd8 && signature[2] === 0xff;
export function hasJpegSignature(bytes: Uint8Array): boolean {
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}

/** Decode only bounded JPEG output; never trust the picker or encoder's MIME label. */
export function jpegBytesFromBase64(base64: string): Uint8Array<ArrayBuffer> | null {
if (!base64 || byteLength(base64) > MAX_UPLOAD_BYTES) return null;
const bytes = new Uint8Array(toByteArray(base64));
if (!hasJpegSignature(bytes)) throw new Error('native encoder did not return JPEG');
return bytes;
}

/** The API accepts JPEG bytes up to 5 MB, regardless of picker metadata. */
export async function prepareProfilePhoto(asset: ImagePickerAsset): Promise<Blob> {
export async function prepareProfilePhoto(asset: ImagePickerAsset): Promise<Uint8Array<ArrayBuffer>> {
if (!asset.uri || !Number.isFinite(asset.width) || !Number.isFinite(asset.height)
|| asset.width <= 0 || asset.height <= 0) {
throw new ProfilePhotoPreparationError('This photo could not be read. Choose another image.');
}

// The picker can already provide JPEG bytes for gallery and camera images.
// Use them when bounded so saving still works if native resizing fails.
if (asset.base64) {
try {
const picked = jpegBytesFromBase64(asset.base64);
if (picked) return picked;
} catch { /* Try the converter for a non-JPEG picker result. */ }
}

for (const { longestSide, quality } of ATTEMPTS) {
try {
// Load only when saving: installed binaries need the native module in
// their next APK, but older builds should still open the profile screen.
// Load the converter only when the picker output needs resizing or
// normalization; a valid bounded JPEG needs no further native work.
const { ImageManipulator, SaveFormat } = await import('expo-image-manipulator');
const context = ImageManipulator.manipulate(asset.uri);
const longer = Math.max(asset.width, asset.height);
Expand All @@ -49,14 +55,11 @@ export async function prepareProfilePhoto(asset: ImagePickerAsset): Promise<Blob
else context.resize({ height: longestSide });
}
const image = await context.renderAsync();
const saved = await image.saveAsync({ format: SaveFormat.JPEG, compress: quality });
const response = await fetch(saved.uri);
const bytes = await response.blob();
// Never claim JPEG if the native encoder returned something else.
if (!await hasJpegSignature(bytes)) {
throw new Error('native encoder did not return JPEG');
}
if (bytes.size > 0 && bytes.size <= MAX_UPLOAD_BYTES) return bytes;
// Expo supplies the encoded JPEG directly. Avoid fetch(file://) and the
// React Native Blob/FileReader bridge, which can fail on Android devices.
const saved = await image.saveAsync({ format: SaveFormat.JPEG, compress: quality, base64: true });
const bytes = jpegBytesFromBase64(saved.base64 ?? '');
if (bytes) return bytes;
} catch {
throw new ProfilePhotoPreparationError('This phone could not prepare that photo. Try a different JPEG or PNG image.');
}
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/services/progressRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Category, ProgressState } from '../types';
export interface ProgressRepository {
load(): Promise<ProgressState>;
updateProfile?(input: { displayName?: string; bio?: string; avatarPreset?: string }, userId: string): Promise<ProgressState>;
uploadAvatar?(jpeg: Blob, userId: string): Promise<ProgressState>;
uploadAvatar?(jpeg: Uint8Array<ArrayBuffer>, userId: string): Promise<ProgressState>;
removeAvatar?(userId: string): Promise<ProgressState>;

/** Last known state without touching the network, or null if none exists.
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/services/remoteProgressRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ export class RemoteProgressRepository implements ProgressRepository {
return this.fromState(payload, epoch);
}

async uploadAvatar(jpeg: Blob, userId: string): Promise<ProgressState> {
async uploadAvatar(jpeg: Uint8Array<ArrayBuffer>, userId: string): Promise<ProgressState> {
const epoch = this.epoch;
const payload = await this.request<StatePayload>(epoch, '/v1/me/avatar?compact=true', { method: 'PUT', rawBody: jpeg, contentType: 'image/jpeg', expectedUserId: userId });
return this.fromState(payload, epoch);
Expand Down
57 changes: 18 additions & 39 deletions mobile/tests/profilePhotoPreparation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,26 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import './helpers/resolve-ts.mjs';

const { hasJpegSignature } = await import('../src/services/profilePhotoPreparation.ts');
const { hasJpegSignature, jpegBytesFromBase64, prepareProfilePhoto } = await import('../src/services/profilePhotoPreparation.ts');

function nativeStyleBlob(bytes) {
return {
size: bytes.length,
// React Native 0.86 Blob exposes slice(), but not arrayBuffer().
slice(start, end) { return { bytes: bytes.slice(start, end) }; },
};
}
test('Expo base64 JPEG output becomes the exact binary upload body', () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x81, 0xfe]);
const bytes = jpegBytesFromBase64(jpeg.toString('base64'));
assert.deepEqual(Buffer.from(bytes), jpeg);
assert.equal(hasJpegSignature(bytes), true);
});

test('JPEG signature is read through FileReader from a native-style Blob', async () => {
const original = globalThis.FileReader;
let bytesRead = 0;
globalThis.FileReader = class {
readAsArrayBuffer(blob) {
bytesRead = blob.bytes.length;
this.result = blob.bytes.buffer;
queueMicrotask(() => this.onload());
}
};
try {
assert.equal(await hasJpegSignature(nativeStyleBlob(Uint8Array.of(0xff, 0xd8, 0xff, 0x00))), true);
assert.equal(bytesRead, 3, 'only the header should cross the JS/native boundary');
assert.equal(await hasJpegSignature(nativeStyleBlob(Uint8Array.of(0x89, 0x50, 0x4e, 0x47))), false);
assert.equal(await hasJpegSignature(nativeStyleBlob(Uint8Array.of())), false);
} finally {
globalThis.FileReader = original;
}
test('a bounded gallery or camera JPEG saves without opening the native converter', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x81, 0xfe]);
const asset = { uri: 'file:///chosen.jpg', width: 100, height: 100, base64: jpeg.toString('base64') };
assert.deepEqual(Buffer.from(await prepareProfilePhoto(asset)), jpeg);
});

test('an unreadable native photo header rejects instead of uploading unchecked bytes', async () => {
const original = globalThis.FileReader;
globalThis.FileReader = class {
readAsArrayBuffer() {
this.error = new Error('native read failed');
queueMicrotask(() => this.onerror());
}
};
try {
await assert.rejects(hasJpegSignature(nativeStyleBlob(Uint8Array.of(0xff, 0xd8, 0xff))), /native read failed/);
} finally {
globalThis.FileReader = original;
}
test('empty, non-JPEG and oversized encoder output cannot be uploaded as JPEG', () => {
assert.equal(jpegBytesFromBase64(''), null);
assert.equal(hasJpegSignature(Uint8Array.of(0xff, 0xd8)), false);
assert.throws(() => jpegBytesFromBase64(Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')), /JPEG/);
const oversized = Buffer.alloc(5 * 1024 * 1024 + 1);
oversized.set([0xff, 0xd8, 0xff]);
assert.equal(jpegBytesFromBase64(oversized.toString('base64')), null);
});
Loading