diff --git a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt index 40d345fdd8..92f8583284 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -26,6 +26,8 @@ import java.util.concurrent.ConcurrentHashMap import io.pebbletemplates.pebble.template.PebbleTemplate import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import kotlinx.serialization.builtins.UByteArraySerializer +import okio.ByteString.Companion.toByteString data class ServerConfig( @@ -39,6 +41,8 @@ data class ServerConfig( "/Download/CodeOnTheGo.webserver.debug", val experimentsEnablePath: String = android.os.Environment.getExternalStorageDirectory().toString() + "/Download/CodeOnTheGo.exp", // TODO: Centralize this concept. --DS, 9-Feb-2026 + val clearCacheEnablePath: String = android.os.Environment.getExternalStorageDirectory().toString() + + "/Download/CodeOnTheGo.webserver.cs0", // Yes, this is hack code. val projectDatabasePath: String = "/data/data/com.itsaky.androidide/databases/RecentProject_database" @@ -60,10 +64,12 @@ class WebServer(private val config: ServerConfig) { private val debugEnabled : Boolean = File(config.debugEnablePath).exists() // TODO: Use the centralized experiments flag instead of this ad-hoc check. --DS, 10-Feb-2026 private val experimentsEnabled : Boolean = File(config.experimentsEnablePath).exists() // Frozen at startup. Restart server if needed. + private val clearCacheEnabled : Boolean = File(config.clearCacheEnablePath).exists() // Frozen at startup. Restart server if needed. private val encodingHeader : String = "Accept-Encoding" private val brotliCompression : String = "br" - private val pebbleEngine = PebbleEngine.Builder().loader(StringLoader()).build() - private val templateCache = ConcurrentHashMap() + private val pebbleEngine = PebbleEngine.Builder().loader(StringLoader()).build() + private val templateCache = ConcurrentHashMap() + private var bookshelfTemplateId : Int = -1; private val contentChunkSize = 1024 * 1024 @@ -243,6 +249,19 @@ clientSocket and the catch block logic are updated accordingly. return String(bytes, 0, len, Charsets.ISO_8859_1) } + /** + * Handles a single HTTP request on the given client socket and writes the corresponding HTTP response. + * + * Parses the request line and headers, enforces GET-only for normal routes, and routes high-priority + * "pr/" endpoints (including bookshelf, database table views, projects, and experiments). For other + * paths it reads content and metadata from the server's SQLite database, reassembles multi-fragment + * content, handles Brotli compression negotiation and decompression, optionally renders Pebble + * templates, and writes appropriate HTTP response headers and body. If a newer debug database is + * present on disk, swaps the server database to that file before serving the request. On errors + * sends an HTTP error response and logs the failure. + * + * @param clientSocket Connected client socket used for reading the request and writing the response. + */ private fun handleClient(clientSocket: Socket) { if (debugEnabled) log.debug("In handleClient(), socket is {}.", clientSocket) @@ -397,6 +416,17 @@ clientSocket and the catch block logic are updated accordingly. } } + /** + * Renders a Pebble template identified by `templateId` using the provided JSON data and returns the rendered output as bytes. + * + * @param templateId The database ID of the Pebble template to load and compile. + * @param dbContent JSON bytes that will be parsed and supplied as the template context. + * @param path The request/content path associated with this template (used for diagnostic/logging purposes). + * @param dbMimeType The MIME type of the stored content (used for diagnostic/logging purposes). + * @param compression The compression label of the stored content (e.g., "br", "none") (used for diagnostic/logging purposes). + * @return The rendered template encoded as UTF-8 bytes. + * @throws Exception If the template ID is not found, is duplicated in the database, or if template lookup/instantiation fails. + */ private fun instantiatePebbleTemplate(templateId: Int, dbContent: ByteArray, path: String, dbMimeType: String, compression: String): ByteArray { if (debugEnabled) log.debug("Processing template for templateId={}", templateId) @@ -446,6 +476,7 @@ clientSocket and the catch block logic are updated accordingly. } else -> { val templateBlob = cursor.getBlob(0) + if (debugEnabled) log.debug("templateBlob = '${String(templateBlob)}'") pebbleEngine.getTemplate(templateBlob.toString(Charsets.UTF_8)) } } @@ -456,6 +487,10 @@ clientSocket and the catch block logic are updated accordingly. val mapper = ObjectMapper() val context: Map = mapper.readValue(dbContent.toString(Charsets.UTF_8), object : TypeReference>() {}) + /*******************DEBUGGING ONLY*******************/ + log.debug("context = ${context}") + /*******************DEBUGGING ONLY*******************/ + // Evaluate template with loaded data and return the output val sw = StringWriter() compiledTemplate.evaluate(sw, context) @@ -463,6 +498,14 @@ clientSocket and the catch block logic are updated accordingly. } + /** + * Serve an HTML page showing the 20 most recent rows of the `LastChange` table. + * + * Queries the table schema to determine column names, selects the latest 20 rows + * ordered by `changeTime`, escapes cell values for HTML, assembles an HTML table, + * and writes a normal 200 HTML response to the client. On database or rendering + * errors a 500 error response is sent. All database cursors are closed before returning. + */ private fun handleDbEndpoint(writer: PrintWriter, output: java.io.OutputStream) { if (debugEnabled) log.debug("Entering handleDbEndpoint().") @@ -556,31 +599,39 @@ clientSocket and the catch block logic are updated accordingly. } } + /** + * Handles the /pr/bs endpoint by invoking the bookshelf generator and sending a 500 error if generation fails. + * + * Calls realHandleBsEndpoint to produce and write the response body; if an exception occurs, sends an HTTP 500 + * error using the reported output-start state so no additional headers/body are written after output has begun. + * + * @param writer PrintWriter used for writing textual HTTP response headers. + * @param output Raw OutputStream used for writing the response body bytes. + */ private fun handleBsEndpoint(writer: PrintWriter, output: java.io.OutputStream) { if (debugEnabled) log.debug("Entering handleBsEndpoint().") + if(clearCacheEnabled) templateCache.clear() - var projectDatabase : SQLiteDatabase? = null var outputStarted = false try { - projectDatabase = SQLiteDatabase.openDatabase(config.projectDatabasePath, - null, - SQLiteDatabase.OPEN_READONLY) - outputStarted = realHandleBsEndpoint(writer, output, projectDatabase) + outputStarted = realHandleBsEndpoint(writer, output) } catch (e: Exception) { log.error("Error handling /pr/bs endpoint: {}", e.message) - sendError(writer, output, 500, "Internal Server Error 6", "Error generating database table.", outputStarted) - - } finally { - projectDatabase?.close() + sendError(writer, output, 500, "Internal Server Error 6", "Error generating bookshelf HTML.", outputStarted) } if (debugEnabled) log.debug("Leaving handleBsEndpoint().") } + /** + * Writes a small CSS response that shows or hides elements with the + * `.code_on_the_go_experiment` class depending on the server's + * `experimentsEnabled` flag. + */ private fun handleExEndpoint(writer: PrintWriter, output: java.io.OutputStream) { val flag = if (experimentsEnabled) "{}" else "{display: none;}" @@ -589,6 +640,12 @@ clientSocket and the catch block logic are updated accordingly. sendCSS(writer, output, ".code_on_the_go_experiment $flag") } + /** + * Handle the /pr/pr endpoint by opening the project database, delegating page generation to realHandlePrEndpoint, and sending an HTTP 500 error if generation fails. + * + * @param writer PrintWriter used to write response headers. + * @param output OutputStream used to write response body bytes. + */ private fun handlePrEndpoint(writer: PrintWriter, output: java.io.OutputStream) { if (debugEnabled) log.debug("Entering handlePrEndpoint().") @@ -644,63 +701,105 @@ second response. if (debugEnabled) log.debug("Leaving handlePrEndpoint().") } - private fun realHandleBsEndpoint(writer: PrintWriter, output: java.io.OutputStream, projectDatabase: SQLiteDatabase) : Boolean { + /** + * Builds the Bookshelf content, renders it with the `bookshelf` template, and sends the resulting response to the client. + * + * @param writer PrintWriter for sending HTTP headers and control output. + * @param output OutputStream for writing the response body bytes. + * @return `true` if the templated response was written to the client, `false` if an error response was sent or no output was produced. + */ + private fun realHandleBsEndpoint(writer: PrintWriter, output: java.io.OutputStream) : Boolean { if (debugEnabled) log.debug("Entering realHandleBsEndpoint().") - val query = """ -SELECT id, - name, - DATETIME(create_at / 1000, 'unixepoch'), - DATETIME(last_modified / 1000, 'unixepoch'), - location, - template_name, - language -FROM recent_project_table -ORDER BY last_modified DESC""" + // Database fetch + val sql_query = +""" +SELECT '{"result" : [' || group_concat(Item) || ']}' FROM ( + SELECT + JSON_OBJECT( + 'category', IFNULL(BC.category, 'General'), + 'description', BC.description, + 'books', JSON_GROUP_ARRAY(JSON_OBJECT( + 'title', IFNULL(B.title, C.path), + 'description', B.description, + 'link', C.path) + ) + ) AS Item + FROM Content AS C, + Bookshelf AS B, + BookCategories AS BC + WHERE C.id = B.contentID + AND B.bookCategoryID = BC.id + GROUP BY BC.category + ORDER BY BC.category, + B.title +); +""".trimIndent() + + var cursor = database.rawQuery(sql_query, arrayOf()) + lateinit var jsonText : ByteArray - var html = getTableHtml("Projects", "Projects") + """ - -Id -Name -Created -Modified   V -Directory -Template -Language -""" + // Process database fetch + try { + if (cursor.count != 1) { + if (cursor.count == 0) + sendError(writer, output, 404, "Not Found") + else + sendError(writer, output, 500, "Corrupt database - ${cursor.count} bookshelf results found when one was expected.") + return false + } - val cursor = projectDatabase.rawQuery(query, arrayOf()) + //get the JSON from the bookshelf table + cursor.moveToFirst() + jsonText = cursor.getBlob(0) + if (debugEnabled) log.debug("json content = '${String(jsonText)}'.") + if (debugEnabled) log.debug("before fetch bookshelf template ID = '${bookshelfTemplateId}'") + + //Have we already fetched the template + if (bookshelfTemplateId == -1) { + cursor = database.rawQuery("SELECT id FROM Templates WHERE name = 'bookshelf'", arrayOf()) + + if (cursor.count != 1) { + if (cursor.count == 0) + sendError(writer, output, 404, "Not Found") + else + sendError(writer, output, 500, "Corrupt database - ${cursor.count} Bookshelf templates found when 1 was expected.") + return false + } - try { - if (debugEnabled) log.debug("Retrieved {} rows.", cursor.count) + cursor.moveToFirst() + bookshelfTemplateId = cursor.getInt(0); + if (debugEnabled) log.debug("after the fetch bookshelf template ID = '${bookshelfTemplateId}'") - while (cursor.moveToNext()) { - html += """ -${escapeHtml(cursor.getString(0) ?: "")} -${escapeHtml(cursor.getString(1) ?: "")} -${escapeHtml(cursor.getString(2) ?: "")} -${escapeHtml(cursor.getString(3) ?: "")} -${escapeHtml(cursor.getString(4) ?: "")} -${escapeHtml(cursor.getString(5) ?: "")} -${escapeHtml(cursor.getString(6) ?: "")} -""" } - html += "" + } catch (e: Exception) { + log.error("Error processing request: {}", e.message) + sendError(writer, output, 500, "Internal Server Error", e.message ?: "") } finally { cursor.close() } - if (debugEnabled) log.debug("html is '{}'.", html) // May output a lot of stuff but better too much than too little. --DS, 23-Feb-2026 + val result = instantiatePebbleTemplate(bookshelfTemplateId, jsonText, "/bookshelf", "application/json", "none") - writeNormalToClient(writer, output, html) + if (debugEnabled) log.debug("Bookshelf result is '{}'.", String(result)) - if (debugEnabled) log.debug("Leaving realHandlePrEndpoint().") + writeNormalToClient(writer, output, String(result)) + + if (debugEnabled) log.debug("Leaving realHandleBsEndpoint().") return true } + /** + * Builds an HTML table of recent projects from the provided project database and writes it to the client. + * + * @param writer PrintWriter used for writing HTTP response headers. + * @param output OutputStream used for writing the HTTP response body. + * @param projectDatabase Read-only SQLiteDatabase containing the `recent_project_table`. + * @return `true` if an HTML response was written to the client. + */ private fun realHandlePrEndpoint(writer: PrintWriter, output: java.io.OutputStream, projectDatabase: SQLiteDatabase) : Boolean { if (debugEnabled) log.debug("Entering realHandlePrEndpoint().")