@@ -8,6 +8,7 @@ import java.io.ByteArrayOutputStream
88import java.io.File
99import java.io.InputStream
1010import java.io.PrintWriter
11+ import java.io.StringWriter
1112import android.net.TrafficStats
1213import java.net.InetSocketAddress
1314import java.net.ServerSocket
@@ -18,6 +19,14 @@ import java.text.SimpleDateFormat
1819import java.util.Locale
1920import java.util.concurrent.TimeUnit
2021import java.util.concurrent.atomic.AtomicReference
22+ import io.pebbletemplates.pebble.PebbleEngine
23+ import io.pebbletemplates.pebble.loader.StringLoader
24+ import java.util.concurrent.ConcurrentHashMap
25+ import io.pebbletemplates.pebble.template.PebbleTemplate
26+ import com.fasterxml.jackson.core.type.TypeReference;
27+ import com.fasterxml.jackson.databind.ObjectMapper;
28+
29+
2130data class ServerConfig (
2231 val port : Int = 6174 ,
2332 val databasePath : String ,
@@ -52,6 +61,10 @@ class WebServer(private val config: ServerConfig) {
5261 private val experimentsEnabled : Boolean = File (config.experimentsEnablePath).exists() // Frozen at startup. Restart server if needed.
5362 private val encodingHeader : String = " Accept-Encoding"
5463 private val brotliCompression : String = " br"
64+ private val pebbleEngine = PebbleEngine .Builder ().loader(StringLoader ()).build()
65+ private val templateCache = ConcurrentHashMap <Int , PebbleTemplate >()
66+
67+ private val contentChunkSize = 1024 * 1024
5568
5669
5770 // function to obtain the last modified date of a documentation.db database
@@ -111,6 +124,7 @@ FROM LastChange
111124 }
112125
113126 fun start () {
127+ // Hal Eisen: Required to fix StrictMode.VmPolicy.Builder.detectUntaggedSockets()
114128 TrafficStats .setThreadStatsTag(0xC0DE )
115129 try {
116130 log.info(
@@ -321,118 +335,147 @@ clientSocket and the catch block logic are updated accordingly.
321335 }
322336 }
323337
338+ // Database fetch
324339 val query = """
325- SELECT C.content, CT.value, CT.compression
326- FROM Content C, ContentTypes CT
327- WHERE C.contentTypeID = CT.id
328- AND C.path = ?
340+ SELECT C.content, CT.value, CT.compression, C.templateId
341+ FROM Content C, ContentTypes CT
342+ WHERE C.contentTypeID = CT.id
343+ AND C.path = ?
329344 """
330345 val cursor = database.rawQuery(query, arrayOf(path))
331- val rowCount = cursor.count
332-
333- if (debugEnabled) log.debug(" Database fetch for path='{}' returned {} rows." , path, rowCount)
334-
335- var dbContent : ByteArray
336- var dbMimeType : String
337- var compression : String
338346
347+ // Process database fetch
339348 try {
340- if (rowCount != 1 ) {
341- return when (rowCount) {
342- 0 -> sendError(writer, output, 404 , " Not Found" , " Path requested: '$path '." )
343- else -> sendError(
344- writer,
345- output,
346- 500 ,
347- " Internal Server Error 2" ,
348- " Corrupt database - multiple records found when unique record expected, Path requested: '$path '."
349- )
350- }
349+ if (cursor.count != 1 ) {
350+ return if (cursor.count == 0 ) sendError(writer, output, 404 , " Not Found" )
351+ else sendError(writer, output, 500 , " Corrupt database - multiple records found when unique record expected, Path requested: '$path '." )
351352 }
352353
353354 cursor.moveToFirst()
354- dbContent = cursor.getBlob(0 )
355- dbMimeType = cursor.getString(1 )
356- compression = cursor.getString(2 )
355+ var dbContent = cursor.getBlob(0 )
356+ val dbMimeType = cursor.getString(1 )
357+ var compression = cursor.getString(2 )
358+ val templateId = cursor.getInt(3 )
359+
360+ // Fragment handling for large content (> 1MB)
361+ if (dbContent.size == contentChunkSize) {
362+ val query2 = " SELECT content FROM Content WHERE path = ? AND languageId = 1"
363+ var fragmentNumber = 1
364+ val combined = ByteArrayOutputStream ().apply {write(dbContent)}
365+ var dbContent2 = dbContent
366+ while (dbContent2.size == contentChunkSize) {
367+ val path2 = " $path -$fragmentNumber "
368+ val cursor2 = database.rawQuery(query2, arrayOf(path2))
369+ try {
370+ if (cursor2.moveToFirst()) {
371+ dbContent2 = cursor2.getBlob(0 )
372+ combined.write(dbContent2)
373+ fragmentNumber++
374+ } else break
375+ } finally { cursor2.close() }
376+ }
377+ dbContent = combined.toByteArray()
378+ }
357379
358- if (debugEnabled) log.debug(" len(content)={}, MIME type={}, compression={}." , dbContent.size, dbMimeType, compression)
380+ // If a document is stored in brotli form and the client doesn't support that encoding
381+ // decompress and send that to the client.
382+ // Pebble templates have to be in string form so the retrieved database content may need to be
383+ // decompressed.
384+ if (compression == " brotli" && (! brotliSupported || templateId > 0 )) {
385+ dbContent = BrotliInputStream (ByteArrayInputStream (dbContent)).use { it.readBytes() }
386+ compression = " none"
387+ } else if (compression == " brotli" ) {
388+ compression = " br"
389+ }
359390
391+ // If the file is associated with a template, instantiate that template and send the result to the client
392+ if (templateId > 0 ) {
393+ dbContent = instantiatePebbleTemplate(templateId, dbContent, path, dbMimeType, compression)
394+ }
395+
396+ writer.println (" HTTP/1.1 200 OK" )
397+ writer.println (" Content-Type: $dbMimeType " )
398+ writer.println (" Content-Length: ${dbContent.size} " )
399+ if (compression != " none" ) writer.println (" Content-Encoding: $compression " )
400+ writer.println (" Connection: close" )
401+ writer.println ()
402+ writer.flush()
403+ output.write(dbContent)
404+ output.flush()
405+ } catch (e: Exception ) {
406+ log.error(" Error processing request: {}" , e.message)
407+ sendError(writer, output, 500 , " Internal Server Error" , e.message ? : " " )
360408 } finally {
361409 cursor.close()
362410 }
411+ }
363412
364- if (dbContent.size == 1024 * 1024 ) { // Could use fragmentation to satisfy range requests but only for uncompressed content.
365- val query2 = """
366- SELECT content
367- FROM Content
368- WHERE path = ?
369- AND languageId = 1
370- """
371- var fragmentNumber = 1
372- var dbContent2 = dbContent
373-
374- while (dbContent2.size == 1024 * 1024 ) {
375- val path2 = " $path -$fragmentNumber "
376- if (debugEnabled) log.debug(" DB item > 1 MB. fragment#{} path2='{}'." , fragmentNumber, path2)
377-
378- val cursor2 = database.rawQuery(query2, arrayOf(path2))
379- try {
380- if (cursor2.moveToFirst()) {
381- dbContent2 = cursor2.getBlob(0 )
413+ private fun instantiatePebbleTemplate (templateId : Int , dbContent : ByteArray , path : String , dbMimeType : String , compression : String ): ByteArray {
414+ if (debugEnabled) log.debug(" Processing template for templateId={}" , templateId)
415+
416+ // 1. Get or Compile Template from Cache
417+ val compiledTemplate = templateCache.getOrPut(templateId) {
418+ if (debugEnabled) log.debug(
419+ " Template cache miss for ID {}, path {}, MIME type {}, compression {}}" ,
420+ templateId,
421+ path,
422+ dbMimeType,
423+ compression
424+ )
382425
383- } else {
384- log.error(" No fragment found for path '{}'." , path2)
385- break
426+ val tQuery = " SELECT content FROM Templates WHERE id = ?"
427+ val tCursor = database.rawQuery(tQuery, arrayOf(templateId.toString()))
428+ tCursor.use { cursor ->
429+ when {
430+ cursor.count == 0 -> {
431+ log.debug(
432+ " Template not found, for ID {}, path {}, MIME type {}, compression {}" ,
433+ templateId,
434+ path,
435+ dbMimeType,
436+ compression
437+ )
438+ throw Exception (" Template ID $templateId not found in the database" )
439+ }
440+ cursor.count > 1 -> {
441+ log.debug(
442+ " More than one template found, for ID {}, path {}, MIME type {}, compression {}" ,
443+ templateId,
444+ path,
445+ dbMimeType,
446+ compression
447+ )
448+ throw Exception (" Template ID $templateId is shared by more than one template" )
449+ }
450+ ! cursor.moveToFirst() -> {
451+ log.debug(
452+ " Template not found, for ID {}, path {}, MIME type {}, compression {}" ,
453+ templateId,
454+ path,
455+ dbMimeType,
456+ compression
457+ )
458+ throw Exception (" Template ID $templateId not found in database." )
459+ }
460+ else -> {
461+ val templateBlob = cursor.getBlob(0 )
462+ pebbleEngine.getTemplate(templateBlob.toString(Charsets .UTF_8 ))
386463 }
387-
388- } finally {
389- cursor2.close()
390- }
391-
392- dbContent + = dbContent2 // TODO: Is there a faster way to do this? Is data being copied multiple times? --D.S., 22-Jul-2025
393- fragmentNumber + = 1
394- if (debugEnabled) log.debug(" Fragment size={}, dbContent.length={}." , dbContent2.size, dbContent.size)
395- }
396- }
397-
398- // If the Accept-Encoding header contains "br", the client can handle
399- // Brotli. Send Brotli data as-is, without decompressing it here.
400- // If the client can't handle Brotli, and the content is Brotli-
401- // compressed, decompress the content here.
402-
403- if (compression == " brotli" ) {
404- if (brotliSupported) {
405- compression = " br"
406-
407- } else {
408- try {
409- if (debugEnabled) log.debug(" Brotli content but client doesn't support Brotli. Decoding locally." )
410- dbContent = BrotliInputStream (ByteArrayInputStream (dbContent)).use { it.readBytes() }
411- compression = " none"
412-
413- } catch (e: Exception ) {
414- log.error(" Error decompressing Brotli content: {}" , e.message)
415- return sendError(writer, output, 500 , " Internal Server Error 3" )
416464 }
417465 }
418466 }
419467
420- // send our response
421- writer.println (" HTTP/1.1 200 OK" )
422- writer.println (" Content-Type: $dbMimeType " )
423- writer.println (" Content-Length: ${dbContent.size} " )
424-
425- if (compression != " none" ) {
426- writer.println (" Content-Encoding: $compression " )
427- }
468+ // Load JSON data into a template context Map<> for instantiation
469+ val mapper = ObjectMapper ()
470+ val context: Map <String , Any > = mapper.readValue(dbContent.toString(Charsets .UTF_8 ), object : TypeReference <Map <String , Any >>() {})
428471
429- writer.println (" Connection: close" )
430- writer.println ()
431- writer.flush()
432- output.write(dbContent)
433- output.flush()
472+ // Evaluate template with loaded data and return the output
473+ val sw = StringWriter ()
474+ compiledTemplate.evaluate(sw, context)
475+ return sw.toString().toByteArray()
434476 }
435477
478+
436479 private fun handleDbEndpoint (writer : PrintWriter , output : java.io.OutputStream ) {
437480 if (debugEnabled) log.debug(" Entering handleDbEndpoint()." )
438481
0 commit comments