3 Commits

Author SHA1 Message Date
ml ae2c9be564 Add photo export and receipt list sorting/search
build / build (push) Successful in 3m24s
Photo export (hamburger menu) zips every receipt's photo as
IMG_YYYYMMDD_HHMMSS.jpg, named from the receipt's own extracted
date/time rather than the photo's capture time, with collision-safe
naming for same-timestamp receipts.

The receipt list's Date/Litres/Total column headers are now tappable
to sort (tap again to flip direction), and a search field does a
case-insensitive substring match across every exported field, not
just the three shown in the list.
2026-10-04 19:17:55 +02:00
ml 8980f9599b Make Ollama context size and threads configurable
build / build (push) Successful in 3m23s
2026-10-04 12:45:54 +02:00
ml 7a91a37049 Add optional API key auth to the server
build / build (push) Successful in 3m4s
Deployments reachable outside a trusted LAN need some form of access
control. Set FUELISH_API_KEY and the server requires a matching
X-API-Key header on /jobs; unset, behavior is unchanged (no auth).
The Android app gets a matching Settings field. This only checks the
key - TLS termination is left to a reverse proxy in front of the
server, not handled here.
2026-10-04 11:41:55 +02:00
21 changed files with 385 additions and 42 deletions
+7
View File
@@ -0,0 +1,7 @@
# Copy to .env (gitignored, never commit the real value) to require an
# API key on the server's /jobs endpoint - see docker-compose.yml and
# tooling-container/src/server.py. Leave FUELISH_API_KEY unset/empty for
# the original LAN-only/no-auth setup.
#
# Generate one with: openssl rand -hex 32
FUELISH_API_KEY=
+8 -1
View File
@@ -66,8 +66,15 @@ jobs:
set -euo pipefail
APK="$(ls dist/fuelish-*.apk)"
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
printf '%s\n' '${{ secrets.DL_SFTP_KEY }}' > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key
# Sanity-check the key is actually loadable before attempting the
# SFTP connection - fails fast with a clear message instead of
# SFTP's much less obvious "Permission denied" when the key
# material itself is malformed (e.g. mangled by how the secret
# was entered into Gitea - see DL_SFTP_KEY in README.md).
ssh-keygen -y -f ~/.ssh/dl_sftp_key > /dev/null \
|| { echo "FAIL: ~/.ssh/dl_sftp_key is not a valid private key - re-check the DL_SFTP_KEY secret" >&2; exit 1; }
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
-o StrictHostKeyChecking=accept-new \
uploader@dl.ladkau.de <<EOF
+3
View File
@@ -4,6 +4,9 @@
# Local registry credentials (see registry.env.example)
/registry.env
# Local API key for docker-compose.yml's api service (see .env.example)
/.env
# Local Claude Code settings
/.claude/settings.local.json
+2 -1
View File
@@ -60,7 +60,8 @@ implausibly - is `12_adaptive_preprocess_ocr.sh` below.
steps are short-lived and stateless (one `docker run` per receipt).
`3_extract_data.sh` reaches it via `--network host` at
`http://127.0.0.1:11434` by default (override with `OLLAMA_URL`/
`OLLAMA_MODEL`).
`OLLAMA_MODEL`; `OLLAMA_NUM_CTX` (default 3072) and `OLLAMA_NUM_THREAD`
(default: Ollama's) set the request options).
- `build-image.sh` / `upload-image.sh` - build/push the
`tooling-container/` image, tagged from `tooling-container/VERSION`.
Image name defaults to `fuelish/tools`, overridable via
+14 -5
View File
@@ -157,12 +157,21 @@ is found next to the photo):
docker compose up -d
```
By default this has no auth (fine for a LAN-only setup - see the app's
`network_security_config.xml` for the HTTP-only/private-IP-range
restriction this implies). For a deployment reachable outside a trusted
LAN, copy `.env.example` to `.env`, set `FUELISH_API_KEY` (e.g.
`openssl rand -hex 32`), and enter the same key in the Android app's
Settings tab alongside the server URL - every request then needs a
matching `X-API-Key` header (see `tooling-container/src/server.py`).
This only checks the key; it doesn't add TLS - put a reverse proxy in
front of the server for anything public.
Then, in the Android app's Settings tab, point it at
`http://<this-host>:8080` (both HTTP-only - see the app's
`network_security_config.xml` - and LAN-only by design, no auth/TLS).
Take a photo, watch the 4-stage progress (upload → preprocess → OCR →
extract), correct anything the model got wrong, and save. The Receipts
tab lists everything saved so far and exports to CSV or `.ods`.
`http://<this-host>:8080`. Take a photo, watch the progress (upload →
preprocess+OCR → extract), correct anything the model got wrong, and
save. The Receipts tab lists everything saved so far and exports to CSV
or `.ods`.
## License
@@ -32,13 +32,14 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
.readTimeout(3, TimeUnit.SECONDS)
.build()
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) {
suspend fun submitImage(baseUrl: String, apiKey: String, imageFile: File): String = withContext(Dispatchers.IO) {
try {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("image", imageFile.name, imageFile.asRequestBody("image/jpeg".toMediaType()))
.build()
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body).build()
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs").post(body)
.withApiKey(apiKey).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty()
@@ -50,8 +51,9 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
}
}
suspend fun getJobStatus(baseUrl: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get().build()
suspend fun getJobStatus(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse = withContext(Dispatchers.IO) {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/jobs/$jobId").get()
.withApiKey(apiKey).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("status check failed: HTTP ${response.code}")
val text = response.body?.string().orEmpty()
@@ -84,3 +86,9 @@ sealed interface HealthCheckResult {
data object Ok : HealthCheckResult
data class Failed(val message: String) : HealthCheckResult
}
/** Matches server.py's require_api_key(): omits the header entirely for
* a blank key rather than sending an empty one, so a server with no
* FUELISH_API_KEY configured behaves identically either way. */
private fun Request.Builder.withApiKey(apiKey: String): Request.Builder =
if (apiKey.isBlank()) this else header("X-API-Key", apiKey)
@@ -1,10 +1,14 @@
package de.ladkau.fuelish.data
/**
* Everything the app needs that isn't a saved receipt: just the fuelish
* server's base URL (e.g. "http://192.168.1.10:8080") right now.
* Everything the app needs that isn't a saved receipt: the fuelish
* server's base URL (e.g. "http://192.168.1.10:8080") and, if the server
* has FUELISH_API_KEY set (see tooling-container/src/server.py), the
* matching key to send as X-API-Key. Blank apiKey is fine against a
* server with no key configured - that's the original no-auth setup.
* Persisted via Preferences DataStore, see [ConfigRepository].
*/
data class AppConfig(
val serverUrl: String = "",
val apiKey: String = "",
)
@@ -15,15 +15,20 @@ private val Context.configDataStore: DataStore<Preferences> by preferencesDataSt
class ConfigRepository(private val context: Context) {
private object Keys {
val SERVER_URL = stringPreferencesKey("server_url")
val API_KEY = stringPreferencesKey("api_key")
}
val config: Flow<AppConfig> = context.configDataStore.data.map { prefs ->
AppConfig(serverUrl = prefs[Keys.SERVER_URL] ?: "")
AppConfig(
serverUrl = prefs[Keys.SERVER_URL] ?: "",
apiKey = prefs[Keys.API_KEY] ?: "",
)
}
suspend fun save(config: AppConfig) {
context.configDataStore.edit { prefs ->
prefs[Keys.SERVER_URL] = config.serverUrl
prefs[Keys.API_KEY] = config.apiKey
}
}
}
@@ -15,9 +15,11 @@ class ReceiptRepository(context: Context) {
suspend fun getById(id: Long): Receipt? = dao.getById(id)
suspend fun submitImage(baseUrl: String, imageFile: File): String = api.submitImage(baseUrl, imageFile)
suspend fun submitImage(baseUrl: String, apiKey: String, imageFile: File): String =
api.submitImage(baseUrl, apiKey, imageFile)
suspend fun pollJob(baseUrl: String, jobId: String): JobStatusResponse = api.getJobStatus(baseUrl, jobId)
suspend fun pollJob(baseUrl: String, apiKey: String, jobId: String): JobStatusResponse =
api.getJobStatus(baseUrl, apiKey, jobId)
suspend fun save(receipt: Receipt): Long = dao.insert(receipt)
@@ -0,0 +1,68 @@
package de.ladkau.fuelish.export
import android.content.ContentResolver
import android.net.Uri
import de.ladkau.fuelish.data.Receipt
import java.io.OutputStream
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/** IMG_YYYYMMDD_HHMMSS.jpg, built from the receipt's own extracted
* date/time (Receipt.date/time - DD.MM.YYYY / HH:MM:SS), not the photo
* file's own capture timestamp or when it was saved in the app - so the
* exported name reflects what's on the receipt. Null if date or time is
* missing or doesn't parse, since there's then no valid name to build. */
fun Receipt.photoExportName(): String? {
val d = date ?: return null
val t = time ?: return null
val parsed = try {
SOURCE_FORMAT.parse("$d $t")
} catch (e: ParseException) {
null
} ?: return null
return "IMG_${TARGET_FORMAT.format(parsed)}.jpg"
}
private val SOURCE_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY)
private val TARGET_FORMAT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
/** Zips every receipt's photo (skipping receipts with no photo, or no
* parseable date/time to name it with) under photoExportName(), appending
* "_2", "_3", ... on a name collision (e.g. two receipts logged with the
* same date and time to the second) so nothing silently overwrites
* another entry. Returns the count actually written. */
fun exportPhotos(receipts: List<Receipt>, resolver: ContentResolver, out: OutputStream): Int {
val usedNames = mutableSetOf<String>()
var written = 0
ZipOutputStream(out).use { zip ->
for (receipt in receipts) {
val imageUri = receipt.imageUri ?: continue
val baseName = receipt.photoExportName() ?: continue
val name = uniqueName(baseName, usedNames)
usedNames += name
val bytes = try {
resolver.openInputStream(Uri.parse(imageUri))?.use { it.readBytes() }
} catch (e: Exception) {
null
} ?: continue
zip.putNextEntry(ZipEntry(name))
zip.write(bytes)
zip.closeEntry()
written++
}
}
return written
}
private fun uniqueName(baseName: String, used: Set<String>): String {
if (baseName !in used) return baseName
val stem = baseName.removeSuffix(".jpg")
var n = 2
while ("${stem}_$n.jpg" in used) n++
return "${stem}_$n.jpg"
}
@@ -9,6 +9,7 @@ import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.export.exportCsv as writeCsvRows
import de.ladkau.fuelish.export.exportOds as writeOdsRows
import de.ladkau.fuelish.export.exportPhotos as writePhotosZip
import de.ladkau.fuelish.log.AppLog
import java.io.File
import java.io.OutputStream
@@ -27,6 +28,25 @@ class ExportViewModel(application: Application) : AndroidViewModel(application)
fun exportOds(onReady: (Uri) -> Unit) = export("fuelish-receipts.ods", ::writeOdsRows, onReady)
/** Zips every receipt's photo as IMG_YYYYMMDD_HHMMSS.jpg (from the
* receipt's own date/time, not the photo's capture time) - see
* export/PhotoExporter.kt's photoExportName(). Receipts with no photo
* or no parseable date/time are skipped, not failed. */
fun exportPhotos(onReady: (Uri) -> Unit) {
val app = getApplication<Application>()
viewModelScope.launch {
val receipts = repository.receipts.first()
val (uri, written) = withContext(Dispatchers.IO) {
val dir = File(app.cacheDir, "exports").apply { mkdirs() }
val file = File(dir, "fuelish-photos.zip")
val count = file.outputStream().use { writePhotosZip(receipts, app.contentResolver, it) }
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file) to count
}
AppLog.log("Exported $written of ${receipts.size} photo(s) to fuelish-photos.zip")
onReady(uri)
}
}
private fun export(fileName: String, write: (List<Receipt>, OutputStream) -> Unit, onReady: (Uri) -> Unit) {
val app = getApplication<Application>()
viewModelScope.launch {
@@ -117,6 +117,15 @@ fun FuelishApp() {
},
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.overview_export_photos)) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
exportViewModel.exportPhotos { uri -> shareFile(context, uri, "application/zip") }
},
modifier = Modifier.padding(horizontal = 12.dp),
)
}
},
) {
@@ -36,23 +36,24 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
_uiState.value = CaptureUiState.Uploading
try {
val baseUrl = configRepository.config.first().serverUrl
val config = configRepository.config.first()
val baseUrl = config.serverUrl
if (baseUrl.isBlank()) {
_uiState.value = CaptureUiState.Error("Set the server URL in Settings first")
return@launch
}
val jobId = repository.submitImage(baseUrl, imageFile)
pollUntilDone(baseUrl, jobId)
val jobId = repository.submitImage(baseUrl, config.apiKey, imageFile)
pollUntilDone(baseUrl, config.apiKey, jobId)
} catch (e: Exception) {
_uiState.value = CaptureUiState.Error(e.message ?: "unknown error")
}
}
}
private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
private suspend fun pollUntilDone(baseUrl: String, apiKey: String, jobId: String) {
var lastStage: String? = null
while (true) {
val status = repository.pollJob(baseUrl, jobId)
val status = repository.pollJob(baseUrl, apiKey, jobId)
when (status.status) {
JobStatusResponse.STATUS_DONE -> {
AppLog.log("Job $jobId done")
@@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -32,14 +37,25 @@ private val DELETE_COLUMN_WIDTH = 48.dp
@Composable
fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel = viewModel()) {
val receipts by viewModel.receipts.collectAsState()
val sort by viewModel.sort.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
ReceiptTableHeader()
SearchField(
query = searchQuery,
onQueryChange = viewModel::setSearchQuery,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
ReceiptTableHeader(sort = sort, onColumnClick = viewModel::setSortColumn)
HorizontalDivider()
if (receipts.isEmpty()) {
Text(
text = stringResource(R.string.overview_empty),
text = if (searchQuery.isBlank()) {
stringResource(R.string.overview_empty)
} else {
stringResource(R.string.overview_empty_search, searchQuery)
},
modifier = Modifier.padding(16.dp),
)
} else {
@@ -58,32 +74,80 @@ fun OverviewScreen(onReceiptClick: (Long) -> Unit, viewModel: OverviewViewModel
}
@Composable
private fun ReceiptTableHeader() {
private fun SearchField(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text(stringResource(R.string.overview_search_placeholder)) },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.overview_search_clear))
}
}
},
singleLine = true,
modifier = modifier.fillMaxWidth(),
)
}
@Composable
private fun ReceiptTableHeader(sort: SortState, onColumnClick: (SortColumn) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.overview_column_date),
style = MaterialTheme.typography.labelLarge,
SortableHeaderCell(
label = stringResource(R.string.overview_column_date),
column = SortColumn.DATE,
sort = sort,
onClick = onColumnClick,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.overview_column_litres),
style = MaterialTheme.typography.labelLarge,
SortableHeaderCell(
label = stringResource(R.string.overview_column_litres),
column = SortColumn.LITRES,
sort = sort,
onClick = onColumnClick,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.overview_column_total),
style = MaterialTheme.typography.labelLarge,
SortableHeaderCell(
label = stringResource(R.string.overview_column_total),
column = SortColumn.TOTAL_PRICE,
sort = sort,
onClick = onColumnClick,
modifier = Modifier.weight(1f),
)
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {}
}
}
@Composable
private fun SortableHeaderCell(
label: String,
column: SortColumn,
sort: SortState,
onClick: (SortColumn) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.clickable { onClick(column) },
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, style = MaterialTheme.typography.labelLarge)
if (sort.column == column) {
Icon(
imageVector = if (sort.direction == SortDirection.ASCENDING) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.padding(start = 2.dp).width(16.dp),
)
}
}
}
@Composable
private fun ReceiptRow(receipt: Receipt, onClick: () -> Unit, onDelete: () -> Unit) {
Row(
@@ -5,17 +5,56 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.fuelish.data.Receipt
import de.ladkau.fuelish.data.ReceiptRepository
import de.ladkau.fuelish.export.toExportRow
import de.ladkau.fuelish.log.AppLog
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** The overview table's 3 visible/sortable columns (price/litre and
* discount are detail-only, not shown in the list - see
* ReceiptDetailScreen). DATE sorts by the combined date+time instant,
* not the date string alone, since within-day ordering still matters for
* a fuel-cost log. */
enum class SortColumn { DATE, LITRES, TOTAL_PRICE }
enum class SortDirection { ASCENDING, DESCENDING }
data class SortState(val column: SortColumn, val direction: SortDirection)
class OverviewViewModel(application: Application) : AndroidViewModel(application) {
private val repository = ReceiptRepository(application)
val receipts: StateFlow<List<Receipt>> = repository.receipts
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _sort = MutableStateFlow(SortState(SortColumn.DATE, SortDirection.DESCENDING))
val sort: StateFlow<SortState> = _sort.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val receipts: StateFlow<List<Receipt>> = combine(repository.receipts, _searchQuery, _sort) { all, query, sort ->
all.filter { matchesQuery(it, query) }.sortedWith(comparator(sort))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
/** Tapping the already-active column flips its direction; tapping a
* different one switches to it, ascending. */
fun setSortColumn(column: SortColumn) {
_sort.value = if (_sort.value.column == column) {
_sort.value.copy(direction = _sort.value.direction.flipped())
} else {
SortState(column, SortDirection.ASCENDING)
}
}
fun delete(receipt: Receipt) {
viewModelScope.launch {
@@ -24,3 +63,47 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application
}
}
}
private fun SortDirection.flipped() =
if (this == SortDirection.ASCENDING) SortDirection.DESCENDING else SortDirection.ASCENDING
/** Substring match (case-insensitive) against the same formatted field
* values CSV/ODS export uses (export/ReceiptRow.kt's toExportRow()) -
* "fulltext" across the table's actual fields, not just the three shown
* in the overview list. */
private fun matchesQuery(receipt: Receipt, query: String): Boolean {
if (query.isBlank()) return true
val needle = query.trim()
return receipt.toExportRow().any { it.contains(needle, ignoreCase = true) }
}
private val DATE_TIME_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.GERMANY)
/** Receipts with no parseable date+time sort to the very end regardless
* of direction, rather than jumping between "first" and "last" depending
* on which way the column is currently sorted. */
private fun dateTimeSortKey(receipt: Receipt): Long {
val date = receipt.date ?: return Long.MAX_VALUE
val time = receipt.time ?: "00:00:00"
return try {
DATE_TIME_FORMAT.parse("$date $time")?.time ?: Long.MAX_VALUE
} catch (e: ParseException) {
Long.MAX_VALUE
}
}
private fun comparator(sort: SortState): Comparator<Receipt> {
val base: Comparator<Receipt> = when (sort.column) {
SortColumn.DATE -> compareBy { dateTimeSortKey(it) }
SortColumn.LITRES -> nullsLastDouble { it.litres }
SortColumn.TOTAL_PRICE -> nullsLastDouble { it.totalPrice }
}
return if (sort.direction == SortDirection.DESCENDING) base.reversed() else base
}
/** compareBy(nullsLast()) alone would put nulls first when .reversed() is
* applied for descending order - build the null-handling into the base
* comparator instead so nulls stay last in both directions, same as
* dateTimeSortKey()'s Long.MAX_VALUE approach. */
private fun nullsLastDouble(selector: (Receipt) -> Double?): Comparator<Receipt> =
compareBy { selector(it) ?: Double.MAX_VALUE }
@@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.fuelish.R
@@ -38,6 +39,14 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = config.apiKey,
onValueChange = { viewModel.update { c -> c.copy(apiKey = it) } },
label = { Text(stringResource(R.string.settings_api_key)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { viewModel.save() }, enabled = isDirty) {
Text(stringResource(R.string.capture_save))
@@ -34,16 +34,21 @@
<string name="receipt_delete">Delete</string>
<string name="overview_empty">No receipts have been scanned yet</string>
<string name="overview_empty_search">No receipts match “%1$s”</string>
<string name="overview_search_placeholder">Search</string>
<string name="overview_search_clear">Clear search</string>
<string name="overview_column_date">Date</string>
<string name="overview_column_litres">Litres</string>
<string name="overview_column_total">Total</string>
<string name="overview_delete">Delete</string>
<string name="overview_export_csv">Export CSV</string>
<string name="overview_export_ods">Export ODS</string>
<string name="overview_export_photos">Export photos</string>
<string name="overview_export_done">Exported to %1$s</string>
<string name="settings_server_url">Server URL</string>
<string name="settings_server_url_example">http://192.168.1.10:8080</string>
<string name="settings_api_key">API key (leave blank if the server has none set)</string>
<string name="settings_test_connection">Test connection</string>
<string name="settings_test_testing">Testing…</string>
<string name="settings_test_success">Connected successfully</string>
+8
View File
@@ -9,6 +9,13 @@
#
# Usage: docker compose up -d
# API reachable at http://<this-host>:8080 from any device on the LAN.
#
# For a deployment reachable outside a trusted LAN, copy .env.example to
# .env and set FUELISH_API_KEY - every /jobs request then needs a
# matching X-API-Key header (see tooling-container/src/server.py).
# Unset (the default), the server has no auth, same as before. This
# server does not terminate TLS itself - put a reverse proxy in front of
# it for anything public.
services:
ollama:
image: ollama/ollama
@@ -26,6 +33,7 @@ services:
OLLAMA_URL: http://ollama:11434/api/chat
OLLAMA_BASE_URL: http://ollama:11434
OLLAMA_MODEL: qwen2.5:3b
FUELISH_API_KEY: ${FUELISH_API_KEY:-}
command: ["uvicorn", "server:app", "--app-dir", "/opt/app/src", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "8080:8000"
+1 -1
View File
@@ -1 +1 @@
0.1.0
0.1.1
+9 -1
View File
@@ -10,6 +10,11 @@ import requests
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434/api/chat")
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
# Ollama restarts its runner when a request's context size or thread count
# differs from the loaded one; set these to match other clients of a shared
# Ollama. OLLAMA_NUM_THREAD unset = Ollama's default.
NUM_CTX = int(os.environ.get("OLLAMA_NUM_CTX", "3072"))
NUM_THREAD = os.environ.get("OLLAMA_NUM_THREAD")
SCHEMA = {
"type": "object",
@@ -57,6 +62,9 @@ Respond with ONLY the JSON object, no other text.
def structure(ocr_text: str) -> dict:
options = {"temperature": 0, "num_ctx": NUM_CTX}
if NUM_THREAD:
options["num_thread"] = int(NUM_THREAD)
payload = {
"model": MODEL,
"messages": [
@@ -64,7 +72,7 @@ def structure(ocr_text: str) -> dict:
],
"format": SCHEMA,
"stream": False,
"options": {"temperature": 0, "num_ctx": 3072},
"options": options,
}
start = time.time()
+27 -5
View File
@@ -10,8 +10,13 @@ Jobs are processed asynchronously so the app can show real per-stage
progress: POST /jobs returns immediately with a job id; GET /jobs/{id}
reports which of preprocessing/ocr/extracting is currently running, and
the final result once done. Job state lives in memory only - this is a
personal, LAN-only, single-instance server with no need for persistence
here (the Android app is the system of record for confirmed receipts).
personal, single-instance server with no need for persistence here (the
Android app is the system of record for confirmed receipts).
Auth: set FUELISH_API_KEY to require a matching X-API-Key header on
/jobs (GET /health stays open for reverse-proxy health checks). Leave it
unset for the original LAN-only/no-auth setup. This server does not
terminate TLS itself - see require_api_key()'s docstring.
"""
import os
import shutil
@@ -21,17 +26,34 @@ import time
import uuid
import requests
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi import Depends, FastAPI, HTTPException, Security, UploadFile
from fastapi.responses import JSONResponse
from fastapi.security.api_key import APIKeyHeader
from adaptive import preprocess_and_ocr_adaptive
from extract import structure
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
API_KEY = os.environ.get("FUELISH_API_KEY", "")
app = FastAPI(title="fuelish")
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def require_api_key(provided: str | None = Security(_api_key_header)) -> None:
"""No-op when FUELISH_API_KEY isn't set - the unchanged default for a
LAN-only deployment with no key configured. When it is set, every
request to a protected route must carry a matching X-API-Key header.
This only checks the key; it doesn't terminate TLS - a reverse proxy
in front of this server (e.g. Traefik) is expected to do that for any
deployment reachable outside a trusted LAN."""
if not API_KEY:
return
if provided != API_KEY:
raise HTTPException(status_code=401, detail="invalid or missing API key")
_jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock()
@@ -96,7 +118,7 @@ def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None:
shutil.rmtree(scratch_dir, ignore_errors=True)
@app.post("/jobs", status_code=201)
@app.post("/jobs", status_code=201, dependencies=[Depends(require_api_key)])
async def create_job(image: UploadFile) -> JSONResponse:
job_id = uuid.uuid4().hex
scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-")
@@ -113,7 +135,7 @@ async def create_job(image: UploadFile) -> JSONResponse:
return JSONResponse({"job_id": job_id}, status_code=201)
@app.get("/jobs/{job_id}")
@app.get("/jobs/{job_id}", dependencies=[Depends(require_api_key)])
def get_job(job_id: str) -> dict:
with _jobs_lock:
job = _jobs.get(job_id)