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
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ Thumbs.db
*.orig
*.rej

# Python local environments and caches
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.ruff_cache/
.venv/
venv/
.env

# Local editor/project scratch
.vscode/
tools/

# Project-specific generated reports and caches
app/.DS_Store
app/src/.DS_Store
Expand Down
23 changes: 9 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Zest

Zest helps you scan packaged food labels and understand how processed they look. It focuses on the ingredient panel, gives a NOVA-style classification, highlights important ingredients, and keeps your scan history on your device.
Zest helps you scan packaged food labels and understand how processed they look. It focuses on the ingredient panel, gives a NOVA-style classification, and highlights important ingredients without retaining scan content beyond the active session.

## Why The Name Zest?

Expand All @@ -20,10 +20,8 @@ In practice, Zest is about:
- Show a NOVA-style result for the full label.
- Break ingredients into compact color-coded bubbles.
- Show allergen signals in a separate section.
- Save scan history locally on your phone.
- Show token and cost usage in scan history, using provider-reported usage when available.
- Use the same Zest splash, launcher icon, typography, and sound setting across the app.
- Keep API keys encrypted on device.
- Use the Zest backend proxy for AI analysis without user AI keys.

## Feature List

Expand All @@ -42,26 +40,23 @@ Zest is designed as a consumer-grade ingredient intelligence layer: fast enough

- Images stay on device: captured and uploaded label images are never sent to AI providers.
- On-device OCR first: ML Kit extracts text locally before any model call happens.
- Text-only AI analysis: providers receive extracted ingredient text, not the original image.
- Encrypted key storage: LLM and USDA API keys are stored through encrypted on-device storage.
- Local-first history: scan results and failed scans are stored locally through Room.
- Text-only AI analysis: the Zest backend proxy receives extracted ingredient text, not the original image.
- USDA key storage: USDA barcode lookup keys are stored through encrypted on-device storage when configured.
- No account required: users can analyze labels without sign-in or cloud sync.
- Backend abuse controls are a required production hardening item; unauthenticated proxy deployment is only acceptable for testing or limited rollout.

### Product Intelligence

- Staged AI pipeline: one call for NOVA classification, one call for ingredient cleanup and ultra-processed marker detection, one call for allergens, and chat only on demand.
- Backend AI pipeline: the app calls the Zest proxy, which runs a backend-owned structured analysis prompt and result chat through Vertex AI.
- Deterministic model settings: API calls use low-variance parameters for more consistent product behavior.
- Result-scoped chat: users can ask follow-up questions about the current scan without turning the app into a general chatbot.
- Barcode support: barcode scans can enrich analysis through USDA data when configured.
- Failed scan recovery: failed image-based scans can appear in History with a rerun path.
- Usage visibility: History shows provider-reported token and cost usage by model/provider when available, with local estimates as fallback.

### Operational Readiness

- Professional Compose UI: shared typography, brand assets, spacing, colors, splash, launcher icon, and sound settings.
- System back handling: Android back and edge-swipe gestures route within the app instead of accidentally closing it.
- Build safeguards: source-tree checks block retired demo, legacy, rule-based, and dataless source files before build work proceeds.
- Release-minded storage: Room migrations preserve history and support failed-scan records.
- Developer documentation: architecture, pipeline contracts, security, testing, and roadmap docs are maintained under `documentation/`.

## How To Set It Up
Expand All @@ -83,7 +78,6 @@ Zest is designed as a consumer-grade ingredient intelligence layer: fast enough
3. Review the analysis result.
4. Review the ingredient capsules to see which corrected ingredients were flagged as ultra-processed markers.
5. Check the allergen block for separate allergen signals.
6. Open `History` to revisit old scans.

## What The Result Means

Expand All @@ -98,7 +92,7 @@ Ingredient capsules are color-coded from the API-returned ultra-processed marker

Zest is designed to keep your data local by default.

- Scan history stays on your device.
- Scan images, OCR text, and results exist only for the active session.
- Saved keys are encrypted on device.
- Saved keys are not shown back in plain text.
- No sign-in is required.
Expand Down Expand Up @@ -142,12 +136,13 @@ If the app fails to analyze a label:

