Merge pull request 'Release v26.04.13.1' (#19) from dev into main

This commit was merged in pull request #19.
This commit is contained in:
2026-04-13 05:13:44 +00:00
5 changed files with 155 additions and 74 deletions
+38 -57
View File
@@ -1,6 +1,7 @@
# CI runs only on release tags. # CI runs first; build only proceeds if all checks pass.
# #
# Tag v*: analyze + test → build APK → attach to Forgejo Release # Push to dev or main: flutter analyze + flutter test
# Tag v* (release): gates + signed APK build + attach to Forgejo Release
# #
# To cut a release: # To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name. # Create a release via the Forgejo UI on main with a v* tag name.
@@ -11,19 +12,39 @@
# commands directly instead. # commands directly instead.
# #
# Required secrets (repo → Settings → Secrets → Actions): # Required secrets (repo → Settings → Secrets → Actions):
# RELEASE_TOKEN — Forgejo PAT with write:repository scope # RELEASE_TOKEN — Forgejo PAT with write:repository scope
# RELEASE_KEYSTORE_BASE64 — base64 of the signing keystore
# RELEASE_KEYSTORE_PASSWORD — keystore + key password
# RELEASE_KEY_ALIAS — key alias within the keystore
name: CI & Build name: CI & Build
on: on:
push: push:
branches: [dev, main]
tags: ["v*"] tags: ["v*"]
# Cancel older runs on the same branch when a newer push lands. Tag runs
# are never cancelled so a release build can't kill itself mid-flight.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
# Least-privilege default. The build job upgrades to contents: write
# so it can attach the APK to a Forgejo release.
permissions:
contents: read
jobs: jobs:
analyze: analyze:
name: Analyze & test name: Analyze & test
runs-on: py3.12-node22 runs-on: ci-runner
container: container:
image: ghcr.io/cirruslabs/flutter:stable # Pinned to a specific Flutter version for reproducible builds.
# Floating :stable means a random Flutter minor bump could change
# analyzer output or break the build without any commit landing.
# Bump this line (and verify locally with `flutter --version`)
# when you intentionally want a newer Flutter.
image: ghcr.io/cirruslabs/flutter:3.41.6
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
@@ -42,9 +63,14 @@ jobs:
build: build:
name: Build release APK name: Build release APK
needs: [analyze] needs: [analyze]
runs-on: py3.12-node22 # Only tag pushes produce a signed release build. dev/main pushes
# run the gates above and stop there.
if: startsWith(github.ref, 'refs/tags/')
runs-on: ci-runner
container: container:
image: ghcr.io/cirruslabs/flutter:stable image: ghcr.io/cirruslabs/flutter:3.41.6
permissions:
contents: write
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
@@ -74,57 +100,12 @@ jobs:
--build-name="$BUILD_NAME" \ --build-name="$BUILD_NAME" \
--build-number="$BUILD_NUMBER" --build-number="$BUILD_NUMBER"
- name: Set artifact name
id: artifact
run: |
echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Upload artifact to Forgejo
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
APK: build/app/outputs/flutter-apk/app-release.apk
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
ARTIFACT_NAME: ${{ steps.artifact.outputs.name }}
run: |
# Upload the APK as a workflow artifact via the Forgejo API.
curl -s -X POST "$API/actions/artifacts" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "name=$ARTIFACT_NAME" \
-F "file=@$APK" || echo "Artifact upload skipped (API may not support this endpoint)."
- name: Publish Forgejo release - name: Publish Forgejo release
# Release-publish logic lives in a shell script so it's
# testable locally (bash -x scripts/publish_apk_release.sh
# with env vars set) instead of trapped in YAML.
env: env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp APK: build/app/outputs/flutter-apk/app-release.apk
run: | run: bash scripts/publish_apk_release.sh
# Look for an existing release (created via the UI or a prior run).
EXISTING=$(curl -s \
"$API/releases/tags/$TAG" \
-H "Authorization: token $RELEASE_TOKEN")
RELEASE_ID=$(echo "$EXISTING" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -n "$RELEASE_ID" ]; then
echo "Found existing release $TAG (id $RELEASE_ID), attaching APK..."
else
echo "No existing release found, creating $TAG..."
RESPONSE=$(curl -s -X POST "$API/releases" \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}")
RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release. API response:"
echo "$RESPONSE"
exit 1
fi
echo "Release created with id $RELEASE_ID."
fi
curl -s -X POST "$API/releases/$RELEASE_ID/assets" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk"
echo "Done — $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
+3 -2
View File
@@ -15,8 +15,9 @@ class VoiceStatus {
required this.tts, required this.tts,
}); });
/// True only when voice is enabled AND both STT and TTS are ready. /// True when voice is enabled and at least STT is ready.
bool get fullyAvailable => enabled && stt && tts; /// TTS is optional — voice mode works without it (STT-only).
bool get fullyAvailable => enabled && stt;
factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus( factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus(
enabled: json['enabled'] as bool? ?? false, enabled: json['enabled'] as bool? ?? false,
+3
View File
@@ -246,6 +246,9 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
isLoadingIds: false, isLoadingIds: false,
hasMore: combined.length < total, hasMore: combined.length < total,
); );
// Hydrate the newly fetched IDs immediately — the user is
// already at the scroll bottom so _onScroll won't re-fire.
await hydrateNext();
} catch (_) { } catch (_) {
state = state.copyWith(isLoadingIds: false); state = state.copyWith(isLoadingIds: false);
} }
+39 -15
View File
@@ -112,6 +112,9 @@ class VoiceNotifier extends Notifier<VoiceState> {
// to reduce STT mishearings of domain-specific words. // to reduce STT mishearings of domain-specific words.
String _lastAssistantContent = ''; String _lastAssistantContent = '';
// Empty transcript counter — show feedback after consecutive blanks
int _emptyTranscriptCount = 0;
// TTS playback queue // TTS playback queue
final _ttsQueue = Queue<Uint8List>(); final _ttsQueue = Queue<Uint8List>();
bool _ttsPlaying = false; bool _ttsPlaying = false;
@@ -120,7 +123,6 @@ class VoiceNotifier extends Notifier<VoiceState> {
@override @override
VoiceState build() { VoiceState build() {
_recorder = AudioRecorder();
_player = AudioPlayer(); _player = AudioPlayer();
ref.onDispose(() { ref.onDispose(() {
_amplitudeSubscription?.cancel(); _amplitudeSubscription?.cancel();
@@ -146,32 +148,38 @@ class VoiceNotifier extends Notifier<VoiceState> {
return; return;
} }
// Check server availability // Check server availability — STT is required, TTS is optional.
try { try {
final status = await ref.read(voiceRepositoryProvider).checkStatus(); final status = await ref.read(voiceRepositoryProvider).checkStatus();
if (!status.fullyAvailable) { if (!status.enabled || !status.stt) {
onError('Voice not available on this server'); onError('Speech-to-text not available on this server');
return; return;
} }
// Downgrade to STT-only when TTS is unavailable
if (!status.tts) enableTts = false;
} catch (_) { } catch (_) {
onError('Voice not available on this server'); onError('Could not reach voice service');
return; return;
} }
// Check microphone permission // Check microphone permission
final permStatus = await Permission.microphone.request(); var permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.denied || if (permStatus == PermissionStatus.permanentlyDenied) {
permStatus == PermissionStatus.permanentlyDenied) { onError('Microphone blocked — opening settings');
final opened = await openAppSettings();
if (!opened) return;
// Re-check after user returns from settings
permStatus = await Permission.microphone.status;
}
if (!permStatus.isGranted) {
onError('Microphone permission required'); onError('Microphone permission required');
if (permStatus == PermissionStatus.permanentlyDenied) {
await openAppSettings();
}
return; return;
} }
_onTranscript = onTranscript; _onTranscript = onTranscript;
_onError = onError; _onError = onError;
_enableTts = enableTts; _enableTts = enableTts;
_emptyTranscriptCount = 0;
_tempDir = await getTemporaryDirectory(); _tempDir = await getTemporaryDirectory();
state = state.copyWith(voiceModeActive: true, available: true); state = state.copyWith(voiceModeActive: true, available: true);
@@ -225,12 +233,22 @@ class VoiceNotifier extends Notifier<VoiceState> {
state = state.copyWith(mode: VoiceMode.recording); state = state.copyWith(mode: VoiceMode.recording);
final dir = _tempDir ?? await getTemporaryDirectory(); final dir = _tempDir ?? await getTemporaryDirectory();
// AAC/M4A is reliably supported on Android (unlike WebM/Opus which can
// produce OGG bytes in a .webm file, confusing server-side decoders).
final path = final path =
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a'; '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
try { try {
// Recreate the recorder each session — the record package can leave
// the native AudioRecord in a bad state after stop/error cycles,
// and a stale instance is the most common cause of "could not start".
_recorder?.dispose();
_recorder = AudioRecorder();
if (!await _recorder!.hasPermission()) {
_onError?.call('Microphone permission was revoked');
exitVoiceMode();
return;
}
await _recorder!.start( await _recorder!.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
path: path, path: path,
@@ -241,7 +259,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
.onAmplitudeChanged(const Duration(milliseconds: 200)) .onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude); .listen(_onAmplitude);
} catch (e) { } catch (e) {
_onError?.call('Microphone error: could not start recording'); _onError?.call('Microphone error: $e');
exitVoiceMode(); exitVoiceMode();
} }
} }
@@ -291,10 +309,16 @@ class VoiceNotifier extends Notifier<VoiceState> {
if (!state.voiceModeActive) return; if (!state.voiceModeActive) return;
if (transcript.isEmpty) { if (transcript.isEmpty) {
// Empty transcript — restart silently _emptyTranscriptCount++;
if (_emptyTranscriptCount >= 3) {
_onError?.call('No speech detected — tap the mic to try again');
exitVoiceMode();
return;
}
await _startListening(); await _startListening();
return; return;
} }
_emptyTranscriptCount = 0;
// Reset TTS state for this new turn // Reset TTS state for this new turn
_sentenceBuffer = ''; _sentenceBuffer = '';
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# Publish a signed release APK to Forgejo.
#
# Finds the release matching $TAG (creating it if the CI job got here
# before the UI did), then attaches the APK as a release asset.
#
# Required env:
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# TAG — release tag (e.g. v26.04.11)
# APK — path to the built APK
#
# Optional env:
# FORGEJO_API — defaults to the FabledApp repo API root
#
# Exits non-zero if the release can't be created or the asset upload
# fails. Designed to be testable locally:
# RELEASE_TOKEN=... TAG=v0.0.1 APK=/tmp/test.apk bash -x scripts/publish_apk_release.sh
set -euo pipefail
: "${RELEASE_TOKEN:?RELEASE_TOKEN not set}"
: "${TAG:?TAG not set}"
: "${APK:?APK not set}"
if [ ! -f "$APK" ]; then
echo "APK not found at $APK" >&2
exit 1
fi
API="${FORGEJO_API:-https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp}"
# jq isn't in the cirruslabs Flutter image, so we parse JSON with grep.
# Fragile but bounded: we only care about the first "id": <n> field,
# which is always the release id in Forgejo's responses for these
# endpoints. If Forgejo ever adds a preceding id field, revisit.
extract_id() {
echo "$1" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+'
}
# Look for an existing release (created via the UI or a prior run).
# curl -f turns 4xx/5xx into non-zero so we can distinguish "not found"
# (no release yet) from "auth broken" (real failure).
existing=$(curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
"$API/releases/tags/$TAG" 2>/dev/null || true)
release_id=$(extract_id "$existing")
if [ -z "$release_id" ]; then
echo "No existing release for $TAG — creating one..."
response=$(curl -fsS -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}" \
"$API/releases")
release_id=$(extract_id "$response")
if [ -z "$release_id" ]; then
echo "Failed to create release. API response:" >&2
echo "$response" >&2
exit 1
fi
echo "Created release $TAG (id=$release_id)."
else
echo "Found existing release $TAG (id=$release_id). Attaching APK..."
fi
curl -fsS -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@$APK" \
"$API/releases/$release_id/assets" > /dev/null
echo "Done — $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"