Skip to content

Commit 2fc52b0

Browse files
authored
fix(android): recover missing EXIF tags and normalize across platforms (#32)
* fix(android): use file path for ExifInterface read on file:// URIs Use file path constructor for file:// URIs instead of InputStream, giving ExifInterface seek capability. Bump exifinterface 1.3.7 → 1.4.2. * fix(android): read IFD0 tags misplaced in ExifIFD Some image editors (e.g. ON1 Photo RAW) place IFD0 tags like Make, Model, Artist, Copyright inside ExifIFD. Android's ExifInterface ignores them there. Add a lightweight fallback JPEG parser that scans the ExifIFD for these missing tags. * fix: normalize EXIF tags across iOS and Android - ISOSpeedRatings returns number[] on Android (was string) - skip invalid Orientation 0 on Android - add BodySerialNumber tag on Android - remap iOS PixelWidth/PixelHeight to PixelXDimension/PixelYDimension - serialize arrays without brackets in Android write path * fix: extend IFD0 fallback to non-string tags, emit EXIF aliases on iOS - fallback reads SHORT/LONG/RATIONAL IFD0 tags (Orientation, XResolution, YResolution, ResolutionUnit) - detect missing numeric tags with default 0 for fallback - iOS emits standard EXIF aliases alongside CGImageSource keys
1 parent b39189c commit 2fc52b0

7 files changed

Lines changed: 360 additions & 26 deletions

File tree

android/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,5 @@ android {
6464

6565
dependencies {
6666
implementation "com.facebook.react:react-android"
67-
implementation "androidx.exifinterface:exifinterface:1.3.7"
67+
implementation "androidx.exifinterface:exifinterface:1.4.2"
6868
}
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package com.lodev09.exify
2+
3+
import java.io.InputStream
4+
import java.nio.ByteBuffer
5+
import java.nio.ByteOrder
6+
7+
/**
8+
* IFD0 tags that ExifInterface may miss when an image editor
9+
* (e.g. ON1 Photo RAW) places them inside the ExifIFD instead of IFD0.
10+
*
11+
* Maps EXIF tag number → ExifInterface tag name.
12+
*/
13+
private val FALLBACK_TAGS =
14+
mapOf(
15+
0x010E to "ImageDescription",
16+
0x010F to "Make",
17+
0x0110 to "Model",
18+
0x0112 to "Orientation",
19+
0x011A to "XResolution",
20+
0x011B to "YResolution",
21+
0x0128 to "ResolutionUnit",
22+
0x0131 to "Software",
23+
0x013B to "Artist",
24+
0x8298 to "Copyright",
25+
)
26+
27+
private const val IFD_FORMAT_SHORT = 3
28+
private const val IFD_FORMAT_LONG = 4
29+
private const val IFD_FORMAT_RATIONAL = 5
30+
private const val IFD_FORMAT_STRING = 2
31+
private const val IFD_FORMAT_UNDEFINED = 7
32+
private const val EXIF_IFD_POINTER_TAG = 0x8769
33+
34+
/**
35+
* Scans raw JPEG bytes for IFD0 tags that may have been placed in
36+
* the ExifIFD. Returns a map of tag name → value for any tags found.
37+
*/
38+
fun readFallbackTags(
39+
inputStream: InputStream,
40+
missingTags: Set<String>,
41+
): Map<String, Any> {
42+
if (missingTags.isEmpty()) return emptyMap()
43+
44+
val neededTagNumbers = FALLBACK_TAGS.filterValues { it in missingTags }.keys
45+
if (neededTagNumbers.isEmpty()) return emptyMap()
46+
47+
val bytes = readExifSegment(inputStream) ?: return emptyMap()
48+
val result = mutableMapOf<String, Any>()
49+
50+
val app1 = findApp1Exif(bytes) ?: return emptyMap()
51+
val tiffOffset = app1.tiffOffset
52+
val buf = ByteBuffer.wrap(bytes)
53+
buf.order(app1.byteOrder)
54+
55+
// Read IFD0 to find ExifIFD offset (offsets are relative to TIFF start)
56+
val ifd0Offset = buf.getInt(tiffOffset + 4)
57+
val exifIfdOffset = findExifIfdOffset(buf, tiffOffset, ifd0Offset) ?: return emptyMap()
58+
59+
// Scan ExifIFD entries for our missing tags
60+
scanIfd(buf, tiffOffset, exifIfdOffset, neededTagNumbers, result)
61+
62+
return result
63+
}
64+
65+
/**
66+
* Reads only the JPEG header segments up to and including the EXIF APP1,
67+
* avoiding reading the full image file into memory.
68+
*/
69+
private fun readExifSegment(inputStream: InputStream): ByteArray? {
70+
val dis = java.io.DataInputStream(inputStream)
71+
val header = ByteArray(2)
72+
dis.readFully(header)
73+
if (header[0] != 0xFF.toByte() || header[1] != 0xD8.toByte()) return null
74+
75+
val out = java.io.ByteArrayOutputStream(65536)
76+
out.write(header)
77+
78+
val segHeader = ByteArray(4)
79+
while (true) {
80+
try {
81+
dis.readFully(segHeader)
82+
} catch (_: java.io.EOFException) {
83+
break
84+
}
85+
val marker = segHeader[1].toInt() and 0xFF
86+
val segLen = ((segHeader[2].toInt() and 0xFF) shl 8) or (segHeader[3].toInt() and 0xFF)
87+
88+
if (segHeader[0] != 0xFF.toByte() || segLen < 2) break
89+
90+
out.write(segHeader)
91+
val segData = ByteArray(segLen - 2)
92+
dis.readFully(segData)
93+
out.write(segData)
94+
95+
// Found EXIF APP1 — we have enough
96+
if (marker == 0xE1 && segData.size >= 6 &&
97+
segData[0] == 0x45.toByte() &&
98+
segData[1] == 0x78.toByte() &&
99+
segData[2] == 0x69.toByte() &&
100+
segData[3] == 0x66.toByte()
101+
) {
102+
break
103+
}
104+
105+
// Stop if we hit SOS or image data
106+
if (marker == 0xDA) break
107+
}
108+
109+
return out.toByteArray()
110+
}
111+
112+
private data class App1Info(
113+
val tiffOffset: Int,
114+
val byteOrder: ByteOrder,
115+
)
116+
117+
private fun findApp1Exif(bytes: ByteArray): App1Info? {
118+
if (bytes.size < 4 || bytes[0] != 0xFF.toByte() || bytes[1] != 0xD8.toByte()) return null
119+
120+
var pos = 2
121+
while (pos + 4 < bytes.size) {
122+
if (bytes[pos] != 0xFF.toByte()) return null
123+
val marker = bytes[pos + 1].toInt() and 0xFF
124+
125+
// Read segment length (big-endian)
126+
val segLen = ((bytes[pos + 2].toInt() and 0xFF) shl 8) or (bytes[pos + 3].toInt() and 0xFF)
127+
128+
if (marker == 0xE1 && segLen >= 8) {
129+
// Check for "Exif\0\0"
130+
if (pos + 10 < bytes.size &&
131+
bytes[pos + 4] == 0x45.toByte() && // E
132+
bytes[pos + 5] == 0x78.toByte() && // x
133+
bytes[pos + 6] == 0x69.toByte() && // i
134+
bytes[pos + 7] == 0x66.toByte() && // f
135+
bytes[pos + 8] == 0x00.toByte() &&
136+
bytes[pos + 9] == 0x00.toByte()
137+
) {
138+
val tiffOffset = pos + 10
139+
val order =
140+
if (bytes[tiffOffset] == 0x49.toByte() && bytes[tiffOffset + 1] == 0x49.toByte()) {
141+
ByteOrder.LITTLE_ENDIAN
142+
} else {
143+
ByteOrder.BIG_ENDIAN
144+
}
145+
return App1Info(tiffOffset, order)
146+
}
147+
}
148+
149+
pos += 2 + segLen
150+
}
151+
return null
152+
}
153+
154+
private fun findExifIfdOffset(
155+
buf: ByteBuffer,
156+
tiffOffset: Int,
157+
ifdOffset: Int,
158+
): Int? {
159+
if (ifdOffset < 0 || tiffOffset + ifdOffset + 2 > buf.limit()) return null
160+
161+
val count = buf.getShort(tiffOffset + ifdOffset).toInt() and 0xFFFF
162+
for (i in 0 until count) {
163+
val entryOffset = tiffOffset + ifdOffset + 2 + i * 12
164+
if (entryOffset + 12 > buf.limit()) return null
165+
166+
val tagNumber = buf.getShort(entryOffset).toInt() and 0xFFFF
167+
if (tagNumber == EXIF_IFD_POINTER_TAG) {
168+
return buf.getInt(entryOffset + 8)
169+
}
170+
}
171+
return null
172+
}
173+
174+
private fun scanIfd(
175+
buf: ByteBuffer,
176+
tiffOffset: Int,
177+
ifdOffset: Int,
178+
neededTagNumbers: Set<Int>,
179+
result: MutableMap<String, Any>,
180+
) {
181+
val absOffset = tiffOffset + ifdOffset
182+
if (absOffset + 2 > buf.limit()) return
183+
184+
val count = buf.getShort(absOffset).toInt() and 0xFFFF
185+
for (i in 0 until count) {
186+
val entryOffset = absOffset + 2 + i * 12
187+
if (entryOffset + 12 > buf.limit()) return
188+
189+
val tagNumber = buf.getShort(entryOffset).toInt() and 0xFFFF
190+
if (tagNumber !in neededTagNumbers) continue
191+
192+
val format = buf.getShort(entryOffset + 2).toInt() and 0xFFFF
193+
val componentCount = buf.getInt(entryOffset + 4)
194+
if (componentCount <= 0) continue
195+
196+
val tagName = FALLBACK_TAGS[tagNumber] ?: continue
197+
198+
when (format) {
199+
IFD_FORMAT_SHORT -> {
200+
if (componentCount != 1) continue
201+
val value = buf.getShort(entryOffset + 8).toInt() and 0xFFFF
202+
if (value == 0) continue
203+
result[tagName] = value
204+
}
205+
206+
IFD_FORMAT_LONG -> {
207+
if (componentCount != 1) continue
208+
val value = buf.getInt(entryOffset + 8).toLong() and 0xFFFFFFFFL
209+
if (value == 0L) continue
210+
result[tagName] = value.toInt()
211+
}
212+
213+
IFD_FORMAT_RATIONAL -> {
214+
if (componentCount != 1) continue
215+
val dataOffset = tiffOffset + buf.getInt(entryOffset + 8)
216+
if (dataOffset < 0 || dataOffset + 8 > buf.limit()) continue
217+
val numerator = buf.getInt(dataOffset).toLong() and 0xFFFFFFFFL
218+
val denominator = buf.getInt(dataOffset + 4).toLong() and 0xFFFFFFFFL
219+
if (denominator == 0L) continue
220+
result[tagName] = numerator.toDouble() / denominator.toDouble()
221+
}
222+
223+
IFD_FORMAT_STRING, IFD_FORMAT_UNDEFINED -> {
224+
if (componentCount > 1024) continue
225+
val dataOffset =
226+
if (componentCount <= 4) {
227+
entryOffset + 8
228+
} else {
229+
tiffOffset + buf.getInt(entryOffset + 8)
230+
}
231+
if (dataOffset < 0 || dataOffset + componentCount > buf.limit()) continue
232+
233+
val strBytes = ByteArray(componentCount)
234+
buf.position(dataOffset)
235+
buf.get(strBytes)
236+
237+
var len = strBytes.size
238+
while (len > 0 && strBytes[len - 1] == 0.toByte()) len--
239+
if (len == 0) continue
240+
241+
result[tagName] = String(strBytes, 0, len, Charsets.UTF_8).trim()
242+
}
243+
}
244+
}
245+
}

android/src/main/java/com/lodev09/exify/ExifyModule.kt

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.facebook.react.modules.core.PermissionAwareActivity
1616
import com.facebook.react.modules.core.PermissionListener
1717
import com.facebook.react.util.RNLog
1818
import com.lodev09.exify.ExifyUtils.formatTags
19+
import java.io.File
1920
import java.io.IOException
2021

2122
private const val ERROR_TAG = "E_EXIFY_ERROR"
@@ -72,35 +73,77 @@ class ExifyModule(
7273
promise: Promise,
7374
) {
7475
try {
75-
val inputStream =
76-
if (scheme == "http" || scheme == "https") {
77-
java.net.URL(uri).openStream()
78-
} else if (scheme == "content" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
79-
try {
80-
context.contentResolver.openInputStream(MediaStore.setRequireOriginal(photoUri))
81-
} catch (e: SecurityException) {
82-
context.contentResolver.openInputStream(photoUri)
83-
}
76+
val exif =
77+
if (scheme == "file") {
78+
ExifInterface(photoUri.path!!)
8479
} else {
85-
context.contentResolver.openInputStream(photoUri)
80+
val inputStream =
81+
if (scheme == "content" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
82+
try {
83+
context.contentResolver.openInputStream(MediaStore.setRequireOriginal(photoUri))
84+
} catch (_: SecurityException) {
85+
context.contentResolver.openInputStream(photoUri)
86+
}
87+
} else if (scheme == "http" || scheme == "https") {
88+
java.net.URL(uri).openStream()
89+
} else {
90+
context.contentResolver.openInputStream(photoUri)
91+
}
92+
93+
if (inputStream == null) {
94+
RNLog.w(context, "Exify: Could not open URI: $uri")
95+
promise.reject(ERROR_TAG, "Could not open URI: $uri")
96+
return
97+
}
98+
99+
inputStream.use { ExifInterface(it) }
86100
}
87101

88-
if (inputStream == null) {
89-
RNLog.w(context, "Exify: Could not open URI: $uri")
90-
promise.reject(ERROR_TAG, "Could not open URI: $uri")
91-
return
92-
}
102+
val tags = formatTags(exif)
93103

94-
inputStream.use {
95-
val tags = formatTags(ExifInterface(it))
96-
promise.resolve(tags)
104+
// ExifInterface ignores IFD0 tags placed in ExifIFD (non-standard but common
105+
// with some image editors). Fall back to raw EXIF parsing for missing tags.
106+
val missingTags =
107+
IFD0_FALLBACK_TAGS
108+
.filter {
109+
if (!tags.hasKey(it)) return@filter true
110+
val type = tags.getType(it)
111+
(type == ReadableType.Number && tags.getDouble(it) == 0.0)
112+
}.toSet()
113+
if (missingTags.isNotEmpty()) {
114+
val fallback =
115+
try {
116+
openInputStream(uri, photoUri, scheme)?.use { readFallbackTags(it, missingTags) }
117+
} catch (_: Exception) {
118+
null
119+
}
120+
fallback?.forEach { (tag, value) ->
121+
when (value) {
122+
is String -> tags.putString(tag, value)
123+
is Int -> tags.putInt(tag, value)
124+
is Double -> tags.putDouble(tag, value)
125+
}
126+
}
97127
}
128+
129+
promise.resolve(tags)
98130
} catch (e: Exception) {
99131
RNLog.w(context, "Exify: ${e.message}")
100132
promise.reject(ERROR_TAG, e.message, e)
101133
}
102134
}
103135

136+
private fun openInputStream(
137+
uri: String,
138+
photoUri: Uri,
139+
scheme: String,
140+
): java.io.InputStream? =
141+
when (scheme) {
142+
"file" -> File(photoUri.path!!).inputStream()
143+
"content" -> context.contentResolver.openInputStream(photoUri)
144+
else -> java.net.URL(uri).openStream()
145+
}
146+
104147
@Throws(IOException::class)
105148
override fun write(
106149
uri: String,
@@ -141,7 +184,10 @@ class ExifyModule(
141184
exif.setAttribute(tag, value.toBigDecimal().toPlainString())
142185
}
143186
}
144-
else -> exif.setAttribute(tag, tags.getDouble(tag).toInt().toString())
187+
188+
else -> {
189+
exif.setAttribute(tag, tags.getDouble(tag).toInt().toString())
190+
}
145191
}
146192
}
147193

@@ -150,7 +196,9 @@ class ExifyModule(
150196
}
151197

152198
ReadableType.Array -> {
153-
exif.setAttribute(tag, tags.getArray(tag).toString())
199+
val arr = tags.getArray(tag)!!
200+
val values = (0 until arr.size()).joinToString(", ") { arr.getInt(it).toString() }
201+
exif.setAttribute(tag, values)
154202
}
155203

156204
else -> {

0 commit comments

Comments
 (0)