Compare commits

...
Author SHA1 Message Date
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
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
23 changed files with 464 additions and 155 deletions
+33
View File
@@ -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
+5 -14
View File
@@ -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 }}
@@ -102,11 +104,7 @@ jobs:
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
# 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
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -145,12 +143,7 @@ jobs:
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
# 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
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
@@ -191,9 +184,7 @@ jobs:
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
# continue-on-error: website publish is best-effort; a missing SSH_PRIVATE_KEY
# should not block the overall workflow status.
continue-on-error: true
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
-3
View File
@@ -11,7 +11,6 @@ jobs:
name: Build & Deploy Windows (Nightly)
runs-on: windows-runner
if: false
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -32,7 +31,6 @@ jobs:
- name: Set up SSH key
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
@@ -42,7 +40,6 @@ jobs:
- name: Deploy Windows to server
if: env.SKIP_BUILD != 'true'
continue-on-error: true
env:
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
+6 -1
View File
@@ -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
+1 -1
View File
@@ -739,7 +739,7 @@ func (m *Ci) UploadToPlayStore(
From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
WithExec([]string{"pip", "install", "requests", "google-auth"}).
WithExec([]string{"pip", "install", "google-api-python-client", "google-auth-httplib2", "httplib2"}).
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
+3 -2
View File
@@ -94,8 +94,9 @@
sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [
google-auth
requests
google-api-python-client
google-auth-httplib2
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py
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/di.dart';
class UndoService extends StateNotifier<List<UndoAction>> {
UndoService(this._ref) : super([]);
final Ref _ref;
class UndoService extends Notifier<List<UndoAction>> {
static const int _maxHistory = 10;
// Resolves once init() has loaded persisted history. Default to an already-
// resolved future so operations are safe even if init() is never called.
Future<void> _ready = Future.value();
// Resolves once build() has loaded persisted history.
late Future<void> _ready;
Future<void> init() async {
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
if (mounted) state = history;
@override
List<UndoAction> build() {
_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 {
await _ready;
final newList = [...state, action];
if (newList.length > _maxHistory) {
final removed = newList.removeAt(0);
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
await ref.read(undoRepositoryProvider).deleteAction(removed.id);
}
state = newList;
await _ref.read(undoRepositoryProvider).saveAction(action);
await ref.read(undoRepositoryProvider).saveAction(action);
}
Future<void> clear() async {
await _ready;
state = [];
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
unawaited(ref.read(undoRepositoryProvider).clearHistory());
}
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
// 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) {
// 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: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();
+46 -1
View File
@@ -609,6 +609,17 @@ Future<String> _resolveDatabasePath() async {
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(
code: 'channel-error',
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
@@ -616,10 +627,44 @@ Future<String> _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<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() {
return LazyDatabase(() async {
+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/search_history_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/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
@@ -101,7 +102,7 @@ final searchHistoryRepositoryProvider =
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
});
final syncLogRepositoryProvider = Provider((ref) {
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
@@ -181,11 +182,7 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
});
final undoServiceProvider =
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
final service = UndoService(ref);
unawaited(service.init());
return service;
});
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
/// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -194,16 +191,18 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
EmailDetailNotifier.new,
);
class EmailDetailNotifier
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
EmailDetailNotifier(this._emailId);
final String _emailId;
@override
Future<(Email?, EmailBody)> build(String emailId) async {
Future<(Email?, EmailBody)> build() async {
final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([
repo.getEmail(emailId),
repo.getEmailBody(emailId),
repo.getEmail(_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);
}
}
+1
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.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/sync/background_sync.dart';
+3 -3
View File
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId),
(_, next) {
final email = next.valueOrNull?.$1;
final email = next.value?.$1;
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
},
);
final header = detail.valueOrNull?.$1;
final body = detail.valueOrNull?.$2;
final header = detail.value?.$1;
final body = detail.value?.$2;
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
+3 -3
View File
@@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
@@ -350,7 +350,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.valueOrNull;
final error = errorAsync.value;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
+4 -4
View File
@@ -415,10 +415,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.3.1"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -891,10 +891,10 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.2.1"
share_plus:
dependency: "direct main"
description:
+1 -1
View File
@@ -24,7 +24,7 @@ dependencies:
path: ^1.9.1
# State management
flutter_riverpod: ^2.6.1
flutter_riverpod: ^3.0.0
# Navigation
go_router: ^17.2.3
+54 -83
View File
@@ -6,76 +6,17 @@ import os
import sys
import time
import requests
from google.auth.transport.requests import AuthorizedSession
import google_auth_httplib2
import httplib2
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal"
_TIMEOUT = 300 # seconds — AAB uploads can be large
_MAX_UPLOAD_ATTEMPTS = 3
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
def _make_session(config_json: str) -> AuthorizedSession:
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
return AuthorizedSession(creds)
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
"""Resumable upload of the AAB. Returns the version code."""
file_size = os.path.getsize(AAB_PATH)
with open(AAB_PATH, "rb") as f:
data = f.read()
last_exc = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
init_resp = session.post(
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():
@@ -88,31 +29,61 @@ def main():
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
sys.exit(1)
session = _make_session(config_json)
edit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits",
json={},
timeout=30,
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
version_code = _upload_aab(session, edit_id)
authorized_http = google_auth_httplib2.AuthorizedHttp(
creds, http=httplib2.Http(timeout=_TIMEOUT)
)
service = build("androidpublisher", "v3", http=authorized_http)
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3)
edit_id = edit["id"]
# The resumable upload can fail with RedirectMissingLocation on transient
# network hiccups. Retry with a fresh MediaFileUpload each time (resumable
# uploads can't reuse the same object) using exponential backoff.
version_code = None
last_exc = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
media = MediaFileUpload(
AAB_PATH, mimetype="application/octet-stream", resumable=True
)
bundle = (
service.edits()
.bundles()
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
.execute(num_retries=3)
)
version_code = bundle["versionCode"]
break
except httplib2.error.RedirectMissingLocation as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(
f"Upload attempt {attempt + 1} failed (redirect error), "
f"retrying in {delay}s…"
)
time.sleep(delay)
else:
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
print(f"Uploaded AAB, version code: {version_code}")
tracks_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30,
)
tracks_resp.raise_for_status()
service.edits().tracks().update(
packageName=PACKAGE_NAME,
editId=edit_id,
track=TRACK,
body={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
).execute(num_retries=3)
commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
timeout=30,
)
commit_resp.raise_for_status()
service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute(num_retries=3)
print(f"Deployed version {version_code} to {TRACK} track")
+35 -7
View File
@@ -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
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Tests for deploy_playstore.py."""
import io
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(Path(__file__).parent))
import deploy_playstore
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):
mock_service = MagicMock()
mock_edits = mock_service.edits.return_value
mock_edits.insert.return_value.execute.return_value = {"id": "edit-42"}
mock_edits.bundles.return_value.upload.return_value.execute.return_value = {
"versionCode": 7
}
mock_edits.tracks.return_value.update.return_value.execute.return_value = {}
mock_edits.commit.return_value.execute.return_value = {}
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
deploy_playstore.main()
return mock_edits
def test_insert_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.insert.return_value.execute.assert_called_once_with(num_retries=3)
def test_bundle_upload_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.bundles.return_value.upload.return_value.execute.assert_called_once_with(num_retries=3)
def test_tracks_update_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.tracks.return_value.update.return_value.execute.assert_called_once_with(num_retries=3)
def test_commit_called_with_num_retries(self):
edits = self._run_main('{"type":"service_account"}')
edits.commit.return_value.execute.assert_called_once_with(num_retries=3)
def test_authorized_http_uses_timeout(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=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.httplib2.Http") as mock_http_cls:
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp") as mock_auth:
mock_service = MagicMock()
mock_edits = mock_service.edits.return_value
mock_edits.insert.return_value.execute.return_value = {"id": "e1"}
mock_edits.bundles.return_value.upload.return_value.execute.return_value = {
"versionCode": 1
}
mock_edits.tracks.return_value.update.return_value.execute.return_value = {}
mock_edits.commit.return_value.execute.return_value = {}
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
deploy_playstore.main()
mock_http_cls.assert_called_once_with(timeout=deploy_playstore._TIMEOUT)
def _redirect_error():
import httplib2
return httplib2.error.RedirectMissingLocation("redirect missing", {}, b"")
class TestUploadRetry(unittest.TestCase):
def _make_mock_service(self, upload_side_effects):
mock_service = MagicMock()
mock_edits = mock_service.edits.return_value
mock_edits.insert.return_value.execute.return_value = {"id": "edit-1"}
mock_edits.bundles.return_value.upload.return_value.execute.side_effect = (
upload_side_effects
)
mock_edits.tracks.return_value.update.return_value.execute.return_value = {}
mock_edits.commit.return_value.execute.return_value = {}
return mock_service, mock_edits
def _run_with_service(self, mock_service):
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=True):
with patch("deploy_playstore.service_account.Credentials.from_service_account_info"):
with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
with patch("deploy_playstore.time.sleep"):
deploy_playstore.main()
def test_succeeds_on_first_attempt(self):
mock_service, mock_edits = self._make_mock_service([{"versionCode": 5}])
self._run_with_service(mock_service)
mock_edits.bundles.return_value.upload.return_value.execute.assert_called_once_with(
num_retries=3
)
def test_retries_once_on_redirect_error_then_succeeds(self):
mock_service, mock_edits = self._make_mock_service(
[_redirect_error(), {"versionCode": 9}]
)
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload") as mock_media_cls:
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
self.assertEqual(
mock_edits.bundles.return_value.upload.return_value.execute.call_count, 2
)
mock_sleep.assert_called_once_with(10)
self.assertEqual(mock_media_cls.call_count, 2)
def test_raises_after_all_attempts_exhausted(self):
mock_service, _ = self._make_mock_service(
[_redirect_error(), _redirect_error(), _redirect_error()]
)
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
with patch("deploy_playstore.time.sleep"):
with self.assertRaises(RuntimeError) as ctx:
deploy_playstore.main()
self.assertIn(
str(deploy_playstore._MAX_UPLOAD_ATTEMPTS), str(ctx.exception)
)
def test_backoff_delays_are_10s_then_20s(self):
mock_service, _ = self._make_mock_service(
[_redirect_error(), _redirect_error(), {"versionCode": 3}]
)
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload"):
with patch("deploy_playstore.time.sleep") as mock_sleep:
deploy_playstore.main()
mock_sleep.assert_has_calls([call(10), call(20)])
def test_fresh_media_upload_created_on_each_attempt(self):
mock_service, _ = self._make_mock_service(
[_redirect_error(), {"versionCode": 2}]
)
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.google_auth_httplib2.AuthorizedHttp"):
with patch("deploy_playstore.build", return_value=mock_service):
with patch("deploy_playstore.MediaFileUpload") as mock_media_cls:
with patch("deploy_playstore.time.sleep"):
deploy_playstore.main()
self.assertEqual(mock_media_cls.call_count, 2)
if __name__ == "__main__":
unittest.main()
+23
View File
@@ -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);
}
},
);
}
+1
View File
@@ -1,5 +1,6 @@
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:go_router/go_router.dart';
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:convert';
import 'dart:io';
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:path_provider_platform_interface/path_provider_platform_interface.dart';
@@ -1,5 +1,5 @@
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:sharedinbox/core/models/email.dart';
+13 -3
View File
@@ -6,6 +6,7 @@
import 'package:flutter/material.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: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/search_history_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/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -473,10 +475,18 @@ Widget buildApp({
);
return ProviderScope(
// Always neutralise the ManageSieve probe so widget tests never open a
// real socket. Tests that need to assert on probe behaviour should supply
// their own override before this default in [overrides].
// Defaults come first so tests can override them via [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: [
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(),
),
...overrides,
manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(),