- Check that the ingredient panel is visible and readable.
- Try a clearer photo with better lighting.
- Confirm your API key is saved in `Settings`.
- Wait a moment and try again if the AI service is temporarily unavailable.
- For barcode scans, confirm USDA lookup is configured.

## Project Links

- Repository: https://github.com/benevolentbandwidth/ultraprocessed
- License: [LICENSE/LICENSE.md](LICENSE/LICENSE.md)
- Product requirements: [documentation/00-product-requirements.md](documentation/00-product-requirements.md)
- Technical documentation: [documentation/README.md](documentation/README.md)
- Non-Android architecture guide: [documentation/00-android-app-guide.md](documentation/00-android-app-guide.md)
59 changes: 46 additions & 13 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp") version "2.3.2"
}

fun String.asBuildConfigStringLiteral(): String =
Expand All @@ -31,6 +30,11 @@ val proxyBaseUrl = localProperties
.orEmpty()
.trim()
.ifBlank { "https://ultraprocessed-ai-proxy-894254677159.us-east1.run.app" }
val analysisDiagnosticsEnabled = localProperties
.getProperty("ZEST_ANALYSIS_DIAGNOSTICS_ENABLED")
.orEmpty()
.trim()
.toBoolean()
val releaseStoreFile = providers.environmentVariable("ZEST_RELEASE_STORE_FILE").orNull
val releaseStorePassword = providers.environmentVariable("ZEST_RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.environmentVariable("ZEST_RELEASE_KEY_ALIAS").orNull
Expand Down Expand Up @@ -62,6 +66,11 @@ android {
"PROXY_BASE_URL",
proxyBaseUrl.asBuildConfigStringLiteral(),
)
buildConfigField(
"boolean",
"ANALYSIS_DIAGNOSTICS_ENABLED",
analysisDiagnosticsEnabled.toString(),
)

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
Expand Down Expand Up @@ -196,10 +205,45 @@ val verifyNoDatalessSources = tasks.register("verifyNoDatalessSources") {
}
}

val verifySessionOnlyStorage = tasks.register("verifySessionOnlyStorage") {
group = "verification"
description = "Fails if archived Room/history code or active persistence wiring returns."
doLast {
val sourceRoot = layout.projectDirectory.dir("src/main").asFile
val forbidden = listOf(
"NovaDatabase",
"ScanResultDao",
"androidx.room",
"HistoryScreen",
"AppDestination.History",
"insertScanResult",
"getAllScanResults",
)
val violations = sourceRoot.walkTopDown()
.filter {
it.isFile &&
(it.extension == "kt" || it.extension == "xml") &&
!it.relativeTo(sourceRoot).path.startsWith("archive/")
}
.flatMap { file ->
val contents = file.readText()
forbidden.filter { token -> contents.contains(token) }.map { token ->
"${file.relativeTo(projectDir)} contains $token"
}
}
.toList()
if (violations.isNotEmpty()) {
throw GradleException(
"Session-only storage policy violation:\n" + violations.joinToString("\n")
)
}
}
}

val verifySourceTreeForBuild = tasks.register("verifySourceTreeForBuild") {
group = "verification"
description = "Guards the Android source tree from retired files and macOS dataless placeholders."
dependsOn(verifyNoRetiredSourceFiles, verifyNoDatalessSources)
dependsOn(verifyNoRetiredSourceFiles, verifyNoDatalessSources, verifySessionOnlyStorage)
}

