Skip to content

Commit f3d858d

Browse files
committed
Working POC! strip ICC profile + minor refactor
- move extractIccFromJpeg() into it's own JpegUtils class - adjust extraction to return a data object containing the start and end position of the APP2 section so the caller can know where to strip bytes. - function to strip bytes
1 parent 963149b commit f3d858d

2 files changed

Lines changed: 213 additions & 132 deletions

File tree

app/src/main/java/app/grapheneos/camera/capturer/ImageSaver.kt

Lines changed: 19 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ import app.grapheneos.camera.clearExif
2828
import app.grapheneos.camera.fixExif
2929
import app.grapheneos.camera.util.ImageResizer
3030
import app.grapheneos.camera.util.executeIfAlive
31+
import app.grapheneos.camera.util.extractIccFromJpeg
3132
import app.grapheneos.camera.util.getTreeDocumentUri
3233
import app.grapheneos.camera.util.removePendingFlagFromUri
34+
import app.grapheneos.camera.util.stripBytes
3335
import java.io.ByteArrayInputStream
3436
import java.io.ByteArrayOutputStream
3537
import java.io.IOException
@@ -159,6 +161,23 @@ class ImageSaver(
159161

160162
processedJpegBytes = processExif(uncroppedJpegBytes)
161163

164+
if (removeICCAfterCapture) {
165+
val start = System.nanoTime()
166+
val icc = extractIccFromJpeg(processedJpegBytes)
167+
if (icc != null) {
168+
val originalSize = processedJpegBytes.size
169+
try {
170+
processedJpegBytes = stripBytes(processedJpegBytes, icc.start, icc.end)
171+
} catch (e: Exception) {
172+
Log.e("ICC_Profile", "Failed to strip ICC APP2 section: ${e.message}")
173+
}
174+
val end = System.nanoTime()
175+
val durationMs = (end - start) / 1_000_000.0
176+
Log.d("ICC_PROFILE",
177+
"stripped in ${durationMs}ms. orig: ${originalSize}, cur: ${processedJpegBytes.size} (${originalSize - processedJpegBytes.size})")
178+
}
179+
}
180+
162181
val startOfWriting = timestamp()
163182

164183
val uri = try {
@@ -247,12 +266,6 @@ class ImageSaver(
247266
exifInterface.fixExif(captureTime)
248267
}
249268

250-
if (removeICCAfterCapture) {
251-
// TODO does this belong somewhere else? just put here for now...
252-
//exifInterface.clearICC()
253-
extractIccFromJpeg(uncroppedJpegBytes)
254-
}
255-
256269
// location metadata setting intentionally ignores the "clear EXIF after capture" setting
257270
val location = metadata.location
258271
if (location != null) {
@@ -279,132 +292,6 @@ class ImageSaver(
279292
return res
280293
}
281294

282-
283-
/**
284-
* Extracts the ICC profile from a JPEG byte array.
285-
* Based off iccDEV -> iccJpegDump
286-
* @param jpegBytes The JPEG file contents.
287-
* @return The ICC profile bytes, or an empty array if none was found.
288-
*/
289-
fun extractIccFromJpeg(jpegBytes: ByteArray): ByteArray {
290-
val tag = "ICC_PROFILE"
291-
292-
if (jpegBytes.size < 2 || jpegBytes[0] != 0xFF.toByte() || jpegBytes[1] != 0xD8.toByte()) {
293-
Log.e(tag, "Input data is not a valid JPEG (missing SOI).")
294-
return ByteArray(0)
295-
}
296-
297-
// ICC profiles are carried in one or more APP2 segments, each beginning with
298-
// the 12-byte "ICC_PROFILE\0" signature followed by a 1-based chunk number and
299-
// a chunk count; the profile is those chunks concatenated in order (ITU-T T.871
300-
// / ICC Annex B). Walk the JPEG marker structure and reassemble them.
301-
val iccSig = byteArrayOf(
302-
'I'.code.toByte(), 'C'.code.toByte(), 'C'.code.toByte(), '_'.code.toByte(),
303-
'P'.code.toByte(), 'R'.code.toByte(), 'O'.code.toByte(), 'F'.code.toByte(),
304-
'I'.code.toByte(), 'L'.code.toByte(), 'E'.code.toByte(), 0x00.toByte()
305-
) // "ICC_PROFILE\0"
306-
307-
var pos = 2 // skip SOI
308-
var totalChunks = 0
309-
var seenChunks: BooleanArray? = null
310-
val chunks: MutableList<ByteArray?> = mutableListOf()
311-
312-
while (pos < jpegBytes.size) {
313-
if (jpegBytes[pos] != 0xFF.toByte()) {
314-
pos++
315-
continue // resync – skip stray byte
316-
}
317-
318-
// Skip any padding 0xFF bytes that can appear before the marker code
319-
while (pos < jpegBytes.size && jpegBytes[pos] == 0xFF.toByte()) pos++
320-
if (pos >= jpegBytes.size) break
321-
322-
val marker = jpegBytes[pos].toInt() and 0xFF
323-
pos++ // move past the marker code
324-
325-
// EOI, or SOS (entropy data follows)
326-
if (marker == 0xD9 || marker == 0xDA) break
327-
328-
// standalone markers (no length)
329-
if (marker == 0x01 || (marker in 0xD0..0xD7)) continue
330-
331-
if (pos + 1 >= jpegBytes.size) break // malformed JPEG
332-
333-
val segLength = ((jpegBytes[pos].toInt() and 0xFF) shl 8) or
334-
(jpegBytes[pos + 1].toInt() and 0xFF)
335-
pos += 2
336-
337-
if (segLength < 2 || pos + (segLength - 2) > jpegBytes.size) break
338-
339-
val payloadLength = segLength - 2
340-
val segStart = pos
341-
342-
// Handle APP2 segments that may contain ICC data
343-
if (marker == 0xE2 && payloadLength >= 14) {
344-
if (jpegBytes.copyOfRange(segStart, segStart + 12).contentEquals(iccSig)) {
345-
val seq = jpegBytes[segStart + 12].toInt() and 0xFF
346-
val total = jpegBytes[segStart + 13].toInt() and 0xFF
347-
348-
if (seq == 0 || total == 0 || seq > total) {
349-
Log.e(tag, "Invalid ICC_PROFILE APP2 chunk sequence (seq=$seq, total=$total).")
350-
return ByteArray(0)
351-
}
352-
353-
if (totalChunks == 0) {
354-
totalChunks = total
355-
chunks.clear()
356-
repeat(total) { chunks.add(null) }
357-
seenChunks = BooleanArray(total) { false }
358-
} else if (total != totalChunks) {
359-
Log.e(tag, "Inconsistent ICC_PROFILE APP2 chunk count (expected $totalChunks, found $total).")
360-
return ByteArray(0)
361-
}
362-
363-
if (seenChunks!![seq - 1]) {
364-
Log.e(tag, "Duplicate ICC_PROFILE APP2 chunk number $seq.")
365-
return ByteArray(0)
366-
}
367-
368-
// Store the payload (excluding the 12‑byte signature + 2‑byte header)
369-
val chunkData = jpegBytes.copyOfRange(segStart + 14, segStart + payloadLength)
370-
chunks[seq - 1] = chunkData
371-
seenChunks[seq - 1] = true
372-
373-
Log.d(tag, "ICC_PROFILE APP2 chunk ${seq}/${total} (${chunkData.size} bytes).")
374-
}
375-
}
376-
377-
// Advance to next marker
378-
pos += payloadLength
379-
}
380-
381-
if (totalChunks == 0) {
382-
Log.e(tag, "No ICC_PROFILE APP2 markers found in JPEG.")
383-
return ByteArray(0)
384-
}
385-
386-
// Verify all chunks were seen
387-
for ((index, seen) in seenChunks!!.withIndex()) {
388-
if (!seen) {
389-
Log.e(tag, "Missing ICC_PROFILE APP2 chunk number ${index + 1}.")
390-
return ByteArray(0)
391-
}
392-
}
393-
394-
// Concatenate all chunks
395-
val iccStream = ByteArrayOutputStream()
396-
for (chunk in chunks) {
397-
iccStream.write(chunk!!)
398-
}
399-
val iccBytes = iccStream.toByteArray()
400-
401-
Log.d(tag, "ICC Profile found! Size: ${iccBytes.size} bytes. Total chunks: ${totalChunks}.")
402-
val preview = iccBytes.joinToString(" ") { "%02X".format(it) }
403-
Log.d(tag, preview)
404-
405-
return iccBytes
406-
}
407-
408295
private fun generateThumbnail() {
409296
val source = ImageDecoder.createSource(ByteBuffer.wrap(processedJpegBytes))
410297
val bitmap = try {
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package app.grapheneos.camera.util
2+
3+
import android.util.Log
4+
import java.io.ByteArrayOutputStream
5+
6+
7+
/**
8+
* Data class to hold the extracted ICC profile information.
9+
*/
10+
data class IccProfile(
11+
val start: Int, // Start index of the APP2 section containing the ICC profile
12+
val end: Int, // End index of the APP2 section containing the ICC profile
13+
// The full ICC profile bytes segment [signature + header + data] (this will be smaller than the total APP2 section)
14+
val iccBytes: ByteArray
15+
)
16+
17+
/**
18+
* Extracts the ICC profile from a JPEG byte array.
19+
* Based off iccDEV -> iccJpegDump
20+
* @param jpegBytes The JPEG file contents.
21+
* @return The ICC profile bytes, or null if none was found.
22+
*/
23+
fun extractIccFromJpeg(jpegBytes: ByteArray): IccProfile? {
24+
val tag = "ICC_PROFILE"
25+
26+
if (jpegBytes.size < 2 || jpegBytes[0] != 0xFF.toByte() || jpegBytes[1] != 0xD8.toByte()) {
27+
Log.e(tag, "Input data is not a valid JPEG (missing SOI).")
28+
return null
29+
}
30+
31+
val iccSig = byteArrayOf(
32+
'I'.code.toByte(), 'C'.code.toByte(), 'C'.code.toByte(), '_'.code.toByte(),
33+
'P'.code.toByte(), 'R'.code.toByte(), 'O'.code.toByte(), 'F'.code.toByte(),
34+
'I'.code.toByte(), 'L'.code.toByte(), 'E'.code.toByte(), 0x00.toByte()
35+
) // "ICC_PROFILE\0"
36+
37+
var pos = 2 // skip SOI
38+
var totalChunks = 0
39+
var seenChunks: BooleanArray? = null
40+
val chunks: MutableList<ByteArray?> = mutableListOf()
41+
42+
// Track indices within the original jpegBytes
43+
var minStart = Int.MAX_VALUE
44+
var maxEnd = 0
45+
46+
while (pos < jpegBytes.size) {
47+
if (jpegBytes[pos] != 0xFF.toByte()) {
48+
pos++
49+
continue // resync – skip stray byte
50+
}
51+
52+
val magicPos = pos //FF magic marks beginning of section before marker
53+
54+
// Skip any padding 0xFF bytes that can appear before the marker code
55+
while (pos < jpegBytes.size && jpegBytes[pos] == 0xFF.toByte()) pos++
56+
if (pos >= jpegBytes.size) break
57+
58+
//Log.d(tag, "marker - %d: %02x %02x".format(markerPos, jpegBytes[markerPos].toInt() and 0xFF, jpegBytes[markerPos+1].toInt() and 0xFF))
59+
val marker = jpegBytes[pos].toInt() and 0xFF
60+
pos++ // move past the marker code
61+
62+
// EOI, or SOS (entropy data follows)
63+
if (marker == 0xD9 || marker == 0xDA) break
64+
65+
// standalone markers (no length)
66+
if (marker == 0x01 || (marker in 0xD0..0xD7)) continue
67+
68+
if (pos + 1 >= jpegBytes.size) break // malformed JPEG
69+
70+
val segLength = ((jpegBytes[pos].toInt() and 0xFF) shl 8) or
71+
(jpegBytes[pos + 1].toInt() and 0xFF)
72+
pos += 2
73+
74+
if (segLength < 2 || pos + (segLength - 2) > jpegBytes.size) break
75+
76+
val payloadLength = segLength - 2
77+
val segStart = pos
78+
79+
// Handle APP2 segments that may contain ICC data
80+
if (marker == 0xE2 && payloadLength >= 14) {
81+
if (jpegBytes.copyOfRange(segStart, segStart + 12).contentEquals(iccSig)) {
82+
//Log.d(tag, "sig found @ $segStart. starting pos $magicPos. Payload length $payloadLength bytes")
83+
val seq = jpegBytes[segStart + 12].toInt() and 0xFF
84+
val total = jpegBytes[segStart + 13].toInt() and 0xFF
85+
86+
if (seq == 0 || total == 0 || seq > total) {
87+
Log.e(tag, "Invalid ICC_PROFILE APP2 chunk sequence (seq=$seq, total=$total).")
88+
return null
89+
}
90+
91+
if (totalChunks == 0) {
92+
totalChunks = total
93+
chunks.clear()
94+
repeat(total) { chunks.add(null) }
95+
seenChunks = BooleanArray(total) { false }
96+
} else if (total != totalChunks) {
97+
Log.e(tag, "Inconsistent ICC_PROFILE APP2 chunk count (expected $totalChunks, found $total).")
98+
return null
99+
}
100+
101+
if (seenChunks!![seq - 1]) {
102+
Log.e(tag, "Duplicate ICC_PROFILE APP2 chunk number $seq.")
103+
return null
104+
}
105+
106+
val chunkEnd = segStart + payloadLength
107+
if (magicPos < minStart) minStart = magicPos
108+
if (chunkEnd > maxEnd) maxEnd = chunkEnd
109+
110+
// Include header in the returned bytes,
111+
// store the FULL segment (signature + header + data) for the first chunk,
112+
// and only the data for subsequent chunks.
113+
val chunkData = if (seq == 1) {
114+
jpegBytes.copyOfRange(segStart, segStart + payloadLength)
115+
} else {
116+
jpegBytes.copyOfRange(segStart + 14, segStart + payloadLength)
117+
}
118+
119+
chunks[seq - 1] = chunkData
120+
seenChunks[seq - 1] = true
121+
122+
val chunkSize = if (seq == 1) payloadLength - 14 else payloadLength //exclude header on first section
123+
Log.d(tag, "ICC_PROFILE APP2 chunk ${seq}/${total} (${chunkSize} bytes). found at [$segStart-$chunkEnd]. (${chunkEnd-segStart} bytes)")
124+
}
125+
}
126+
127+
// Advance to next marker
128+
pos += payloadLength
129+
}
130+
131+
if (totalChunks == 0) {
132+
Log.e(tag, "No ICC_PROFILE APP2 markers found in JPEG.")
133+
return null
134+
}
135+
136+
for ((index, seen) in seenChunks!!.withIndex()) {
137+
if (!seen) {
138+
Log.e(tag, "Missing ICC_PROFILE APP2 chunk number ${index + 1}.")
139+
return null
140+
}
141+
}
142+
143+
val iccStream = ByteArrayOutputStream()
144+
for (chunk in chunks) {
145+
chunk?.let { iccStream.write(it) }
146+
}
147+
val iccBytes = iccStream.toByteArray()
148+
149+
//debug output
150+
/*
151+
Log.d(tag, "--- ICC Profile Extraction Complete ---")
152+
Log.d(tag, "Start Index: $minStart")
153+
Log.d(tag, "End Index: $maxEnd")
154+
Log.d(tag, "Total APP2 size: ${maxEnd - minStart}")
155+
Log.d(tag, "ICC Size: ${iccBytes.size} bytes") //including header
156+
val preview = iccBytes.joinToString(" ") { "%02X".format(it) }
157+
Log.d(tag, "Data Preview: $preview...")
158+
Log.d(tag, "---------------------------------------")*/
159+
Log.d(tag, "ICC Profile found! Size: ${iccBytes.size} bytes. Total chunks: ${totalChunks}.")
160+
161+
return IccProfile(minStart, maxEnd - minStart, iccBytes)
162+
}
163+
164+
/**
165+
* Returns a new ByteArray with the specified section removed.
166+
*
167+
* @param original The source byte array.
168+
* @param start The starting index of the section to remove.
169+
* @param length The number of bytes to remove.
170+
* @return A new ByteArray containing the remaining bytes.
171+
*/
172+
fun stripBytes(original: ByteArray, start: Int, length: Int): ByteArray {
173+
if (length <= 0) return original.copyOf()
174+
175+
require(start >= 0) { "Start index must be non-negative (was $start)" }
176+
require(length >= 0) { "Length must be non-negative (was $length)" }
177+
require(start + length <= original.size) {
178+
"Strip range ($start to ${start + length}) exceeds array size (${original.size})"
179+
}
180+
181+
/*
182+
Log.d("ICC strip","start - %d: %02x".format(start, original[start].toInt() and 0xFF))
183+
Log.d("ICC strip","end - %d: %02x".format(start+length, original[start+length].toInt() and 0xFF))
184+
Log.d("ICC strip","length - $length:")*/
185+
val newSize = original.size - length
186+
val result = ByteArray(newSize)
187+
188+
// copy section BEFORE
189+
original.copyInto(result, destinationOffset = 0, startIndex = 0, endIndex = start)
190+
// copy section AFTER
191+
original.copyInto(result, destinationOffset = start, startIndex = start + length)
192+
193+
return result
194+
}

0 commit comments

Comments
 (0)