|
| 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