diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index c054629..16baa49 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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: # Create a release via the Forgejo UI on main with a v* tag name. @@ -11,19 +12,39 @@ # commands directly instead. # # 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 on: push: + branches: [dev, main] 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: analyze: name: Analyze & test - runs-on: py3.12-node22 + runs-on: ci-runner 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: - name: Checkout run: | @@ -42,9 +63,14 @@ jobs: build: name: Build release APK 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: - image: ghcr.io/cirruslabs/flutter:stable + image: ghcr.io/cirruslabs/flutter:3.41.6 + permissions: + contents: write steps: - name: Checkout run: | @@ -74,57 +100,12 @@ jobs: --build-name="$BUILD_NAME" \ --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 + # 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: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} TAG: ${{ github.ref_name }} - API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp - run: | - # 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" + APK: build/app/outputs/flutter-apk/app-release.apk + run: bash scripts/publish_apk_release.sh diff --git a/lib/data/api/voice_api.dart b/lib/data/api/voice_api.dart index 31d4eac..6f57378 100644 --- a/lib/data/api/voice_api.dart +++ b/lib/data/api/voice_api.dart @@ -15,8 +15,9 @@ class VoiceStatus { required this.tts, }); - /// True only when voice is enabled AND both STT and TTS are ready. - bool get fullyAvailable => enabled && stt && tts; + /// True when voice is enabled and at least STT is ready. + /// TTS is optional — voice mode works without it (STT-only). + bool get fullyAvailable => enabled && stt; factory VoiceStatus.fromJson(Map json) => VoiceStatus( enabled: json['enabled'] as bool? ?? false, diff --git a/lib/providers/knowledge_provider.dart b/lib/providers/knowledge_provider.dart index 86a4e26..a5b8404 100644 --- a/lib/providers/knowledge_provider.dart +++ b/lib/providers/knowledge_provider.dart @@ -246,6 +246,9 @@ class KnowledgeNotifier extends Notifier { isLoadingIds: false, 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 (_) { state = state.copyWith(isLoadingIds: false); } diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 26e9d64..5e55ad5 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -112,6 +112,9 @@ class VoiceNotifier extends Notifier { // to reduce STT mishearings of domain-specific words. String _lastAssistantContent = ''; + // Empty transcript counter — show feedback after consecutive blanks + int _emptyTranscriptCount = 0; + // TTS playback queue final _ttsQueue = Queue(); bool _ttsPlaying = false; @@ -120,7 +123,6 @@ class VoiceNotifier extends Notifier { @override VoiceState build() { - _recorder = AudioRecorder(); _player = AudioPlayer(); ref.onDispose(() { _amplitudeSubscription?.cancel(); @@ -146,32 +148,38 @@ class VoiceNotifier extends Notifier { return; } - // Check server availability + // Check server availability — STT is required, TTS is optional. try { final status = await ref.read(voiceRepositoryProvider).checkStatus(); - if (!status.fullyAvailable) { - onError('Voice not available on this server'); + if (!status.enabled || !status.stt) { + onError('Speech-to-text not available on this server'); return; } + // Downgrade to STT-only when TTS is unavailable + if (!status.tts) enableTts = false; } catch (_) { - onError('Voice not available on this server'); + onError('Could not reach voice service'); return; } // Check microphone permission - final permStatus = await Permission.microphone.request(); - if (permStatus == PermissionStatus.denied || - permStatus == PermissionStatus.permanentlyDenied) { + var permStatus = await Permission.microphone.request(); + if (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'); - if (permStatus == PermissionStatus.permanentlyDenied) { - await openAppSettings(); - } return; } _onTranscript = onTranscript; _onError = onError; _enableTts = enableTts; + _emptyTranscriptCount = 0; _tempDir = await getTemporaryDirectory(); state = state.copyWith(voiceModeActive: true, available: true); @@ -225,12 +233,22 @@ class VoiceNotifier extends Notifier { state = state.copyWith(mode: VoiceMode.recording); 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 = '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a'; 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( const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), path: path, @@ -241,7 +259,7 @@ class VoiceNotifier extends Notifier { .onAmplitudeChanged(const Duration(milliseconds: 200)) .listen(_onAmplitude); } catch (e) { - _onError?.call('Microphone error: could not start recording'); + _onError?.call('Microphone error: $e'); exitVoiceMode(); } } @@ -291,10 +309,16 @@ class VoiceNotifier extends Notifier { if (!state.voiceModeActive) return; 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(); return; } + _emptyTranscriptCount = 0; // Reset TTS state for this new turn _sentenceBuffer = ''; diff --git a/scripts/publish_apk_release.sh b/scripts/publish_apk_release.sh new file mode 100755 index 0000000..aebef6a --- /dev/null +++ b/scripts/publish_apk_release.sh @@ -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": 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"