|
import android.content.ContentValues |
|
import android.content.Context |
|
import android.os.Build |
|
import android.os.Environment |
|
import android.provider.MediaStore |
|
import com.google.gson.JsonArray |
|
import com.google.gson.JsonElement |
|
import com.google.gson.JsonObject |
|
import com.google.gson.JsonParser |
|
import com.google.gson.internal.Streams |
|
import com.google.gson.stream.JsonWriter |
|
import kotlinx.coroutines.CoroutineExceptionHandler |
|
import kotlinx.coroutines.CoroutineScope |
|
import kotlinx.coroutines.Dispatchers |
|
import kotlinx.coroutines.launch |
|
import okhttp3.ResponseBody |
|
import okhttp3.ResponseBody.Companion.toResponseBody |
|
import retrofit2.Converter |
|
import retrofit2.Retrofit |
|
import timber.log.Timber |
|
import java.io.File |
|
import java.io.FileOutputStream |
|
import java.io.OutputStream |
|
import java.io.Serializable |
|
import java.lang.reflect.Type |
|
import java.math.BigDecimal |
|
import java.math.RoundingMode |
|
import java.text.SimpleDateFormat |
|
import java.util.Date |
|
import java.util.Locale |
|
|
|
// Constants for file naming and organization |
|
private const val RESPONSE_SUFFIX = "_response" |
|
private const val ROOT = "root" |
|
|
|
// Directory structure constants |
|
private const val FOLDER_NAME_FIRST_PATH = "parsing_times" |
|
private const val FOLDER_DATE_FORMAT = "yyyy/MMM/dd/hh-mm-ss aa" // e.g., "2025/Jan/28/02-30-45 PM" |
|
|
|
// File naming constants |
|
private const val RESPONSE_FILE_NAME = "response" |
|
private const val TOP_FILE_PREFIX = "top_" |
|
private const val TOP_COUNT_LIMIT = 100 // Maximum number of slowest fields to report |
|
private const val TREE_FILE_NAME = "tree" |
|
|
|
// MIME types and extensions for different file outputs |
|
private const val MIME_TYPE_TEXT = "text/plain" |
|
private const val TEXT_FILE_EXTENSION = "txt" |
|
private const val MIME_TYPE_JSON = "application/json" |
|
private const val JSON_FILE_EXTENSION = "json" |
|
|
|
/** |
|
* A custom Retrofit converter factory that profiles JSON parsing performance at the field level. |
|
* |
|
* This converter intercepts HTTP responses and measures the time taken to parse each individual |
|
* field in the JSON structure. It generates comprehensive reports saved to device storage including: |
|
* - Top slowest parsing fields ranked by time |
|
* - Original JSON response for reference |
|
* - JSON with embedded timing metadata |
|
* - Hierarchical tree view of parsing times |
|
* |
|
* Use cases: |
|
* - Performance optimization: Identify slow-parsing fields in API responses |
|
* - API monitoring: Track parsing performance over time |
|
* - Debugging: Understand which parts of large JSON responses cause bottlenecks |
|
* |
|
* Files are organized by page name and timestamp in the Downloads folder: |
|
* Downloads/parsing_times/{page_name}/{timestamp}/ |
|
* ├── response.json (original response) |
|
* ├── tree.json (response with timing metadata) |
|
* ├── tree.txt (hierarchical text view) |
|
* └── top_100.txt (ranked list of slowest fields) |
|
* |
|
* @param applicationContext Android application context for file system access |
|
* @param getPageName Lambda function to extract page name from response type and data. |
|
* Defaults to converting response type to snake_case (e.g., "UserProfileResponse" -> "user_profile") |
|
*/ |
|
class FieldLevelJsonParsingProfiler( |
|
private val applicationContext: Context, |
|
private val getPageName: (type: Type, response: Any?) -> String = { type, response -> |
|
((type as? Class<*>)?.simpleName ?: type.toString()).toLowerSnakeCase().removeSuffix(RESPONSE_SUFFIX) |
|
} |
|
): Converter.Factory() { |
|
|
|
// Coroutine scope for background file operations with error handling |
|
private val scope by lazy { CoroutineScope(Dispatchers.IO + CoroutineExceptionHandler { _, t -> Timber.e(t) }) } |
|
|
|
/** |
|
* Creates a response body converter that measures JSON parsing performance. |
|
* |
|
* This method is automatically called by Retrofit for each HTTP response. |
|
* The converter works by: |
|
* 1. Extracting the raw JSON string from the response |
|
* 2. Letting Retrofit perform normal parsing (so app functionality is unaffected) |
|
* 3. Performing detailed timing analysis in a background thread |
|
* 4. Saving multiple report formats to device storage |
|
* |
|
* @param type The target type for conversion (e.g., UserProfileResponse::class.java) |
|
* @param annotations Annotations present on the API method (unused but required by interface) |
|
* @param retrofit The Retrofit instance (used to get the next converter in chain) |
|
* @return A converter that measures parsing time while preserving normal functionality |
|
*/ |
|
override fun responseBodyConverter( |
|
type: Type, annotations: Array<Annotation>, retrofit: Retrofit |
|
): Converter<ResponseBody, *> = Converter<ResponseBody, Any> { responseBody -> |
|
// Extract raw JSON string before it's consumed by normal parsing |
|
val rawBody = responseBody.string() |
|
|
|
// Perform normal Retrofit parsing to maintain app functionality |
|
// This uses the next converter in the chain (typically GsonConverter) |
|
val response = retrofit |
|
.nextResponseBodyConverter<Any>(this, type, annotations) |
|
.convert(rawBody.toResponseBody(responseBody.contentType())) |
|
|
|
// Perform detailed timing analysis asynchronously to avoid blocking the response |
|
scope.launch { |
|
// Parse JSON for timing analysis (separate from normal parsing) |
|
val jsonElement = JsonParser.parseString(rawBody) |
|
|
|
// Recursively measure parsing time for each field in the JSON structure |
|
// Returns both timing data and a modified JSON with embedded timing info |
|
val (parsingTimes, jsonWithTimingData) = measureParsingTime(jsonElement, "") |
|
|
|
// Generate a human-readable name for this response type (e.g., "user_profile") |
|
val pageName = getPageName(type, response) |
|
|
|
// Create timestamp for file organization and traceability |
|
val formattedDate = SimpleDateFormat(FOLDER_DATE_FORMAT, Locale.getDefault()).format(Date()) |
|
|
|
// Generate and save multiple report formats: |
|
|
|
// 1. Top N slowest fields in ranked text format (most actionable for developers) |
|
getTopFileOutputStream( |
|
pageName, formattedDate, TOP_COUNT_LIMIT |
|
)?.perform { printTopParsingTimes(parsingTimes, TOP_COUNT_LIMIT) } |
|
|
|
// 2. Original JSON response for reference and debugging |
|
getResponseOutputStream(pageName, formattedDate)?.perform { printResponse(jsonElement) } |
|
|
|
// 3. JSON with embedded timing metadata (for programmatic analysis) |
|
getJsonTreeOutputStream(pageName, formattedDate)?.perform { printResponse(jsonWithTimingData) } |
|
|
|
// 4. Hierarchical tree view in text format (human-readable structure) |
|
getTextTreeOutputStream( |
|
pageName, formattedDate |
|
)?.perform { printParsingTimeTree(buildParsingTimeTree(parsingTimes), 0) } |
|
} |
|
|
|
// Return the normally parsed response (converter is transparent to app logic) |
|
response |
|
} |
|
|
|
/** |
|
* Recursively measures the time taken to parse each field in a JSON structure. |
|
* |
|
* This method performs a depth-first traversal of the JSON tree, timing each operation. |
|
* For each field, it records: |
|
* - The full path to the field (e.g., "user.profile.addresses[0].street") |
|
* - The time taken to parse that field (in microseconds) |
|
* - The actual value of the field (for primitive types) |
|
* |
|
* The method also creates a parallel JSON structure with timing information embedded, |
|
* which can be useful for visualization or further analysis. |
|
* |
|
* Timing methodology: |
|
* - For objects/arrays: Measures time to process all children |
|
* - For primitives: Records the value instead of timing (parsing is negligible) |
|
* - Uses System.nanoTime() for high precision, converts to microseconds for readability |
|
* |
|
* @param element The JSON element to analyze (object, array, or primitive) |
|
* @param path The current path in the JSON structure (e.g., "user.profile.name") |
|
* @return Pair containing: |
|
* - Map of field paths to timing data (path -> (microseconds, value)) |
|
* - Modified JSON element with timing information embedded in keys |
|
*/ |
|
private fun measureParsingTime( |
|
element: JsonElement, path: String = "" |
|
): Pair<Map<String, Pair<Double?, String?>>, JsonElement> { |
|
// Storage for timing data: path -> (parsing_time_microseconds, field_value) |
|
val times = mutableMapOf<String, Pair<Double?, String?>>() |
|
val newElement: JsonElement |
|
|
|
// Record high-precision start time for this element |
|
val startTime = System.nanoTime() |
|
|
|
times[path] = when { |
|
// Handle JSON arrays: ["item1", "item2", {...}] |
|
element.isJsonArray -> { |
|
// Initialize with null timing (will be calculated after processing children) |
|
times[path] = Pair(null, null) |
|
newElement = JsonArray() |
|
|
|
val array = element.asJsonArray |
|
// Process each array element with indexed path (e.g., "items[0]", "items[1]") |
|
for (i in 0 until array.size()) { |
|
val (childTimes, childElement) = measureParsingTime(array.get(i), "${path}[${i}]") |
|
|
|
// Merge child timing data into our results |
|
times.putAll(childTimes) |
|
|
|
// Add processed element to new array |
|
newElement.add(childElement) |
|
} |
|
|
|
// Calculate total time for processing this entire array |
|
// Convert nanoseconds to microseconds (÷1000) for better readability |
|
(System.nanoTime() - startTime) / 1000.0 to null |
|
} |
|
|
|
// Handle JSON objects: {"key1": "value1", "key2": {...}} |
|
element.isJsonObject -> { |
|
// Initialize with null timing (will be calculated after processing children) |
|
times[path] = Pair(null, null) |
|
newElement = JsonObject() |
|
|
|
val obj = element.asJsonObject |
|
// Process each object property with dotted path notation |
|
for ((key, value) in obj.entrySet()) { |
|
// Build hierarchical path (e.g., "" -> "user" -> "user.profile" -> "user.profile.name") |
|
val keyPath = if (path.isEmpty()) key else "${path}.${key}" |
|
val (childTimes, childElement) = measureParsingTime(value, keyPath) |
|
|
|
// Merge child timing data into our results |
|
times.putAll(childTimes) |
|
|
|
// Create new key with timing information embedded for visualization |
|
// e.g., "name" becomes "name: 123.45 µs" in the timing-annotated JSON |
|
val newKey = childTimes[keyPath]?.first?.let { "${key}: $it µs" } ?: key |
|
newElement.add(newKey, childElement) |
|
} |
|
|
|
// Calculate total time for processing this entire object |
|
(System.nanoTime() - startTime) / 1000.0 to null |
|
} |
|
|
|
// Handle primitive values: strings, numbers, booleans, null |
|
else -> { |
|
// Primitives don't need timing (parsing is negligible) |
|
// Just preserve the original element and record its value |
|
newElement = element |
|
|
|
// Store the actual value for reporting purposes |
|
// First element is timing (null for primitives), second is the value |
|
null to element.toString() |
|
} |
|
} |
|
|
|
// Remove the empty root path entry to avoid clutter in reports |
|
if (path == "") times.remove(path) |
|
|
|
return times to newElement |
|
} |
|
|
|
/** |
|
* Extension function to safely perform operations on OutputStream with automatic resource management. |
|
* |
|
* @param block The operation to perform on the stream |
|
*/ |
|
private fun OutputStream.perform(block: OutputStream.() -> Unit) = try { |
|
block() |
|
} catch (t: Throwable) { |
|
Timber.e(t) |
|
} finally { |
|
close() |
|
} |
|
|
|
/** |
|
* Writes a ranked list of the slowest parsing fields to a text file. |
|
* |
|
* This generates a report like: |
|
* ``` |
|
* 1: user.profile.largeImageArray[0]: 1250.67 µs |
|
* 2: response.data.complexObject: 890.12 µs |
|
* 3: items[5].metadata: 567.89 µs |
|
* ``` |
|
* |
|
* This is typically the most actionable report for developers, as it immediately |
|
* highlights which fields are performance bottlenecks. |
|
* |
|
* @param parsingTimes Map of field paths to their timing data |
|
* @param count Maximum number of entries to include in the report |
|
*/ |
|
private fun OutputStream.printTopParsingTimes( |
|
parsingTimes: Map<String, Pair<Double?, String?>>, @Suppress("SameParameterValue") count: Int |
|
) { |
|
// Filter out primitive values (no timing data) and sort by parsing time (slowest first) |
|
val sortedParsingTimes = |
|
parsingTimes.entries.filter { it.value.first != null }.sortedByDescending { it.value.first }.take(count) |
|
|
|
// Write each entry with consistent formatting |
|
for (i in sortedParsingTimes.indices) { |
|
// Format ranking number with consistent width |
|
val number = "${i + 1}".padStart(TOP_COUNT_LIMIT.toString().length) |
|
|
|
val (path, entry) = sortedParsingTimes[i] |
|
val (time, value) = entry |
|
|
|
// Format timing information (should always be present for filtered entries) |
|
val timeStr = if (time != null) ": ${time.roundTo2Digits()} µs" else "" |
|
|
|
// Include value for context (usually null for objects/arrays) |
|
val valueStr = if (value != null) " (Value: ${value})" else "" |
|
|
|
// Write formatted line to file |
|
write("${number}: ${path}${timeStr}${valueStr}\n".toByteArray()) |
|
} |
|
} |
|
|
|
/** |
|
* Writes a JsonElement to the output stream as a nicely formatted JSON. |
|
* |
|
* @param response The JSON element to write (original response or timing-annotated version) |
|
*/ |
|
private fun OutputStream.printResponse(response: JsonElement) { |
|
val writer = JsonWriter(writer()) |
|
writer.setIndent(" ") // 4-space indentation for readability |
|
writer.isLenient = true // Handle any JSON quirks gracefully |
|
|
|
// Use Gson's internal streaming writer for efficient, formatted output |
|
Streams.write(response, writer) |
|
|
|
writer.flush() |
|
writer.close() |
|
} |
|
|
|
/** |
|
* Builds a hierarchical tree structure from the flat field-path timing data. |
|
* |
|
* This converts flat paths like: |
|
* - "user.profile.name" |
|
* - "user.profile.addresses[0].street" |
|
* - "user.settings.notifications" |
|
* |
|
* Into a tree structure like: |
|
* ``` |
|
* root |
|
* └── user |
|
* ├── profile |
|
* │ ├── name |
|
* │ └── addresses |
|
* │ └── [0] |
|
* │ └── street |
|
* └── settings |
|
* └── notifications |
|
* ``` |
|
* |
|
* This tree structure is then used to generate the hierarchical text report. |
|
* |
|
* Array handling: |
|
* - "items[0]" becomes separate nodes: "items" -> "[0]" |
|
* - This allows proper visualization of array structures in the tree |
|
* |
|
* @param parsingTimes Map of field paths to their timing data |
|
* @return Root node of the constructed tree with all children populated |
|
*/ |
|
private fun buildParsingTimeTree(parsingTimes: Map<String, Pair<Double?, String?>>): ResponseNode { |
|
val root = ResponseNode(ROOT, isRoot = true) |
|
|
|
// Process each field path from the timing data |
|
for ((key, pair) in parsingTimes) { |
|
var currentNode = root |
|
|
|
// Split the path and traverse/build the tree structure |
|
// e.g., "user.profile.name" becomes ["user", "profile", "name"] |
|
for (segment in key.split(".")) { |
|
if (segment.contains("[")) { |
|
// Handle array notation: "items[0]" becomes "items" and "[0]" |
|
val arrayName = segment.substringBefore("[") // "items" |
|
|
|
// Find or create the array container node |
|
val arrayNode = currentNode.children.find { it.name == arrayName } ?: run { |
|
ResponseNode( |
|
name = arrayName, path = key, parent = currentNode |
|
).apply { currentNode.children.add(this) } |
|
} |
|
|
|
// Create node for the specific array index: "[0]", "[1]", etc. |
|
val indexNodeName = "[${segment.substringAfter("[").substringBefore("]")}]" |
|
currentNode = arrayNode.children.find { it.name == indexNodeName } ?: run { |
|
ResponseNode( |
|
name = indexNodeName, path = key, value = pair.second, time = pair.first, parent = arrayNode |
|
).apply { arrayNode.children.add(this) } |
|
} |
|
} else { |
|
// Handle regular object properties |
|
currentNode = currentNode.children.find { it.name == segment } ?: run { |
|
ResponseNode( |
|
name = segment, path = key, value = pair.second, time = pair.first, parent = currentNode |
|
).apply { currentNode.children.add(this) } |
|
} |
|
} |
|
} |
|
} |
|
|
|
return root |
|
} |
|
|
|
/** |
|
* Recursively prints a tree structure with ASCII art formatting. |
|
* |
|
* Creates a visual representation like: |
|
* ``` |
|
* ├── user: 125.5 µs |
|
* │ ├── profile: 45.2 µs (Path: user.profile) |
|
* │ │ ├── name (Value: "John Doe") (Path: user.profile.name) |
|
* │ │ └── addresses: 30.1 µs |
|
* │ │ └── [0]: 25.8 µs |
|
* │ │ ├── street (Value: "123 Main St") (Path: user.profile.addresses[0].street) |
|
* │ │ └── city (Value: "Springfield") (Path: user.profile.addresses[0].city) |
|
* │ └── settings: 80.3 µs (Path: user.settings) |
|
* └── metadata: 15.7 µs (Path: metadata) |
|
* ``` |
|
* |
|
* @param node Current node to print |
|
* @param depth Current depth in the tree (used for recursive spacing calculation) |
|
*/ |
|
private fun OutputStream.printParsingTimeTree(node: ResponseNode, depth: Int = 0) { |
|
// Determine the appropriate tree connector symbol |
|
val prefix = if (depth > 0) { |
|
// Use └── for last child, ├── for others |
|
if (node == node.parent?.children?.last()) "└── " else "├── " |
|
} else { |
|
"" // No prefix for root level |
|
} |
|
|
|
// Format the timing information (if available) |
|
val time = if (node.time != null) ": ${node.time.roundTo2Digits()} µs" else "" |
|
|
|
// Format the value information (for primitive fields) |
|
val value = if (node.value != null) " (Value: ${node.value})" else "" |
|
|
|
// Include the full path for reference (helps with debugging) |
|
val path = if (node.path.isNotBlank()) " (Path: ${node.path})" else "" |
|
|
|
// Build the proper spacing for tree structure |
|
// Walk up the parent hierarchy to determine vertical line placement |
|
var spacing = "" |
|
var nodeParent = node.parent |
|
while (nodeParent != null && nodeParent.isRoot.not()) { |
|
// Add vertical line if parent has more siblings, or spaces if it's the last child |
|
spacing = (if (nodeParent == nodeParent.parent?.children?.last()) " " else "│ ") + spacing |
|
nodeParent = nodeParent.parent |
|
} |
|
|
|
// Write the complete formatted line |
|
write("${spacing}${prefix}${node.name}${time}${value}${path}\n".toByteArray()) |
|
|
|
// Recursively print all children with increased depth |
|
for (child in node.children) printParsingTimeTree(child, depth + 1) |
|
} |
|
|
|
// File output stream creation methods for different report types |
|
// These methods handle the complexity of Android's storage system across different API levels |
|
|
|
/** |
|
* Creates output stream for the top parsing times report (ranked text file). |
|
* |
|
* @param pageName API endpoint name (e.g., "user_profile") |
|
* @param formattedDate Timestamp for file organization |
|
* @param count Number of top entries (used in filename) |
|
* @return OutputStream for writing the report, or null if creation fails |
|
*/ |
|
private fun getTopFileOutputStream( |
|
pageName: String, formattedDate: String, @Suppress("SameParameterValue") count: Int |
|
): OutputStream? = getFileOutputStream(pageName, formattedDate, "${TOP_FILE_PREFIX}${count}", TEXT_FILE_EXTENSION) |
|
|
|
/** |
|
* Creates output stream for the original JSON response file. |
|
* |
|
* @param pageName API endpoint name |
|
* @param formattedDate Timestamp for file organization |
|
* @return OutputStream for writing the JSON response, or null if creation fails |
|
*/ |
|
private fun getResponseOutputStream(pageName: String, formattedDate: String): OutputStream? = |
|
getFileOutputStream(pageName, formattedDate, RESPONSE_FILE_NAME, JSON_FILE_EXTENSION) |
|
|
|
/** |
|
* Creates output stream for the JSON with embedded timing metadata. |
|
* |
|
* @param pageName API endpoint name |
|
* @param formattedDate Timestamp for file organization |
|
* @return OutputStream for writing the timing-annotated JSON, or null if creation fails |
|
*/ |
|
private fun getJsonTreeOutputStream(pageName: String, formattedDate: String): OutputStream? = |
|
getFileOutputStream(pageName, formattedDate, TREE_FILE_NAME, JSON_FILE_EXTENSION) |
|
|
|
/** |
|
* Creates output stream for the hierarchical text tree representation. |
|
* |
|
* @param pageName API endpoint name |
|
* @param formattedDate Timestamp for file organization |
|
* @return OutputStream for writing the ASCII tree, or null if creation fails |
|
*/ |
|
private fun getTextTreeOutputStream(pageName: String, formattedDate: String): OutputStream? = |
|
getFileOutputStream(pageName, formattedDate, TREE_FILE_NAME, TEXT_FILE_EXTENSION) |
|
|
|
/** |
|
* Generic method to create file output streams with proper Android storage handling. |
|
* |
|
* Android 10+ (API 29+): Uses scoped storage with MediaStore API |
|
* Android 9 and below: Uses legacy File API |
|
* |
|
* File organization structure: |
|
* Downloads/parsing_times/{pageName}/{formattedDate}/{fileName}.{extension} |
|
* |
|
* @param pageName Name of the API endpoint (e.g., "user_profile") |
|
* @param formattedDate Timestamp string for file organization |
|
* @param fileName Base name of the file (e.g., "response", "tree", "top_100") |
|
* @param extension File extension ("json" or "txt") |
|
* @return OutputStream for writing file content, or null if creation fails |
|
*/ |
|
private fun getFileOutputStream( |
|
pageName: String, formattedDate: String, fileName: String, extension: String |
|
): OutputStream? { |
|
// Build the folder structure for organizing files |
|
val folderName = "${FOLDER_NAME_FIRST_PATH}/${pageName}/${formattedDate}" |
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { |
|
// Modern Android (10+): Use scoped storage with MediaStore |
|
val contentValues = ContentValues().apply { |
|
val mimeType = if (extension == JSON_FILE_EXTENSION) MIME_TYPE_JSON else MIME_TYPE_TEXT |
|
put(MediaStore.Downloads.MIME_TYPE, mimeType) |
|
put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/${folderName}") |
|
put(MediaStore.Downloads.DISPLAY_NAME, "${fileName}.${extension}") |
|
} |
|
|
|
// Insert the file entry into MediaStore and get a content URI |
|
val uri = |
|
applicationContext.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) |
|
|
|
// Open an OutputStream to the created file |
|
return uri?.let { applicationContext.contentResolver.openOutputStream(it) } |
|
} else { |
|
// Legacy Android (9 and below): Use direct file system access |
|
|
|
// Check if external storage is available and writable |
|
if (Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED) return null |
|
|
|
// Create the target directory structure |
|
val folder = |
|
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), folderName) |
|
|
|
// Create directories if they don't exist |
|
if (folder.exists().not() && folder.mkdirs().not()) return null |
|
|
|
// Create and return FileOutputStream for the target file |
|
return FileOutputStream(File(folder, "${fileName}.${extension}")) |
|
} |
|
} |
|
|
|
/** |
|
* Rounds double values to 2 decimal places with intelligent formatting. |
|
* |
|
* This method: |
|
* - Uses HALF_EVEN rounding mode for consistent behavior |
|
* - Removes unnecessary ".0" for whole numbers (e.g., "123.0" -> "123") |
|
* - Preserves meaningful decimal places (e.g., "123.45" stays "123.45") |
|
* |
|
* Examples: |
|
* - 123.456789 -> "123.46" |
|
* - 123.000000 -> "123" |
|
* - 123.100000 -> "123.1" |
|
* |
|
* @return Formatted string representation of the number |
|
*/ |
|
private fun Double.roundTo2Digits(): String = |
|
BigDecimal(this).setScale(2, RoundingMode.HALF_EVEN) // Round to 2 decimal places |
|
.toDouble().run { |
|
// Remove unnecessary ".0" suffix for whole numbers |
|
if (this - toInt() > 0) toString() else toInt().toString() |
|
} |
|
|
|
/** |
|
* Data class representing a node in the parsing time tree structure. |
|
* |
|
* This class models the hierarchical structure of JSON data with associated timing information. |
|
* Each node can represent: |
|
* - A JSON object property (e.g., "user", "profile") |
|
* - An array container (e.g., "items") |
|
* - An array index (e.g., "[0]", "[1]") |
|
* - A primitive value (e.g., "name", "age") |
|
* |
|
* The tree structure allows for easy traversal and visualization of complex JSON structures |
|
* with their associated parsing performance data. |
|
* |
|
* @param name Display name of the node (field name or array index like "[0]") |
|
* @param path Full path to this node in the JSON structure (e.g., "user.profile.addresses[0].street") |
|
* @param value String representation of the field value (only for primitive types like strings, numbers) |
|
* @param time Parsing time in microseconds (null for primitive values, as their parsing time is negligible) |
|
* @param isRoot Whether this is the root node of the tree (used for formatting) |
|
* @param parent Reference to parent node for tree traversal and formatting |
|
* @param children Mutable list of child nodes (empty for primitive values) |
|
*/ |
|
private data class ResponseNode( |
|
val name: String, |
|
val path: String = "", |
|
val value: String? = null, |
|
val time: Double? = null, |
|
val isRoot: Boolean = false, |
|
val parent: ResponseNode? = null, |
|
val children: MutableList<ResponseNode> = mutableListOf(), |
|
): Serializable |
|
|
|
companion object { |
|
|
|
/** |
|
* Converts strings to snake_case format. |
|
* |
|
* Conversion rules: |
|
* - Insert underscores before uppercase letters that follow lowercase letters |
|
* - Convert all letters to lowercase |
|
* - Replace spaces and dashes with underscores |
|
* - Remove non-alphanumeric characters (except underscores) |
|
* - Trim leading/trailing underscores |
|
* |
|
* Examples: |
|
* - "UserProfileResponse" -> "user_profile_response" |
|
* - "APIDataModel" -> "a_p_i_data_model" |
|
* - "getUserData" -> "get_user_data" |
|
* - "simple" -> "simple" |
|
* - "HTTPSConnection" -> "h_t_t_p_s_connection" |
|
* |
|
* @return The converted snake_case string |
|
*/ |
|
private fun String.toLowerSnakeCase(): String = |
|
replace(Regex("([a-z])([A-Z])"), "$1_$2") // handle camelCase → snake_case |
|
.replace(Regex("[\\s\\-]+"), "_") // convert spaces/dashes to underscores |
|
.replace(Regex("[^a-zA-Z0-9_]"), "") // remove non-word characters |
|
.lowercase().trim('_') |
|
} |
|
} |