6 Commits
Author SHA1 Message Date
florian ea5e5592a8 Fixed a Build Issue
Build Android Apk / android (release) Successful in 11m6s
Build Linux Application / linux (release) Successful in 4m41s
Publish Docker Image / docker (release) Successful in 13m46s
2026-09-07 12:49:02 +02:00
florian d87eabfd2a Chaning API Connection to Prod Servcice
Build Android Apk / android (release) Failing after 4m48s
Build Linux Application / linux (release) Failing after 4m32s
Publish Docker Image / docker (release) Successful in 13m52s
2026-09-07 12:15:13 +02:00
florian 6c9c650b68 Adding support for Obtainium with fixed Signing Keys and apk Release publishing 2026-09-07 11:31:57 +02:00
florian 5ec71f6ae8 Lowering time between API Refreshs 2026-09-04 22:17:10 +02:00
florian 2566371ce9 Fixed issues with Build files for Android and Linux
Build Android Apk / android (release) Successful in 11m25s
Build Linux Application / linux (release) Successful in 4m40s
Publish Docker Image / docker (release) Successful in 14m10s
2026-09-04 21:38:49 +02:00
florian 2751bd8b4c Fixed issues with Build files for Android and Linux
Build Android Apk / android (release) Failing after 11m8s
Build Linux Application / linux (release) Failing after 4m49s
Publish Docker Image / docker (release) Successful in 13m55s
2026-09-01 15:24:41 +02:00
7 changed files with 247 additions and 37 deletions
+126 -14
View File
@@ -29,11 +29,18 @@ jobs:
- name: Checkout
uses: https://github.com/actions/checkout@v4
# The flutter tool runs git inside the workspace, which the container
# user does not own after the checkout.
# The flutter tool runs git inside the workspace and inside its own sdk,
# and git refuses to work in directories it considers to belong to
# somebody else.
- name: Trust the workspace
run: git config --global --add safe.directory '*'
# Everything the flutter download below needs.
- name: Install the download tools
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl xz-utils jq
- name: Set up Java
uses: https://github.com/actions/setup-java@v4
with:
@@ -47,12 +54,19 @@ jobs:
- name: Set up the Android sdk
uses: https://github.com/android-actions/setup-android@v3
# `subosito/flutter-action` cannot be used: it caches the sdk through
# `actions/cache@v5`, and the act-runner of this gitea instance only
# supports actions up to node20 ("runs.using ... got node24"). Unpacking
# the official archive does the same thing and pins the version exactly,
# just like `ac_web/Dockerfile` does.
- name: Set up Flutter
uses: https://github.com/subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
run: |
curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz" \
| tar -xJ -C /opt
echo "/opt/flutter/bin" >> "$GITHUB_PATH"
- name: Show the flutter version
run: flutter --version
- name: Install dependencies
run: flutter pub get
@@ -60,24 +74,122 @@ jobs:
- name: Run tests
run: flutter test
# Android accepts an update only when it is signed with the same key as
# the installed app, so every release has to carry one key that never
# changes. The debug key cannot be used: the runner generates a fresh one
# on every run, which would make each apk a different app to android and
# break every update through Obtainium.
#
# `android/app/build.gradle.kts` picks the key up from
# `android/key.properties`, which is written here from the repository
# secrets (Settings -> Actions -> Secrets). See CLAUDE.md for the
# `keytool` command that creates the keystore.
- name: Write the release signing key
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
# The act-runner executes `run` with sh (dash), not bash, so this
# script has to stay posix - `set -o pipefail` for example dies
# with "Illegal option -o pipefail" before anything else runs.
set -eu
# Failing here is on purpose: a silently debug signed apk would be
# installable once and never updatable afterwards.
if [ -z "${KEYSTORE_BASE64:-}" ]; then
echo "::error::The secret ANDROID_KEYSTORE_BASE64 is empty. Add the keystore secrets (see CLAUDE.md) before publishing a release."
exit 1
fi
printf '%s' "$KEYSTORE_BASE64" | base64 -d > android/app/release-keystore.jks
# storeFile is relative to `android/app`. The file is gitignored.
cat > android/key.properties <<PROPERTIES
storeFile=release-keystore.jks
storePassword=$KEYSTORE_PASSWORD
keyAlias=$KEY_ALIAS
keyPassword=$KEY_PASSWORD
PROPERTIES
# One apk for all abis. `--split-per-abi` would produce three smaller
# ones instead, at the cost of having to pick the right one manually.
#
# The release build is signed with a debug key, because
# `android/app/build.gradle.kts` has no release signing config yet. To
# ship the apk, add a keystore as a secret, write it into
# `android/key.properties` here and reference it from the gradle file.
# The version in the pubspec (`1.0.0+1`) never moves, so it is replaced
# here: the version name becomes the tag of the release and the version
# code the run number, which only ever grows. Android refuses to install
# an apk whose version code is lower than the installed one, so the code
# must never go backwards.
- name: Build the apk
run: flutter build apk --release
run: |
set -eu
version="${{ github.ref_name }}"
version="${version#v}"
# `--build-name` only accepts a dotted number, so an unusual tag
# ("nightly") keeps the version of the pubspec instead of failing
# the build.
case "$version" in
*[!0-9.]* | '' ) buildName='' ;;
* ) buildName="$version" ;;
esac
if [ -n "$buildName" ]; then
flutter build apk --release \
--build-name "$buildName" \
--build-number "${{ github.run_number }}"
else
echo "::warning::'${{ github.ref_name }}' is not a dotted version, keeping the version of the pubspec."
flutter build apk --release --build-number "${{ github.run_number }}"
fi
# Gradle always writes the same file name, so the release the apk belongs
# to would not be visible after downloading it.
- name: Name the apk after the release
run: mv build/app/outputs/flutter-apk/app-release.apk ac_web-${{ github.ref_name }}.apk
# Artifacts of v4 need gitea 1.22 or newer, older instances need @v3.
# Obtainium reads `<host>/api/v1/repos/<owner>/<repo>/releases` and
# downloads the *assets* of a release, so the apk has to hang off the
# release itself. A workflow artifact is invisible to it: it lives under
# the run, is zipped and needs a login.
- name: Attach the apk to the release
env:
# The token gitea creates for the run. If the instance hands out a
# read only token, put a personal access token with repository write
# access in a secret and use that instead.
TOKEN: ${{ github.token }}
run: |
set -eu
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
asset="ac_web-${{ github.ref_name }}.apk"
# The release event carries the id; the lookup by tag is only there
# for a manual re-run of the job.
releaseId="${{ github.event.release.id }}"
if [ -z "$releaseId" ] || [ "$releaseId" = "null" ]; then
release=$(curl -fsSL -H "Authorization: token $TOKEN" \
"$api/releases/tags/${{ github.ref_name }}")
releaseId=$(printf '%s' "$release" | jq -r '.id')
fi
curl -fsSL -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@$asset" \
"$api/releases/$releaseId/assets?name=$asset" > /dev/null
echo "Attached $asset to release $releaseId."
# @v3 on purpose: `upload-artifact@v4` bundles `@actions/artifact` v2,
# which refuses to run as soon as `GITHUB_SERVER_URL` is not github.com
# ("GHESNotSupportedError") -- and on this instance it is the gitea host.
# The check happens in the action itself, before any request, so it
# cannot be fixed on the gitea side. @v3 talks to the older artifact api
# gitea implements and still runs on node20, which the act-runner allows.
- name: Upload the apk
uses: https://github.com/actions/upload-artifact@v4
uses: https://github.com/actions/upload-artifact@v3
with:
name: ac_web-android-${{ github.ref_name }}
# Artifact paths are relative to the repository, not to the
+57 -10
View File
@@ -28,8 +28,9 @@ jobs:
- name: Checkout
uses: https://github.com/actions/checkout@v4
# The flutter tool runs git inside the workspace, which the container
# user does not own after the checkout.
# The flutter tool runs git inside the workspace and inside its own sdk,
# and git refuses to work in directories it considers to belong to
# somebody else.
- name: Trust the workspace
run: git config --global --add safe.directory '*'
@@ -40,14 +41,21 @@ jobs:
apt-get install -y --no-install-recommends \
clang cmake ninja-build pkg-config \
libgtk-3-dev liblzma-dev libstdc++-12-dev \
curl unzip xz-utils
curl unzip xz-utils jq
# `subosito/flutter-action` cannot be used: it caches the sdk through
# `actions/cache@v5`, and the act-runner of this gitea instance only
# supports actions up to node20 ("runs.using ... got node24"). Unpacking
# the official archive does the same thing and pins the version exactly,
# just like `ac_web/Dockerfile` does.
- name: Set up Flutter
uses: https://github.com/subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
run: |
curl -fsSL "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz" \
| tar -xJ -C /opt
echo "/opt/flutter/bin" >> "$GITHUB_PATH"
- name: Show the flutter version
run: flutter --version
- name: Install dependencies
run: flutter pub get
@@ -64,9 +72,48 @@ jobs:
- name: Pack the bundle
run: tar -czf ac_web-linux-x64-${{ github.ref_name }}.tar.gz -C build/linux/x64/release bundle
# Artifacts of v4 need gitea 1.22 or newer, older instances need @v3.
# The tarball hangs off the release itself, next to the apk of the
# android job, so it can be downloaded without a login and stays after
# the artifacts of the run have been pruned.
- name: Attach the bundle to the release
env:
# The token gitea creates for the run. If the instance hands out a
# read only token, put a personal access token with repository write
# access in a secret and use that instead.
TOKEN: ${{ github.token }}
run: |
# The act-runner executes `run` with sh (dash), not bash, so this
# script has to stay posix - `set -o pipefail` for example dies
# with "Illegal option -o pipefail" before anything else runs.
set -eu
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
asset="ac_web-linux-x64-${{ github.ref_name }}.tar.gz"
# The release event carries the id; the lookup by tag is only there
# for a manual re-run of the job.
releaseId="${{ github.event.release.id }}"
if [ -z "$releaseId" ] || [ "$releaseId" = "null" ]; then
release=$(curl -fsSL -H "Authorization: token $TOKEN" \
"$api/releases/tags/${{ github.ref_name }}")
releaseId=$(printf '%s' "$release" | jq -r '.id')
fi
curl -fsSL -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@$asset" \
"$api/releases/$releaseId/assets?name=$asset" > /dev/null
echo "Attached $asset to release $releaseId."
# @v3 on purpose: `upload-artifact@v4` bundles `@actions/artifact` v2,
# which refuses to run as soon as `GITHUB_SERVER_URL` is not github.com
# ("GHESNotSupportedError") -- and on this instance it is the gitea host.
# The check happens in the action itself, before any request, so it
# cannot be fixed on the gitea side. @v3 talks to the older artifact api
# gitea implements and still runs on node20, which the act-runner allows.
- name: Upload the bundle
uses: https://github.com/actions/upload-artifact@v4
uses: https://github.com/actions/upload-artifact@v3
with:
name: ac_web-linux-x64-${{ github.ref_name }}
# Artifact paths are relative to the repository, not to the
+17 -4
View File
@@ -38,17 +38,30 @@ Widget tests run against a mocked backend: build a `MockClient` that answers by
| Workflow | Builds | Result |
| --- | --- | --- |
| `BuildLinux.yml` | `flutter build linux --release` | `ac_web-linux-x64-<tag>.tar.gz` (the whole bundle directory) as an artifact |
| `BuildAndroid.yml` | `flutter build apk --release` | `ac_web-<tag>.apk` as an artifact |
| `BuildLinux.yml` | `flutter build linux --release` | `ac_web-linux-x64-<tag>.tar.gz` (the whole bundle directory), attached to the release and uploaded as an artifact |
| `BuildAndroid.yml` | `flutter build apk --release` | `ac_web-<tag>.apk`, attached to the release and uploaded as an artifact |
| `PublishDocker.yml` | `ac_web/Dockerfile` | image pushed to the Gitea registry as `<tag>`, `latest` and the short sha |
Notes that are easy to get wrong:
- **The act-runner of the gitea instance only supports actions up to `node20`.** Anything newer fails with `The runs.using key in action.yml must be one of: [...], got node24`, which is why the Flutter sdk is unpacked from the official archive instead of through `subosito/flutter-action` (that one caches through `actions/cache@v5`). Check the `runs.using` of an action before adding it.
- The Flutter version is pinned in three places (`FLUTTER_VERSION` in both build workflows, `ARG FLUTTER_VERSION` in `ac_web/Dockerfile`) and must be kept in sync.
- Steps run with `working-directory: ac_web`, but `upload-artifact` paths are relative to the repository root.
- **The act-runner executes `run:` with sh (dash), not bash.** A script with `set -o pipefail` fails immediately with `Illegal option -o pipefail`, so the scripts stay posix (`set -eu`, no `[[ ]]`, no arrays) and pipelines are split where the exit code of the *first* command matters.
- **`actions/upload-artifact` must stay at `@v3`.** `@v4` bundles `@actions/artifact` v2, which aborts with `GHESNotSupportedError` whenever `GITHUB_SERVER_URL` is not `github.com` -- on this instance it is the gitea host, so the check fires before any request and cannot be worked around on the server side. `@v3` uses the older artifact api gitea implements.
- The workflows are read from the tag the release points at, so they have to be committed **before** the release is created.
- Artifacts and the built files carry the tag name (`github.ref_name`); the docker image is pushed as the tag, as `latest` and as the short sha.
- The apk is signed with a debug key, because `android/app/build.gradle.kts` still has no release signing config.
- **The built files are attached to the release, not only uploaded as artifacts.** Artifacts live under the workflow run, are zipped and need a login; a release asset is a plain download on the release page. The upload is a `curl` against `POST <server>/api/v1/repos/<owner>/<repo>/releases/<id>/assets?name=<file>` with `${{ github.token }}`, and the release id comes from the event (falling back to a lookup by tag for a manual re-run). Obtainium needs the apk exactly there, see below.
- **The apk must keep one signing key forever.** Android identifies an app by its application id *and* its certificate, so an update signed with a different key is rejected (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) and the app has to be uninstalled first. The debug key is regenerated on every runner, so it can never be used for a published apk. `android/app/build.gradle.kts` reads the key from `android/key.properties` (gitignored) and falls back to the debug key when that file is missing, so `flutter run --release` still works locally. The workflow writes the file from four secrets and fails loudly when the first one is empty: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Create the keystore once and keep a backup — losing it means every installed app has to be uninstalled before it can be updated again:
```bash
keytool -genkeypair -v -keystore ac_web-release.p12 -storetype PKCS12 \
-keyalg RSA -keysize 2048 -validity 10000 -alias ac-web
base64 -w0 ac_web-release.p12 # the value of ANDROID_KEYSTORE_BASE64
```
- **The apk version comes from the release, not from the pubspec.** `version: 1.0.0+1` never moves, so the workflow passes `--build-name <tag without a leading v>` and `--build-number ${{ github.run_number }}`; the run number only grows, and android refuses an apk whose version code is lower than the installed one. A tag that is not a dotted number keeps the pubspec version and only warns.
- **Obtainium** tracks the app through the Gitea api: add the repository url (`https://<host>/ArtificialCompany/AcWeb`) and pick `Codeberg (Forgejo)` under "Override Source" — self hosted instances are not detected automatically, but the source builds its api calls from the host in the url. It reads `/api/v1/repos/.../releases` and downloads the release assets, so the repository has to be readable without a login from the phone.
- The docker image is self contained: it compiles the web bundle itself, so `docker build -f ac_web/Dockerfile ac_web` gives the same result locally. It serves the static bundle with nginx (`ac_web/nginx.conf`), which falls back to `index.html` so deep links survive a reload. The backend address is compiled into the bundle, so the host in `main.dart` must be reachable from the browser of every visitor, not only from the container.
## Backend connection
@@ -79,7 +92,7 @@ Enums (`EAgentType`, `EAgentStatus`, `EReportType`) mirror .NET enums. **The dec
- `MainApp` picks the layout by width: `>= DesktopView.minimumWidth` (900) → `DesktopView` (three columns side by side: Reports | LatestReports | Agents), otherwise `MobileView` (same three pages behind a `NavigationBar`). Both layouts reuse the *same* page widgets, so pages must not assume they own the whole screen.
- **No state management package and no routing package.** Data loading is consistently: `StatefulWidget` → `late Future<T> mXFuture` assigned in `initState` from `AcApi.instance...` → `FutureBuilder` with explicit loading / `snapshot.hasError` / empty-list branches. Reload = reassign the future inside `setState`. Navigation is plain `Navigator.of(context).push(MaterialPageRoute(...))`.
- **Automatic refresh**: every widget that shows api data polls the backend (it has no push channel) through the `AutoRefreshState` mixin (`lib/Widgets/AutoRefreshState.dart`): mix it into the `State` and implement `RefreshData`, which fetches again and, only on success, assigns the result as an already completed future. The mixin owns the timer, guards against overlapping requests and swallows errors of a tick. The interval (30 s) lives once in `AutoRefreshState.refreshInterval`. Because a refresh must not flash the spinner or drop the list, the `FutureBuilder`s branch on `snapshot.hasData` / `hasError`, **not** on `connectionState`. A refresh the user did not ask for stays silent — no snackbars, no disabled buttons (see `AgentDetails._reloadAgent`).
- **Automatic refresh**: every widget that shows api data polls the backend (it has no push channel) through the `AutoRefreshState` mixin (`lib/Widgets/AutoRefreshState.dart`): mix it into the `State` and implement `RefreshData`, which fetches again and, only on success, assigns the result as an already completed future. The mixin owns the timer, guards against overlapping requests and swallows errors of a tick. The interval (15 s) lives once in `AutoRefreshState.refreshInterval`. Because a refresh must not flash the spinner or drop the list, the `FutureBuilder`s branch on `snapshot.hasData` / `hasError`, **not** on `connectionState`. A refresh the user did not ask for stays silent — no snackbars, no disabled buttons (see `AgentDetails._reloadAgent`).
- Icons per enum value live in extensions (`AgentTypeIcons`, `ReportTypeIcons` — `type.icon`) so every page renders a type identically. Human labels come from `displayName` on the enum itself.
- `MarkdownText` is a hand-written markdown renderer (no `flutter_markdown` dependency) covering the subset the reports use: headings, lists, block quotes, fenced code, rules, and inline bold/italic/strikethrough/code/links. Extend it rather than adding a package.
- Dependencies are intentionally minimal (`http`, `cupertino_icons`). Prefer solving things with Flutter's own widgets before adding a package.
+42 -4
View File
@@ -1,9 +1,31 @@
import java.util.Properties
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// Android identifies an app by its application id *and* the certificate it was
// signed with, so an update is only accepted when it carries the same
// signature as the installed app. Obtainium therefore needs every release to
// be signed with one key that never changes - the debug key does not work, it
// is generated per machine and a fresh one appears on every ci run.
//
// The key itself is described by `android/key.properties` (storeFile,
// storePassword, keyAlias, keyPassword). That file and the keystore are
// gitignored and never committed: a developer creates them once locally, the
// workflow writes them from the repository secrets. Without the file the
// release build falls back to the debug key so that `flutter run --release`
// keeps working on a machine that has no keystore.
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()
val keystoreProperties = Properties()
if (hasReleaseKeystore) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
android {
namespace = "com.fscheffers.ac_web"
compileSdk = flutter.compileSdkVersion
@@ -15,21 +37,37 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.fscheffers.ac_web"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
// Both come from the pubspec (`version: <name>+<code>`); the workflow
// overrides them with the release tag and the run number.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
// A relative storeFile is resolved against `android/app`, an
// absolute one is taken as it is.
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
+2 -2
View File
@@ -5,8 +5,8 @@
class ApiConfig {
const ApiConfig({
this.scheme = 'http',
this.host = 'localhost',
this.port = 5118,
this.host = '10.10.50.30',
this.port = 12781,
this.basePath = '',
this.timeout = const Duration(seconds: 15),
this.defaultHeaders = const {},
+2 -2
View File
@@ -15,13 +15,13 @@ import 'package:flutter/material.dart';
///
/// A refresh happens quietly: implementations are expected to keep the data
/// that is already on screen while the request runs, so that the user neither
/// sees a spinner nor loses the scroll position every half minute. Failures of
/// sees a spinner nor loses the scroll position every few seconds. Failures of
/// a refresh are swallowed on purpose - the data on screen simply stays as it
/// is and the next tick tries again.
mixin AutoRefreshState<TWidget extends StatefulWidget> on State<TWidget> {
/// Interval in which the api data of the whole app is refreshed.
static const Duration refreshInterval = Duration(seconds: 30);
static const Duration refreshInterval = Duration(seconds: 15);
Timer? _refreshTimer;
+1 -1
View File
@@ -6,7 +6,7 @@ void main() {
// Rest API of the backend. Plain http is fine, we only talk inside of a
// trusted network.
AcApi.configure(const ApiConfig(host: 'localhost', port: 5118));
AcApi.configure(const ApiConfig(host: '10.10.50.30', port: 12781));
runApp(const MainApp());
}