| ... | @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ | ... | @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ |
| 11 | open: vi.fn(), | 11 | open: vi.fn(), |
| 12 | size: vi.fn(), | 12 | size: vi.fn(), |
| 13 | readBase64: vi.fn(), | 13 | readBase64: vi.fn(), |
| | 14 | manipulate: vi.fn(), |
| | 15 | release: vi.fn(), |
| 14 | })); | 16 | })); |
| 15 | | 17 | |
| 16 | vi.mock("expo-file-system", () => { | 18 | vi.mock("expo-file-system", () => { |
| ... | @@ -80,6 +82,10 @@ vi.mock("expo-file-system", () => { | ... | @@ -80,6 +82,10 @@ vi.mock("expo-file-system", () => { |
| 80 | | 82 | |
| 81 | vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); | 83 | vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); |
| 82 | vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile })); | 84 | vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile })); |
| | 85 | vi.mock("expo-image-manipulator", () => ({ |
| | 86 | SaveFormat: { JPEG: "jpeg", PNG: "png", WEBP: "webp" }, |
| | 87 | ImageManipulator: { manipulate: mocks.manipulate }, |
| | 88 | })); |
| 83 | vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); | 89 | vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); |
| 84 | | 90 | |
| 85 | import { | 91 | import { |
| ... | @@ -106,20 +112,50 @@ describe("composer file attachments", () => { | ... | @@ -106,20 +112,50 @@ describe("composer file attachments", () => { |
| 106 | }); | 112 | }); |
| 107 | | 113 | |
| 108 | describe("photo library image conversion", () => { | 114 | describe("photo library image conversion", () => { |
| 109 | const jpeg = "/9j/2Q=="; | 115 | const rendered = { uri: "file:///cache/ImageManipulator/photo.jpg", base64: "/9j/2Q==" }; |
| 110 | const photo: ImagePickerAsset = { | 116 | const photo: ImagePickerAsset = { |
| 111 | uri: "file:///picker/photo.heic", | 117 | uri: "file:///picker/photo.heic", |
| 112 | type: "image", | 118 | type: "image", |
| 113 | fileName: "photo.HEIC", | 119 | fileName: "photo.HEIC", |
| 114 | mimeType: "image/heic", | 120 | mimeType: "image/heic", |
| 115 | fileSize: 20 * 1024 * 1024, | 121 | fileSize: 4 * 1024 * 1024, |
| 116 | base64: jpeg, | 122 | width: 4032, |
| 117 | width: 1, | 123 | height: 3024, |
| 118 | height: 1, | 124 | }; |
| | 125 | /** |
| | 126 | * Stands in for the native manipulator: `size` is what decoding the source yields, |
| | 127 | * `resizes` records every requested resize, and `saved` is what saving returns. |
| | 128 | */ |
| | 129 | const native = { |
| | 130 | size: { width: 1, height: 1 }, |
| | 131 | resizes: [] as Array<{ width?: number | null; height?: number | null }>, |
| | 132 | saved: rendered as { uri: string; base64?: string }, |
| 119 | }; | 133 | }; |
| 120 | | 134 | |
| | 135 | beforeEach(() => { |
| | 136 | native.size = { width: 1, height: 1 }; |
| | 137 | native.resizes = []; |
| | 138 | native.saved = rendered; |
| | 139 | mocks.manipulate.mockReset(); |
| | 140 | mocks.release.mockReset(); |
| | 141 | mocks.manipulate.mockImplementation(() => { |
| | 142 | const context = { |
| | 143 | resize(size: { width?: number | null; height?: number | null }) { |
| | 144 | native.resizes.push(size); |
| | 145 | return context; |
| | 146 | }, |
| | 147 | renderAsync: async () => ({ |
| | 148 | ...native.size, |
| | 149 | release: mocks.release, |
| | 150 | saveAsync: async () => native.saved, |
| | 151 | }), |
| | 152 | }; |
| | 153 | return context; |
| | 154 | }); |
| | 155 | }); |
| | 156 | |
| 121 | it.each(["image/heic", "image/heif", undefined])( | 157 | it.each(["image/heic", "image/heif", undefined])( |
| 122 | "attaches the native JPEG conversion with matching metadata when the source MIME is %s", | 158 | "renders a %s photo to JPEG natively and previews the rendered file", |
| 123 | async (mimeType) => { | 159 | async (mimeType) => { |
| 124 | mocks.pickMedia.mockResolvedValue({ | 160 | mocks.pickMedia.mockResolvedValue({ |
| 125 | canceled: false, | 161 | canceled: false, |
| ... | @@ -128,6 +164,9 @@ describe("composer file attachments", () => { | ... | @@ -128,6 +164,9 @@ describe("composer file attachments", () => { |
| 128 | | 164 | |
| 129 | const result = await pickComposerImages({ existingCount: 0 }); | 165 | const result = await pickComposerImages({ existingCount: 0 }); |
| 130 | | 166 | |
| | 167 | expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ base64: false })); |
| | 168 | expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); |
| | 169 | expect(mocks.readBase64).not.toHaveBeenCalled(); |
| 131 | expect(result).toEqual({ | 170 | expect(result).toEqual({ |
| 132 | images: [ | 171 | images: [ |
| 133 | { | 172 | { |
| ... | @@ -136,8 +175,8 @@ describe("composer file attachments", () => { | ... | @@ -136,8 +175,8 @@ describe("composer file attachments", () => { |
| 136 | name: "photo.jpg", | 175 | name: "photo.jpg", |
| 137 | mimeType: "image/jpeg", | 176 | mimeType: "image/jpeg", |
| 138 | sizeBytes: 4, | 177 | sizeBytes: 4, |
| 139 | dataUrl: `data:image/jpeg;base64,${jpeg}`, | 178 | dataUrl: `data:image/jpeg;base64,${rendered.base64}`, |
| 140 | previewUri: `data:image/jpeg;base64,${jpeg}`, | 179 | previewUri: rendered.uri, |
| 141 | }, | 180 | }, |
| 142 | ], | 181 | ], |
| 143 | error: null, | 182 | error: null, |
| ... | @@ -145,11 +184,26 @@ describe("composer file attachments", () => { | ... | @@ -145,11 +184,26 @@ describe("composer file attachments", () => { |
| 145 | }, | 184 | }, |
| 146 | ); | 185 | ); |
| 147 | | 186 | |
| | 187 | it.each([ |
| | 188 | { size: { width: 4032, height: 3024 }, resizes: [{ width: 2048 }] }, |
| | 189 | { size: { width: 3024, height: 4032 }, resizes: [{ height: 2048 }] }, |
| | 190 | { size: { width: 2048, height: 1536 }, resizes: [] }, |
| | 191 | ])("bounds a $size.width x $size.height photo to a 2048 px longest edge", async (input) => { |
| | 192 | native.size = input.size; |
| | 193 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); |
| | 194 | |
| | 195 | await pickComposerImages({ existingCount: 0 }); |
| | 196 | |
| | 197 | expect(native.resizes).toEqual(input.resizes); |
| | 198 | // Every decoded bitmap is released, including the full-size one a resize replaces. |
| | 199 | expect(mocks.release).toHaveBeenCalledTimes(input.resizes.length + 1); |
| | 200 | }); |
| | 201 | |
| 148 | it.each([ | 202 | it.each([ |
| 149 | { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, | 203 | { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, |
| 150 | { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, | 204 | { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, |
| 151 | { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" }, | 205 | { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" }, |
| 152 | ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => { | 206 | ])("keeps original $extension bytes from the picker file", async (original) => { |
| 153 | const name = `photo.${original.extension}`; | 207 | const name = `photo.${original.extension}`; |
| 154 | mocks.pickMedia.mockResolvedValue({ | 208 | mocks.pickMedia.mockResolvedValue({ |
| 155 | canceled: false, | 209 | canceled: false, |
| ... | @@ -159,6 +213,8 @@ describe("composer file attachments", () => { | ... | @@ -159,6 +213,8 @@ describe("composer file attachments", () => { |
| 159 | | 213 | |
| 160 | const result = await pickComposerImages({ existingCount: 0 }); | 214 | const result = await pickComposerImages({ existingCount: 0 }); |
| 161 | | 215 | |
| | 216 | expect(mocks.manipulate).not.toHaveBeenCalled(); |
| | 217 | expect(mocks.readBase64).toHaveBeenCalledWith(photo.uri); |
| 162 | expect(result.error).toBeNull(); | 218 | expect(result.error).toBeNull(); |
| 163 | expect(result.images).toEqual([ | 219 | expect(result.images).toEqual([ |
| 164 | expect.objectContaining({ | 220 | expect.objectContaining({ |
| ... | @@ -166,37 +222,74 @@ describe("composer file attachments", () => { | ... | @@ -166,37 +222,74 @@ describe("composer file attachments", () => { |
| 166 | mimeType: original.mimeType, | 222 | mimeType: original.mimeType, |
| 167 | dataUrl: `data:${original.mimeType};base64,${original.base64}`, | 223 | dataUrl: `data:${original.mimeType};base64,${original.base64}`, |
| 168 | sizeBytes: Buffer.from(original.base64, "base64").byteLength, | 224 | sizeBytes: Buffer.from(original.base64, "base64").byteLength, |
| | 225 | previewUri: photo.uri, |
| 169 | }), | 226 | }), |
| 170 | ]); | 227 | ]); |
| 171 | }); | 228 | }); |
| 172 | | 229 | |
| 173 | it("checks the converted JPEG size even when the HEIC source was smaller", async () => { | 230 | it("renders a supported original that exceeds the image limit instead of rejecting it", async () => { |
| 174 | const oversized = | | |
| 175 | jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4); | | |
| 176 | mocks.pickMedia.mockResolvedValue({ | 231 | mocks.pickMedia.mockResolvedValue({ |
| 177 | canceled: false, | 232 | canceled: false, |
| 178 | assets: [{ ...photo, fileSize: 42, base64: oversized }], | 233 | assets: [{ ...photo, fileName: "photo.jpg", mimeType: "image/jpeg" }], |
| 179 | }); | 234 | }); |
| | 235 | mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); |
| 180 | | 236 | |
| 181 | await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ | 237 | const result = await pickComposerImages({ existingCount: 0 }); |
| 182 | images: [], | 238 | |
| 183 | error: "'photo.HEIC' exceeds the 10 MB attachment limit.", | 239 | expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); |
| | 240 | expect(result.error).toBeNull(); |
| | 241 | expect(result.images).toEqual([ |
| | 242 | expect.objectContaining({ name: "photo.jpg", mimeType: "image/jpeg", sizeBytes: 4 }), |
| | 243 | ]); |
| | 244 | }); |
| | 245 | |
| | 246 | it("measures the picker file instead of trusting the reported size", async () => { |
| | 247 | // A content stream can deliver more bytes than the picker advertises; a supported |
| | 248 | // original only skips rendering when the file itself measures within the limit. |
| | 249 | mocks.pickMedia.mockResolvedValue({ |
| | 250 | canceled: false, |
| | 251 | assets: [{ ...photo, fileName: "photo.png", mimeType: "image/png", fileSize: 42 }], |
| 184 | }); | 252 | }); |
| | 253 | mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); |
| | 254 | |
| | 255 | const result = await pickComposerImages({ existingCount: 0 }); |
| | 256 | |
| | 257 | expect(mocks.readBase64).not.toHaveBeenCalled(); |
| | 258 | expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); |
| | 259 | expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]); |
| 185 | }); | 260 | }); |
| 186 | | 261 | |
| 187 | it("does not relabel unconverted HEIC bytes as JPEG", async () => { | 262 | it("renders a supported original whose size cannot be measured", async () => { |
| 188 | mocks.pickMedia.mockResolvedValue({ | 263 | mocks.pickMedia.mockResolvedValue({ |
| 189 | canceled: false, | 264 | canceled: false, |
| 190 | assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }], | 265 | assets: [ |
| | 266 | { ...photo, uri: "content://media/1", fileName: "photo.png", mimeType: "image/png" }, |
| | 267 | ], |
| 191 | }); | 268 | }); |
| 192 | | 269 | |
| 193 | const result = await pickComposerImages({ existingCount: 0 }); | 270 | const result = await pickComposerImages({ existingCount: 0 }); |
| 194 | | 271 | |
| 195 | expect(result.images).toEqual([]); | 272 | expect(mocks.readBase64).not.toHaveBeenCalled(); |
| 196 | expect(result.error).toContain("not a supported image type"); | 273 | expect(mocks.manipulate).toHaveBeenCalledWith("content://media/1"); |
| | 274 | expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]); |
| 197 | }); | 275 | }); |
| 198 | | 276 | |
| 199 | it("retains a converted photo when another original cannot be read", async () => { | 277 | it("checks the rendered JPEG against the image limit", async () => { |
| | 278 | native.saved = { |
| | 279 | uri: rendered.uri, |
| | 280 | base64: |
| | 281 | rendered.base64.slice(0, 4) + |
| | 282 | "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4), |
| | 283 | }; |
| | 284 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); |
| | 285 | |
| | 286 | await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ |
| | 287 | images: [], |
| | 288 | error: "'photo.HEIC' exceeds the 10 MB attachment limit.", |
| | 289 | }); |
| | 290 | }); |
| | 291 | |
| | 292 | it("retains a rendered photo when another original cannot be read", async () => { |
| 200 | mocks.pickMedia.mockResolvedValue({ | 293 | mocks.pickMedia.mockResolvedValue({ |
| 201 | canceled: false, | 294 | canceled: false, |
| 202 | assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo], | 295 | assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo], |
| ... | @@ -208,6 +301,21 @@ describe("composer file attachments", () => { | ... | @@ -208,6 +301,21 @@ describe("composer file attachments", () => { |
| 208 | expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]); | 301 | expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]); |
| 209 | expect(result.error).toBe("Failed to read 'missing.gif'."); | 302 | expect(result.error).toBe("Failed to read 'missing.gif'."); |
| 210 | }); | 303 | }); |
| | 304 | |
| | 305 | it("reports a photo the native renderer cannot decode", async () => { |
| | 306 | mocks.manipulate.mockImplementation(() => ({ |
| | 307 | resize: () => { |
| | 308 | throw new Error("unreachable"); |
| | 309 | }, |
| | 310 | renderAsync: () => Promise.reject(new Error("corrupt")), |
| | 311 | })); |
| | 312 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); |
| | 313 | |
| | 314 | await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ |
| | 315 | images: [], |
| | 316 | error: "Failed to read 'photo.HEIC'.", |
| | 317 | }); |
| | 318 | }); |
| 211 | }); | 319 | }); |
| 212 | | 320 | |
| 213 | describe("photo library videos", () => { | 321 | describe("photo library videos", () => { |
| ... | @@ -217,10 +325,13 @@ describe("composer file attachments", () => { | ... | @@ -217,10 +325,13 @@ describe("composer file attachments", () => { |
| 217 | fileName: "photo.png", | 325 | fileName: "photo.png", |
| 218 | mimeType: "image/png", | 326 | mimeType: "image/png", |
| 219 | fileSize: 3, | 327 | fileSize: 3, |
| 220 | base64: "YWJj", | | |
| 221 | width: 1, | 328 | width: 1, |
| 222 | height: 1, | 329 | height: 1, |
| 223 | }; | 330 | }; |
| | 331 | |
| | 332 | beforeEach(() => { |
| | 333 | mocks.readBase64.mockResolvedValue("YWJj"); |
| | 334 | }); |
| 224 | const video: ImagePickerAsset = { | 335 | const video: ImagePickerAsset = { |
| 225 | uri: "file:///picker/clip.mov", | 336 | uri: "file:///picker/clip.mov", |
| 226 | type: "video", | 337 | type: "video", |
| ... | @@ -234,7 +345,9 @@ describe("composer file attachments", () => { | ... | @@ -234,7 +345,9 @@ describe("composer file attachments", () => { |
| 234 | | 345 | |
| 235 | it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => { | 346 | it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => { |
| 236 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); | 347 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); |
| 237 | mocks.size.mockReturnValue(video.fileSize); | 348 | mocks.size.mockImplementation((uri: string) => |
| | 349 | uri.endsWith("clip.mov") ? video.fileSize : 3, |
| | 350 | ); |
| 238 | | 351 | |
| 239 | const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 }); | 352 | const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 }); |
| 240 | | 353 | |
| ... | @@ -345,7 +458,7 @@ describe("composer file attachments", () => { | ... | @@ -345,7 +458,7 @@ describe("composer file attachments", () => { |
| 345 | canceled: false, | 458 | canceled: false, |
| 346 | assets: [{ ...video, fileSize: reported }, image], | 459 | assets: [{ ...video, fileSize: reported }, image], |
| 347 | }); | 460 | }); |
| 348 | mocks.size.mockReturnValue(stored); | 461 | mocks.size.mockImplementation((uri: string) => (uri.endsWith("clip.mov") ? stored : 3)); |
| 349 | | 462 | |
| 350 | const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit }); | 463 | const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit }); |
| 351 | | 464 | |