Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 c517f604e0 test: update deploy_playstore tests for requests-based transport
The previous tests patched google_auth_httplib2 and googleapiclient which
no longer exist in the new implementation. Rewrite to mock AuthorizedSession
and _upload_aab_resumable, covering the same scenarios: happy path, retry
on transient errors, backoff delays, and exhausted attempts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 07:40:17 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7d393ec818 fix: switch Play Store upload from httplib2 to requests
httplib2 treats 308 Resume Incomplete responses (used by Google's
resumable upload API) as redirects and raises RedirectMissingLocation
when the response lacks a Location header. Switch to
google.auth.transport.requests.AuthorizedSession + direct HTTP calls
so the upload uses the requests library, which handles 308 correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 07:32:22 +02:00
Bot of Thomas Güttler 5c38357033 fix: limit dagger-data volume growth by pruning named caches (#193) (#197) 2026-05-24 06:00:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7715190cbf fix: retry AAB upload on RedirectMissingLocation with exponential backoff
Adds a 3-attempt retry loop around the resumable AAB upload that catches
httplib2.error.RedirectMissingLocation (a transient network error) and
retries with exponential backoff (10s, 20s). A fresh MediaFileUpload is
created on each attempt because resumable upload objects cannot be reused
after failure. Also adds TestUploadRetry covering the retry path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 05:30:24 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 80cde04d87 fix: retry AAB upload on RedirectMissingLocation with exponential backoff (#186)
Wrap the resumable bundle upload in a loop of up to _MAX_UPLOAD_ATTEMPTS (3)
attempts. On httplib2.error.RedirectMissingLocation, recreate MediaFileUpload
(resumable uploads cannot reuse the same object) and wait 10 s / 20 s before
retrying. After all attempts are exhausted, raise RuntimeError chained to the
last exception. Add tests covering the retry path, backoff delays, fresh
MediaFileUpload on each attempt, and exhaustion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 04:59:05 +02:00
Bot of Thomas Güttler 83060bc1bf fix: add timeout and retries to Play Store upload (#185) (#195) 2026-05-24 04:45:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 fb6f2cca68 fix: add timeout and retries to Play Store upload (#185)
Switch deploy_playstore.py from requests/AuthorizedSession to the
googleapiclient.discovery client with google-auth-httplib2, so that
AuthorizedHttp(timeout=300) enforces a hard socket timeout on all
requests and num_retries=3 on every .execute() call enables automatic
retries for transient failures.

Update flake.nix and ci/main.go to install the new dependencies
(google-api-python-client, google-auth-httplib2, httplib2) instead of
the old google-auth + requests pair.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 04:38:36 +02:00
Bot of Thomas Güttler 71ccf24d0c fix: survive permanently broken path_provider channel on Android (#192) (#194) 2026-05-24 03:50:07 +02:00
Bot of Thomas Güttler 4f6f1d9437 fix: migrate to Riverpod 3.x and update dependencies (#175) (#190) 2026-05-23 19:50:11 +02:00
Bot of Thomas Güttler 833e8d49b0 fix: remove continue-on-error from CI workflows (#172) (#189) 2026-05-23 19:05:08 +02:00
Bot of Thomas Güttler 6adba9b001 perf: parallelize APK deploy and reduce fetch-depth in deploy.yml (#171) (#188) 2026-05-23 18:55:08 +02:00
Bot of Thomas Güttler 11d9805fca test: cover _resolveDatabasePath retry logic (#167) (#187) 2026-05-23 18:35:15 +02:00
23 changed files with 613 additions and 146 deletions
+37
View File
@@ -30,11 +30,48 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh 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"
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
# when total cache exceeds the limit; without args only unreferenced entries are removed.
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Run Full Check Suite - name: Run Full Check Suite
env: env:
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task check-dagger run: task check-dagger
- name: Prune Dagger cache after check
if: always()
env:
DAGGER_NO_NAG: "1"
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Cleanup TLS credentials - name: Cleanup TLS credentials
if: always() if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+41 -22
View File
@@ -14,7 +14,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 50 fetch-depth: 1
- name: Check runner tools - name: Check runner tools
run: | run: |
@@ -31,6 +31,7 @@ jobs:
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab - name: Run Android Tests on Firebase Test Lab
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
env: env:
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }} FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
@@ -49,7 +50,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 50 fetch-depth: 1
- name: Check runner tools - name: Check runner tools
run: | run: |
@@ -66,6 +67,7 @@ jobs:
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Publish Android to Play Store - name: Publish Android to Play Store
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
@@ -73,12 +75,36 @@ jobs:
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task publish-android run: task publish-android
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-apk:
name: Build & Deploy APK to Server
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server - name: Build & Deploy APK to server
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
# precondition fails, but we don't want that to fail the whole job — the Play
# Store publish above already succeeded. The overall job stays green even
# though this step shows as failed/orange in the UI.
continue-on-error: true
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }} SSH_USER: ${{ secrets.SSH_USER }}
@@ -100,7 +126,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 50 fetch-depth: 1
- name: Check runner tools - name: Check runner tools
run: | run: |
@@ -117,12 +143,7 @@ jobs:
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server - name: Build & Deploy Linux to server
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
# precondition fails, but the build step that precedes this (done via Dagger)
# already succeeded. Deployment is best-effort; a missing secret should not
# turn the job red. The step will show as failed/orange in the UI even though
# the overall job is green — this is intentional.
continue-on-error: true
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }} SSH_USER: ${{ secrets.SSH_USER }}
@@ -137,16 +158,16 @@ jobs:
publish-website: publish-website:
name: Publish Website Build History name: Publish Website Build History
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore] needs: [build-linux, deploy-playstore, deploy-apk]
if: | if: |
always() && always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success') (needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 50 fetch-depth: 1
- name: Check runner tools - name: Check runner tools
run: | run: |
@@ -163,9 +184,7 @@ jobs:
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website - name: Generate build history and deploy website
# continue-on-error: website publish is best-effort; a missing SSH_PRIVATE_KEY if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
# should not block the overall workflow status.
continue-on-error: true
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }} SSH_USER: ${{ secrets.SSH_USER }}
@@ -180,7 +199,7 @@ jobs:
label-deploy-health: label-deploy-health:
name: Update Deploy Health Label name: Update Deploy Health Label
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, build-linux] needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
if: always() && vars.DEPLOY_HEALTH_ISSUE != '' if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
timeout-minutes: 5 timeout-minutes: 5
@@ -190,7 +209,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }} FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }} FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }} DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }} ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.deploy-apk.result == 'success' && needs.build-linux.result == 'success' }}
run: | run: |
python3 - << 'PYEOF' python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error import os, json, urllib.request, urllib.error
-3
View File
@@ -11,7 +11,6 @@ jobs:
name: Build & Deploy Windows (Nightly) name: Build & Deploy Windows (Nightly)
runs-on: windows-runner runs-on: windows-runner
if: false if: false
continue-on-error: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -32,7 +31,6 @@ jobs:
- name: Set up SSH key - name: Set up SSH key
if: env.SKIP_BUILD != 'true' if: env.SKIP_BUILD != 'true'
continue-on-error: true
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: | run: |
@@ -42,7 +40,6 @@ jobs:
- name: Deploy Windows to server - name: Deploy Windows to server
if: env.SKIP_BUILD != 'true' if: env.SKIP_BUILD != 'true'
continue-on-error: true
env: env:
SSH_USER: ${{ secrets.SSH_USER }} SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }} SSH_HOST: ${{ secrets.SSH_HOST }}
+12 -1
View File
@@ -284,8 +284,13 @@ tasks:
for attempt in 1 2 3; do for attempt in 1 2 3; do
run_dagger "$@" && return 0 run_dagger "$@" && return 0
RC=$? 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 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(targetSpace: "20gb") } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90
else else
return "$RC" return "$RC"
fi fi
@@ -315,6 +320,12 @@ tasks:
wait "$RECV_PID" 2>/dev/null || true wait "$RECV_PID" 2>/dev/null || true
exit $RC exit $RC
dagger-prune:
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
cmds:
- |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
integration-android: integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2) desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
deps: [_preflight, _android-sdk-check, _android-avd-setup] deps: [_preflight, _android-sdk-check, _android-avd-setup]
+1 -1
View File
@@ -739,7 +739,7 @@ func (m *Ci) UploadToPlayStore(
From("python:3.12-alpine"). From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "curl"}). WithExec([]string{"apk", "add", "--no-cache", "curl"}).
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")). WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
WithExec([]string{"pip", "install", "requests", "google-auth"}). WithExec([]string{"pip", "install", "google-auth", "requests"}).
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab). WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")). WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig). WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
+3 -2
View File
@@ -94,8 +94,9 @@
sqlite sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py) # python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [ (python3.withPackages (ps: with ps; [
google-auth google-api-python-client
requests google-auth-httplib2
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py ])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub) fgj # Codeberg/Forgejo CLI (like gh for GitHub)
]); ]);
+16 -15
View File
@@ -4,38 +4,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
class UndoService extends StateNotifier<List<UndoAction>> { class UndoService extends Notifier<List<UndoAction>> {
UndoService(this._ref) : super([]);
final Ref _ref;
static const int _maxHistory = 10; static const int _maxHistory = 10;
// Resolves once init() has loaded persisted history. Default to an already- // Resolves once build() has loaded persisted history.
// resolved future so operations are safe even if init() is never called. late Future<void> _ready;
Future<void> _ready = Future.value();
Future<void> init() async { @override
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) { List<UndoAction> build() {
if (mounted) state = history; _ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
if (ref.mounted) state = history;
}); });
await _ready; return [];
} }
/// Waits for the persisted history to finish loading. Called by tests to
/// ensure the provider is ready before asserting state.
Future<void> init() => _ready;
Future<void> pushAction(UndoAction action) async { Future<void> pushAction(UndoAction action) async {
await _ready; await _ready;
final newList = [...state, action]; final newList = [...state, action];
if (newList.length > _maxHistory) { if (newList.length > _maxHistory) {
final removed = newList.removeAt(0); final removed = newList.removeAt(0);
await _ref.read(undoRepositoryProvider).deleteAction(removed.id); await ref.read(undoRepositoryProvider).deleteAction(removed.id);
} }
state = newList; state = newList;
await _ref.read(undoRepositoryProvider).saveAction(action); await ref.read(undoRepositoryProvider).saveAction(action);
} }
Future<void> clear() async { Future<void> clear() async {
await _ready; await _ready;
state = []; state = [];
unawaited(_ref.read(undoRepositoryProvider).clearHistory()); unawaited(ref.read(undoRepositoryProvider).clearHistory());
} }
Future<void> undo({String? actionId}) async { Future<void> undo({String? actionId}) async {
@@ -57,7 +58,7 @@ class UndoService extends StateNotifier<List<UndoAction>> {
// happened and retry if the undo failed (e.g. after an IMAP sync reverted // happened and retry if the undo failed (e.g. after an IMAP sync reverted
// the local change). The inverse action added below allows undoing the undo. // the local change). The inverse action added below allows undoing the undo.
final repo = _ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
for (final id in action.emailIds) { for (final id in action.emailIds) {
// 1. Try to cancel the original change (if not started yet). // 1. Try to cancel the original change (if not started yet).
+4
View File
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:enough_mail/enough_mail.dart' as imap; import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -24,6 +25,9 @@ const _kResourceType = 'background_check';
@pragma('vm:entry-point') @pragma('vm:entry-point')
void callbackDispatcher() { void callbackDispatcher() {
// Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((_, __) async { Workmanager().executeTask((_, __) async {
try { try {
await _doBackgroundSync(); await _doBackgroundSync();
+50
View File
@@ -609,6 +609,17 @@ Future<String> _resolveDatabasePath() async {
await Future<void>.delayed(Duration(milliseconds: ms)); await Future<void>.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( throw PlatformException(
code: 'channel-error', code: 'channel-error',
message: 'path_provider unavailable after ${delays.length + 1} attempts — ' message: 'path_provider unavailable after ${delays.length + 1} attempts — '
@@ -616,6 +627,45 @@ Future<String> _resolveDatabasePath() async {
); );
} }
// 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<String?> _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<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
void resetDatabasePathForTesting() => _dbPath = null;
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
LazyDatabase _openConnection() { LazyDatabase _openConnection() {
return LazyDatabase(() async { return LazyDatabase(() async {
final file = File(await _resolveDatabasePath()); final file = File(await _resolveDatabasePath());
+11 -12
View File
@@ -11,6 +11,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart'; import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
@@ -101,7 +102,7 @@ final searchHistoryRepositoryProvider =
return SearchHistoryRepositoryImpl(ref.watch(dbProvider)); return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
}); });
final syncLogRepositoryProvider = Provider((ref) { final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider)); return SyncLogRepositoryImpl(ref.watch(dbProvider));
}); });
@@ -181,11 +182,7 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
}); });
final undoServiceProvider = final undoServiceProvider =
StateNotifierProvider<UndoService, List<UndoAction>>((ref) { NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
final service = UndoService(ref);
unawaited(service.init());
return service;
});
/// Loads email header + body and marks the email as seen. /// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree. /// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -194,16 +191,18 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
EmailDetailNotifier.new, EmailDetailNotifier.new,
); );
class EmailDetailNotifier class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> { EmailDetailNotifier(this._emailId);
final String _emailId;
@override @override
Future<(Email?, EmailBody)> build(String emailId) async { Future<(Email?, EmailBody)> build() async {
final repo = ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([ final results = await Future.wait([
repo.getEmail(emailId), repo.getEmail(_emailId),
repo.getEmailBody(emailId), repo.getEmailBody(_emailId),
]); ]);
unawaited(repo.setFlag(emailId, seen: true)); unawaited(repo.setFlag(_emailId, seen: true));
return (results[0] as Email?, results[1] as EmailBody); return (results[0] as Email?, results[1] as EmailBody);
} }
} }
+1
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:sharedinbox/core/services/notification_service.dart'; import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart'; import 'package:sharedinbox/core/sync/background_sync.dart';
+3 -3
View File
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ref.listen<AsyncValue<(Email?, EmailBody)>>( ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId), emailDetailProvider(widget.emailId),
(_, next) { (_, next) {
final email = next.valueOrNull?.$1; final email = next.value?.$1;
if (email != null && mounted) { if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged); setState(() => _isFlagged = email.isFlagged);
} }
}, },
); );
final header = detail.valueOrNull?.$1; final header = detail.value?.$1;
final body = detail.valueOrNull?.$2; final body = detail.value?.$2;
final isMobile = defaultTargetPlatform == TargetPlatform.android || final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS; defaultTargetPlatform == TargetPlatform.iOS;
+3 -3
View File
@@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncButton(EmailRepository emailRepo) { Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing = final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false; ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
final hasError = final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null; ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
return IconButton( return IconButton(
tooltip: isSyncing tooltip: isSyncing
? 'Syncing…' ? 'Syncing…'
@@ -350,7 +350,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncErrorBanner() { Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId)); final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.valueOrNull; final error = errorAsync.value;
if (error == null || error == _dismissedError) { if (error == null || error == _dismissedError) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
+4 -4
View File
@@ -415,10 +415,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_riverpod name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" version: "3.3.1"
flutter_secure_storage: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -891,10 +891,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: riverpod name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" version: "3.2.1"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
+1 -1
View File
@@ -24,7 +24,7 @@ dependencies:
path: ^1.9.1 path: ^1.9.1
# State management # State management
flutter_riverpod: ^2.6.1 flutter_riverpod: ^3.0.0
# Navigation # Navigation
go_router: ^17.2.3 go_router: ^17.2.3
+60 -67
View File
@@ -6,76 +6,49 @@ import os
import sys import sys
import time import time
import requests
from google.auth.transport.requests import AuthorizedSession from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua" PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab" AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal" TRACK = "internal"
_TIMEOUT = 300 # seconds — AAB uploads can be large
_MAX_UPLOAD_ATTEMPTS = 3
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications" _BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications" _UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
_MAX_UPLOAD_ATTEMPTS = 3
def _make_session(config_json: str) -> AuthorizedSession: def _upload_aab_resumable(session, package, edit_id, aab_path):
creds = service_account.Credentials.from_service_account_info( """Upload AAB using the Google resumable upload protocol."""
json.loads(config_json), file_size = os.path.getsize(aab_path)
scopes=["https://www.googleapis.com/auth/androidpublisher"], init_url = f"{_UPLOAD_BASE}/{package}/edits/{edit_id}/bundles"
# Step 1: initiate the resumable upload session
init_resp = session.post(
init_url,
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
"Content-Length": "0",
},
timeout=60,
) )
return AuthorizedSession(creds) init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
# Step 2: upload the file in a single PUT to the session URI
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int: with open(aab_path, "rb") as f:
"""Resumable upload of the AAB. Returns the version code.""" upload_resp = session.put(
file_size = os.path.getsize(AAB_PATH) upload_url,
data=f,
with open(AAB_PATH, "rb") as f: headers={
data = f.read() "Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
last_exc = None },
for attempt in range(_MAX_UPLOAD_ATTEMPTS): timeout=600,
try: )
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure. upload_resp.raise_for_status()
init_resp = session.post( return upload_resp.json()
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
params={"uploadType": "resumable"},
headers={
"X-Upload-Content-Type": "application/octet-stream",
"X-Upload-Content-Length": str(file_size),
},
json={},
timeout=30,
)
if not init_resp.ok:
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
init_resp.raise_for_status()
upload_url = init_resp.headers["Location"]
upload_resp = session.put(
upload_url,
data=data,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size),
},
timeout=_TIMEOUT,
)
if not upload_resp.ok:
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
upload_resp.raise_for_status()
return upload_resp.json()["versionCode"]
except requests.RequestException as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
time.sleep(delay)
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
def main(): def main():
@@ -88,25 +61,45 @@ def main():
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr) print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
sys.exit(1) sys.exit(1)
session = _make_session(config_json) creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
edit_resp = session.post( scopes=["https://www.googleapis.com/auth/androidpublisher"],
f"{_BASE}/{PACKAGE_NAME}/edits",
json={},
timeout=30,
) )
session = AuthorizedSession(creds)
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
edit_resp.raise_for_status() edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"] edit_id = edit_resp.json()["id"]
version_code = _upload_aab(session, edit_id) last_exc = None
bundle = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
bundle = _upload_aab_resumable(session, PACKAGE_NAME, edit_id, AAB_PATH)
break
except Exception as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(
f"Upload attempt {attempt + 1} failed ({type(exc).__name__}: {exc}), "
f"retrying in {delay}s…"
)
time.sleep(delay)
if bundle is None:
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
version_code = bundle["versionCode"]
print(f"Uploaded AAB, version code: {version_code}") print(f"Uploaded AAB, version code: {version_code}")
tracks_resp = session.put( track_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}", f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]}, json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30, timeout=30,
) )
tracks_resp.raise_for_status() track_resp.raise_for_status()
commit_resp = session.post( commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit", f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
+35 -7
View File
@@ -14,14 +14,42 @@ if [ "$host" == "$port" ]; then
port="8774" port="8774"
fi fi
echo "Probing $host:$port..." MAX_PROBE_ATTEMPTS=5
if ! nc -zw 3 "$host" "$port" 2>/dev/null; then PROBE_DELAY=30
echo "Error: No Dagger server responded on $host:$port" for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
exit 1 echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
fi if nc -zw 5 "$host" "$port" 2>/dev/null; then
echo "Found active Dagger server on $host:$port" 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 mkdir -p /tmp/dagger-tls
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Tests for deploy_playstore.py."""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(Path(__file__).parent))
import deploy_playstore
def _make_session(
edit_id="edit-42",
version_code=7,
upload_side_effects=None,
):
"""Return a mock AuthorizedSession with sensible defaults."""
session = MagicMock()
# POST /edits → create edit
edit_resp = MagicMock()
edit_resp.json.return_value = {"id": edit_id}
session.post.return_value = edit_resp
# POST resumable-init → Location header
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/session"}
# PUT upload → bundle JSON
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": version_code}
if upload_side_effects is not None:
# Use side_effect list: first call is edit create, rest are upload inits
# We override the PUT side effects via _upload_aab_resumable mock instead
pass
return session, init_resp, upload_resp
class TestMainEnvChecks(unittest.TestCase):
def test_missing_env_exits(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
def test_missing_aab_exits(self):
fake_config = '{"type": "service_account"}'
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=False):
with self.assertRaises(SystemExit) as ctx:
deploy_playstore.main()
self.assertEqual(ctx.exception.code, 1)
class TestMainHappyPath(unittest.TestCase):
def _run_main(self, fake_config='{"type":"service_account"}'):
mock_session = MagicMock()
# POST for edit create and commit
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-42"}}), # create edit
MagicMock(), # commit
]
mock_session.post.side_effect = post_responses
# PUT for track update
mock_session.put.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
return_value={"versionCode": 7},
):
deploy_playstore.main()
return mock_session
def test_creates_edit(self):
session = self._run_main()
create_call = session.post.call_args_list[0]
self.assertIn("/edits", create_call[0][0])
def test_commits_edit(self):
session = self._run_main()
commit_call = session.post.call_args_list[1]
self.assertIn(":commit", commit_call[0][0])
def test_updates_track(self):
session = self._run_main()
track_call = session.put.call_args_list[0]
self.assertIn("/tracks/", track_call[0][0])
class TestUploadRetry(unittest.TestCase):
def _run_main(self, upload_side_effects, sleep_mock=None):
mock_session = MagicMock()
post_responses = [
MagicMock(**{"json.return_value": {"id": "edit-1"}}),
MagicMock(),
]
mock_session.post.side_effect = post_responses
mock_session.put.return_value = MagicMock()
patches = [
patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}),
patch("deploy_playstore.os.path.exists", return_value=True),
patch("deploy_playstore.service_account.Credentials.from_service_account_info"),
patch("deploy_playstore.AuthorizedSession", return_value=mock_session),
patch("deploy_playstore._upload_aab_resumable", side_effect=upload_side_effects),
patch("deploy_playstore.time.sleep"),
]
for p in patches:
p.start()
try:
deploy_playstore.main()
finally:
for p in patches:
p.stop()
def test_succeeds_on_first_attempt(self):
with patch("deploy_playstore._upload_aab_resumable", return_value={"versionCode": 5}) as mock_upload:
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
deploy_playstore.main()
mock_upload.assert_called_once()
def test_retries_once_on_error_then_succeeds(self):
self._run_main([ValueError("transient"), {"versionCode": 9}])
def test_raises_after_all_attempts_exhausted(self):
with self.assertRaises(RuntimeError) as ctx:
self._run_main([ValueError("err"), ValueError("err"), ValueError("err")])
self.assertIn(str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception))
def test_backoff_delays_are_10s_then_20s(self):
mock_session = MagicMock()
mock_session.post.side_effect = [
MagicMock(**{"json.return_value": {"id": "e1"}}),
MagicMock(),
]
mock_session.put.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("deploy_playstore.os.path.exists", return_value=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.AuthorizedSession", return_value=mock_session):
with patch(
"deploy_playstore._upload_aab_resumable",
side_effect=[ValueError("e"), ValueError("e"), {"versionCode": 3}],
):
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
mock_sleep.assert_has_calls([call(10), call(20)])
class TestUploadAabResumable(unittest.TestCase):
def test_initiates_and_uploads(self):
mock_session = MagicMock()
init_resp = MagicMock()
init_resp.headers = {"Location": "https://upload.example.com/sess"}
upload_resp = MagicMock()
upload_resp.json.return_value = {"versionCode": 42}
mock_session.post.return_value = init_resp
mock_session.put.return_value = upload_resp
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"fake-aab-content")
aab_path = f.name
try:
result = deploy_playstore._upload_aab_resumable(
mock_session, "com.example.app", "edit-1", aab_path
)
finally:
os.unlink(aab_path)
self.assertEqual(result["versionCode"], 42)
mock_session.post.assert_called_once()
mock_session.put.assert_called_once()
put_call = mock_session.put.call_args
self.assertEqual(put_call[0][0], "https://upload.example.com/sess")
if __name__ == "__main__":
unittest.main()
+115
View File
@@ -1,3 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
@@ -19,6 +23,30 @@ class _UnavailablePathProvider extends Fake
} }
} }
// Fake PathProviderPlatform that fails the first [failCount] calls, then
// returns a fixed path. Used to exercise the retry loop in
// _resolveDatabasePath() without waiting for real timers.
class _SucceedAfterNPathProvider extends Fake
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
_SucceedAfterNPathProvider({required this.failCount});
final int failCount;
int _callCount = 0;
@override
Future<String?> getApplicationSupportPath() async {
_callCount++;
if (_callCount <= failCount) {
throw PlatformException(
code: 'channel-error',
message: 'Simulated: path_provider channel not ready',
);
}
return '/tmp/test_app_support';
}
}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -38,4 +66,91 @@ void main() {
await expectLater(initDatabasePath(), completes); await expectLater(initDatabasePath(), completes);
}, },
); );
// Tests for _resolveDatabasePath() — the lazy retry path called on first DB
// access when initDatabasePath() already failed. fake_async lets us advance
// the back-off timers without waiting real-world milliseconds.
test(
'_resolveDatabasePath retries and eventually succeeds after transient failures',
() {
resetDatabasePathForTesting();
final prev = PathProviderPlatform.instance;
// Fail 3 times, succeed on the 4th call. The delays in
// _resolveDatabasePath are [200, 500, 1000, 2000, 4000] ms, so three
// failures cost 200+500+1000 = 1700 ms before the fourth attempt.
PathProviderPlatform.instance = _SucceedAfterNPathProvider(failCount: 3);
addTearDown(() {
PathProviderPlatform.instance = prev;
resetDatabasePathForTesting();
});
fakeAsync((fake) {
String? result;
unawaited(resolveDatabasePathForTesting().then((r) => result = r));
// Advance fake time through the three back-off delays.
fake.elapse(const Duration(milliseconds: 200 + 500 + 1000 + 1));
expect(result, isNotNull);
expect(result, endsWith('sharedinbox.db'));
});
},
);
test(
'_resolveDatabasePath throws PlatformException after exhausting all retries',
() {
resetDatabasePathForTesting();
final prev = PathProviderPlatform.instance;
PathProviderPlatform.instance = _UnavailablePathProvider();
addTearDown(() {
PathProviderPlatform.instance = prev;
resetDatabasePathForTesting();
});
fakeAsync((fake) {
Object? caughtError;
unawaited(
resolveDatabasePathForTesting().catchError((Object e) {
caughtError = e;
return ''; // ignored; satisfies the Future<String> return type
}),
);
// Advance past all five back-off delays: 200+500+1000+2000+4000 ms.
fake.elapse(
const Duration(milliseconds: 200 + 500 + 1000 + 2000 + 4000 + 1),
);
expect(caughtError, isA<PlatformException>());
expect(
(caughtError! as PlatformException).message,
contains('cannot open database'),
);
});
},
// 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);
}
},
);
} }
+1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
+13 -3
View File
@@ -6,6 +6,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
@@ -19,6 +20,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -473,10 +475,18 @@ Widget buildApp({
); );
return ProviderScope( return ProviderScope(
// Always neutralise the ManageSieve probe so widget tests never open a // Defaults come first so tests can override them via [overrides].
// real socket. Tests that need to assert on probe behaviour should supply //
// their own override before this default in [overrides]. // syncHealthProvider and syncLogRepositoryProvider are backed by Drift
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
// framework then fails the test with "A Timer is still pending". Replacing
// these with simple synchronous streams avoids the pending-timer assertion.
overrides: [ overrides: [
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(),
),
...overrides, ...overrides,
manageSieveProbeServiceProvider.overrideWith( manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(), (ref) => _NoOpManageSieveProbeService(),