Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76851c893 | ||
|
|
c04764b565 |
@@ -4,7 +4,6 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ jobs:
|
|||||||
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
||||||
name: Build & Deploy Windows (Nightly)
|
name: Build & Deploy Windows (Nightly)
|
||||||
runs-on: windows-runner
|
runs-on: windows-runner
|
||||||
timeout-minutes: 90
|
|
||||||
if: false
|
if: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ repos:
|
|||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
|
||||||
- repo: https://github.com/guettli/sync-branch
|
- repo: https://github.com/guettli/pre-commit-branch-up-to-date
|
||||||
rev: v0.0.11
|
rev: v0.0.5
|
||||||
hooks:
|
hooks:
|
||||||
- id: sync-branch
|
- id: branch-up-to-date
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
@@ -32,7 +32,7 @@ repos:
|
|||||||
- id: dart-check
|
- id: dart-check
|
||||||
name: dart format (autofix) + check-fast (parallel)
|
name: dart format (autofix) + check-fast (parallel)
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: ci-no-direct-dagger
|
- id: ci-no-direct-dagger
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 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,3 +216,8 @@ test/
|
|||||||
- **Settings** — list and remove accounts
|
- **Settings** — list and remove accounts
|
||||||
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
|
- **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
|
- **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
|
||||||
|
|||||||
+45
-26
@@ -96,19 +96,34 @@ tasks:
|
|||||||
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
codegen:
|
codegen:
|
||||||
desc: Generate Drift DB code via Dagger (exports generated files back to host)
|
desc: Generate Drift DB code (run after any schema change)
|
||||||
|
deps: [_preflight, _pub-get]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- pubspec.yaml
|
||||||
|
generates:
|
||||||
|
- lib/**/*.g.dart
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. codegen -o .
|
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
desc: Static analysis via Dagger (dart analyze --fatal-infos)
|
desc: Static analysis (flutter analyze)
|
||||||
|
deps: [_preflight, _codegen]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/**/*.dart
|
||||||
|
- pubspec.yaml
|
||||||
|
- analysis_options.yaml
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. analyze
|
- scripts/run_analyze.sh
|
||||||
|
|
||||||
format:
|
format:
|
||||||
desc: Format all Dart source files via Dagger (writes back to host)
|
desc: Format all Dart source files
|
||||||
|
deps: [_preflight]
|
||||||
|
sources:
|
||||||
|
- "**/*.dart"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. format-write -o .
|
- fvm dart format lib test
|
||||||
|
|
||||||
check-mocks:
|
check-mocks:
|
||||||
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
||||||
@@ -121,9 +136,13 @@ tasks:
|
|||||||
- scripts/check_mocks_fresh.sh
|
- scripts/check_mocks_fresh.sh
|
||||||
|
|
||||||
analyze-fix:
|
analyze-fix:
|
||||||
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
|
desc: Auto-fix lint issues with dart fix --apply
|
||||||
|
deps: [_preflight]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/**/*.dart
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
|
- fvm dart fix --apply
|
||||||
|
|
||||||
test:
|
test:
|
||||||
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
||||||
@@ -158,17 +177,17 @@ tasks:
|
|||||||
test-backend:
|
test-backend:
|
||||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
|
- dagger call --progress=plain -q -m ci --source=. test-backend
|
||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
|
- dagger call --progress=plain -q -m ci --source=. test-integration
|
||||||
|
|
||||||
sync-reliability:
|
sync-reliability:
|
||||||
desc: Run sync reliability runner (via Dagger)
|
desc: Run sync reliability runner (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||||
|
|
||||||
test-android-firebase:
|
test-android-firebase:
|
||||||
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||||
@@ -183,7 +202,7 @@ tasks:
|
|||||||
ci-graph:
|
ci-graph:
|
||||||
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||||
cmds:
|
cmds:
|
||||||
- timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
|
- dagger call --progress=plain -q -m ci --source=. graph
|
||||||
|
|
||||||
stalwart:
|
stalwart:
|
||||||
desc: Start a Stalwart instance for local development (via Dagger)
|
desc: Start a Stalwart instance for local development (via Dagger)
|
||||||
@@ -199,13 +218,13 @@ tasks:
|
|||||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
msg: "SSH_KNOWN_HOSTS is not set"
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- 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"
|
- 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"
|
||||||
|
|
||||||
build-android-bundle:
|
build-android-bundle:
|
||||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
cmds:
|
cmds:
|
||||||
- mkdir -p build/app/outputs/bundle/release
|
- mkdir -p build/app/outputs/bundle/release
|
||||||
- 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
|
- 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
|
||||||
|
|
||||||
upload-android-bundle:
|
upload-android-bundle:
|
||||||
desc: Upload AAB from build/ to Play Store via Dagger
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
@@ -215,7 +234,7 @@ tasks:
|
|||||||
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||||
msg: "AAB not found — run build-android-bundle first"
|
msg: "AAB not found — run build-android-bundle first"
|
||||||
cmds:
|
cmds:
|
||||||
- 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
|
- 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:
|
publish-android:
|
||||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||||
@@ -228,7 +247,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- 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"
|
- 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"
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
desc: Build and deploy Android APK via Dagger
|
desc: Build and deploy Android APK via Dagger
|
||||||
@@ -242,7 +261,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- 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)"
|
- 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)"
|
||||||
|
|
||||||
publish-website:
|
publish-website:
|
||||||
desc: Build and publish website via Dagger
|
desc: Build and publish website via Dagger
|
||||||
@@ -252,7 +271,7 @@ tasks:
|
|||||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
msg: "SSH_KNOWN_HOSTS is not set"
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- 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"
|
- 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"
|
||||||
|
|
||||||
check-dagger:
|
check-dagger:
|
||||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||||
@@ -332,7 +351,7 @@ tasks:
|
|||||||
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
||||||
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||||
|
|
||||||
integration-android:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||||
@@ -525,14 +544,15 @@ tasks:
|
|||||||
deploy-android-bundle:
|
deploy-android-bundle:
|
||||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||||
deps: [build-android-bundle-local]
|
deps: [build-android-bundle-local]
|
||||||
dotenv: [".env"]
|
preconditions:
|
||||||
|
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||||
|
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
- python3 scripts/deploy_playstore.py
|
||||||
|
|
||||||
build-android-bundle-local:
|
build-android-bundle-local:
|
||||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||||
dotenv: [".env"]
|
|
||||||
method: timestamp
|
method: timestamp
|
||||||
sources:
|
sources:
|
||||||
- lib/**/*.dart
|
- lib/**/*.dart
|
||||||
@@ -541,7 +561,7 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/app/outputs/bundle/release/app-release.aab
|
- build/app/outputs/bundle/release/app-release.aab
|
||||||
cmds:
|
cmds:
|
||||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
- 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"
|
||||||
|
|
||||||
deploy-android:
|
deploy-android:
|
||||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||||
@@ -671,9 +691,8 @@ tasks:
|
|||||||
${SSH_USER}@${SSH_HOST}:public_html/
|
${SSH_USER}@${SSH_HOST}:public_html/
|
||||||
|
|
||||||
check-fast:
|
check-fast:
|
||||||
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
|
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
||||||
cmds:
|
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
||||||
- dagger call --progress=plain -q -m ci --source=. check-fast
|
|
||||||
|
|
||||||
check-layers:
|
check-layers:
|
||||||
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||||
|
|||||||
@@ -22,17 +22,15 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
if (ksPath != null) {
|
// Hardcoded alias matching t.sh
|
||||||
signingConfigs {
|
keyAlias = "upload"
|
||||||
create("release") {
|
// Use the same password for both key and keystore
|
||||||
keyAlias = "upload"
|
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
|
storePassword = pass
|
||||||
storePassword = pass
|
keyPassword = pass
|
||||||
keyPassword = pass
|
storeFile = file("upload-keystore.jks")
|
||||||
storeFile = file(ksPath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +46,14 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
if (ksPath != null) {
|
// Use the signing config defined above for release builds.
|
||||||
signingConfig = signingConfigs.getByName("release")
|
// 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
isShrinkResources = false
|
isShrinkResources = false
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
|
||||||
|
|||||||
+1
-64
@@ -440,68 +440,6 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
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.
|
// 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
|
// It snapshots the committed source (including any stale generated files) before
|
||||||
// running build_runner, so git diff detects real staleness instead of always
|
// running build_runner, so git diff detects real staleness instead of always
|
||||||
@@ -749,8 +687,7 @@ func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagg
|
|||||||
return m.androidBase().
|
return m.androidBase().
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||||
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/upload-keystore.jks")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# Plan Log
|
|
||||||
|
|
||||||
## 2026-05-10
|
|
||||||
- Improved Undo Log (Issue #7): Added support for undoing any action from history.
|
|
||||||
- Refactored `UndoService.undo()` to support targeted rollbacks by action ID.
|
|
||||||
- Removed "latest only" restriction from `UndoLogScreen`.
|
|
||||||
- Successfully deployed release APK to distribution server via `task deploy-android`.
|
|
||||||
- Verified system integrity with unit, widget, and E2E integration tests.
|
|
||||||
|
|
||||||
## 2026-05-10
|
|
||||||
- Implemented global Undo Log with persistent history.
|
|
||||||
- Refactored `UndoService` to store a list of recent actions instead of just the latest one.
|
|
||||||
- Added `UndoLogScreen` to view and interact with undo history.
|
|
||||||
- Added "History" icon to account list for better discoverability.
|
|
||||||
- Updated `.gitignore` to better handle Dart/Flutter and Android tool artifacts.
|
|
||||||
- Verified all changes with fast check suite (analyze + unit + widget tests).
|
|
||||||
|
|
||||||
## 2026-05-09
|
|
||||||
- Fixed Crash Page (Issue 3): Added Codeberg reporting button.
|
|
||||||
- Fixed Show Mail Headers (Issue 1): Added raw header storage and UI display.
|
|
||||||
- Fixed Exception on Undo of delete (Issue 2): Added serialization to EmailAddress.
|
|
||||||
- Updated Taskfile with Nix experimental features check.
|
|
||||||
- Pushed all changes to branch `fix-issues`.
|
|
||||||
|
|
||||||
## 2026-05-09
|
|
||||||
- Fixed Undo feature for IMAP accounts.
|
|
||||||
- Identified that IMAP moveEmail hard-deletes local rows, making Undo impossible without data.
|
|
||||||
- Added `originalEmails` to `UndoAction` and `restoreEmails` to `EmailRepository`.
|
|
||||||
- Updated UI to fetch email data before move/delete to support restoration.
|
|
||||||
- Fixed `UndoService` to restore rows and be more robust with pending change cancellation.
|
|
||||||
- Verified with `test/unit/undo_reproduction_test.dart` and updated unit tests.
|
|
||||||
- Successfully deployed to Android.
|
|
||||||
|
|
||||||
## 2026-05-09
|
|
||||||
- Implemented Network Resilience (Task 1/4 from next.md).
|
|
||||||
- Added exponential backoff logic (5s to 15m) to IMAP and JMAP sync loops.
|
|
||||||
- Added permanent error detection (auth/credentials) to stop sync loops gracefully.
|
|
||||||
- Improved "Pull to Refresh" in email list to trigger full account sync and bypass backoff.
|
|
||||||
- Verified with integration tests.
|
|
||||||
|
|
||||||
- Started work on Sync Reliability (Task 1/5 from next.md).
|
|
||||||
- Added `verifySyncReliability` to `EmailRepository` interface and models.
|
|
||||||
- Implemented `verifySyncReliability` in `EmailRepositoryImpl` for IMAP and JMAP.
|
|
||||||
- Added `SyncHealth` table to database (Schema v19).
|
|
||||||
- Created `ReliabilityRunner` for periodic verification.
|
|
||||||
- Integrated sync health indicators in `AccountListScreen` UI.
|
|
||||||
- Added manual "Verify sync health" action.
|
|
||||||
- Verified with new integration tests in `test/integration/sync_reliability_test.dart`.
|
|
||||||
- All integration tests (IMAP and JMAP) passing.
|
|
||||||
- Fixed several compilation and analysis issues.
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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").
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/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"
|
FILE="$ROOT/ci/main.go"
|
||||||
|
|
||||||
# Static images from From("...") literals in ci/main.go
|
# Static images from From("...") literals in ci/main.go
|
||||||
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | grep -v ':$' | sort -u)
|
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | sort -u)
|
||||||
|
|
||||||
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
|
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
|
||||||
FVMRC="$ROOT/.fvmrc"
|
FVMRC="$ROOT/.fvmrc"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ _filter_noise() {
|
|||||||
_run() {
|
_run() {
|
||||||
: > "$OUT" ; : > "$RC_FILE"
|
: > "$OUT" ; : > "$RC_FILE"
|
||||||
{
|
{
|
||||||
timeout --kill-after=10 2400 dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
||||||
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
||||||
--project-id "$FIREBASE_PROJECT_ID"
|
--project-id "$FIREBASE_PROJECT_ID"
|
||||||
echo $? > "$RC_FILE"
|
echo $? > "$RC_FILE"
|
||||||
@@ -44,10 +44,6 @@ _run() {
|
|||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
_run && break
|
_run && break
|
||||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
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
|
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
|
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -54,22 +54,12 @@ echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
|
|||||||
chmod 600 ~/.ssh/dagger_key
|
chmod 600 ~/.ssh/dagger_key
|
||||||
|
|
||||||
# Add remote host to known_hosts
|
# Add remote host to known_hosts
|
||||||
_t0=$SECONDS
|
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
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.
|
# Create a background SSH tunnel to the Dagger engine.
|
||||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
||||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
||||||
_t0=$SECONDS
|
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
||||||
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 to use the tunnel.
|
||||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 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`.
|
||||||
Reference in New Issue
Block a user