Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0d64c2d
Replace AI router debug Toast with bottom auto-hiding status strip
biafra23 Apr 29, 2026
1ad30bc
Wire HF token end-to-end and fix gated-model 401s
biafra23 Apr 29, 2026
48a8e57
Address PR #12 review: monotonic timing, robust Completed emit, DROP_…
biafra23 Apr 29, 2026
b8cc107
Disable vision/audio backends in LiteRT-LM EngineConfig
biafra23 Apr 29, 2026
6eb9496
Reject LEAP degenerate description / title output
biafra23 Apr 29, 2026
c7de63b
Fire AI tags even when description generation fails
biafra23 Apr 29, 2026
1a5a309
Re-trigger share-intent AI when aiCoreEnabled flips on after a delay
biafra23 Apr 29, 2026
6453641
Append debug provenance tag to AI-generated tag lists
biafra23 Apr 29, 2026
8e2c6c0
Debug provenance tag now shows generation duration, short SDK name
biafra23 Apr 29, 2026
527f0fa
Drop dbg: prefix from debug provenance tag
biafra23 Apr 29, 2026
7e91f44
Include model name in debug provenance tag
biafra23 Apr 29, 2026
bca82e8
Show LiteRT-LM (not MEDIAPIPE) as runtime label in comparison rows
biafra23 Apr 29, 2026
ddcd2e6
Handle system back from Settings / AddEdit / Comparison
biafra23 Apr 29, 2026
8d774bd
Self-heal LiteRT-LM 'no OpenCL on this device' failure
biafra23 Apr 29, 2026
3f75958
Don't reload+retry LiteRT-LM inline — OOMed Pixel 7a at 5.96 GB
biafra23 Apr 29, 2026
3300669
Prefer CPU over GPU in LiteRT-LM backend strategy
biafra23 Apr 29, 2026
49a004e
LiteRT debug tag now shows backend label; restore NPU→GPU→CPU strategy
biafra23 Apr 29, 2026
64610a9
Mode-aware dedup so AI doesn't fire twice when toggle flips on
biafra23 Apr 29, 2026
9e44224
Re-deprioritise GPU below CPU; OpenCL bug confirmed on Tensor G5 too
biafra23 Apr 29, 2026
e58a524
Skip LLM for description when the page already provides one
biafra23 Apr 29, 2026
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
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ jobs:
run: ./gradlew :shared:check --no-daemon

- name: Build Android debug APK
env:
# Optional Hugging Face read-token baked into the APK at build
# time; needed to download gated LiteRT-LM / Gemma model bundles
# without the user pasting one into Settings. Repository secret;
# build still succeeds if absent (token defaults to empty).
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: ./gradlew :androidApp:assembleDebug --no-daemon

