Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad2ef1689e | |||
| d5f4381169 |
+21
-40
@@ -1,10 +1,13 @@
|
||||
name: build
|
||||
|
||||
# Builds the Android app (android/) debug APK on every push/PR, plus
|
||||
# on-demand via the Gitea "Run workflow" button. Runs inside a public,
|
||||
# prebuilt Android CI image (JDK + Android SDK/cmdline-tools bundled -
|
||||
# fuelish's app has no native/NDK code, so it doesn't need a custom
|
||||
# builder image the way a project with native components would).
|
||||
# on-demand via the Gitea "Run workflow" button. Runs inside build-image
|
||||
# (see ../../build-image/Dockerfile, built/pushed via ../../build-image.sh
|
||||
# and ../../upload-image.sh), which bundles the JDK, Android SDK, and
|
||||
# Node.js (needed by the JS-based actions below - see
|
||||
# build-image/Dockerfile's own comment) so the runner itself doesn't need
|
||||
# any of that installed. The Gradle build still needs network access at
|
||||
# job runtime (Gradle/AGP/androidx dependency resolution).
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
@@ -21,52 +24,28 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
# JDK + Android SDK cmdline-tools, no platform/build-tools installed -
|
||||
# the "Install SDK components" step below fetches exactly what
|
||||
# compileSdk in android/app/build.gradle.kts needs. Much smaller than
|
||||
# kitchen-sink alternatives (~0.7GB vs. several GB), which matters
|
||||
# since every job pulls it fresh.
|
||||
image: thyrlian/android-sdk:10.0
|
||||
image: cr.ladkau.de/fuelish/android-builder:latest
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# APK naming below uses `git rev-parse --short HEAD` - harmless
|
||||
# with the default shallow clone, but fetch-depth: 0 keeps this
|
||||
# consistent if that ever switches to `git describe --tags`.
|
||||
# `make android`'s version comes from `git describe --tags` -
|
||||
# needs full history/tags, not actions/checkout's default
|
||||
# shallow single-commit clone.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Preflight
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v java >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: java not in PATH" >&2; exit 1; }
|
||||
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
|
||||
[ -n "${ANDROID_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_HOME not set" >&2; exit 1; }
|
||||
[ -x android/gradlew ] || { echo "PREFLIGHT FAIL: android/gradlew missing or not executable" >&2; exit 1; }
|
||||
|
||||
- name: Install SDK components
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# python3-pil: android/scripts/gen_app_icons.py's Pillow dependency
|
||||
# (see android/app/build.gradle.kts's generateAppIcons preBuild task)
|
||||
# - not bundled in this image.
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends python3-pil
|
||||
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||
# Keep in sync with android/app/build.gradle.kts's compileSdk.
|
||||
sdkmanager "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
- name: Build
|
||||
working-directory: android
|
||||
run: ./gradlew :app:assembleDebug --console=plain
|
||||
|
||||
- name: Package
|
||||
# Renames the debug APK with the commit it was built from, so
|
||||
# multiple builds in dist/ (and on dl.ladkau.de) don't collide.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
sha="$(git rev-parse --short HEAD)"
|
||||
cp android/app/build/outputs/apk/debug/app-debug.apk "dist/fuelish-$sha.apk"
|
||||
run: make android
|
||||
|
||||
- name: Upload build artifact
|
||||
# v4 uses the newer @actions/artifact backend, which this Gitea
|
||||
@@ -78,9 +57,11 @@ jobs:
|
||||
|
||||
- name: Publish to dl.ladkau.de
|
||||
# Uploads the APK over SFTP instead of using actions/upload-artifact
|
||||
# (whose zip wrapping can't be disabled). Only runs on push so PR
|
||||
# builds don't publish.
|
||||
if: gitea.event_name == 'push'
|
||||
# (whose zip wrapping can't be disabled). Runs on push and on a
|
||||
# manual "Run workflow" dispatch (the on-demand way to publish an
|
||||
# update without pushing a commit) - excludes pull_request so PR
|
||||
# builds from unreviewed branches don't publish.
|
||||
if: gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK="$(ls dist/fuelish-*.apk)"
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Build output (see the root Makefile's `android` target, run-image.sh)
|
||||
/dist/
|
||||
|
||||
# Android app build output/local config and generated resources (launcher
|
||||
# icon + splash art - see android/app/build.gradle.kts's
|
||||
# generateAppIcons task and android/scripts/gen_app_icons.py's own
|
||||
@@ -32,3 +35,7 @@ __pycache__/
|
||||
/android/app/src/main/res/mipmap-xhdpi
|
||||
/android/app/src/main/res/mipmap-xxhdpi
|
||||
/android/app/src/main/res/mipmap-xxxhdpi
|
||||
|
||||
# Android Studio project files - per-developer, not shared
|
||||
/android/.idea/
|
||||
/android/gradle/gradle-daemon-jvm.properties
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Builds the Android app (android/) and packages a versioned release APK.
|
||||
# The OCR pipeline itself (tooling-container/, 0-3_*.sh, test_batch.sh) is
|
||||
# container-only and has no host build step, so this Makefile only covers
|
||||
# the Android app.
|
||||
|
||||
ANDROID_DIR := android
|
||||
ANDROID_APK := $(ANDROID_DIR)/app/build/outputs/apk/debug/app-debug.apk
|
||||
DIST_DIR := dist
|
||||
|
||||
.PHONY: all android clean
|
||||
|
||||
all: android
|
||||
|
||||
# Builds the Android app (`./gradlew assembleDebug` in android/, via its
|
||||
# own Gradle/AGP toolchain) and copies the resulting debug APK into
|
||||
# dist/, named with the current git tag, or `make android VERSION=1.2.3`
|
||||
# to override. Untagged builds (including every CI build, unless a
|
||||
# release tag is pushed) get a 0.0.0-dev+<sha> placeholder version.
|
||||
# Ships the auto-generated debug keystore, not a release signing config -
|
||||
# fine for sideloading; a real release signing setup is a separate,
|
||||
# not-yet-needed step. Requires the Android SDK (ANDROID_HOME set) - see
|
||||
# build-image/Dockerfile for the exact version this is built/tested
|
||||
# against, or run this inside it via ./run-image.sh.
|
||||
android:
|
||||
cd $(ANDROID_DIR) && ./gradlew assembleDebug
|
||||
@mkdir -p $(DIST_DIR)
|
||||
@V="$(VERSION)"; \
|
||||
if [ -z "$$V" ]; then \
|
||||
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
|
||||
V=$${TAG#v}; \
|
||||
else \
|
||||
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
|
||||
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
|
||||
fi; \
|
||||
fi; \
|
||||
case "$$V" in \
|
||||
[0-9]*.[0-9]*.[0-9]*) ;; \
|
||||
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
|
||||
esac; \
|
||||
APK_NAME="fuelish-$$V.apk"; \
|
||||
cp $(ANDROID_APK) "$(DIST_DIR)/$$APK_NAME"; \
|
||||
echo "Wrote $(DIST_DIR)/$$APK_NAME"
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST_DIR) $(ANDROID_DIR)/build $(ANDROID_DIR)/app/build
|
||||
@@ -1,3 +1,152 @@
|
||||
<p align="center">
|
||||
<img src="logo.png" width="280" alt="fuelish logo">
|
||||
</p>
|
||||
|
||||
# fuelish
|
||||
|
||||
Extracting and organising fuel data from fuel receipts.
|
||||
Extracts structured data from photos of German fuel/petrol receipts —
|
||||
petrol station, transaction number, date, time, price per litre, litres,
|
||||
discount, and total price — and lets you review, correct, save, and
|
||||
export it from an Android app.
|
||||
|
||||
The extraction pipeline is self-hosted (no cloud AI APIs): Tesseract OCR
|
||||
plus a small local LLM (`qwen2.5:3b`, served by Ollama), all running in
|
||||
containers. It's built to eventually run on a Raspberry Pi 4 cluster
|
||||
under k3s; see [`AGENT.md`](AGENT.md) for the pipeline's architecture,
|
||||
preprocessing rationale, and current extraction-accuracy notes.
|
||||
|
||||
## How it fits together
|
||||
|
||||
- **Pipeline** (`tooling-container/`): preprocess → OCR → LLM-structure a
|
||||
receipt photo into JSON. Runs either as three independently-inspectable
|
||||
host scripts (for development/testing) or as an HTTP job API (for the
|
||||
app).
|
||||
- **Server** (`docker-compose.yml`): the same pipeline, served over HTTP
|
||||
(`POST /jobs`, `GET /jobs/{id}`) so the Android app can submit a photo
|
||||
and poll real per-stage progress, backed by an Ollama container.
|
||||
- **Android app** (`android/`): take a photo, watch it get processed,
|
||||
correct any misread fields, save it locally, browse everything saved
|
||||
so far, and export to CSV or `.ods` (matching the layout of the
|
||||
ground-truth spreadsheets under `testdata/`).
|
||||
- **CI** (`.gitea/workflows/build.yml`): builds the Android debug APK on
|
||||
every push and publishes it to dl.ladkau.de, using a custom build image
|
||||
(`build-image/`) so the runner needs nothing preinstalled.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
tooling-container/ Pipeline source (preprocess.py, ocr.py, extract.py,
|
||||
server.py, compare_ground_truth.py) and the
|
||||
Dockerfile/VERSION for the fuelish/tools image
|
||||
both the host scripts and docker-compose.yml run.
|
||||
0-3_*.sh Numbered host scripts driving one pipeline stage
|
||||
each, for independent inspection during
|
||||
development (0_run_ollama.sh starts Ollama).
|
||||
test_batch.sh Runs the full pipeline over a directory of test
|
||||
receipts and compares against its ground-truth
|
||||
.ods - see AGENT.md's "Current state".
|
||||
docker-compose.yml Runs the pipeline as a long-lived HTTP job API
|
||||
(api + ollama services) for the Android app.
|
||||
android/ The Android app: Kotlin + Jetpack Compose, Room
|
||||
for local storage, OkHttp against the server API,
|
||||
CSV/.ods export.
|
||||
build-image/ Dockerfile for the Android CI/release build
|
||||
environment (JDK + Android SDK, no NDK needed -
|
||||
the app has no native code), used by CI and by
|
||||
the build-image.sh/run-image.sh/upload-image.sh
|
||||
scripts at the repo root (see "Building" below).
|
||||
.gitea/workflows/ CI: builds the Android APK and publishes it to
|
||||
dl.ladkau.de.
|
||||
Makefile Root Makefile - packages a versioned release APK
|
||||
(`make android`).
|
||||
testdata/ Real receipt photos + ground-truth .ods files
|
||||
(gitignored, not checked in - personal data).
|
||||
```
|
||||
|
||||
See [`AGENT.md`](AGENT.md) for the pipeline's architecture, why the
|
||||
current preprocessing approach was chosen over heavier alternatives, and
|
||||
the latest batch-accuracy numbers and known failure modes.
|
||||
|
||||
## Building
|
||||
|
||||
**The pipeline image** (`tooling-container/`), used by the numbered host
|
||||
scripts, `test_batch.sh`, and `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
./build-image.sh # builds fuelish/tools and the Android build-image
|
||||
# (below), and pulls ollama/ollama
|
||||
```
|
||||
|
||||
**The server**, once the tools image is built:
|
||||
|
||||
```bash
|
||||
docker compose up -d # starts ollama + api; api reachable at :8080
|
||||
```
|
||||
|
||||
**The Android app** (`android/`) needs the Android SDK
|
||||
(`ANDROID_HOME` set). Locally, without installing it yourself:
|
||||
|
||||
```bash
|
||||
cp registry.env.example registry.env # fill in your registry credentials
|
||||
./build-image.sh # builds the android-builder image (JDK + Android SDK)
|
||||
./run-image.sh # runs `make android` inside it, against this
|
||||
# checkout -> dist/fuelish-<version>.apk
|
||||
```
|
||||
|
||||
With the SDK already installed, `make android` (or
|
||||
`cd android && ./gradlew assembleDebug`) works directly on the host.
|
||||
|
||||
**CI** builds the same way — `.gitea/workflows/build.yml` runs
|
||||
`make android` inside the `build-image/` image (pulled from the
|
||||
registry, published via `./upload-image.sh` after `./build-image.sh`)
|
||||
and publishes the APK to dl.ladkau.de on every push to the default
|
||||
branch.
|
||||
|
||||
CI needs its own copy of credentials, separate from your local
|
||||
`registry.env` (which only `build-image.sh`/`upload-image.sh`/
|
||||
`run-image.sh` on your machine read - Gitea Actions never sees it). Add
|
||||
these as **repo Settings → Actions → Secrets**:
|
||||
|
||||
- `REGISTRY_USER` / `REGISTRY_PASSWORD` - to pull the private
|
||||
`build-image/` image (same values as `registry.env`)
|
||||
- `DL_SFTP_KEY` - the private key for the SFTP publish step to
|
||||
dl.ladkau.de
|
||||
|
||||
Without them, the workflow fails early (pulling the build image, or
|
||||
publishing) rather than partway through the build.
|
||||
|
||||
## Running
|
||||
|
||||
**Inspect the pipeline stage by stage**, for development/debugging one
|
||||
receipt at a time:
|
||||
|
||||
```bash
|
||||
./0_run_ollama.sh # once, starts Ollama
|
||||
./1_preprocess_image.sh -in photo.jpg -out scratch/
|
||||
./2_ocr_image.sh -in scratch/03_final.png -out scratch/
|
||||
./3_extract_data.sh -in scratch/ocr.txt -out scratch/ # -> extracted.json
|
||||
```
|
||||
|
||||
**Batch-test against ground truth**:
|
||||
|
||||
```bash
|
||||
./test_batch.sh -in testdata/<some-set> # needs a *.ods ground-truth
|
||||
# file in that directory
|
||||
```
|
||||
|
||||
**Serve it for the app**:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [`LICENSE`](LICENSE).
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.ladkau.fuelish.data
|
||||
|
||||
import de.ladkau.fuelish.log.AppLog
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
@@ -13,7 +15,7 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
|
||||
/** Talks to tooling-container/src/server.py's two endpoints. Every call
|
||||
/** Talks to tooling-container/src/server.py's endpoints. Every call
|
||||
* takes the server's base URL explicitly (from [ConfigRepository]) rather
|
||||
* than storing it, since the user can change it from Settings mid-session. */
|
||||
class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
||||
@@ -23,16 +25,28 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
}
|
||||
|
||||
// Short timeouts for /health - it's polled every few seconds by the
|
||||
// connection status bar and shouldn't hang waiting on a dead server.
|
||||
private val healthClient = client.newBuilder()
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.readTimeout(3, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
suspend fun submitImage(baseUrl: String, imageFile: File): String = withContext(Dispatchers.IO) {
|
||||
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()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
|
||||
val text = response.body?.string().orEmpty()
|
||||
json.decodeFromString(CreateJobResponse.serializer(), text).jobId
|
||||
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()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("upload failed: HTTP ${response.code}")
|
||||
val text = response.body?.string().orEmpty()
|
||||
json.decodeFromString(CreateJobResponse.serializer(), text).jobId
|
||||
}.also { jobId -> AppLog.log("Uploaded ${imageFile.name} to $baseUrl - job $jobId created") }
|
||||
} catch (e: IOException) {
|
||||
AppLog.log("Upload to $baseUrl failed: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,4 +58,29 @@ class ApiClient(private val client: OkHttpClient = OkHttpClient()) {
|
||||
json.decodeFromString(JobStatusResponse.serializer(), text)
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /health. Used by the settings "Test connection" button and the
|
||||
* connection status bar - both surface [HealthCheckResult.Failed]'s
|
||||
* message so a bad URL/unreachable server is diagnosable from the
|
||||
* phone's App log screen alone, without needing adb logcat. */
|
||||
suspend fun checkHealth(baseUrl: String): HealthCheckResult = withContext(Dispatchers.IO) {
|
||||
if (baseUrl.isBlank()) return@withContext HealthCheckResult.Failed("no server URL configured")
|
||||
try {
|
||||
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/health").get().build()
|
||||
healthClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) HealthCheckResult.Ok else HealthCheckResult.Failed("HTTP ${response.code}")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// e.message is usually the genuinely useful part (e.g. "Failed
|
||||
// to connect to /10.0.0.21:8000" for a wrong port, "Unable to
|
||||
// resolve host" for a bad address) - the exception class name
|
||||
// alone (e.g. ConnectException) isn't specific enough on its own.
|
||||
HealthCheckResult.Failed("${e.javaClass.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface HealthCheckResult {
|
||||
data object Ok : HealthCheckResult
|
||||
data class Failed(val message: String) : HealthCheckResult
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.ladkau.fuelish.log
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* A small in-memory, in-process log the app writes to at points a user
|
||||
* would actually want to inspect when something goes wrong (server
|
||||
* requests, connectivity changes, saves) - surfaced via the drawer's
|
||||
* "App log" entry (ui/log/LogScreen.kt). Not persisted - it's a
|
||||
* this-session diagnostic aid, not an audit trail.
|
||||
*/
|
||||
object AppLog {
|
||||
data class Entry(val timestampMillis: Long, val message: String)
|
||||
|
||||
private const val MAX_ENTRIES = 500
|
||||
|
||||
private val _entries = MutableStateFlow<List<Entry>>(emptyList())
|
||||
val entries: StateFlow<List<Entry>> = _entries.asStateFlow()
|
||||
|
||||
fun log(message: String) {
|
||||
_entries.update { (it + Entry(System.currentTimeMillis(), message)).takeLast(MAX_ENTRIES) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_entries.value = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.ladkau.fuelish.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.ladkau.fuelish.R
|
||||
|
||||
@Composable
|
||||
fun ConnectionStatusBar(viewModel: ConnectionStatusViewModel = viewModel()) {
|
||||
val status by viewModel.status.collectAsState()
|
||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||
|
||||
val color = when (status) {
|
||||
ConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.primary
|
||||
ConnectionStatus.DISCONNECTED -> MaterialTheme.colorScheme.error
|
||||
ConnectionStatus.NOT_CONFIGURED, ConnectionStatus.CHECKING -> MaterialTheme.colorScheme.outline
|
||||
}
|
||||
val text = when (status) {
|
||||
ConnectionStatus.CONNECTED -> stringResource(R.string.status_connected, serverUrl)
|
||||
ConnectionStatus.DISCONNECTED -> stringResource(R.string.status_disconnected, serverUrl)
|
||||
ConnectionStatus.NOT_CONFIGURED -> stringResource(R.string.status_not_configured)
|
||||
ConnectionStatus.CHECKING -> stringResource(R.string.status_checking)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
Text(text = text, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.ladkau.fuelish.ui
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.ladkau.fuelish.data.ApiClient
|
||||
import de.ladkau.fuelish.data.ConfigRepository
|
||||
import de.ladkau.fuelish.data.HealthCheckResult
|
||||
import de.ladkau.fuelish.log.AppLog
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val POLL_INTERVAL_MILLIS = 10_000L
|
||||
|
||||
enum class ConnectionStatus { CHECKING, CONNECTED, DISCONNECTED, NOT_CONFIGURED }
|
||||
|
||||
/** Scoped to the Activity (created once at [de.ladkau.fuelish.ui.FuelishApp]'s
|
||||
* top level, not per-screen), so the status bar keeps polling regardless of
|
||||
* which drawer destination is showing. */
|
||||
class ConnectionStatusViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val configRepository = ConfigRepository(application)
|
||||
private val api = ApiClient()
|
||||
|
||||
private val _status = MutableStateFlow(ConnectionStatus.CHECKING)
|
||||
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
|
||||
|
||||
private val _serverUrl = MutableStateFlow("")
|
||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
// collectLatest: a settings change (new server URL) cancels
|
||||
// whatever poll loop is in flight and restarts against the
|
||||
// new URL, rather than continuing to poll the old one.
|
||||
configRepository.config.collectLatest { config ->
|
||||
_serverUrl.value = config.serverUrl
|
||||
if (config.serverUrl.isBlank()) {
|
||||
_status.value = ConnectionStatus.NOT_CONFIGURED
|
||||
} else {
|
||||
pollLoop(config.serverUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollLoop(baseUrl: String) {
|
||||
var lastStable: ConnectionStatus? = null
|
||||
while (true) {
|
||||
_status.value = ConnectionStatus.CHECKING
|
||||
val result = api.checkHealth(baseUrl)
|
||||
val newStatus = if (result is HealthCheckResult.Ok) ConnectionStatus.CONNECTED else ConnectionStatus.DISCONNECTED
|
||||
if (newStatus != lastStable) {
|
||||
AppLog.log(
|
||||
when (result) {
|
||||
is HealthCheckResult.Ok -> "Connected to $baseUrl"
|
||||
is HealthCheckResult.Failed -> "Cannot reach server at $baseUrl: ${result.message}"
|
||||
},
|
||||
)
|
||||
lastStable = newStatus
|
||||
}
|
||||
_status.value = newStatus
|
||||
delay(POLL_INTERVAL_MILLIS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.ladkau.fuelish.R
|
||||
import de.ladkau.fuelish.ui.capture.CaptureScreen
|
||||
import de.ladkau.fuelish.ui.log.LogScreen
|
||||
import de.ladkau.fuelish.ui.overview.OverviewScreen
|
||||
import de.ladkau.fuelish.ui.settings.ConfigScreen
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -34,10 +35,12 @@ private object Destinations {
|
||||
const val CAPTURE = "capture"
|
||||
const val OVERVIEW = "overview"
|
||||
const val SETTINGS = "settings"
|
||||
const val LOG = "log"
|
||||
}
|
||||
|
||||
/** Burger menu = ModalNavigationDrawer, leading to Scan/Receipts/Settings -
|
||||
* the app's single navigation host. */
|
||||
/** Burger menu = ModalNavigationDrawer, leading to Receipts/Scan/Settings/App
|
||||
* log - the app's single navigation host. Receipts is the front page (start
|
||||
* destination) since that's what most opens of the app want to see. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FuelishApp() {
|
||||
@@ -50,24 +53,30 @@ fun FuelishApp() {
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.nav_capture)) },
|
||||
selected = currentRoute == Destinations.CAPTURE,
|
||||
onClick = { navigateTo(navController, Destinations.CAPTURE); scope.launch { drawerState.close() } },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.nav_overview)) },
|
||||
selected = currentRoute == Destinations.OVERVIEW,
|
||||
onClick = { navigateTo(navController, Destinations.OVERVIEW); scope.launch { drawerState.close() } },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.nav_capture)) },
|
||||
selected = currentRoute == Destinations.CAPTURE,
|
||||
onClick = { navigateTo(navController, Destinations.CAPTURE); scope.launch { drawerState.close() } },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.nav_settings)) },
|
||||
selected = currentRoute == Destinations.SETTINGS,
|
||||
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.nav_log)) },
|
||||
selected = currentRoute == Destinations.LOG,
|
||||
onClick = { navigateTo(navController, Destinations.LOG); scope.launch { drawerState.close() } },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -82,15 +91,17 @@ fun FuelishApp() {
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = { ConnectionStatusBar() },
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Destinations.CAPTURE,
|
||||
startDestination = Destinations.OVERVIEW,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
) {
|
||||
composable(Destinations.CAPTURE) { CaptureScreen() }
|
||||
composable(Destinations.OVERVIEW) { OverviewScreen() }
|
||||
composable(Destinations.CAPTURE) { CaptureScreen() }
|
||||
composable(Destinations.SETTINGS) { ConfigScreen() }
|
||||
composable(Destinations.LOG) { LogScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package de.ladkau.fuelish.ui.capture
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -65,16 +67,32 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
||||
if (granted) launchCamera()
|
||||
}
|
||||
|
||||
val pickImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||
if (uri != null) {
|
||||
val file = copyPickedImageToCache(context, uri)
|
||||
if (file != null) viewModel.submit(file, uri)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
when (val state = uiState) {
|
||||
is CaptureUiState.Idle -> Button(
|
||||
onClick = {
|
||||
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (hasPermission) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.capture_take_photo))
|
||||
is CaptureUiState.Idle -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (hasPermission) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.capture_take_photo))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
pickImageLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.capture_choose_gallery))
|
||||
}
|
||||
}
|
||||
|
||||
is CaptureUiState.Uploading -> ProgressStage(stringResource(R.string.capture_stage_uploading), 0.2f)
|
||||
@@ -99,6 +117,22 @@ fun CaptureScreen(viewModel: CaptureViewModel = viewModel()) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Gallery picks come back as a content:// Uri, not a File - submit()
|
||||
* needs a real File for the multipart upload, so copy it into the same
|
||||
* cache dir the camera flow writes to. */
|
||||
private fun copyPickedImageToCache(context: Context, uri: Uri): File? {
|
||||
val dir = File(context.cacheDir, "captures").apply { mkdirs() }
|
||||
val file = File(dir, "picked-${System.currentTimeMillis()}.jpg")
|
||||
return try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: return null
|
||||
file
|
||||
} catch (e: java.io.IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressStage(label: String, progress: Float) {
|
||||
Column(
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.ladkau.fuelish.data.ExtractedReceiptDto
|
||||
import de.ladkau.fuelish.data.JobStatusResponse
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
import de.ladkau.fuelish.data.ReceiptRepository
|
||||
import de.ladkau.fuelish.log.AppLog
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -49,18 +50,25 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
private suspend fun pollUntilDone(baseUrl: String, jobId: String) {
|
||||
var lastStage: String? = null
|
||||
while (true) {
|
||||
val status = repository.pollJob(baseUrl, jobId)
|
||||
when (status.status) {
|
||||
JobStatusResponse.STATUS_DONE -> {
|
||||
AppLog.log("Job $jobId done")
|
||||
_uiState.value = CaptureUiState.Editing(status.result ?: ExtractedReceiptDto())
|
||||
return
|
||||
}
|
||||
JobStatusResponse.STATUS_ERROR -> {
|
||||
AppLog.log("Job $jobId failed: ${status.error}")
|
||||
_uiState.value = CaptureUiState.Error(status.error ?: "processing failed")
|
||||
return
|
||||
}
|
||||
else -> {
|
||||
if (status.status != lastStage) {
|
||||
AppLog.log("Job $jobId: ${status.status}")
|
||||
lastStage = status.status
|
||||
}
|
||||
_uiState.value = CaptureUiState.Processing(status.status)
|
||||
delay(POLL_INTERVAL_MILLIS)
|
||||
}
|
||||
@@ -84,6 +92,7 @@ class CaptureViewModel(application: Application) : AndroidViewModel(application)
|
||||
createdAtEpochMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
AppLog.log("Saved receipt (date=${draft.date}, total=${draft.totalPrice})")
|
||||
pendingImageUri = null
|
||||
_uiState.value = CaptureUiState.Idle
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.ladkau.fuelish.ui.log
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.ladkau.fuelish.R
|
||||
import de.ladkau.fuelish.log.AppLog
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun LogScreen() {
|
||||
val entries by AppLog.entries.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Button(
|
||||
onClick = { AppLog.clear() },
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.log_clear))
|
||||
}
|
||||
|
||||
if (entries.isEmpty()) {
|
||||
Text(stringResource(R.string.log_empty), modifier = Modifier.padding(16.dp))
|
||||
} else {
|
||||
// Newest first - the most recent event (usually what someone
|
||||
// opened this screen to check) is what you want without scrolling.
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(entries.asReversed()) { entry ->
|
||||
Text(
|
||||
text = formatLogEntry(entry),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
|
||||
private fun formatLogEntry(entry: AppLog.Entry): String =
|
||||
"${TIME_FORMAT.format(Date(entry.timestampMillis))} ${entry.message}"
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -15,6 +16,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -28,6 +30,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.ladkau.fuelish.R
|
||||
import de.ladkau.fuelish.data.Receipt
|
||||
|
||||
private val DELETE_COLUMN_WIDTH = 48.dp
|
||||
|
||||
@Composable
|
||||
fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
|
||||
val receipts by viewModel.receipts.collectAsState()
|
||||
@@ -48,6 +52,9 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
|
||||
}
|
||||
}
|
||||
|
||||
ReceiptTableHeader()
|
||||
HorizontalDivider()
|
||||
|
||||
if (receipts.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.overview_empty),
|
||||
@@ -65,20 +72,45 @@ fun OverviewScreen(viewModel: OverviewViewModel = viewModel()) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) {
|
||||
private fun ReceiptTableHeader() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(text = receipt.date.orEmpty())
|
||||
Text(text = listOfNotNull(receipt.petrolStation, receipt.totalPrice?.let { "%.2f €".format(it) }).joinToString(" · "))
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null)
|
||||
Text(
|
||||
text = stringResource(R.string.overview_column_date),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.overview_column_station),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.weight(2f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.overview_column_total),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Row(modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiptRow(receipt: Receipt, onDelete: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = receipt.date.orEmpty(), modifier = Modifier.weight(1f))
|
||||
Text(text = receipt.petrolStation.orEmpty(), modifier = Modifier.weight(2f))
|
||||
Text(text = receipt.totalPrice?.let { "%.2f €".format(it) }.orEmpty(), modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onDelete, modifier = Modifier.width(DELETE_COLUMN_WIDTH)) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.overview_delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.log.AppLog
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -24,7 +25,10 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun delete(receipt: Receipt) {
|
||||
viewModelScope.launch { repository.delete(receipt) }
|
||||
viewModelScope.launch {
|
||||
repository.delete(receipt)
|
||||
AppLog.log("Deleted receipt (date=${receipt.date}, total=${receipt.totalPrice})")
|
||||
}
|
||||
}
|
||||
|
||||
fun exportCsv(onReady: (Uri) -> Unit) = export("fuelish-receipts.csv", ::writeCsvRows, onReady)
|
||||
@@ -40,6 +44,7 @@ class OverviewViewModel(application: Application) : AndroidViewModel(application
|
||||
file.outputStream().use { write(receipts.value, it) }
|
||||
FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", file)
|
||||
}
|
||||
AppLog.log("Exported ${receipts.value.size} receipt(s) to $fileName")
|
||||
onReady(uri)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package de.ladkau.fuelish.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -20,6 +23,7 @@ import de.ladkau.fuelish.R
|
||||
fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
|
||||
val config by viewModel.config.collectAsState()
|
||||
val isDirty by viewModel.isDirty.collectAsState()
|
||||
val testResult by viewModel.testResult.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -31,12 +35,28 @@ fun ConfigScreen(viewModel: ConfigViewModel = viewModel()) {
|
||||
value = config.serverUrl,
|
||||
onValueChange = { viewModel.update { c -> c.copy(serverUrl = it) } },
|
||||
label = { Text(stringResource(R.string.settings_server_url)) },
|
||||
placeholder = { Text(stringResource(R.string.settings_server_url_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(onClick = { viewModel.save() }, enabled = isDirty) {
|
||||
Text(stringResource(R.string.capture_save))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = { viewModel.save() }, enabled = isDirty) {
|
||||
Text(stringResource(R.string.capture_save))
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.testConnection() }) {
|
||||
Text(stringResource(R.string.settings_test_connection))
|
||||
}
|
||||
}
|
||||
when (val result = testResult) {
|
||||
ConnectionTestResult.Testing -> Text(stringResource(R.string.settings_test_testing))
|
||||
ConnectionTestResult.Success -> Text(
|
||||
stringResource(R.string.settings_test_success),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
is ConnectionTestResult.Failure -> Text(
|
||||
stringResource(R.string.settings_test_failure, result.message),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ package de.ladkau.fuelish.ui.settings
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.ladkau.fuelish.R
|
||||
import de.ladkau.fuelish.data.ApiClient
|
||||
import de.ladkau.fuelish.data.AppConfig
|
||||
import de.ladkau.fuelish.data.ConfigRepository
|
||||
import de.ladkau.fuelish.data.HealthCheckResult
|
||||
import de.ladkau.fuelish.log.AppLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -14,8 +18,15 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface ConnectionTestResult {
|
||||
data object Testing : ConnectionTestResult
|
||||
data object Success : ConnectionTestResult
|
||||
data class Failure(val message: String) : ConnectionTestResult
|
||||
}
|
||||
|
||||
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val configRepository = ConfigRepository(application)
|
||||
private val api = ApiClient()
|
||||
|
||||
// A local editable draft, seeded once from the persisted config below -
|
||||
// deliberately not a live collection of configRepository.config, so
|
||||
@@ -29,16 +40,28 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
private val _testResult = MutableStateFlow<ConnectionTestResult?>(null)
|
||||
val testResult: StateFlow<ConnectionTestResult?> = _testResult.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val loaded = configRepository.config.first()
|
||||
_config.value = loaded
|
||||
// Prefill an editable example rather than leaving the field
|
||||
// blank with just a placeholder, so there's something to
|
||||
// select-and-overwrite instead of typing a URL from scratch.
|
||||
val seeded = if (loaded.serverUrl.isBlank()) {
|
||||
loaded.copy(serverUrl = getApplication<Application>().getString(R.string.settings_server_url_example))
|
||||
} else {
|
||||
loaded
|
||||
}
|
||||
_config.value = seeded
|
||||
_savedConfig.value = loaded
|
||||
}
|
||||
}
|
||||
|
||||
fun update(transform: (AppConfig) -> AppConfig) {
|
||||
_config.value = transform(_config.value)
|
||||
_testResult.value = null
|
||||
}
|
||||
|
||||
fun save() {
|
||||
@@ -48,4 +71,21 @@ class ConfigViewModel(application: Application) : AndroidViewModel(application)
|
||||
_savedConfig.value = toSave
|
||||
}
|
||||
}
|
||||
|
||||
fun testConnection() {
|
||||
val url = _config.value.serverUrl
|
||||
viewModelScope.launch {
|
||||
_testResult.value = ConnectionTestResult.Testing
|
||||
when (val result = api.checkHealth(url)) {
|
||||
is HealthCheckResult.Ok -> {
|
||||
AppLog.log("Test connection to $url succeeded")
|
||||
_testResult.value = ConnectionTestResult.Success
|
||||
}
|
||||
is HealthCheckResult.Failed -> {
|
||||
AppLog.log("Test connection to $url failed: ${result.message}")
|
||||
_testResult.value = ConnectionTestResult.Failure(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
<string name="nav_capture">Scan receipt</string>
|
||||
<string name="nav_overview">Receipts</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="nav_log">App log</string>
|
||||
|
||||
<string name="capture_take_photo">Take photo</string>
|
||||
<string name="capture_choose_gallery">Choose from gallery</string>
|
||||
<string name="capture_stage_uploading">Uploading…</string>
|
||||
<string name="capture_stage_preprocessing">Preprocessing image…</string>
|
||||
<string name="capture_stage_ocr">Reading text (OCR)…</string>
|
||||
@@ -23,11 +25,27 @@
|
||||
<string name="capture_field_discount">Discount</string>
|
||||
<string name="capture_field_total_price">Total price</string>
|
||||
|
||||
<string name="overview_empty">No receipts saved yet</string>
|
||||
<string name="overview_empty">No receipts have been scanned yet</string>
|
||||
<string name="overview_column_date">Date</string>
|
||||
<string name="overview_column_station">Station</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_done">Exported to %1$s</string>
|
||||
|
||||
<string name="settings_server_url">Server URL</string>
|
||||
<string name="settings_server_url_hint">http://192.168.1.10:8080</string>
|
||||
<string name="settings_server_url_example">http://192.168.1.10:8080</string>
|
||||
<string name="settings_test_connection">Test connection</string>
|
||||
<string name="settings_test_testing">Testing…</string>
|
||||
<string name="settings_test_success">Connected successfully</string>
|
||||
<string name="settings_test_failure">Could not reach server: %1$s</string>
|
||||
|
||||
<string name="status_connected">Connected to %1$s</string>
|
||||
<string name="status_disconnected">Cannot reach %1$s</string>
|
||||
<string name="status_not_configured">No server configured - see Settings</string>
|
||||
<string name="status_checking">Checking connection…</string>
|
||||
|
||||
<string name="log_clear">Clear log</string>
|
||||
<string name="log_empty">Nothing logged yet</string>
|
||||
</resources>
|
||||
|
||||
+27
-14
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the fuelish tools image (tooling-container/Dockerfile) locally, tagged with
|
||||
# tooling-container/VERSION and :latest, and pulls the other image the pipeline
|
||||
# needs (ollama/ollama, used by 0_run_ollama.sh) so all images are ready before
|
||||
# a pipeline run. Does not push - test it with the numbered pipeline scripts
|
||||
# first, then publish with ./upload-image.sh.
|
||||
# Builds every image this repo's pipeline/CI needs: the fuelish tools
|
||||
# image (tooling-container/Dockerfile) and the Android CI/release build
|
||||
# image (build-image/Dockerfile), each tagged from its own VERSION file,
|
||||
# plus pulls ollama/ollama (used by 0_run_ollama.sh). Does not push - test
|
||||
# the tools image with the numbered pipeline scripts and the
|
||||
# android-builder image with ./run-image.sh, then publish both with
|
||||
# ./upload-image.sh.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
@@ -14,26 +16,37 @@ fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
|| fail "docker not found in PATH"
|
||||
|
||||
IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
|
||||
TOOLS_IMAGE="${REGISTRY_IMAGE:-fuelish/tools}"
|
||||
ANDROID_IMAGE="${REGISTRY_ANDROID_IMAGE:-cr.ladkau.de/fuelish/android-builder}"
|
||||
if [ -f "$ROOT/registry.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/registry.env"
|
||||
IMAGE="${REGISTRY_IMAGE:-$IMAGE}"
|
||||
TOOLS_IMAGE="${REGISTRY_IMAGE:-$TOOLS_IMAGE}"
|
||||
ANDROID_IMAGE="${REGISTRY_ANDROID_IMAGE:-$ANDROID_IMAGE}"
|
||||
fi
|
||||
|
||||
VERSION="$(<"$ROOT/tooling-container/VERSION")"
|
||||
[ -n "$VERSION" ] || fail "tooling-container/VERSION is empty"
|
||||
TOOLS_VERSION="$(<"$ROOT/tooling-container/VERSION")"
|
||||
[ -n "$TOOLS_VERSION" ] || fail "tooling-container/VERSION is empty"
|
||||
|
||||
echo "== Building $IMAGE:$VERSION =="
|
||||
ANDROID_VERSION="$(<"$ROOT/build-image/VERSION")"
|
||||
[ -n "$ANDROID_VERSION" ] || fail "build-image/VERSION is empty"
|
||||
|
||||
echo "== Building $TOOLS_IMAGE:$TOOLS_VERSION =="
|
||||
docker build \
|
||||
-f "$ROOT/tooling-container/Dockerfile" \
|
||||
-t "$IMAGE:$VERSION" \
|
||||
-t "$IMAGE:latest" \
|
||||
-t "$TOOLS_IMAGE:$TOOLS_VERSION" \
|
||||
-t "$TOOLS_IMAGE:latest" \
|
||||
"$ROOT/tooling-container"
|
||||
|
||||
echo "== Building $ANDROID_IMAGE:$ANDROID_VERSION =="
|
||||
docker build \
|
||||
-t "$ANDROID_IMAGE:$ANDROID_VERSION" \
|
||||
-t "$ANDROID_IMAGE:latest" \
|
||||
"$ROOT/build-image"
|
||||
|
||||
echo "== Pulling ollama/ollama (used by 0_run_ollama.sh) =="
|
||||
docker pull ollama/ollama
|
||||
|
||||
echo "== Done =="
|
||||
echo "Image: $IMAGE:$VERSION (and :latest)"
|
||||
echo "Test it with the numbered pipeline scripts, then publish with ./upload-image.sh"
|
||||
echo "Images: $TOOLS_IMAGE:$TOOLS_VERSION, $ANDROID_IMAGE:$ANDROID_VERSION (both also :latest)"
|
||||
echo "Test the tools image with the numbered pipeline scripts, the android-builder image with ./run-image.sh, then publish both with ./upload-image.sh"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Build environment for the Android app (android/): JDK + Android SDK
|
||||
# cmdline-tools, pinned to the versions validated on the maintainer's dev
|
||||
# machine (matching android/app/build.gradle.kts's compileSdk). Used both
|
||||
# by .gitea/workflows/build.yml's CI container and by ../run-image.sh for
|
||||
# a local, CI-equivalent build. Rebuild with ../build-image.sh whenever a
|
||||
# version below changes.
|
||||
#
|
||||
# fuelish's Android app has no native/NDK code, so unlike a build image
|
||||
# that also needs an NDK/CMake toolchain, this is just a JDK + the SDK's
|
||||
# own cmdline-tools/platform/build-tools.
|
||||
#
|
||||
# Does NOT contain any secrets - registry/SFTP credentials are injected
|
||||
# at job runtime from Gitea Actions secrets, never baked into this image.
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ARG NODE_VERSION=24.16.0
|
||||
ARG ANDROID_CMDLINE_TOOLS_VERSION=9862592
|
||||
ARG ANDROID_PLATFORM_VERSION=36
|
||||
ARG ANDROID_BUILD_TOOLS_VERSION=36.0.0
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK (see
|
||||
# android/app/build.gradle.kts's compileOptions).
|
||||
# make: the root Makefile's `android` target (run by both run-image.sh
|
||||
# and .gitea/workflows/build.yml).
|
||||
# python3/python3-pil: android/scripts/gen_app_icons.py's Pillow
|
||||
# dependency (see android/app/build.gradle.kts's generateAppIcons
|
||||
# preBuild task) - apt's python3-pil is used instead of pip/venv since
|
||||
# nothing else in this image needs a Python environment.
|
||||
# git/curl/ca-certificates/unzip/xz-utils: checkout and SDK/Node tool
|
||||
# downloads (xz-utils: Node's release tarball below is .tar.xz).
|
||||
# openssh-client: the build workflow's `sftp` publish to dl.ladkau.de.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openjdk-17-jdk-headless make python3 python3-pil \
|
||||
git curl ca-certificates unzip xz-utils openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js: actions/checkout@v4 and actions/upload-artifact@v3 (used by
|
||||
# .gitea/workflows/build.yml) are both JS actions - Gitea's act_runner
|
||||
# execs them with whatever `node` is on this image's PATH rather than
|
||||
# injecting one itself (https://github.com/nektos/act/issues/107), and
|
||||
# this base image doesn't bring one.
|
||||
RUN curl -sSL -o /tmp/node.tar.xz \
|
||||
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
|
||||
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
|
||||
&& rm /tmp/node.tar.xz
|
||||
|
||||
# Android SDK: the cmdline-tools' own sdkmanager installs exactly the
|
||||
# platform/build-tools version android/app/build.gradle.kts pins -
|
||||
# resolved here so no `docker run` of this image needs network access to
|
||||
# install anything before `./gradlew assembleDebug` works. The Gradle
|
||||
# build itself still needs network access at build time (Gradle/AGP/
|
||||
# androidx dependency resolution against Google's Maven + Maven Central) -
|
||||
# android/gradlew resolves its own pinned Gradle version, no Gradle
|
||||
# binary is baked into this image.
|
||||
ENV ANDROID_HOME=/opt/android-sdk \
|
||||
ANDROID_SDK_ROOT=/opt/android-sdk
|
||||
ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}
|
||||
|
||||
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
|
||||
&& curl -sSL -o /tmp/cmdline-tools.zip \
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
|
||||
&& unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
|
||||
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
|
||||
&& rm /tmp/cmdline-tools.zip \
|
||||
&& yes | sdkmanager --licenses >/dev/null \
|
||||
&& sdkmanager --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-${ANDROID_PLATFORM_VERSION}" \
|
||||
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}"
|
||||
|
||||
WORKDIR /workspace
|
||||
@@ -0,0 +1 @@
|
||||
0.1.0
|
||||
+16
-6
@@ -1,9 +1,19 @@
|
||||
# Copy to registry.env (gitignored, never commit the real values) and fill
|
||||
# in your registry credentials. Used by build-image.sh, the numbered
|
||||
# pipeline scripts, and upload-image.sh. Only REGISTRY_IMAGE is needed for
|
||||
# local builds/runs; REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only
|
||||
# needed to push with upload-image.sh.
|
||||
REGISTRY=
|
||||
REGISTRY_IMAGE=fuelish/tools
|
||||
# in your registry credentials. Used by build-image.sh, upload-image.sh,
|
||||
# run-image.sh, and the numbered pipeline scripts.
|
||||
#
|
||||
# REGISTRY_IMAGE (the OCR pipeline's tools image, tooling-container/) and
|
||||
# REGISTRY_ANDROID_IMAGE (the Android CI/release build image,
|
||||
# build-image/) must both be fully-qualified with the registry host -
|
||||
# upload-image.sh does `docker push "$REGISTRY_IMAGE:..."` directly, so
|
||||
# an unqualified name (e.g. "fuelish/tools") silently resolves to Docker
|
||||
# Hub instead of $REGISTRY and fails to push there ("insufficient_scope:
|
||||
# authorization failed") even though `docker login "$REGISTRY"`
|
||||
# succeeded. Adjust the host below if your registry isn't cr.ladkau.de.
|
||||
# REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only needed to push with
|
||||
# upload-image.sh.
|
||||
REGISTRY=cr.ladkau.de
|
||||
REGISTRY_IMAGE=cr.ladkau.de/fuelish/tools
|
||||
REGISTRY_ANDROID_IMAGE=cr.ladkau.de/fuelish/android-builder
|
||||
REGISTRY_USER=
|
||||
REGISTRY_PASSWORD=
|
||||
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs `make android` inside the local android-builder image, mirroring
|
||||
# what the Gitea Actions build workflow does - useful for testing
|
||||
# build-image/ changes locally before pushing anything to a registry.
|
||||
#
|
||||
# Usage: ./run-image.sh [VERSION]
|
||||
# VERSION is passed through to `make android` (see the Makefile's
|
||||
# `android` target); omit it for its own git-tag-based default.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
ROOT="$(pwd)"
|
||||
|
||||
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
|| fail "docker not found in PATH"
|
||||
|
||||
IMAGE="${REGISTRY_ANDROID_IMAGE:-cr.ladkau.de/fuelish/android-builder}"
|
||||
if [ -f "$ROOT/registry.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/registry.env"
|
||||
IMAGE="${REGISTRY_ANDROID_IMAGE:-$IMAGE}"
|
||||
fi
|
||||
|
||||
IMAGE_TAG="$(<"$ROOT/build-image/VERSION")"
|
||||
[ -n "$IMAGE_TAG" ] || fail "build-image/VERSION is empty"
|
||||
IMAGE="$IMAGE:$IMAGE_TAG"
|
||||
|
||||
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||
|| fail "$IMAGE not found locally - run ./build-image.sh first"
|
||||
|
||||
VERSION="${1:-${VERSION:-}}"
|
||||
|
||||
# CI always starts from a fresh checkout, but this script bind-mounts the
|
||||
# live host repo - so build output left over from a previous local run
|
||||
# (dist/, android/build, the generated app icon/splash resources) would
|
||||
# make this run skip work a real fresh checkout wouldn't, silently hiding
|
||||
# bugs that only show up in CI. Wipe them first so every run exercises a
|
||||
# true from-scratch build, same as CI.
|
||||
echo "== Cleaning generated build artifacts for a fresh build =="
|
||||
rm -rf \
|
||||
"$ROOT/dist" "$ROOT/android/build" "$ROOT/android/app/build" \
|
||||
"$ROOT/android/app/src/main/res/drawable-nodpi" \
|
||||
"$ROOT/android/app/src/main/res"/mipmap-mdpi "$ROOT/android/app/src/main/res"/mipmap-hdpi \
|
||||
"$ROOT/android/app/src/main/res"/mipmap-xhdpi "$ROOT/android/app/src/main/res"/mipmap-xxhdpi \
|
||||
"$ROOT/android/app/src/main/res"/mipmap-xxxhdpi
|
||||
|
||||
echo "== Running make android inside $IMAGE =="
|
||||
# The container runs as root (needed for the SDK/Gradle caches baked into
|
||||
# the image), so anything it writes into this bind mount - dist/,
|
||||
# android/build, android/.gradle, etc. - would otherwise come back owned
|
||||
# by root, leaving the host repo unusable without sudo. Chown everything
|
||||
# back to the host user on exit, whether the build succeeds or fails.
|
||||
docker run --rm \
|
||||
-v "$ROOT:/workspace" \
|
||||
-w /workspace \
|
||||
-e VERSION="$VERSION" \
|
||||
-e HOST_UID="$(id -u)" \
|
||||
-e HOST_GID="$(id -g)" \
|
||||
"$IMAGE" \
|
||||
bash -c '
|
||||
git config --global --add safe.directory /workspace
|
||||
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
|
||||
make android
|
||||
'
|
||||
|
||||
echo "== Done - artifact in dist/ =="
|
||||
ls -la "$ROOT/dist"
|
||||
+23
-11
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pushes the fuelish tools image - already built locally with
|
||||
# ./build-image.sh, and ideally verified with the numbered pipeline
|
||||
# scripts - to a container registry, so the k3s cluster (or other
|
||||
# machines) can pull it without rebuilding it.
|
||||
# Pushes every image ./build-image.sh built locally - the fuelish tools
|
||||
# image and the Android CI/release build image, each already verified
|
||||
# (tools image with the numbered pipeline scripts, android-builder image
|
||||
# with ./run-image.sh) - to a container registry, so the k3s cluster can
|
||||
# pull the tools image and Gitea Actions can pull the android-builder
|
||||
# image without rebuilding either.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
@@ -21,21 +23,31 @@ source "$ROOT/registry.env"
|
||||
|
||||
: "${REGISTRY:?registry.env must set REGISTRY}"
|
||||
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
|
||||
: "${REGISTRY_ANDROID_IMAGE:?registry.env must set REGISTRY_ANDROID_IMAGE}"
|
||||
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
|
||||
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
|
||||
|
||||
VERSION="$(<"$ROOT/tooling-container/VERSION")"
|
||||
[ -n "$VERSION" ] || fail "tooling-container/VERSION is empty"
|
||||
TOOLS_VERSION="$(<"$ROOT/tooling-container/VERSION")"
|
||||
[ -n "$TOOLS_VERSION" ] || fail "tooling-container/VERSION is empty"
|
||||
|
||||
docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|
||||
|| fail "$REGISTRY_IMAGE:$VERSION not found locally - run ./build-image.sh first"
|
||||
ANDROID_VERSION="$(<"$ROOT/build-image/VERSION")"
|
||||
[ -n "$ANDROID_VERSION" ] || fail "build-image/VERSION is empty"
|
||||
|
||||
docker image inspect "$REGISTRY_IMAGE:$TOOLS_VERSION" >/dev/null 2>&1 \
|
||||
|| fail "$REGISTRY_IMAGE:$TOOLS_VERSION not found locally - run ./build-image.sh first"
|
||||
docker image inspect "$REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION" >/dev/null 2>&1 \
|
||||
|| fail "$REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION not found locally - run ./build-image.sh first"
|
||||
|
||||
echo "== Logging in to $REGISTRY =="
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||
|
||||
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest =="
|
||||
docker push "$REGISTRY_IMAGE:$VERSION"
|
||||
echo "== Pushing $REGISTRY_IMAGE:$TOOLS_VERSION and :latest =="
|
||||
docker push "$REGISTRY_IMAGE:$TOOLS_VERSION"
|
||||
docker push "$REGISTRY_IMAGE:latest"
|
||||
|
||||
echo "== Pushing $REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION and :latest =="
|
||||
docker push "$REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION"
|
||||
docker push "$REGISTRY_ANDROID_IMAGE:latest"
|
||||
|
||||
echo "== Done =="
|
||||
echo "Image: $REGISTRY_IMAGE:$VERSION"
|
||||
echo "Images: $REGISTRY_IMAGE:$TOOLS_VERSION, $REGISTRY_ANDROID_IMAGE:$ANDROID_VERSION"
|
||||
|
||||
Reference in New Issue
Block a user