/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*
* mail: epistemereader@gmail.com
*/
#include
#include
#include
#include
#include "mobi.h"
#include "index.h" // Needed for INDX_TAG_NCX_FILEPOS
#include "util.h" // Needed for mobi_determine_resource_type
#define TAG "MobiJNI"
#define AZW3_DEBUG_TAG "AZW3_DEBUG" // Common tag for diagnosis
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
#define LOG_AZW3(...) __android_log_print(ANDROID_LOG_DEBUG, AZW3_DEBUG_TAG, __VA_ARGS__)
// Helper function to find a class and handle exceptions
jclass find_class(JNIEnv *env, const char *name) {
jclass clazz = (*env)->FindClass(env, name);
if (clazz == NULL) {
(*env)->ExceptionClear(env);
LOGE("Failed to find class: %s", name);
}
return clazz;
}
// Helper function to create a Java String from a C string (UTF-8)
jstring new_string_utf(JNIEnv *env, const char *str) {
if (str == NULL) {
return NULL;
}
return (*env)->NewStringUTF(env, str);
}
// Main JNI function
JNIEXPORT jobject JNICALL
Java_com_aryan_reader_epub_MobiParser_parseMobiFile(JNIEnv *env, jobject thiz, jstring file_path) {
const char *native_file_path = (*env)->GetStringUTFChars(env, file_path, 0);
LOGD("Starting to parse file: %s", native_file_path);
MOBIData *m = mobi_init();
if (m == NULL) {
LOGE("mobi_init() failed");
(*env)->ReleaseStringUTFChars(env, file_path, native_file_path);
return NULL;
}
FILE *file = fopen(native_file_path, "rb");
if (file == NULL) {
LOGE("Failed to open file: %s", native_file_path);
mobi_free(m);
(*env)->ReleaseStringUTFChars(env, file_path, native_file_path);
return NULL;
}
MOBI_RET mobi_ret = mobi_load_file(m, file);
fclose(file);
if (mobi_ret != MOBI_SUCCESS) {
LOGE("mobi_load_file() failed with error: %d", mobi_ret);
mobi_free(m);
(*env)->ReleaseStringUTFChars(env, file_path, native_file_path);
return NULL;
}
if (m->rh && m->rh->encryption_type != MOBI_ENCRYPTION_NONE) {
LOGE("File is protected by DRM (encryption type: %d) and cannot be opened.", m->rh->encryption_type);
mobi_free(m);
(*env)->ReleaseStringUTFChars(env, file_path, native_file_path);
return NULL;
}
// Add diagnostic logs
if (m->mh) {
LOG_AZW3("MOBI Version: %u", (m->mh->version) ? *m->mh->version : 0);
LOG_AZW3("Is KF8: %s", mobi_is_kf8(m) ? "yes" : "no");
LOG_AZW3("Is Hybrid: %s", mobi_is_hybrid(m) ? "yes" : "no");
}
MOBIRawml *rawml = mobi_init_rawml(m);
if (rawml == NULL) {
LOGE("mobi_init_rawml() failed");
mobi_free(m);
(*env)->ReleaseStringUTFChars(env, file_path, native_file_path);
return NULL;
}
// Use the _opt version to explicitly enable parsing of all TOC types (NCX, Guide, etc.)
mobi_ret = mobi_parse_rawml_opt(rawml, m, true, false, true);
if (mobi_ret != MOBI_SUCCESS) {
LOGE("mobi_parse_rawml_opt() failed with error: %d", mobi_ret);
mobi_free_rawml(rawml);
mobi_free(m);
(*env)->ReleaseStringUTFChars(env, file_path, native_file_path);
return NULL;
}
LOGD("Successfully parsed MOBI file.");
// Find Kotlin data classes
jclass parsed_data_class = find_class(env, "com/aryan/reader/epub/MobiParser$ParsedMobiData");
jclass resource_class = find_class(env, "com/aryan/reader/epub/MobiParser$ParsedMobiResource");
jclass toc_entry_class = find_class(env, "com/aryan/reader/epub/MobiParser$ParsedMobiTocEntry");
if (!parsed_data_class || !resource_class || !toc_entry_class) {
LOGE("Could not find one or more required Kotlin data classes.");
mobi_free_rawml(rawml);
mobi_free(m);
(*env)->ReleaseStringUTFChars(env, file_path, native_file_path);
return NULL;
}
// Get constructors for data classes (NOTE THE NEW SIGNATURE for ParsedMobiData)
jmethodID parsed_data_ctor = (*env)->GetMethodID(env, parsed_data_class, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Lcom/aryan/reader/epub/MobiParser$ParsedMobiResource;[Lcom/aryan/reader/epub/MobiParser$ParsedMobiTocEntry;I)V");
jmethodID resource_ctor = (*env)->GetMethodID(env, resource_class, "", "(ILjava/lang/String;[BLjava/lang/String;)V");
jmethodID toc_entry_ctor = (*env)->GetMethodID(env, toc_entry_class, "", "(Ljava/lang/String;I)V");
// --- Manually concatenate flow parts to get the full raw HTML content ---
size_t total_html_size = 0;
size_t num_flow_parts = 0;
MOBIPart *curr_part = rawml->flow;
while (curr_part) {
if (curr_part->data) {
total_html_size += curr_part->size;
}
num_flow_parts++;
curr_part = curr_part->next;
}
jstring raw_html_string = NULL;
if (total_html_size > 0) {
char *full_html = malloc(total_html_size + 1);
if (full_html) {
size_t current_offset = 0;
curr_part = rawml->flow;
while (curr_part) {
if (curr_part->data) {
memcpy(full_html + current_offset, curr_part->data, curr_part->size);
current_offset += curr_part->size;
}
curr_part = curr_part->next;
}
full_html[total_html_size] = '\0'; // Null-terminate the final string
raw_html_string = new_string_utf(env, full_html);
free(full_html);
LOGD("Manually concatenated %zu flow parts into single string (%zu bytes).", num_flow_parts, total_html_size);
} else {
LOGE("Failed to allocate memory for full HTML string.");
raw_html_string = NULL;
}
} else {
LOGE("No data found in rawml->flow parts.");
}
// --- Create Resource Array ---
// 1. Find the cover record first.
jint cover_uid = -1;
MOBIPdbRecord *cover_image_rec = NULL;
MOBIExthHeader *cover_exth = mobi_get_exthrecord_by_tag(m, EXTH_COVEROFFSET);
if (cover_exth) {
uint32_t cover_offset = mobi_decode_exthvalue(cover_exth->data, cover_exth->size);
if (m->mh && m->mh->image_index) {
size_t first_image_seq = *m->mh->image_index;
cover_image_rec = mobi_get_record_by_seqnumber(m, first_image_seq + cover_offset);
if (cover_image_rec) {
cover_uid = (jint)cover_image_rec->uid;
}
}
} else {
LOGD("JNI: No cover metadata found (EXTH 201).");
}
// 2. Count resources from rawml (binary resources AND flow-based CSS)
size_t num_binary_resources = 0;
bool cover_in_resources = false;
curr_part = rawml->resources;
while (curr_part) {
num_binary_resources++;
if (cover_image_rec && curr_part->uid == cover_image_rec->uid) {
cover_in_resources = true;
}
curr_part = curr_part->next;
}
// NEW: Count flow parts that are CSS
size_t num_css_flow_parts = 0;
size_t flow_part_index = 0;
curr_part = rawml->flow;
while (curr_part) {
// mobi_determine_flowpart_type is key here
if (mobi_determine_flowpart_type(rawml, flow_part_index) == T_CSS) {
num_css_flow_parts++;
}
flow_part_index++;
curr_part = curr_part->next;
}
LOG_AZW3("Found %zu binary resources and %zu CSS resources in flow.", num_binary_resources, num_css_flow_parts);
size_t total_resources = num_binary_resources + num_css_flow_parts;
if (cover_image_rec && !cover_in_resources) {
total_resources++;
}
// 3. Create the array and populate it.
jobjectArray resource_array = (*env)->NewObjectArray(env, total_resources, resource_class, NULL);
size_t i = 0; // This is now the main index for the resource_array
// Add binary resources first
curr_part = rawml->resources;
while (curr_part) {
if (curr_part->data) {
MOBIFileMeta meta = mobi_get_filemeta_by_type(curr_part->type);
char filename[64];
snprintf(filename, sizeof(filename), "res_%05zu.%s", curr_part->uid, meta.extension);
LOG_AZW3("Binary Resource -> UID: %zu, Type: %s, Mime: %s, Filename: %s", curr_part->uid, meta.extension, meta.mime_type, filename);
jstring path = new_string_utf(env, filename);
jstring mimetype = new_string_utf(env, meta.mime_type);
jbyteArray data_array = (*env)->NewByteArray(env, curr_part->size);
(*env)->SetByteArrayRegion(env, data_array, 0, curr_part->size, (jbyte *)curr_part->data);
jobject resource_obj = (*env)->NewObject(env, resource_class, resource_ctor, (jint)curr_part->uid, path, data_array, mimetype);
(*env)->SetObjectArrayElement(env, resource_array, i++, resource_obj);
(*env)->DeleteLocalRef(env, path);
(*env)->DeleteLocalRef(env, mimetype);
(*env)->DeleteLocalRef(env, data_array);
(*env)->DeleteLocalRef(env, resource_obj);
}
curr_part = curr_part->next;
}
// NEW: Add flow-based CSS resources
flow_part_index = 0;
curr_part = rawml->flow;
while (curr_part) {
if (mobi_determine_flowpart_type(rawml, flow_part_index) == T_CSS) {
MOBIFileMeta meta = mobi_get_filemeta_by_type(T_CSS);
char filename[64];
// We use the flow part index for a unique filename, since flow parts don't have UIDs
snprintf(filename, sizeof(filename), "flow_%05zu.css", flow_part_index);
LOG_AZW3("Flow Resource (CSS) -> Index: %zu, Mime: %s, Filename: %s", flow_part_index, meta.mime_type, filename);
jstring path = new_string_utf(env, filename);
jstring mimetype = new_string_utf(env, meta.mime_type);
jbyteArray data_array = (*env)->NewByteArray(env, curr_part->size);
(*env)->SetByteArrayRegion(env, data_array, 0, curr_part->size, (jbyte *)curr_part->data);
// Use flow_part_index for UID as it's unique in this context. Add a large offset to avoid collisions with real UIDs.
jobject resource_obj = (*env)->NewObject(env, resource_class, resource_ctor, (jint)(100000 + flow_part_index), path, data_array, mimetype);
(*env)->SetObjectArrayElement(env, resource_array, i++, resource_obj);
(*env)->DeleteLocalRef(env, path);
(*env)->DeleteLocalRef(env, mimetype);
(*env)->DeleteLocalRef(env, data_array);
(*env)->DeleteLocalRef(env, resource_obj);
}
flow_part_index++;
curr_part = curr_part->next;
}
// 4. If we need to add the cover manually, do it now.
if (cover_image_rec && !cover_in_resources) {
MOBIFiletype cover_type = mobi_determine_resource_type(cover_image_rec);
MOBIFileMeta meta = mobi_get_filemeta_by_type(cover_type);
char filename[64];
snprintf(filename, sizeof(filename), "res_%05zu.%s", cover_image_rec->uid, meta.extension);
LOG_AZW3("Manually adding cover resource -> UID: %zu, Type: %s, Mime: %s", cover_image_rec->uid, meta.extension, meta.mime_type);
jstring path = new_string_utf(env, filename);
jstring mimetype = new_string_utf(env, meta.mime_type);
jbyteArray data_array = (*env)->NewByteArray(env, cover_image_rec->size);
(*env)->SetByteArrayRegion(env, data_array, 0, cover_image_rec->size, (jbyte *)cover_image_rec->data);
jobject resource_obj = (*env)->NewObject(env, resource_class, resource_ctor, (jint)cover_image_rec->uid, path, data_array, mimetype);
(*env)->SetObjectArrayElement(env, resource_array, i, resource_obj); // Use i, not i++
(*env)->DeleteLocalRef(env, path);
(*env)->DeleteLocalRef(env, mimetype);
(*env)->DeleteLocalRef(env, data_array);
(*env)->DeleteLocalRef(env, resource_obj);
}
LOGD("Processed %zu total resources.", total_resources);
// --- Create TOC Array (from NCX index or Guide index) ---
jobjectArray toc_array = NULL;
MOBIIndx *toc_indx = NULL;
const char *toc_type_str = "None";
if (rawml->ncx && rawml->ncx->entries_count > 0) {
toc_indx = rawml->ncx;
toc_type_str = "NCX";
} else if (rawml->guide && rawml->guide->entries_count > 0) {
toc_indx = rawml->guide;
toc_type_str = "Guide";
}
LOG_AZW3("TOC type detected: %s", toc_type_str);
if (toc_indx) {
size_t valid_entries_count = 0;
// First, count valid entries to allocate the exact array size.
for (size_t i = 0; i < toc_indx->entries_count; ++i) {
MOBIIndexEntry entry = toc_indx->entries[i];
uint32_t file_pos = 0;
if (mobi_get_indxentry_tagvalue(&file_pos, &entry, INDX_TAG_NCX_FILEPOS) == MOBI_SUCCESS) {
valid_entries_count++;
}
}
if (valid_entries_count > 0) {
toc_array = (*env)->NewObjectArray(env, valid_entries_count, toc_entry_class, NULL);
size_t current_valid_index = 0;
for (size_t i = 0; i < toc_indx->entries_count; ++i) {
MOBIIndexEntry entry = toc_indx->entries[i];
uint32_t file_pos = 0;
// We check again, this time processing the valid entries.
if (mobi_get_indxentry_tagvalue(&file_pos, &entry, INDX_TAG_NCX_FILEPOS) == MOBI_SUCCESS) {
char *chapter_title_str = NULL;
uint32_t cncx_text_offset = 0;
// Try to get the title from the CNCX text tag first. This is the most reliable.
if (toc_indx->cncx_record &&
mobi_get_indxentry_tagvalue(&cncx_text_offset, &entry, INDX_TAG_NCX_TEXT_CNCX) == MOBI_SUCCESS) {
chapter_title_str = mobi_get_cncx_string(toc_indx->cncx_record, cncx_text_offset);
}
jstring title;
if (chapter_title_str != NULL && strlen(chapter_title_str) > 0) {
// Success! Use the title from CNCX.
title = new_string_utf(env, chapter_title_str);
free(chapter_title_str); // mobi_get_cncx_string allocates memory that we must free.
} else {
// Fallback to using the entry label if CNCX fails or is empty.
title = new_string_utf(env, entry.label);
LOGD("%s: TOC Entry [%zu]: Title from Label: '%s'", TAG, current_valid_index, entry.label);
}
jobject toc_entry_obj = (*env)->NewObject(env, toc_entry_class, toc_entry_ctor, title, (jint) file_pos);
(*env)->SetObjectArrayElement(env, toc_array, current_valid_index, toc_entry_obj);
current_valid_index++;
(*env)->DeleteLocalRef(env, title);
(*env)->DeleteLocalRef(env, toc_entry_obj);
}
}
}
} else {
LOGD("%s: No NCX or Guide TOC found in the document.", TAG);
}
// --- Create Final Data Object ---
jstring title = new_string_utf(env, mobi_meta_get_title(m));
jstring author = new_string_utf(env, mobi_meta_get_author(m));
jstring publisher = new_string_utf(env, mobi_meta_get_publisher(m));
jobject result = (*env)->NewObject(env, parsed_data_class, parsed_data_ctor,
title, author, publisher,
raw_html_string, // Pass the single HTML string
resource_array, toc_array, cover_uid);
// --- Cleanup ---
(*env)->DeleteLocalRef(env, title);
(*env)->DeleteLocalRef(env, author);
(*env)->DeleteLocalRef(env, publisher);
if (raw_html_string) {
(*env)->DeleteLocalRef(env, raw_html_string);
}
(*env)->DeleteLocalRef(env, resource_array);
if (toc_array) {
(*env)->DeleteLocalRef(env, toc_array);
}
mobi_free_rawml(rawml);
mobi_free(m);
(*env)->ReleaseStringUTFChars(env, file_path, native_file_path);
return result;
}