Merge branch 'main' into issue-422-move-to-folder-create-new
@@ -4,6 +4,7 @@ jobs:
|
||||
check:
|
||||
name: Full Project Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Dagger Remote Engine
|
||||
|
||||
@@ -10,6 +10,7 @@ jobs:
|
||||
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
||||
name: Build & Deploy Windows (Nightly)
|
||||
runs-on: windows-runner
|
||||
timeout-minutes: 90
|
||||
if: false
|
||||
|
||||
steps:
|
||||
|
||||
@@ -10,6 +10,11 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/guettli/sync-branch
|
||||
rev: v0.0.11
|
||||
hooks:
|
||||
- id: sync-branch
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-no-binary
|
||||
@@ -27,7 +32,7 @@ repos:
|
||||
- id: dart-check
|
||||
name: dart format (autofix) + check-fast (parallel)
|
||||
language: system
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: ci-no-direct-dagger
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Implementation Plan: Secure WebView for HTML Emails (#21)
|
||||
|
||||
## Goal
|
||||
Replace the current `flutter_html` based rendering with a hardened WebView-based approach to improve rendering fidelity while strictly enforcing security and privacy.
|
||||
|
||||
## 1. Dependency Management
|
||||
- **Core**: `webview_flutter` (v4+)
|
||||
- **Linux Platform**: `webview_flutter_linux` (Official community-supported or WebKitGTK based implementation). *Note: I will verify the exact package name during implementation.*
|
||||
- **Utilities**: `url_launcher` (existing) for opening links in the system browser.
|
||||
|
||||
## 2. Secure WebView Component (`lib/ui/widgets/secure_email_webview.dart`)
|
||||
Create a new widget `SecureEmailWebView` that encapsulates the `WebViewWidget` and its controller.
|
||||
|
||||
### Configuration & Hardening
|
||||
- **Disable JavaScript**: `controller.setJavaScriptMode(JavaScriptMode.disabled)`.
|
||||
- **Background**: Match the application theme (e.g., transparent or surface color).
|
||||
- **Security Headers/CSP**: Inject a Content Security Policy via `<meta>` tag in the HTML wrapper:
|
||||
- `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:;` (Blocks all external assets by default).
|
||||
|
||||
### Image Blocking Logic
|
||||
- **Initial State**: Block remote images by injecting a CSP that restricts `img-src` to `data:` and local schemes.
|
||||
- **Toggle Mechanism**:
|
||||
- Provide a "Load Remote Images" button in the Flutter UI.
|
||||
- When triggered, re-render the HTML with an updated CSP: `img-src * data:;`.
|
||||
|
||||
### Link Interception & Phishing Protection
|
||||
- Implement `NavigationDelegate.onNavigationRequest`.
|
||||
- **Process**:
|
||||
1. Intercept any URL that doesn't start with `about:blank` or `data:`.
|
||||
2. Block the navigation in the WebView.
|
||||
3. Trigger a Flutter `showDialog` for confirmation.
|
||||
- **Phishing Protection Dialog**:
|
||||
- Show the full URL.
|
||||
- **Bold the FQDN**: Parse the URL using `Uri.parse`.
|
||||
- Example: `https://`**`important-bank.com`**`/login`
|
||||
- "Open in Browser" button uses `url_launcher`.
|
||||
|
||||
## 3. Integration Plan
|
||||
### Step 1: Initialization
|
||||
Modify `lib/main.dart` to initialize the Linux WebView platform (using `webview_flutter_linux` or similar) during app startup.
|
||||
|
||||
### Step 2: Replace Renderer in Screens
|
||||
- **EmailDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
||||
- **ThreadDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
||||
- Remove `flutter_html` imports and dependencies once migration is complete.
|
||||
|
||||
## 4. Verification & Security Audit
|
||||
- **Manual Tests**:
|
||||
- Open emails with complex HTML layouts.
|
||||
- Verify images are blocked initially.
|
||||
- Verify "Load images" works.
|
||||
- Click various links (http, https, mailto) and verify the confirmation dialog and FQDN bolding.
|
||||
- **Security Check**:
|
||||
- Verify that `<script>` tags are not executed.
|
||||
- Verify no network requests for external images occur before user consent (via DevTools or proxy).
|
||||
|
||||
## 5. Potential Challenges
|
||||
- **Linux WebView Stability**: WebKitGTK on Linux can sometimes have rendering or sizing issues in Flutter.
|
||||
- **Scrolling**: Ensuring the WebView integrates smoothly into the `ListView` of the email detail screen (might require fixed height or `SizedBox`).
|
||||
@@ -1,46 +0,0 @@
|
||||
# Snooze Feature Plan
|
||||
|
||||
## Goal
|
||||
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### 1. Metadata Storage (Account Sync)
|
||||
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
|
||||
- **JMAP:** Use `keywords`.
|
||||
- **IMAP:** Use User Flags (keywords).
|
||||
|
||||
### 2. Database Changes
|
||||
- **Migration v22:**
|
||||
- `Emails` table:
|
||||
- `snoozedUntil` (DateTime, nullable)
|
||||
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
|
||||
- Index on `snoozedUntil`.
|
||||
|
||||
### 3. Repository Updates (`EmailRepository`)
|
||||
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
|
||||
- Optimistically update local DB.
|
||||
- Enqueue `snooze` change.
|
||||
- New method: `Future<int> wakeUpEmails(String accountId)`
|
||||
- Find local rows where `snoozedUntil <= now`.
|
||||
- Enqueue `move` back to original mailbox.
|
||||
- Clear snooze metadata.
|
||||
|
||||
### 4. Sync Loop Integration
|
||||
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
|
||||
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
|
||||
|
||||
### 5. UI Implementation
|
||||
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
|
||||
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
|
||||
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
|
||||
|
||||
## Implementation Steps
|
||||
1. [ ] Database migration and model updates.
|
||||
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
|
||||
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
|
||||
4. [ ] Update sync logic to parse snooze keywords.
|
||||
5. [ ] Integrate `wakeUpEmails` into the sync loop.
|
||||
6. [ ] UI: Snooze picker dialog.
|
||||
7. [ ] UI: Add Snooze action to list and detail screens.
|
||||
8. [ ] Testing and validation.
|
||||
@@ -216,8 +216,3 @@ test/
|
||||
- **Settings** — list and remove accounts
|
||||
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
|
||||
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
|
||||
# CI Trigger
|
||||
# CI Trigger 2
|
||||
# Dummy commit to verify CI fixes
|
||||
# Dummy commit 3
|
||||
# CI Trigger 1780415300
|
||||
|
||||
@@ -96,34 +96,19 @@ tasks:
|
||||
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||
|
||||
codegen:
|
||||
desc: Generate Drift DB code (run after any schema change)
|
||||
deps: [_preflight, _pub-get]
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- pubspec.yaml
|
||||
generates:
|
||||
- lib/**/*.g.dart
|
||||
desc: Generate Drift DB code via Dagger (exports generated files back to host)
|
||||
cmds:
|
||||
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||
- dagger call --progress=plain -q -m ci --source=. codegen -o .
|
||||
|
||||
analyze:
|
||||
desc: Static analysis (flutter analyze)
|
||||
deps: [_preflight, _codegen]
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- test/**/*.dart
|
||||
- pubspec.yaml
|
||||
- analysis_options.yaml
|
||||
desc: Static analysis via Dagger (dart analyze --fatal-infos)
|
||||
cmds:
|
||||
- scripts/run_analyze.sh
|
||||
- dagger call --progress=plain -q -m ci --source=. analyze
|
||||
|
||||
format:
|
||||
desc: Format all Dart source files
|
||||
deps: [_preflight]
|
||||
sources:
|
||||
- "**/*.dart"
|
||||
desc: Format all Dart source files via Dagger (writes back to host)
|
||||
cmds:
|
||||
- fvm dart format lib test
|
||||
- dagger call --progress=plain -q -m ci --source=. format-write -o .
|
||||
|
||||
check-mocks:
|
||||
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
||||
@@ -136,13 +121,9 @@ tasks:
|
||||
- scripts/check_mocks_fresh.sh
|
||||
|
||||
analyze-fix:
|
||||
desc: Auto-fix lint issues with dart fix --apply
|
||||
deps: [_preflight]
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- test/**/*.dart
|
||||
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
|
||||
cmds:
|
||||
- fvm dart fix --apply
|
||||
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
|
||||
|
||||
test:
|
||||
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
||||
@@ -177,17 +158,17 @@ tasks:
|
||||
test-backend:
|
||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. test-backend
|
||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
|
||||
|
||||
integration-ui:
|
||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. test-integration
|
||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
|
||||
|
||||
sync-reliability:
|
||||
desc: Run sync reliability runner (via Dagger)
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||
|
||||
test-android-firebase:
|
||||
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||
@@ -202,7 +183,7 @@ tasks:
|
||||
ci-graph:
|
||||
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. graph
|
||||
- timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
|
||||
|
||||
stalwart:
|
||||
desc: Start a Stalwart instance for local development (via Dagger)
|
||||
@@ -218,13 +199,13 @@ tasks:
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
|
||||
build-android-bundle:
|
||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||
cmds:
|
||||
- mkdir -p build/app/outputs/bundle/release
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||
|
||||
upload-android-bundle:
|
||||
desc: Upload AAB from build/ to Play Store via Dagger
|
||||
@@ -234,7 +215,7 @@ tasks:
|
||||
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||
msg: "AAB not found — run build-android-bundle first"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||
|
||||
publish-android:
|
||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||
@@ -247,7 +228,7 @@ tasks:
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
cmds:
|
||||
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||
|
||||
deploy-apk:
|
||||
desc: Build and deploy Android APK via Dagger
|
||||
@@ -261,7 +242,7 @@ tasks:
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
cmds:
|
||||
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||
|
||||
publish-website:
|
||||
desc: Build and publish website via Dagger
|
||||
@@ -271,7 +252,7 @@ tasks:
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
|
||||
check-dagger:
|
||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||
@@ -351,7 +332,7 @@ tasks:
|
||||
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
||||
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||
|
||||
integration-android:
|
||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||
@@ -426,6 +407,22 @@ tasks:
|
||||
fi
|
||||
echo "Uploaded $TARBALL and updated latest.json"
|
||||
|
||||
deploy-bugreport:
|
||||
desc: Deploy the Go bugreport server by restarting the systemd service (it pulls latest code from Codeberg)
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_USER"
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
ssh "root@$SSH_HOST" "systemctl restart bugreport"
|
||||
echo "Restarted bugreport service on $SSH_HOST to pull latest code from Codeberg"
|
||||
|
||||
build-windows-release:
|
||||
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||
deps: [_pub-get, generate-changelog]
|
||||
@@ -525,15 +522,14 @@ tasks:
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
preconditions:
|
||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
- python3 scripts/deploy_playstore.py
|
||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||
|
||||
build-android-bundle-local:
|
||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||
dotenv: [".env"]
|
||||
method: timestamp
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
@@ -542,7 +538,7 @@ tasks:
|
||||
generates:
|
||||
- build/app/outputs/bundle/release/app-release.aab
|
||||
cmds:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||
|
||||
deploy-android:
|
||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||
@@ -672,8 +668,9 @@ tasks:
|
||||
${SSH_USER}@${SSH_HOST}:public_html/
|
||||
|
||||
check-fast:
|
||||
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
||||
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
||||
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. check-fast
|
||||
|
||||
check-layers:
|
||||
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||
|
||||
@@ -22,15 +22,17 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
// Hardcoded alias matching t.sh
|
||||
keyAlias = "upload"
|
||||
// Use the same password for both key and keystore
|
||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
storePassword = pass
|
||||
keyPassword = pass
|
||||
storeFile = file("upload-keystore.jks")
|
||||
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
|
||||
if (ksPath != null) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = "upload"
|
||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
|
||||
storePassword = pass
|
||||
keyPassword = pass
|
||||
storeFile = file(ksPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,14 +48,9 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Use the signing config defined above for release builds.
|
||||
// If the keystore file exists (e.g. in CI or manually placed), sign it.
|
||||
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
if (ksPath != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
ndk {
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
|
||||
|
||||
@@ -440,6 +440,68 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// FormatWrite formats Dart files and exports the modified /src directory.
|
||||
func (m *Ci) FormatWrite() *dagger.Directory {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"dart", "format", "lib", "test"}).
|
||||
Directory("/src")
|
||||
}
|
||||
|
||||
// Analyze runs static analysis with dart analyze --fatal-infos.
|
||||
func (m *Ci) Analyze(ctx context.Context) (string, error) {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"dart", "analyze", "--fatal-infos"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Codegen runs build_runner and exports the modified /src directory.
|
||||
func (m *Ci) Codegen() *dagger.Directory {
|
||||
return m.codegenBase().Directory("/src")
|
||||
}
|
||||
|
||||
// AnalyzeFix runs dart fix --apply and exports the modified /src directory.
|
||||
func (m *Ci) AnalyzeFix() *dagger.Directory {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"dart", "fix", "--apply"}).
|
||||
Directory("/src")
|
||||
}
|
||||
|
||||
// CheckFast runs fast checks (hygiene, layers, format, analyze, mocks, coverage) in parallel.
|
||||
func (m *Ci) CheckFast(ctx context.Context) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var eg errgroup.Group
|
||||
eg.Go(func() error {
|
||||
_, err := m.CheckHygiene(ctx)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
_, err := m.CheckLayers(ctx)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
_, err := m.Format(ctx)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
_, err := m.Analyze(ctx)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
_, err := m.CheckGenerated(ctx)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
_, err := m.Coverage(ctx)
|
||||
return err
|
||||
})
|
||||
if err := eg.Wait(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "All fast checks passed!", nil
|
||||
}
|
||||
|
||||
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
|
||||
// It snapshots the committed source (including any stale generated files) before
|
||||
// running build_runner, so git diff detects real staleness instead of always
|
||||
@@ -687,7 +749,8 @@ func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagg
|
||||
return m.androidBase().
|
||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
|
||||
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/upload-keystore.jks")
|
||||
}
|
||||
|
||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/bug_report_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
||||
@@ -169,6 +170,12 @@ final router = GoRouter(
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/bug-report',
|
||||
builder: (ctx, state) => BugReportScreen(
|
||||
emailId: state.uri.queryParameters['emailId'],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
@@ -197,22 +198,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy to clipboard'),
|
||||
label: const Text('Copy info'),
|
||||
onPressed: () => unawaited(
|
||||
_copyToClipboard(context, imapCount, jmapCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
label: const Text('Create issue'),
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
label: const Text('Public issue'),
|
||||
onPressed: () => unawaited(
|
||||
_createIssue(context, imapCount, jmapCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.feedback_outlined),
|
||||
label: const Text('Report bug'),
|
||||
onPressed: () => context.push('/bug-report'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||
|
||||
const _bugReportApiUrl = String.fromEnvironment(
|
||||
'BUG_REPORT_API_URL',
|
||||
defaultValue: 'https://sharedinbox.de/api/v1/bug-reports',
|
||||
);
|
||||
|
||||
class BugReportScreen extends ConsumerStatefulWidget {
|
||||
const BugReportScreen({super.key, this.emailId});
|
||||
|
||||
final String? emailId;
|
||||
|
||||
@override
|
||||
ConsumerState<BugReportScreen> createState() => _BugReportScreenState();
|
||||
}
|
||||
|
||||
class _BugReportScreenState extends ConsumerState<BugReportScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
|
||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||
late final Future<String?> _deviceModelFuture = getDeviceModel();
|
||||
|
||||
final List<PlatformFile> _attachments = [];
|
||||
bool _includeEmail = false;
|
||||
bool _includeSyncLog = false;
|
||||
bool _submitting = false;
|
||||
|
||||
Email? _attachedEmail;
|
||||
List<Account> _accounts = [];
|
||||
String? _selectedAccountId;
|
||||
String? _deviceModel;
|
||||
bool _loadingEmail = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_loadInitialData());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_descriptionController.dispose();
|
||||
_emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialData() async {
|
||||
setState(() => _loadingEmail = true);
|
||||
try {
|
||||
_deviceModel = await _deviceModelFuture;
|
||||
_accounts =
|
||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||
|
||||
if (widget.emailId != null) {
|
||||
final email =
|
||||
await ref.read(emailRepositoryProvider).getEmail(widget.emailId!);
|
||||
if (mounted && email != null) {
|
||||
_attachedEmail = email;
|
||||
_selectedAccountId = email.accountId;
|
||||
final fromStr =
|
||||
email.from.isNotEmpty ? email.from.first.toString() : 'unknown';
|
||||
final subjectStr = email.subject ?? '(no subject)';
|
||||
_descriptionController.text =
|
||||
'Problem with email from $fromStr: "$subjectStr"\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (_selectedAccountId == null && _accounts.isNotEmpty) {
|
||||
_selectedAccountId = _accounts.first.id;
|
||||
}
|
||||
|
||||
if (_selectedAccountId != null) {
|
||||
final matching =
|
||||
_accounts.where((a) => a.id == _selectedAccountId).firstOrNull;
|
||||
if (matching != null) {
|
||||
_emailController.text = matching.email;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (mounted) {
|
||||
setState(() => _loadingEmail = false);
|
||||
}
|
||||
}
|
||||
|
||||
int get _totalAttachmentSize {
|
||||
return _attachments.fold(0, (sum, f) => sum + f.size);
|
||||
}
|
||||
|
||||
String _formatSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
|
||||
Future<void> _pickAttachments() async {
|
||||
try {
|
||||
final result = await FilePicker.pickFiles();
|
||||
if (result == null) return;
|
||||
final newFiles =
|
||||
result.files.where((PlatformFile f) => f.path != null).toList();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_attachments.addAll(newFiles);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to pick files: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _removeAttachment(int index) {
|
||||
setState(() {
|
||||
_attachments.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
String _serializeSyncLogs(List<SyncLogEntry> entries) {
|
||||
final sb = StringBuffer();
|
||||
for (final entry in entries.take(50)) {
|
||||
sb.writeln('ID: ${entry.id}');
|
||||
sb.writeln('Started: ${entry.startedAt.toIso8601String()}');
|
||||
sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}');
|
||||
sb.writeln('Result: ${entry.result}');
|
||||
if (entry.errorMessage != null) {
|
||||
sb.writeln('Error: ${entry.errorMessage}');
|
||||
}
|
||||
if (entry.stackTrace != null) {
|
||||
sb.writeln('StackTrace:\n${entry.stackTrace}');
|
||||
}
|
||||
sb.writeln('Protocol: ${entry.protocol}');
|
||||
sb.writeln(
|
||||
'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}',
|
||||
);
|
||||
if (entry.protocolLog != null) {
|
||||
sb.writeln('Protocol Log:\n${entry.protocolLog}');
|
||||
}
|
||||
sb.writeln('---');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
Future<void> _submitReport() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final totalSize = _totalAttachmentSize;
|
||||
if (totalSize > 20 * 1024 * 1024) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Total attachments size exceeds the 20 MB limit. Please remove some files.',
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
|
||||
try {
|
||||
final client = ref.read(httpClientProvider);
|
||||
final uri = Uri.parse(_bugReportApiUrl);
|
||||
final request = http.MultipartRequest('POST', uri);
|
||||
|
||||
// Description
|
||||
request.fields['description'] = _descriptionController.text;
|
||||
|
||||
// Email Data if from email view
|
||||
if (_attachedEmail != null) {
|
||||
final emailMap = {
|
||||
'id': _attachedEmail!.id,
|
||||
'subject': _attachedEmail!.subject,
|
||||
'from': _attachedEmail!.from.map((e) => e.toString()).toList(),
|
||||
'date': _attachedEmail!.sentAt?.toIso8601String() ??
|
||||
_attachedEmail!.receivedAt.toIso8601String(),
|
||||
'preview': _attachedEmail!.preview,
|
||||
};
|
||||
request.fields['email_data'] = jsonEncode(emailMap);
|
||||
}
|
||||
|
||||
// Contact Email
|
||||
if (_includeEmail) {
|
||||
request.fields['email'] = _emailController.text;
|
||||
}
|
||||
|
||||
// About Info
|
||||
PackageInfo? pkg;
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
final imapCount =
|
||||
_accounts.where((a) => a.type == AccountType.imap).length;
|
||||
final jmapCount =
|
||||
_accounts.where((a) => a.type == AccountType.jmap).length;
|
||||
|
||||
if (!mounted) return;
|
||||
final aboutInfo = buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: _deviceModel,
|
||||
);
|
||||
request.fields['about_info'] = aboutInfo;
|
||||
|
||||
// Sync Log
|
||||
if (_includeSyncLog && _selectedAccountId != null) {
|
||||
final syncLogs = await ref
|
||||
.read(syncLogRepositoryProvider)
|
||||
.observeSyncLogs(_selectedAccountId!)
|
||||
.first;
|
||||
request.fields['sync_log'] = _serializeSyncLogs(syncLogs);
|
||||
}
|
||||
|
||||
// Attachments
|
||||
for (final file in _attachments) {
|
||||
final multipartFile = await http.MultipartFile.fromPath(
|
||||
'attachments[]',
|
||||
file.path!,
|
||||
filename: file.name,
|
||||
);
|
||||
request.files.add(multipartFile);
|
||||
}
|
||||
|
||||
final streamedResponse = await client.send(request);
|
||||
final response = await http.Response.fromStream(streamedResponse);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final reportId = resData['id'] as String;
|
||||
_showSuccessDialog(reportId);
|
||||
} else if (response.statusCode == 429) {
|
||||
final retryAfter = response.headers['retry-after'] ?? '6';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Rate limited. Please retry in $retryAfter seconds.'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
String errorMsg =
|
||||
'Failed to submit report. Server returned status: ${response.statusCode}';
|
||||
try {
|
||||
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (resData['error'] != null) {
|
||||
errorMsg = resData['error'] as String;
|
||||
}
|
||||
} catch (_) {}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMsg),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('An error occurred: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSuccessDialog(String reportId) {
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Bug Report Submitted'),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
const Text('Thank you for helping us improve SharedInbox!'),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Your Report ID is:\n$reportId',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Your report is handled confidentially and has not been posted to the public issue tracker.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(); // Dismiss dialog
|
||||
context.pop(); // Go back to previous screen
|
||||
},
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final totalSize = _totalAttachmentSize;
|
||||
const sizeLimit = 20 * 1024 * 1024;
|
||||
final approachingLimit = totalSize > 15 * 1024 * 1024;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Report a Bug'),
|
||||
),
|
||||
body: _loadingEmail
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
// Confidentiality info card
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: theme.colorScheme.secondaryContainer
|
||||
.withValues(alpha: 0.4),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
color:
|
||||
theme.colorScheme.secondary.withValues(alpha: 0.4),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Your report is handled confidentially and will not be posted to the public issue tracker.',
|
||||
style: TextStyle(height: 1.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Description Text Field
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
autofocus: true,
|
||||
maxLines: 8,
|
||||
minLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'What went wrong?',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(),
|
||||
helperText:
|
||||
'Please describe the problem and how to reproduce it.',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter a description.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Email info chip if email is attached
|
||||
if (_attachedEmail != null) ...[
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.email_outlined,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'The current email metadata will be attached automatically.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Attachments Section
|
||||
Text(
|
||||
'Attachments',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _submitting ? null : _pickAttachments,
|
||||
icon: const Icon(Icons.add_a_photo_outlined),
|
||||
label: const Text('Add screenshots'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Screenshots help us understand the problem faster.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_attachments.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _attachments.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = _attachments[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: InputChip(
|
||||
label: Text(
|
||||
'${file.name} (${_formatSize(file.size)})',
|
||||
),
|
||||
onDeleted: _submitting
|
||||
? null
|
||||
: () => _removeAttachment(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: totalSize > sizeLimit
|
||||
? Colors.red
|
||||
: approachingLimit
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
fontWeight: approachingLimit
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
if (totalSize > sizeLimit) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 16,
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Email opt-in
|
||||
CheckboxListTile(
|
||||
title: const Text('Include my email for follow-up'),
|
||||
value: _includeEmail,
|
||||
onChanged: _submitting
|
||||
? null
|
||||
: (val) {
|
||||
setState(() => _includeEmail = val ?? false);
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
if (_includeEmail) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Contact Email Address',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (_includeEmail &&
|
||||
(value == null || value.trim().isEmpty)) {
|
||||
return 'Please enter an email address.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Sync log opt-in
|
||||
if (_selectedAccountId != null) ...[
|
||||
CheckboxListTile(
|
||||
title: const Text('Include recent sync log'),
|
||||
subtitle: const Text(
|
||||
'Helps diagnose connection and protocol issues.',
|
||||
),
|
||||
value: _includeSyncLog,
|
||||
onChanged: _submitting
|
||||
? null
|
||||
: (val) {
|
||||
setState(() => _includeSyncLog = val ?? false);
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// System info section
|
||||
FutureBuilder<PackageInfo>(
|
||||
future: _packageInfoFuture,
|
||||
builder: (context, snapshot) {
|
||||
final imapCount = _accounts
|
||||
.where((a) => a.type == AccountType.imap)
|
||||
.length;
|
||||
final jmapCount = _accounts
|
||||
.where((a) => a.type == AccountType.jmap)
|
||||
.length;
|
||||
final aboutMd = buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: snapshot.data,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: _deviceModel,
|
||||
);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
color: theme.dividerColor.withValues(alpha: 0.1),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ExpansionTile(
|
||||
title: const Text(
|
||||
'System Info (attached automatically)',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: MarkdownBody(data: aboutMd),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Submit Button
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submitReport,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Send Bug Report',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
child: Text('Show Mail Structure'),
|
||||
),
|
||||
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'bug_report',
|
||||
child: Text('Report a Bug'),
|
||||
),
|
||||
],
|
||||
onSelected: (value) async {
|
||||
if (value == 'forward' && header != null) {
|
||||
@@ -161,6 +166,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
_showStructure(context, body);
|
||||
} else if (value == 'rfc') {
|
||||
unawaited(_showRaw(context, header));
|
||||
} else if (value == 'bug_report') {
|
||||
unawaited(
|
||||
context.push('/bug-report?emailId=${widget.emailId}'),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Next
|
||||
|
||||
## Introduction
|
||||
|
||||
Continue the momentum from the safety hardening and infrastructure work.
|
||||
The focus is on making the app ready for real-world use with robust error
|
||||
handling and performance optimizations.
|
||||
|
||||
Create several small commits. Every commit should be self contained.
|
||||
|
||||
while working create/append to plan.log, so that the user sees what you are working on.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 0. deploy-android
|
||||
|
||||
Make `task deploy-android` work.
|
||||
|
||||
### 0.5 Debug duration of deploy-android
|
||||
|
||||
Is there a way to make deploy-android faster?
|
||||
|
||||
Use `task --verbose` to see what gets done.
|
||||
|
||||
Maybe avoid doing things again, when nothing changed.
|
||||
Taskfile has features to avoid calling things again, when the input has not changed.
|
||||
|
||||
### 1. Fix Android E2E Race Condition (aliceTile)
|
||||
|
||||
The Android E2E test `integration_test/app_e2e_test.dart` is flaky. It fails
|
||||
at `tap(aliceTile)` with "0 widgets" even though `pumpUntil` found it.
|
||||
The current "double pumpUntil" fix isn't reliable enough.
|
||||
Investigate if the animation state or the Drift stream propagation is the
|
||||
culprit.
|
||||
|
||||
### 2. Implement Global Crash Screen
|
||||
|
||||
Wrap `main()` in `runZonedGuarded` to catch unhandled async errors.
|
||||
Implement a `CrashScreen` widget that shows the stack trace and a
|
||||
"Copy to Clipboard" button for user reporting.
|
||||
|
||||
### 3. Database-Backed Threading
|
||||
|
||||
Currently, emails are grouped into threads in-memory in the repository.
|
||||
Refactor to store thread relationships in the local SQLite database.
|
||||
This is necessary for performance on mailboxes with thousands of messages.
|
||||
|
||||
### 4. Implement Undo for Bulk Actions
|
||||
|
||||
Add a global "Undo" snackbar after deleting or moving emails.
|
||||
The system needs to handle the three sync states:
|
||||
- Queued (easy to undo)
|
||||
- In-progress (cancel network call)
|
||||
- Finished (requires a reverse move/un-delete)
|
||||
|
||||
### 5. Transition to Real Account Testing
|
||||
|
||||
Prepare the integration tests to run against a real test account
|
||||
(`si3e2e@thomas-guettler.de`) instead of the local Stalwart server.
|
||||
This verifies the app against real-world network latency and RFC edge cases.
|
||||
|
||||
### 6. Coverage Gate Maintenance
|
||||
|
||||
Reduce the `_excluded` list in `scripts/check_coverage.dart`.
|
||||
Add a test to ensure the exclusion list doesn't contain files that no longer
|
||||
exist ("ghost paths").
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
tmp=$(mktemp /dev/shm/keystore.XXXXXX.jks)
|
||||
trap "rm -f $tmp" EXIT
|
||||
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$tmp"
|
||||
|
||||
ANDROID_KEYSTORE_PATH="$tmp" \
|
||||
ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" \
|
||||
fvm flutter build appbundle --release --no-pub \
|
||||
--build-number "$(date +%s)" \
|
||||
--build-name "$(date +%y%m%d-%H%M)" \
|
||||
--dart-define="GIT_HASH=$(git rev-parse --short HEAD)" \
|
||||
| grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
@@ -7,7 +7,7 @@ ROOT=$(git rev-parse --show-toplevel)
|
||||
FILE="$ROOT/ci/main.go"
|
||||
|
||||
# Static images from From("...") literals in ci/main.go
|
||||
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | sort -u)
|
||||
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | grep -v ':$' | sort -u)
|
||||
|
||||
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
|
||||
FVMRC="$ROOT/.fvmrc"
|
||||
|
||||
@@ -41,6 +41,7 @@ const _excluded = {
|
||||
'lib/ui/screens/account_send_screen.dart',
|
||||
'lib/ui/screens/add_account_screen.dart',
|
||||
'lib/ui/screens/address_emails_screen.dart',
|
||||
'lib/ui/screens/bug_report_screen.dart',
|
||||
'lib/ui/screens/changelog_screen.dart',
|
||||
'lib/ui/screens/combined_inbox_screen.dart',
|
||||
'lib/ui/screens/compose_screen.dart',
|
||||
|
||||
@@ -34,7 +34,7 @@ _filter_noise() {
|
||||
_run() {
|
||||
: > "$OUT" ; : > "$RC_FILE"
|
||||
{
|
||||
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
||||
timeout --kill-after=10 2400 dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
||||
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
||||
--project-id "$FIREBASE_PROJECT_ID"
|
||||
echo $? > "$RC_FILE"
|
||||
@@ -44,6 +44,10 @@ _run() {
|
||||
for attempt in 1 2 3; do
|
||||
_run && break
|
||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||
if [ "$RC" -eq 124 ]; then
|
||||
echo "::warning::[firebase] attempt $attempt/3 timed out after 2400s" >&2
|
||||
exit 124
|
||||
fi
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|No Dagger server responded" "$OUT"; then
|
||||
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
|
||||
else
|
||||
|
||||
@@ -54,12 +54,22 @@ echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
|
||||
chmod 600 ~/.ssh/dagger_key
|
||||
|
||||
# Add remote host to known_hosts
|
||||
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
_t0=$SECONDS
|
||||
timeout 30 ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
_elapsed=$(( SECONDS - _t0 ))
|
||||
if [ "$_elapsed" -gt 10 ]; then
|
||||
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
|
||||
fi
|
||||
|
||||
# Create a background SSH tunnel to the Dagger engine.
|
||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
||||
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
||||
_t0=$SECONDS
|
||||
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
||||
_elapsed=$(( SECONDS - _t0 ))
|
||||
if [ "$_elapsed" -gt 10 ]; then
|
||||
echo "::warning::SSH tunnel setup took ${_elapsed}s"
|
||||
fi
|
||||
|
||||
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BugReport represents the data stored in report.json
|
||||
type BugReport struct {
|
||||
Description string `json:"description"`
|
||||
AboutInfo string `json:"about_info"`
|
||||
EmailData string `json:"email_data,omitempty"`
|
||||
SyncLog string `json:"sync_log,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
var (
|
||||
rateLimitMu sync.Mutex
|
||||
requestTimes []time.Time
|
||||
)
|
||||
|
||||
// checkRateLimit implements a sliding window rate limiter: max 10 requests per minute globally.
|
||||
func checkRateLimit() (bool, time.Duration) {
|
||||
rateLimitMu.Lock()
|
||||
defer rateLimitMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
// Clean up timestamps older than 1 minute
|
||||
var valid []time.Time
|
||||
for _, t := range requestTimes {
|
||||
if now.Sub(t) < time.Minute {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
requestTimes = valid
|
||||
|
||||
if len(requestTimes) >= 10 {
|
||||
// Calculate time until the oldest request in the window falls out of it
|
||||
oldest := requestTimes[0]
|
||||
remaining := time.Minute - now.Sub(oldest)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return false, remaining
|
||||
}
|
||||
|
||||
requestTimes = append(requestTimes, now)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func generateUUID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Format as UUID v4 structure
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // Variant is 10
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
|
||||
}
|
||||
|
||||
func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Enable CORS so the web app (if applicable) can upload
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limiting check
|
||||
allowed, waitTime := checkRateLimit()
|
||||
if !allowed {
|
||||
retryAfter := int(waitTime.Seconds())
|
||||
if retryAfter < 1 {
|
||||
retryAfter = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Too many requests. Please try again later."})
|
||||
return
|
||||
}
|
||||
|
||||
// Limit body size to 20 MB (20 * 1024 * 1024 bytes)
|
||||
const maxBodySize = 20 * 1024 * 1024
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
|
||||
// Parse the multipart form
|
||||
err := r.ParseMultipartForm(maxBodySize)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse multipart form: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Request body too large or invalid multipart form."})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = r.MultipartForm.RemoveAll()
|
||||
}()
|
||||
|
||||
description := r.FormValue("description")
|
||||
aboutInfo := r.FormValue("about_info")
|
||||
|
||||
if description == "" || aboutInfo == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "description and about_info are required fields."})
|
||||
return
|
||||
}
|
||||
|
||||
email := r.FormValue("email")
|
||||
emailData := r.FormValue("email_data")
|
||||
syncLog := r.FormValue("sync_log")
|
||||
|
||||
uuidVal, err := generateUUID()
|
||||
if err != nil {
|
||||
log.Printf("Failed to generate UUID: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
timestampStr := now.Format("20060102_150405")
|
||||
dirName := fmt.Sprintf("%s_%s", timestampStr, uuidVal)
|
||||
reportDir := filepath.Join(storageDir, dirName)
|
||||
|
||||
err = os.MkdirAll(reportDir, 0750)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create report directory: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Write report.json
|
||||
report := BugReport{
|
||||
Description: description,
|
||||
AboutInfo: aboutInfo,
|
||||
EmailData: emailData,
|
||||
SyncLog: syncLog,
|
||||
Timestamp: now,
|
||||
}
|
||||
|
||||
reportJSONPath := filepath.Join(reportDir, "report.json")
|
||||
reportJSONFile, err := os.OpenFile(reportJSONPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create report.json: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer reportJSONFile.Close()
|
||||
|
||||
enc := json.NewEncoder(reportJSONFile)
|
||||
enc.SetIndent("", " ")
|
||||
err = enc.Encode(report)
|
||||
if err != nil {
|
||||
log.Printf("Failed to write report.json: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Write contact email to mail.eml (kept separate from report.json to isolate PII)
|
||||
if email != "" {
|
||||
mailEmlPath := filepath.Join(reportDir, "mail.eml")
|
||||
err = os.WriteFile(mailEmlPath, []byte(email), 0600)
|
||||
if err != nil {
|
||||
log.Printf("Failed to write mail.eml: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save attachments
|
||||
form := r.MultipartForm
|
||||
files := form.File["attachments[]"]
|
||||
for i, fileHeader := range files {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
log.Printf("Failed to open attachment %d: %v", i, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Sanitize filename to avoid directory traversal
|
||||
baseName := filepath.Base(fileHeader.Filename)
|
||||
attachmentName := fmt.Sprintf("attachment_%d_%s", i, baseName)
|
||||
attachmentPath := filepath.Join(reportDir, attachmentName)
|
||||
|
||||
destFile, err := os.OpenFile(attachmentPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create attachment file %s: %v", attachmentName, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, file)
|
||||
if err != nil {
|
||||
log.Printf("Failed to copy attachment content to %s: %v", attachmentName, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": uuidVal})
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("BUGREPORT_PORT")
|
||||
if port == "" {
|
||||
port = "8090"
|
||||
}
|
||||
|
||||
storageDir := os.Getenv("BUGREPORT_STORAGE_DIR")
|
||||
if storageDir == "" {
|
||||
storageDir = "./reports"
|
||||
}
|
||||
|
||||
// Create storage directory if it doesn't exist
|
||||
err := os.MkdirAll(storageDir, 0750)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create storage directory %s: %v", storageDir, err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/bug-reports", bugReportHandler(storageDir))
|
||||
|
||||
addr := net.JoinHostPort("127.0.0.1", port)
|
||||
log.Printf("Bug report server starting on %s...", addr)
|
||||
log.Printf("Reports storage directory: %s", storageDir)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -86,9 +86,11 @@ void main() {
|
||||
expect(find.textContaining('DB Schema Version'), findsWidgets);
|
||||
// Buttons are in the body, not in the AppBar actions
|
||||
expect(find.byIcon(Icons.copy), findsOneWidget);
|
||||
expect(find.byIcon(Icons.bug_report), findsOneWidget);
|
||||
expect(find.text('Copy to clipboard'), findsOneWidget);
|
||||
expect(find.text('Create issue'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.feedback_outlined), findsOneWidget);
|
||||
expect(find.text('Copy info'), findsOneWidget);
|
||||
expect(find.text('Public issue'), findsOneWidget);
|
||||
expect(find.text('Report bug'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('AboutScreen shows correct IMAP and JMAP account counts', (
|
||||
@@ -193,7 +195,7 @@ void main() {
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bug_report));
|
||||
await tester.tap(find.byIcon(Icons.bug_report_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 89 KiB |
@@ -1,57 +0,0 @@
|
||||
# Play Store Publishing Roadmap
|
||||
|
||||
To publish the Flutter app to the Play Store, you need to transition from a "development" state to a "production-ready" state.
|
||||
|
||||
Data Protection blabla page!
|
||||
|
||||
## 1. What has been done
|
||||
* **Application ID:** Changed to `de.sharedinbox.mua` (verified in `build.gradle.kts`, `MainActivity.kt`, and integration tests).
|
||||
* **Build Logic:** `android/app/build.gradle.kts` now supports:
|
||||
* **Local builds:** Using `key.properties` (ignored by git).
|
||||
* **CI builds:** Using environment variables (`ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`, `ANDROID_KEYSTORE_PASSWORD`).
|
||||
* **Taskfile:** Added `task build-android-bundle` to generate the `.aab` file.
|
||||
* **CI Workflow:** Created `.forgejo/workflows/release.yml` which triggers on merge to `main`.
|
||||
|
||||
|
||||
### A. Create the Keystore
|
||||
Run the helper script I created for you:
|
||||
```bash
|
||||
./t.sh
|
||||
```
|
||||
Follow the prompts and use a strong password (24-32 chars).
|
||||
|
||||
### B. Configure Codeberg Secrets
|
||||
Go to **Settings > Actions > Secrets** in your Codeberg repo and add:
|
||||
1. **`ANDROID_KEYSTORE_BASE64`**: The output of `base64 -w 0 android/app/upload-keystore.jks`.
|
||||
2. **`ANDROID_KEYSTORE_PASSWORD`**: Your keystore password.
|
||||
3. **`PLAY_STORE_CONFIG_JSON`**: The JSON key from your Google Play Service Account.
|
||||
|
||||
|
||||
### C. First Manual Upload
|
||||
Google Play requires the **very first upload** to be done manually through the web console:
|
||||
1. Generate your keystore using `./t.sh`.
|
||||
2. Run the build locally using temporary environment variables:
|
||||
```bash
|
||||
export ANDROID_KEYSTORE_PASSWORD=your_password
|
||||
nix develop --command task build-android-bundle
|
||||
```
|
||||
3. Upload the resulting `.aab` from `build/app/outputs/bundle/release/app-release.aab` to the Play Console (Internal Testing or Production track).
|
||||
4. This "locks in" your signing key.
|
||||
|
||||
## 2. What you need to do next
|
||||
|
||||
|
||||
## 3. Firebase Test Lab
|
||||
Once you have the Service Account JSON, you can add a task to `Taskfile.yml` to run automated tests on real devices:
|
||||
```yaml
|
||||
test-lab:
|
||||
desc: Run integration tests in Firebase Test Lab
|
||||
cmds:
|
||||
- gcloud firebase test android run \
|
||||
--type instrumentation \
|
||||
--app build/app/outputs/apk/debug/app-debug.apk \
|
||||
--test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
|
||||
--device model=virtuall1,version=30
|
||||
```
|
||||
|
||||
**Recommendation:** Complete step **A** (Keystore) and **B** (Secrets) first. Once the first manual upload is done, the CI will take over for all future merges to `main`.
|
||||