diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 4a968f3..49884d8 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -30,11 +30,44 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh + - name: Locate Docker daemon for local Dagger engine + run: | + # Skip if remote Dagger engine is already configured (preferred path) + if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then + echo "Remote Dagger engine configured, no local Docker needed." + exit 0 + fi + + # Try host Docker socket (DooD) if runner mounts it + if [ -S /var/run/docker.sock ]; then + if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then + echo "Docker available via host socket." + echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV" + exit 0 + fi + fi + + echo "WARNING: No remote Dagger engine and no local Docker found." >&2 + echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2 + echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2 + echo "CI will likely fail at the Dagger step." >&2 + + - name: Prune Dagger cache before check + env: + DAGGER_NO_NAG: "1" + run: dagger query '{ engine { localCache { prune } } }' 2>/dev/null || true + - name: Run Full Check Suite env: DAGGER_NO_NAG: "1" run: task check-dagger + - name: Prune Dagger cache after check + if: always() + env: + DAGGER_NO_NAG: "1" + run: dagger query '{ engine { localCache { prune } } }' 2>/dev/null || true + - name: Cleanup TLS credentials if: always() run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index faadde0..64cb704 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -31,6 +31,7 @@ jobs: run: scripts/setup_dagger_remote.sh - name: Run Android Tests on Firebase Test Lab + if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }} env: FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }} FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} @@ -66,6 +67,7 @@ jobs: run: scripts/setup_dagger_remote.sh - name: Publish Android to Play Store + if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }} env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} diff --git a/Taskfile.yml b/Taskfile.yml index 04f6959..f9d7a10 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -284,8 +284,13 @@ tasks: for attempt in 1 2 3; do run_dagger "$@" && return 0 RC=$? - if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused" "$DAGGER_OUT"; then + if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2 + elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then + echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2 + dagger query '{ engine { localCache { prune } } }' 2>/dev/null || true + echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2 + sleep 90 else return "$RC" fi diff --git a/lib/core/sync/background_sync.dart b/lib/core/sync/background_sync.dart index 5d17523..1189854 100644 --- a/lib/core/sync/background_sync.dart +++ b/lib/core/sync/background_sync.dart @@ -6,6 +6,7 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:enough_mail/enough_mail.dart' as imap; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -24,6 +25,9 @@ const _kResourceType = 'background_check'; @pragma('vm:entry-point') void callbackDispatcher() { + // Required so that path_provider and other plugins are available in this + // background isolate (issue #192). + WidgetsFlutterBinding.ensureInitialized(); Workmanager().executeTask((_, __) async { try { await _doBackgroundSync(); diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index efa20e7..18365a2 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -609,6 +609,17 @@ Future _resolveDatabasePath() async { await Future.delayed(Duration(milliseconds: ms)); } } + // On Android, path_provider can be permanently broken on some devices + // regardless of how long we wait (issue #192). Derive the path from + // /proc/self/cmdline (the Android process name == package name) without + // a platform channel as a last resort so the app can still open its DB. + if (Platform.isAndroid) { + final fallback = await _androidFallbackPath(); + if (fallback != null) { + _dbPath = fallback; + return _dbPath!; + } + } throw PlatformException( code: 'channel-error', message: 'path_provider unavailable after ${delays.length + 1} attempts — ' @@ -616,10 +627,44 @@ Future _resolveDatabasePath() async { ); } -// These two functions are only called from unit tests (database_path_test.dart). +// Reads /proc/self/cmdline to extract the Android package name, then +// constructs the standard app files-dir path without a platform channel. +// Returns null when the path cannot be determined or created. +Future _androidFallbackPath() async { + try { + final bytes = await File('/proc/self/cmdline').readAsBytes(); + final end = bytes.indexOf(0); + final packageName = String.fromCharCodes( + end >= 0 ? bytes.sublist(0, end) : bytes, + ).trim(); + // A valid Android package name contains dots but not slashes. + if (packageName.isEmpty || + !packageName.contains('.') || + packageName.contains('/')) { + return null; + } + for (final base in [ + '/data/user/0/$packageName/files', + '/data/data/$packageName/files', + ]) { + try { + await Directory(base).create(recursive: true); + return p.join(base, 'sharedinbox.db'); + } catch (_) { + continue; + } + } + return null; + } catch (_) { + return null; + } +} + +// These functions are only called from unit tests (database_path_test.dart). // They expose internals that cannot be reached via the public API. Future resolveDatabasePathForTesting() => _resolveDatabasePath(); void resetDatabasePathForTesting() => _dbPath = null; +Future androidFallbackPathForTesting() => _androidFallbackPath(); LazyDatabase _openConnection() { return LazyDatabase(() async { diff --git a/scripts/setup_dagger_remote.sh b/scripts/setup_dagger_remote.sh index 86706d4..fd40219 100755 --- a/scripts/setup_dagger_remote.sh +++ b/scripts/setup_dagger_remote.sh @@ -14,14 +14,42 @@ if [ "$host" == "$port" ]; then port="8774" fi -echo "Probing $host:$port..." -if ! nc -zw 3 "$host" "$port" 2>/dev/null; then - echo "Error: No Dagger server responded on $host:$port" - exit 1 -fi -echo "Found active Dagger server on $host:$port" +MAX_PROBE_ATTEMPTS=5 +PROBE_DELAY=30 +for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do + echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..." + if nc -zw 5 "$host" "$port" 2>/dev/null; then + echo "Found active server on $host:$port" + break + fi + if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then + echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts" + echo "Remote engine unavailable — CI will use the local Dagger engine." + exit 0 + fi + echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..." + sleep $PROBE_DELAY +done -# 2. Setup TLS credentials (passed as env vars from secrets) +# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS) +echo "Trying plain TCP Dagger connection at tcp://$host:$port..." +if _DAGGER_RUNNER_HOST="tcp://$host:$port" \ + _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \ + timeout 8 dagger version >/dev/null 2>&1; then + echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed." + if [ -n "${GITHUB_ENV:-}" ]; then + echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV" + echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV" + else + export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" + export _DAGGER_RUNNER_HOST="tcp://$host:$port" + echo "Dagger configured at tcp://$host:$port (plain TCP)" + fi + exit 0 +fi +echo "Plain TCP connection not available; trying TLS stunnel..." + +# 2b. Setup TLS credentials (passed as env vars from secrets) mkdir -p /tmp/dagger-tls echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt diff --git a/test/unit/database_path_test.dart b/test/unit/database_path_test.dart index 69ddbfa..f28d021 100644 --- a/test/unit/database_path_test.dart +++ b/test/unit/database_path_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:fake_async/fake_async.dart'; import 'package:flutter/services.dart'; @@ -129,5 +130,27 @@ void main() { ); }); }, + // The Android fallback runs only on Android, so on the host machine the + // exception is still thrown after all retries. Skip on Android to avoid + // depending on /data/user/0/... being absent in the test environment. + skip: Platform.isAndroid, + ); + + // Regression test for issue #192: _androidFallbackPath must return null when + // the process cmdline does not look like an Android package name (e.g. on + // the host test machine where the process is the Dart executable). + test( + '_androidFallbackPath returns null when process name is not a package name', + () async { + // On non-Android platforms the host process cmdline is a file-system path + // (starts with '/'), which the fallback correctly rejects. On Android + // the process IS named after the package — the fallback is free to + // succeed or return null depending on the device state; we do not assert + // here so as not to constrain Android behaviour. + if (!Platform.isAndroid) { + final result = await androidFallbackPathForTesting(); + expect(result, isNull); + } + }, ); }