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 android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,5 @@ android {

dependencies {
implementation "com.facebook.react:react-android"
implementation "androidx.exifinterface:exifinterface:1.3.7"
implementation "androidx.exifinterface:exifinterface:1.4.2"
}
245 changes: 245 additions & 0 deletions android/src/main/java/com/lodev09/exify/ExifFallback.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package com.lodev09.exify

import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder

/**
* IFD0 tags that ExifInterface may miss when an image editor
* (e.g. ON1 Photo RAW) places them inside the ExifIFD instead of IFD0.
*
* Maps EXIF tag number → ExifInterface tag name.
*/
private val FALLBACK_TAGS =
mapOf(
0x010E to "ImageDescription",
0x010F to "Make",
0x0110 to "Model",
0x0112 to "Orientation",
0x011A to "XResolution",
0x011B to "YResolution",
0x0128 to "ResolutionUnit",
0x0131 to "Software",
0x013B to "Artist",
0x8298 to "Copyright",
)

private const val IFD_FORMAT_SHORT = 3
private const val IFD_FORMAT_LONG = 4
private const val IFD_FORMAT_RATIONAL = 5
private const val IFD_FORMAT_STRING = 2
private const val IFD_FORMAT_UNDEFINED = 7
private const val EXIF_IFD_POINTER_TAG = 0x8769

/**
* Scans raw JPEG bytes for IFD0 tags that may have been placed in
* the ExifIFD. Returns a map of tag name → value for any tags found.
*/
fun readFallbackTags(
inputStream: InputStream,
missingTags: Set<String>,
): Map<String, Any> {
if (missingTags.isEmpty()) return emptyMap()

val neededTagNumbers = FALLBACK_TAGS.filterValues { it in missingTags }.keys
if (neededTagNumbers.isEmpty()) return emptyMap()

val bytes = readExifSegment(inputStream) ?: return emptyMap()
val result = mutableMapOf<String, Any>()

val app1 = findApp1Exif(bytes) ?: return emptyMap()
val tiffOffset = app1.tiffOffset
val buf = ByteBuffer.wrap(bytes)
buf.order(app1.byteOrder)

// Read IFD0 to find ExifIFD offset (offsets are relative to TIFF start)
val ifd0Offset = buf.getInt(tiffOffset + 4)
val exifIfdOffset = findExifIfdOffset(buf, tiffOffset, ifd0Offset) ?: return emptyMap()

// Scan ExifIFD entries for our missing tags
scanIfd(buf, tiffOffset, exifIfdOffset, neededTagNumbers, result)

return result
}

/**
* Reads only the JPEG header segments up to and including the EXIF APP1,
* avoiding reading the full image file into memory.
*/
private fun readExifSegment(inputStream: InputStream): ByteArray? {
val dis = java.io.DataInputStream(inputStream)
val header = ByteArray(2)
dis.readFully(header)
if (header[0] != 0xFF.toByte() || header[1] != 0xD8.toByte()) return null

val out = java.io.ByteArrayOutputStream(65536)
out.write(header)

val segHeader = ByteArray(4)
while (true) {
try {
dis.readFully(segHeader)
} catch (_: java.io.EOFException) {
break
}
val marker = segHeader[1].toInt() and 0xFF
val segLen = ((segHeader[2].toInt() and 0xFF) shl 8) or (segHeader[3].toInt() and 0xFF)

if (segHeader[0] != 0xFF.toByte() || segLen < 2) break

out.write(segHeader)
val segData = ByteArray(segLen - 2)
dis.readFully(segData)
out.write(segData)

// Found EXIF APP1 — we have enough
if (marker == 0xE1 && segData.size >= 6 &&
segData[0] == 0x45.toByte() &&
segData[1] == 0x78.toByte() &&
segData[2] == 0x69.toByte() &&
segData[3] == 0x66.toByte()
) {
break
}

// Stop if we hit SOS or image data
if (marker == 0xDA) break
}

return out.toByteArray()
}

private data class App1Info(
val tiffOffset: Int,
val byteOrder: ByteOrder,
)

private fun findApp1Exif(bytes: ByteArray): App1Info? {
if (bytes.size < 4 || bytes[0] != 0xFF.toByte() || bytes[1] != 0xD8.toByte()) return null

var pos = 2
while (pos + 4 < bytes.size) {
if (bytes[pos] != 0xFF.toByte()) return null
val marker = bytes[pos + 1].toInt() and 0xFF

// Read segment length (big-endian)
val segLen = ((bytes[pos + 2].toInt() and 0xFF) shl 8) or (bytes[pos + 3].toInt() and 0xFF)

if (marker == 0xE1 && segLen >= 8) {
// Check for "Exif\0\0"
if (pos + 10 < bytes.size &&
bytes[pos + 4] == 0x45.toByte() && // E
bytes[pos + 5] == 0x78.toByte() && // x
bytes[pos + 6] == 0x69.toByte() && // i
bytes[pos + 7] == 0x66.toByte() && // f
bytes[pos + 8] == 0x00.toByte() &&
bytes[pos + 9] == 0x00.toByte()
) {
val tiffOffset = pos + 10
val order =
if (bytes[tiffOffset] == 0x49.toByte() && bytes[tiffOffset + 1] == 0x49.toByte()) {
ByteOrder.LITTLE_ENDIAN
} else {
ByteOrder.BIG_ENDIAN
}
return App1Info(tiffOffset, order)
}
}

pos += 2 + segLen
}
return null
}

private fun findExifIfdOffset(
buf: ByteBuffer,
tiffOffset: Int,
ifdOffset: Int,
): Int? {
if (ifdOffset < 0 || tiffOffset + ifdOffset + 2 > buf.limit()) return null

val count = buf.getShort(tiffOffset + ifdOffset).toInt() and 0xFFFF
for (i in 0 until count) {
val entryOffset = tiffOffset + ifdOffset + 2 + i * 12
if (entryOffset + 12 > buf.limit()) return null

val tagNumber = buf.getShort(entryOffset).toInt() and 0xFFFF
if (tagNumber == EXIF_IFD_POINTER_TAG) {
return buf.getInt(entryOffset + 8)
}
}
return null
}

private fun scanIfd(
buf: ByteBuffer,
tiffOffset: Int,
ifdOffset: Int,
neededTagNumbers: Set<Int>,
result: MutableMap<String, Any>,
) {
val absOffset = tiffOffset + ifdOffset
if (absOffset + 2 > buf.limit()) return

val count = buf.getShort(absOffset).toInt() and 0xFFFF
for (i in 0 until count) {
val entryOffset = absOffset + 2 + i * 12
if (entryOffset + 12 > buf.limit()) return

val tagNumber = buf.getShort(entryOffset).toInt() and 0xFFFF
if (tagNumber !in neededTagNumbers) continue

val format = buf.getShort(entryOffset + 2).toInt() and 0xFFFF
val componentCount = buf.getInt(entryOffset + 4)
if (componentCount <= 0) continue

val tagName = FALLBACK_TAGS[tagNumber] ?: continue

when (format) {
IFD_FORMAT_SHORT -> {
if (componentCount != 1) continue
val value = buf.getShort(entryOffset + 8).toInt() and 0xFFFF
if (value == 0) continue
result[tagName] = value
}

IFD_FORMAT_LONG -> {
if (componentCount != 1) continue
val value = buf.getInt(entryOffset + 8).toLong() and 0xFFFFFFFFL
if (value == 0L) continue
result[tagName] = value.toInt()
}

IFD_FORMAT_RATIONAL -> {
if (componentCount != 1) continue
val dataOffset = tiffOffset + buf.getInt(entryOffset + 8)
if (dataOffset < 0 || dataOffset + 8 > buf.limit()) continue
val numerator = buf.getInt(dataOffset).toLong() and 0xFFFFFFFFL
val denominator = buf.getInt(dataOffset + 4).toLong() and 0xFFFFFFFFL
if (denominator == 0L) continue
result[tagName] = numerator.toDouble() / denominator.toDouble()
}

IFD_FORMAT_STRING, IFD_FORMAT_UNDEFINED -> {
if (componentCount > 1024) continue
val dataOffset =
if (componentCount <= 4) {
entryOffset + 8
} else {
tiffOffset + buf.getInt(entryOffset + 8)
}
if (dataOffset < 0 || dataOffset + componentCount > buf.limit()) continue

val strBytes = ByteArray(componentCount)
buf.position(dataOffset)
buf.get(strBytes)

var len = strBytes.size
while (len > 0 && strBytes[len - 1] == 0.toByte()) len--
if (len == 0) continue

result[tagName] = String(strBytes, 0, len, Charsets.UTF_8).trim()
}
}
}
}
88 changes: 68 additions & 20 deletions android/src/main/java/com/lodev09/exify/ExifyModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.facebook.react.modules.core.PermissionAwareActivity
import com.facebook.react.modules.core.PermissionListener
import com.facebook.react.util.RNLog
import com.lodev09.exify.ExifyUtils.formatTags
import java.io.File
import java.io.IOException

private const val ERROR_TAG = "E_EXIFY_ERROR"
Expand Down Expand Up @@ -72,35 +73,77 @@ class ExifyModule(
promise: Promise,
) {
try {
val inputStream =
if (scheme == "http" || scheme == "https") {
java.net.URL(uri).openStream()
} else if (scheme == "content" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
context.contentResolver.openInputStream(MediaStore.setRequireOriginal(photoUri))
} catch (e: SecurityException) {
context.contentResolver.openInputStream(photoUri)
}
val exif =
if (scheme == "file") {
ExifInterface(photoUri.path!!)
} else {
context.contentResolver.openInputStream(photoUri)
val inputStream =
if (scheme == "content" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
context.contentResolver.openInputStream(MediaStore.setRequireOriginal(photoUri))
} catch (_: SecurityException) {
context.contentResolver.openInputStream(photoUri)
}
} else if (scheme == "http" || scheme == "https") {
java.net.URL(uri).openStream()
} else {
context.contentResolver.openInputStream(photoUri)
}

if (inputStream == null) {
RNLog.w(context, "Exify: Could not open URI: $uri")
promise.reject(ERROR_TAG, "Could not open URI: $uri")
return
}

inputStream.use { ExifInterface(it) }
}

if (inputStream == null) {
RNLog.w(context, "Exify: Could not open URI: $uri")
promise.reject(ERROR_TAG, "Could not open URI: $uri")
return
}
val tags = formatTags(exif)

inputStream.use {
val tags = formatTags(ExifInterface(it))
promise.resolve(tags)
// ExifInterface ignores IFD0 tags placed in ExifIFD (non-standard but common
// with some image editors). Fall back to raw EXIF parsing for missing tags.
val missingTags =
IFD0_FALLBACK_TAGS
.filter {
if (!tags.hasKey(it)) return@filter true
val type = tags.getType(it)
(type == ReadableType.Number && tags.getDouble(it) == 0.0)
}.toSet()
if (missingTags.isNotEmpty()) {
val fallback =
try {
openInputStream(uri, photoUri, scheme)?.use { readFallbackTags(it, missingTags) }
} catch (_: Exception) {
null
}
fallback?.forEach { (tag, value) ->
when (value) {
is String -> tags.putString(tag, value)
is Int -> tags.putInt(tag, value)
is Double -> tags.putDouble(tag, value)
}
}
}

promise.resolve(tags)
} catch (e: Exception) {
RNLog.w(context, "Exify: ${e.message}")
promise.reject(ERROR_TAG, e.message, e)
}
}

private fun openInputStream(
uri: String,
photoUri: Uri,
scheme: String,
): java.io.InputStream? =
when (scheme) {
"file" -> File(photoUri.path!!).inputStream()
"content" -> context.contentResolver.openInputStream(photoUri)
else -> java.net.URL(uri).openStream()
}

@Throws(IOException::class)
override fun write(
uri: String,
Expand Down Expand Up @@ -141,7 +184,10 @@ class ExifyModule(
exif.setAttribute(tag, value.toBigDecimal().toPlainString())
}
}
else -> exif.setAttribute(tag, tags.getDouble(tag).toInt().toString())

else -> {
exif.setAttribute(tag, tags.getDouble(tag).toInt().toString())
}
}
}

Expand All @@ -150,7 +196,9 @@ class ExifyModule(
}

ReadableType.Array -> {
exif.setAttribute(tag, tags.getArray(tag).toString())
val arr = tags.getArray(tag)!!
val values = (0 until arr.size()).joinToString(", ") { arr.getInt(it).toString() }
exif.setAttribute(tag, values)
}

else -> {
Expand Down
Loading