- name: Resolve APK artifact name
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ jobs:
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# Optional Hugging Face read-token baked into the APK so gated
# LiteRT-LM / Gemma bundles can be downloaded without the user
# pasting a token. Empty / absent secret leaves the default empty
# and the user is prompted in Settings.
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
./gradlew :androidApp:assembleRelease --no-daemon \
-PappVersion=${{ steps.version.outputs.version }} \
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,46 @@ Per-platform repository implementations:
./gradlew :shared:build # Build the KMP library
```

### Hugging Face Token (gated models)

Most LiteRT-LM models in the catalog (Gemma 3, Gemma 4, FunctionGemma) are *gated* on Hugging Face — the API will reject downloads with HTTP 401 until two things are true:

1. You hold a Hugging Face access token with **read** scope. Create one at <https://huggingface.co/settings/tokens>.
2. You've accepted each model's licence on its HF page (e.g. <https://huggingface.co/google/gemma-3-1b-it>). Acceptance is per-repo and is a one-time click on the web UI.

URLVault accepts the token from three sources, in this order of precedence:

1. **User-entered** — Settings → Local AI Models → "Hugging Face token". Stored in `EncryptedSharedPreferences` on the device. Best for personal builds.
2. **Build-time `HF_TOKEN` env var** — read by `androidApp/build.gradle.kts` and exposed as `BuildConfig.HF_TOKEN_DEFAULT`. Used by CI.
3. **Build-time `hfToken` in `local.properties`** — same destination, fallback when the env var is absent. Used by local developer builds.

The Settings row reads "Using token bundled with this build" when sources 2 or 3 are present and the user hasn't entered one of their own.

#### Local developer builds

Add a single line to `local.properties` at the repo root (already in `.gitignore` — the token never leaves your machine):

```properties
hfToken=hf_xxxxxxxxxxxxxxxxxxxx
```

After that, `./gradlew :androidApp:assembleDebug` and `./gradlew :androidApp:installDebug` will pick the token up automatically. Or set the env var per-invocation:

```bash
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx ./gradlew :androidApp:assembleDebug
```

#### CI builds

Add `HF_TOKEN` as a repository secret on GitHub:

- *Settings → Secrets and variables → Actions → New repository secret*, name `HF_TOKEN`.
- The existing `build.yml` and `release.yml` workflows already read it. With the secret absent, builds still succeed and the APK ships with the field empty (the user is prompted in Settings).

> **Security note.** Anything baked into the APK can be recovered by reverse-engineering. Only ship a *read-only* token that is acceptable for the people who will install the build. The user-entered path stores the token in EncryptedSharedPreferences (Android Keystore-wrapped) and is the safer default for shared / public builds.

The downloader scrubs the `Authorization` header on cross-origin redirects (HF 302s gated downloads to a pre-signed CDN URL on `cas-bridge.xethub.hf.co`, which would otherwise reject the extra header with 401), so the token only travels to `huggingface.co` itself.

### iOS

1. Open `iosApp/iosApp.xcodeproj` in Xcode
Expand Down
26 changes: 26 additions & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.nio.file.Files
import java.util.Base64
import java.util.Properties

plugins {
alias(libs.plugins.android.application)
Expand Down Expand Up @@ -42,6 +43,31 @@ android {

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

// Optional Hugging Face read-token, baked into the APK at build time
// so the downloader can fetch gated LiteRT-LM bundles without the
// user pasting a token. Two sources, in order of precedence:
// 1. HF_TOKEN env var — used by CI (GitHub Actions secret).
// 2. `hfToken` property in <repo>/local.properties — used for
// local developer builds. local.properties is gitignored so
// the token never leaves the developer's machine.
// Empty default lets the build succeed without either; the user can
// paste a token into the Settings screen instead.
// Whitespace and any non-token characters are stripped to keep the
// generated string literal safe — real HF tokens are alphanumeric
// with `_` / `-`. Note: anything baked into the APK is recoverable
// via reverse engineering — only ship a *read-only* HF token here.
val hfTokenFromLocalProps: String? = rootProject.file("local.properties")
.takeIf { it.exists() }
?.let { f ->
val props = Properties()
f.inputStream().use { stream -> props.load(stream) }
props.getProperty("hfToken")
}
val hfTokenDefault = (System.getenv("HF_TOKEN") ?: hfTokenFromLocalProps ?: "")
.trim()
.filter { it.isLetterOrDigit() || it == '_' || it == '-' }
buildConfigField("String", "HF_TOKEN_DEFAULT", "\"$hfTokenDefault\"")

// Llamatik ships native libs for arm64-v8a, armeabi-v7a, x86, x86_64.
// libllama_jni.so alone is ~23 MB per ABI; restricting to arm64-v8a cuts
// ~90 MB of unused code from the APK. Every supported Android device
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package com.jaeckel.urlvault.android

import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.ui.Modifier
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -16,6 +19,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
import com.jaeckel.urlvault.ai.AiProviderIds
import com.jaeckel.urlvault.ai.ModelCatalog
import com.jaeckel.urlvault.ai.ModelCatalogEntry
Expand All @@ -30,6 +34,8 @@ import com.jaeckel.urlvault.android.sync.AndroidBitwardenPreferences
import com.jaeckel.urlvault.model.Bookmark
import com.jaeckel.urlvault.sync.BitwardenSyncService
import com.jaeckel.urlvault.ui.AddEditBookmarkScreen
import com.jaeckel.urlvault.ui.AiActivityState
import com.jaeckel.urlvault.ui.AiActivityStatusLine
import com.jaeckel.urlvault.ui.BookmarkListScreen
import com.jaeckel.urlvault.ui.ModelComparisonScreen
import com.jaeckel.urlvault.ui.ModelStatusBanner
Expand Down Expand Up @@ -85,6 +91,11 @@ class MainActivity : ComponentActivity() {
val warmingIds by localModelRouter.warmingIds.collectAsState()
var customEntries by remember { mutableStateOf(localModelPrefs.loadCustomEntries()) }
var activeIds by remember { mutableStateOf(localModelPrefs.loadActiveIds()) }
// The user-only token (the build-time fallback isn't shown as
// a saved value — the row says "Using token bundled with this
// build" instead).
var hfToken by remember { mutableStateOf(localModelPrefs.loadUserHfToken().orEmpty()) }
val hfTokenFromBuild = remember { localModelPrefs.hasBuildTimeHfToken() }
// Settings reads two heavy values from EncryptedSharedPreferences:
// the Bitwarden credentials (decrypts via Keystore) and the
// field-history blob. Cache them in remembered state and only
Expand All @@ -99,28 +110,47 @@ class MainActivity : ComponentActivity() {
aiCoreService.initialize()
}

// DEBUG-only: surface which provider actually served each AI call
// so we can confirm an "activated" model is what's being used vs.
// silently falling back to AICore.
// DEBUG-only: surface which provider actually served each AI
// call (and how long it took) in a thin auto-hiding strip at
// the bottom of the screen. Replaces a much louder Toast that
// obscured the form while the user was trying to interact
// with it.
var aiActivity by remember { mutableStateOf<AiActivityState>(AiActivityState.Hidden) }
if (BuildConfig.DEBUG) {
LaunchedEffect(Unit) {
localModelRouter.events.collect { event ->
val readinessLine = event.readiness.joinToString { (id, r) ->
"${id.substringAfter(':')}=${if (r) "✓" else "✗"}"
}
val activeLine = if (event.activeIds.isEmpty()) "active=none"
else "active=${event.activeIds.joinToString { it.substringAfter(':') }}"
val head = when (event) {
aiActivity = when (event) {
is LocalModelRouter.RouteEvent.Picked ->
"AI ${event.action}: ${event.providerName}\n${event.reason}"
AiActivityState.Running(event.action, event.providerName)
is LocalModelRouter.RouteEvent.Completed ->
AiActivityState.Completed(
action = event.action,
providerName = event.providerName,
durationMs = event.durationMs,
success = event.success,
)
is LocalModelRouter.RouteEvent.None ->
"AI ${event.action}: NO PROVIDER\n${event.reason}"
AiActivityState.NoProvider(event.action, event.reason)
}
val text = "$head\n$activeLine\n$readinessLine"
Toast.makeText(this@MainActivity, text, Toast.LENGTH_LONG).show()
}
}
}
// Auto-hide once the user has had time to read the result.
// Running stays visible for as long as the LLM is working
// (we only transition out of it when Completed/None arrive).
LaunchedEffect(aiActivity) {
when (aiActivity) {
is AiActivityState.Completed -> {
delay(3_500)
aiActivity = AiActivityState.Hidden
}
is AiActivityState.NoProvider -> {
delay(5_000)
aiActivity = AiActivityState.Hidden
}
else -> {}
}
}

// Show toggle for any status except Unknown (still probing)
val aiCoreAvailable = aiCoreStatus !is AICoreStatus.Unknown && aiCoreStatus !is AICoreStatus.Unavailable
Expand Down Expand Up @@ -152,11 +182,34 @@ class MainActivity : ComponentActivity() {
}
}

// Without an explicit BackHandler, the system back gesture
// bypasses our in-memory `currentScreen` state and finishes
// the Activity — i.e. tapping back from Settings exits the
// app instead of returning to the bookmark list. Mirror the
// in-screen back arrows: Comparison → Settings; Settings and
// AddEdit → List. List is the root, so the handler is
// disabled there and the OS default (finish) applies.
BackHandler(enabled = currentScreen !is Screen.List) {
currentScreen = when (currentScreen) {
is Screen.Comparison -> Screen.Settings
is Screen.Settings, is Screen.AddEdit -> Screen.List
is Screen.List -> Screen.List // unreachable
}
}

Column(
// enableEdgeToEdge() lets content draw under the status
// bar; without statusBarsPadding the banner would land
// behind the system clock / battery icons.
modifier = Modifier.statusBarsPadding(),
// enableEdgeToEdge() lets content draw under the system
// bars; the two *barsPadding modifiers reserve space at
// top and bottom AND consume the corresponding insets so
// descendants (notably the screens' Material Scaffolds
// with BottomAppBar) don't double-pad. Without this, the
// BottomAppBar kept its own gesture-pill padding even
// when the AI activity strip slid in below it, making
// the button row's box visibly grow.
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding(),
) {
// Persistent status banner — surfaces the active model
// warming up or any in-flight download regardless of which
Expand All @@ -169,6 +222,12 @@ class MainActivity : ComponentActivity() {
catalog = ModelCatalog.builtIn + customEntries,
aiCoreId = AiProviderIds.AICORE,
)
// Wrap the active screen in a weighted Box so the AI
// activity strip below can claim its natural height
// without overlapping the screen's own bottom buttons —
// when the strip is visible the screen's available
// height shrinks and its Save / Cancel row reflows up.
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
when (val screen = currentScreen) {
is Screen.List -> BookmarkListScreen(
viewModel = bookmarkViewModel,
Expand Down Expand Up @@ -268,6 +327,12 @@ class MainActivity : ComponentActivity() {
// generate() call doesn't pay model-load cost.
if (active) appScope.launch { localModelRouter.warmUpActive() }
},
hfToken = hfToken,
hfTokenFromBuild = hfTokenFromBuild,
onHfTokenChanged = { newToken ->
hfToken = newToken
localModelPrefs.saveHfToken(newToken)
},
onAddCustomModel = { hfRepo, hfFile, displayName ->
val newEntry = ModelCatalogEntry(
id = "custom:" + hfRepo.lowercase().replace('/', '_') + ":" + hfFile.lowercase(),
Expand Down Expand Up @@ -302,7 +367,19 @@ class MainActivity : ComponentActivity() {
)
}
}
} // close Column wrapping the banner + screen content
} // close weighted Box wrapping the screen

// DEBUG-only AI activity strip. Last child of the Column
// so when AnimatedVisibility expands it from 0-height
// the screen above is pushed up — its Save button stays
// visible. The outer Column already consumed the nav
// bar inset, so the strip needs no padding of its own.
if (BuildConfig.DEBUG) {
AiActivityStatusLine(
state = aiActivity,
)
}
} // close outer Column
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,13 +288,26 @@ class AICoreService(httpClient: HttpClient) {

/**
* Generate a 1-2 sentence description for a bookmark.
* Fetches the web page to provide context for an accurate description.
*
* Same shape as [generateTitle]: if the page itself carries a
* publisher-written summary (`<meta property="og:description">` or
* `<meta name="description">`), return it verbatim — the LLM can't beat
* what the author wrote about their own page, and burning a Gemini Nano
* call to "rewrite" an existing 1-2 sentence summary is wasted work
* that often degrades the result. The LLM only fires for pages with no
* metadata-provided description, where genuine extraction from
* `visibleText` is needed.
*/
suspend fun generateDescription(url: String, title: String): Result<String> {
return runCatching {
val pageContent = fetchPageContent(url)
val pageSummary = pageContent?.bestSummary(MAX_PAGE_CONTENT_LENGTH) ?: ""

val nativeDesc = pageContent?.let { it.ogDescription ?: it.metaDescription }
if (!nativeDesc.isNullOrBlank()) {
return@runCatching validateDescription(nativeDesc.trim())
}

val pageSummary = pageContent?.visibleText.orEmpty().take(MAX_PAGE_CONTENT_LENGTH)
val prompt = buildString {
appendLine("Write a 1-2 sentence factual description for this bookmark.")
appendLine("Return ONLY the description, nothing else.")
Expand All @@ -305,15 +318,12 @@ class AICoreService(httpClient: HttpClient) {
appendLine("Title: $title")
}
if (pageSummary.isNotBlank()) {
appendLine("Page summary: $pageSummary")
appendLine("Page text: $pageSummary")
} else {
appendLine("If you cannot determine what the page is about, respond with: Unable to generate description.")
}
}

// See generateTags() — inline runBenchmarking removed for the
// same reason; explicit comparison lives in
// ModelComparisonScreen.

validateDescription(runInference(prompt).trim())
}
}
Expand Down
Loading
Loading