tasks.named("preBuild") {
Expand All @@ -214,14 +258,8 @@ tasks.matching {
dependsOn(verifyReleaseSigning)
}

ksp {
arg("room.incremental", "true")
arg("room.schemaLocation", "$projectDir/schemas")
}

dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
val roomVersion = "2.8.4"

implementation(composeBom)
androidTestImplementation(composeBom)
Expand Down Expand Up @@ -253,14 +291,9 @@ dependencies {
androidTestImplementation("androidx.test:core:1.6.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.room:room-testing:$roomVersion")

debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Room
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Security Crypto (Keystore)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ class AppChromeFunctionalTest {

@Test
fun scannerScreen_rendersSharedHeaderAndFooter_andRoutesHeaderActions() {
var historyClicks = 0
var settingsClicks = 0
var barcodeClicks = 0

Expand All @@ -36,20 +35,17 @@ class AppChromeFunctionalTest {
onScan = {},
onBarcodeScanned = { barcodeClicks += 1 },
onSettings = { settingsClicks += 1 },
onHistory = { historyClicks += 1 },
)
}
}

composeRule.onNodeWithTag(AppTestTags.HEADER).assertIsDisplayed()
composeRule.onNodeWithTag(AppTestTags.FOOTER).assertIsDisplayed()
composeRule.onNodeWithText("Zest").assertIsDisplayed()
composeRule.onNodeWithTag(AppTestTags.HEADER_ACTION_HISTORY).performClick()
composeRule.onNodeWithTag(AppTestTags.HEADER_ACTION_SETTINGS).performClick()
composeRule.onNodeWithTag(AppTestTags.SCANNER_BARCODE_BUTTON).performClick()

composeRule.runOnIdle {
assertEquals(1, historyClicks)
assertEquals(1, settingsClicks)
assertEquals(1, barcodeClicks)
}
Expand All @@ -62,9 +58,8 @@ class AppChromeFunctionalTest {
ResultsScreen(
result = sampleScanResult,
onScanAgain = {},
onOpenHistory = {},
chatEnabled = false,
onAskAboutResult = { _, _ ->
onAskAboutResult = { _, _, _ ->
Result.failure(IllegalStateException("Chat is disabled in this test."))
},
)
Expand All @@ -84,9 +79,8 @@ class AppChromeFunctionalTest {
ResultsScreen(
result = sampleScanResult.copy(novaGroup = 3),
onScanAgain = {},
onOpenHistory = {},
chatEnabled = false,
onAskAboutResult = { _, _ ->
onAskAboutResult = { _, _, _ ->
Result.failure(IllegalStateException("Chat is disabled in this test."))
},
)
Expand All @@ -108,10 +102,9 @@ class AppChromeFunctionalTest {
ResultsScreen(
result = sampleScanResult,
onScanAgain = {},
onOpenHistory = {},
chatEnabled = true,
onSoundEffect = { sounds += it },
onAskAboutResult = { _, _ ->
onAskAboutResult = { _, _, _ ->
Result.success(
ResultChatReply(
allowed = true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,43 +1,16 @@
package com.b2.ultraprocessed.analysis

import android.content.Context
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

/**
* Retained as a source-compatible no-op while diagnostics are archived.
* Human OCR/model data must never be written to Logcat or an app file.
*/
object AnalysisDebugLogger {
private const val TAG = "ZestAnalysisTrace"
private const val MAX_LOG_BYTES = 512_000L
private val lock = Any()

@Volatile
private var logFile: File? = null

fun initialize(context: Context) {
val directory = File(context.filesDir, "zest-debug").apply { mkdirs() }
logFile = File(directory, "analysis_trace.log")
AnalysisTelemetry.sink = { line -> log("telemetry", line) }
log("logger", "initialized path=${logFile?.absolutePath.orEmpty()}")
AnalysisTelemetry.sink = null
}

fun log(stage: String, message: String) {
val line = "${timestamp()} [$stage] ${message.take(8_000)}"
runCatching { Log.d(TAG, line) }
val file = logFile
if (file == null) {
println(line)
return
}
synchronized(lock) {
if (file.length() > MAX_LOG_BYTES) {
file.writeText("")
}
file.appendText(line + "\n")
}
}
fun log(stage: String, message: String) = Unit

private fun timestamp(): String =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())
fun clear(context: Context) = Unit
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ object AnalysisTelemetry {

private fun log(message: String) {
val formatted = "ZestAnalysis $message"
sink?.invoke(formatted) ?: println(formatted)
sink?.invoke(formatted)
}
}
Loading
Loading