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
7 changes: 5 additions & 2 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ android {
applicationId = "com.kelpie.browser"
minSdk = 28
targetSdk = 34
versionCode = 2
versionName = "0.1.2"
versionCode = 3
versionName = "0.1.3"

externalNativeBuild {
cmake {
Expand Down Expand Up @@ -114,4 +114,7 @@ dependencies {

// Gemini Nano (AI Edge) — accessed via reflection in PlatformAIEngine to avoid hard dependency.
// Uncomment when the SDK stabilizes: implementation("com.google.ai.edge.aicore:aicore:0.0.1-exp02")

// Unit tests (plain JVM, no Android runtime)
testImplementation("junit:junit:4.13.2")
}
44 changes: 44 additions & 0 deletions apps/android/app/src/main/java/com/kelpie/browser/ai/AIHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import kotlinx.serialization.json.double
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import java.io.IOException
import java.net.HttpURLConnection
Expand All @@ -44,6 +45,44 @@ class AIHandler(
router.register("ai-unload") { aiUnload() }
router.register("ai-infer") { aiInfer(it) }
router.register("ai-record") { aiRecord(it) }
router.register("ai-catalog") { aiCatalog() }
router.register("ai-fitness") { aiFitness(it) }
}

private fun aiCatalog(): Map<String, Any?> {
val manager =
ctx.aiManager
?: return errorResponse("AI_UNAVAILABLE", "AI manager is not initialized")
val token = AIState.huggingFaceToken
if (token.isEmpty()) {
return errorResponse(
"AUTH_REQUIRED",
"HuggingFace API key required. Set it in Settings before downloading models.",
)
}
manager.hfToken = token
return successResponse(mapOf("models" to parseJsonArray(manager.listApprovedModels())))
}

private fun aiFitness(body: Map<String, Any?>): Map<String, Any?> {
val modelId = (body["model"] as? String)?.trim().orEmpty()
if (modelId.isEmpty()) {
return errorResponse("MISSING_PARAM", "model is required")
}
val manager =
ctx.aiManager
?: return errorResponse("AI_UNAVAILABLE", "AI manager is not initialized")
val token = AIState.huggingFaceToken
if (token.isEmpty()) {
return errorResponse(
"AUTH_REQUIRED",
"HuggingFace API key required. Set it in Settings before downloading models.",
)
}
manager.hfToken = token
val ramGB = (body["ramGB"] as? Number)?.toDouble() ?: 0.0
val diskGB = (body["diskGB"] as? Number)?.toDouble() ?: 0.0
return successResponse(parseJsonObject(manager.modelFitness(modelId, ramGB, diskGB)))
}

private fun aiStatus(): Map<String, Any?> {
Expand Down Expand Up @@ -508,6 +547,11 @@ class AIHandler(
return jsonObjectToMap(element.jsonObject)
}

private fun parseJsonArray(text: String): List<Any?> {
if (text.isBlank()) return emptyList()
return aiJson.parseToJsonElement(text).jsonArray.map { jsonElementToAny(it) }
}

private fun jsonObjectToMap(obj: JsonObject): Map<String, Any?> = obj.entries.associate { (key, value) -> key to jsonElementToAny(value) }

private fun jsonElementToAny(element: JsonElement): Any? =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.kelpie.browser.handlers.HandlerContext
import com.kelpie.browser.network.Router
import com.kelpie.browser.network.errorResponse
import com.kelpie.browser.network.successResponse
import java.time.Instant

class NetworkLogHandler(
private val ctx: HandlerContext,
Expand All @@ -14,6 +15,9 @@ class NetworkLogHandler(
}

private suspend fun getNetworkLog(body: Map<String, Any?>): Map<String, Any?> {
val typeFilter = body["type"] as? String
val statusCategory = parseStatusCategory(body["status"])
val sinceFilter = parseSinceMillis(body["since"])
val limit = (body["limit"] as? Int) ?: 200
val js = """
(function(){
Expand All @@ -27,9 +31,10 @@ class NetworkLogHandler(
else if (e.initiatorType === 'img') type = 'image';
else if (e.initiatorType === 'fetch') type = 'fetch';
else if (e.initiatorType === 'xmlhttprequest') type = 'xhr';
else if (e.initiatorType === 'font' || (e.name && e.name.match(/\.(woff2?|ttf|otf|eot)/))) type = 'font';
return {
url: e.name, type: type, method: 'GET',
status: e.responseStatus || 200, statusText: 'OK',
status: e.responseStatus || 200, statusText: 'OK', mimeType: '',
size: e.decodedBodySize || 0, transferSize: e.transferSize || 0,
timing: { started: new Date(performance.timeOrigin + e.startTime).toISOString(), total: Math.round(e.duration) },
initiator: e.initiatorType || 'other'
Expand All @@ -39,13 +44,128 @@ class NetworkLogHandler(
"""
return try {
val entries = ctx.evaluateJSReturningArray(js.replace("\n", " "))
val limited = entries.take(limit)
successResponse(mapOf("entries" to limited, "count" to limited.size, "hasMore" to (entries.size > limit)))
var filtered = entries
if (typeFilter != null) {
filtered = filtered.filter { (it["type"] as? String) == typeFilter }
}
if (statusCategory != null) {
filtered = filtered.filter { matchesStatusCategory(entryStatus(it), statusCategory) }
}
if (sinceFilter != null) {
filtered = filtered.filter { entryStartedMillis(it)?.let { started -> started >= sinceFilter } ?: false }
}
val limited = filtered.take(limit)
successResponse(
mapOf(
"entries" to limited,
"count" to limited.size,
"hasMore" to (filtered.size > limit),
"summary" to buildSummary(filtered),
),
)
} catch (e: Exception) {
errorResponse("EVAL_ERROR", e.message ?: "Unknown error")
}
}

/**
* Pure status/`since` parsing helpers. Kept in a companion object so they are
* testable on the plain JVM (`testDebugUnitTest`) without constructing a
* `HandlerContext`, which requires the Android runtime.
*/
companion object {
/** Parse a `status` filter param into a category: "success", "error", or "pending"; null when absent/invalid. */
internal fun parseStatusCategory(value: Any?): String? =
when (val category = (value as? String)?.trim()?.lowercase()) {
"success", "error", "pending" -> category
else -> null
}

/**
* Map an entry's HTTP status code to a category and test membership.
* "success" = final status 200–399; "error" = status >= 400 or failed; "pending" = no final status (0/missing).
*/
internal fun matchesStatusCategory(
status: Int?,
category: String,
): Boolean =
when (category) {
"success" -> status != null && status in 200..399
"error" -> status != null && status >= 400
"pending" -> status == null || status == 0
else -> false
}

/** Parse a `since` param (epoch millis number or ISO-8601 string) into epoch millis, or null when absent. */
internal fun parseSinceMillis(value: Any?): Long? =
when (value) {
is Long -> value
is Int -> value.toLong()
is Double -> value.toLong()
is String -> value.trim().toLongOrNull() ?: parseIso8601Millis(value.trim())
else -> null
}

internal fun parseIso8601Millis(iso: String): Long? =
try {
Instant.parse(iso).toEpochMilli()
} catch (_: Exception) {
null
}
}

private fun entryStatus(entry: Map<String, Any?>): Int? =
when (val s = entry["status"]) {
is Int -> s
is Long -> s.toInt()
is Double -> s.toInt()
else -> null
}

private fun entryStartedMillis(entry: Map<String, Any?>): Long? {
val timing = entry["timing"] as? Map<*, *> ?: return null
val started = timing["started"] as? String ?: return null
return parseIso8601Millis(started)
}

/** Aggregate totals/byType/errors/loadTime over the filtered entries, mirroring iOS/macOS. */
private fun buildSummary(entries: List<Map<String, Any?>>): Map<String, Any?> {
var totalSize = 0L
var totalTransfer = 0L
val byType = mutableMapOf<String, Int>()
var errors = 0
var maxEnd = 0L

for (entry in entries) {
totalSize += numberAsLong(entry["size"])
totalTransfer += numberAsLong(entry["transferSize"])
val type = entry["type"] as? String ?: "other"
byType[type] = (byType[type] ?: 0) + 1
val status = entryStatus(entry) ?: 200
if (status >= 400) errors++
val timing = entry["timing"] as? Map<*, *>
val total = numberAsLong(timing?.get("total"))
if (total > maxEnd) maxEnd = total
}

return mapOf(
"totalRequests" to entries.size,
"totalSize" to totalSize,
"totalTransferSize" to totalTransfer,
"byType" to byType,
"errors" to errors,
"loadTime" to maxEnd,
)
}

private fun numberAsLong(value: Any?): Long =
when (value) {
is Int -> value.toLong()
is Long -> value
is Double -> value.toLong()
else -> 0L
}

private suspend fun getResourceTimeline(): Map<String, Any?> {
val js = """
(function(){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,14 @@ class BrowserManagementHandler(

private fun getClipboard(): Map<String, Any?> {
val cm = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = cm.primaryClip
val text =
cm.primaryClip
clip
?.getItemAt(0)
?.text
?.toString() ?: ""
return successResponse(mapOf("text" to text, "hasImage" to false))
val hasImage = clip?.description?.hasMimeType("image/*") ?: false
return successResponse(mapOf("text" to text, "hasImage" to hasImage))
}

private fun setClipboard(body: Map<String, Any?>): Map<String, Any?> {
Expand Down Expand Up @@ -381,12 +383,15 @@ class BrowserManagementHandler(
val tab =
mapOf(
"id" to "0",
"windowId" to "main",
"url" to (wv?.url ?: ""),
"title" to (wv?.title ?: ""),
"active" to true,
"isLoading" to false,
)
return successResponse(mapOf("tabs" to listOf(tab), "count" to 1, "activeTab" to "0"))
return successResponse(
mapOf("windowId" to "main", "tabs" to listOf(tab), "count" to 1, "activeTab" to "0"),
)
}

val tabs =
Expand All @@ -395,6 +400,7 @@ class BrowserManagementHandler(
}
return successResponse(
mapOf(
"windowId" to "main",
"tabs" to tabs,
"count" to tabs.size,
"activeTab" to (tabStore.activeTabId.value ?: ""),
Expand All @@ -411,6 +417,7 @@ class BrowserManagementHandler(
"tabId" to tab.id,
"tab" to tabInfo(tab = tab, activeTabId = tabStore.activeTabId.value),
"tabCount" to tabStore.tabs.value.size,
"windowId" to "main",
),
)
}
Expand All @@ -427,6 +434,7 @@ class BrowserManagementHandler(
return successResponse(
mapOf(
"tab" to tabInfo(tab = tab, activeTabId = tabStore.activeTabId.value),
"windowId" to "main",
),
)
}
Expand All @@ -445,6 +453,7 @@ class BrowserManagementHandler(
mapOf(
"closed" to tabId,
"tabCount" to tabStore.tabs.value.size,
"windowId" to "main",
),
)
}
Expand All @@ -457,6 +466,7 @@ class BrowserManagementHandler(
): Map<String, Any?> =
mapOf(
"id" to tab.id,
"windowId" to "main",
"url" to tab.currentUrl,
"title" to tab.pageTitle,
"active" to (tab.id == activeTabId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ class CookieHandlers(
val host = hostFromUrl(url) ?: ""
val existing = parseCookieHeader(cm.getCookie(url).orEmpty(), defaultDomain = host)

// No selector supplied: no-op rather than wiping cookies on an empty
// request. Matches the iOS/macOS safe semantics.
if (!deleteAll && nameFilter == null && domainFilter == null) {
return successResponse(mapOf("deleted" to 0))
}

if (deleteAll && nameFilter == null && domainFilter == null) {
val pending = existing.size
cm.removeAllCookies(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class DOMHandler(
"(function(){var el=document.querySelector('$safe');" +
"if(!el)return null;var attrs={};" +
"for(var a of el.attributes){attrs[a.name]=a.value;}" +
"return{attributes:attrs,count:el.attributes.length};})()"
"return{attributes:attrs};})()"
return try {
val result = ctx.evaluateJSReturningJSON(js)
if (result.isEmpty()) errorResponse("ELEMENT_NOT_FOUND", "No element matches: $selector") else successResponse(result)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,30 @@ class NavigationHandler(
private suspend fun reload(): Map<String, Any?> {
val wv = ctx.webView ?: return errorResponse("NO_WEBVIEW", "No WebView")
mainHandler.post { wv.reload() }
delay(200)
return successResponse(mapOf("url" to (wv.url ?: ""), "title" to (wv.title ?: "")))
delay(100)
val timeout = 10000
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < timeout) {
val result = ctx.evaluateJSReturningJSON("({url: location.href, title: document.title, readyState: document.readyState})")
if (result["readyState"] == "complete") {
return successResponse(
mapOf(
"url" to (result["url"] ?: wv.url ?: ""),
"title" to (result["title"] ?: wv.title ?: ""),
"loadTime" to (System.currentTimeMillis() - start),
),
)
}
delay(100)
}
val finalState = ctx.evaluateJSReturningJSON("({url: location.href, title: document.title})")
return successResponse(
mapOf(
"url" to (finalState["url"] ?: wv.url ?: ""),
"title" to (finalState["title"] ?: wv.title ?: ""),
"loadTime" to timeout,
),
)
}

private suspend fun getCurrentUrl(): Map<String, Any?> {
Expand Down
Loading
Loading