Skip to content

TF-4606 Fix cannot download attachment#4608

Merged
hoangdat merged 16 commits into
masterfrom
bugfix/tf-4606-cannot-download-attachment
Jun 12, 2026
Merged

TF-4606 Fix cannot download attachment#4608
hoangdat merged 16 commits into
masterfrom
bugfix/tf-4606-cannot-download-attachment

Conversation

@dab246

@dab246 dab246 commented Jun 12, 2026

Copy link
Copy Markdown
Member

Problem

#4606

Two symptoms were reported:

  1. Attachment download fails — tapping "Download" shows a "download failed" toast.
  2. App restart logs user out — after the failure, killing and reopening the app bounces straight to login.

Both stem from the same root cause: the Hive box storing the OIDC token becomes corrupted (cross-isolate race between the main app and the FCM background isolate), and the app had no recovery mechanism.


Root Cause

flowchart TD
    A[FCM isolate + main app write to same Hive file concurrently]
    A --> B[Partial write — AES block boundary violated]
    B --> C[getStoredTokenOIDC throws RangeError]
    C --> D1[Download fails — toast shown]
    C --> D2[Box never cleared]
    D2 --> E[App restart → same error → logout loop]
Loading

Download fails: ExportAttachmentInteractor calls getStoredTokenOIDC. The RangeError propagates to DownloadController → "download failed" toast. Box is left corrupt.

Logout loop on restart: GetStoredTokenOidcInteractor reads the same corrupt box → GetStoredTokenOidcFailureisNotSignedIn()clearDataAndGoToLoginPage(). Every restart repeats.

Secondary — persistOneTokenOidc propagates corruption: When Dio refreshes an expired token, _removeStaleTokens calls getMapItems()toMap(). A corrupt neighbour entry causes toMap() to crash before pruning completes, propagating UnknownRemoteException to the in-flight request.

Minor — wrong argument to failure handlers: DownloadController was passing the ExportAttachmentFailure wrapper instead of failure.exception, so CancelDownloadFileException was never matched — cancelled downloads showed a spurious "download failed" toast.


Fix

Three defence layers applied in depth.

Layer 1 — TokenOidcCacheManager clears the corrupted box on first detection

getTokenOidc: catches any non-AppBaseException (AES decryption error, RangeError, etc.), logs to Sentry, clears the box, throws NotFoundStoredTokenException.

persistOneTokenOidc: writes the new token first (crash-safe), then prunes stale entries. If pruning crashes on a corrupt neighbour entry, recovers by clearing the box and re-inserting only the new valid token.

→ Eliminates the logout loop on restart. Does not prevent the current-session download failure on its own.


Layer 2 — Export interactors fall back to the in-memory token and repair storage

After Layer 1 clears the box, the valid OIDC token is still held in memory by AuthorizationInterceptors._token. Layer 2 uses it.

flowchart TD
    A[ExportAttachmentInteractor needs OIDC token] --> B{getStoredTokenOIDC accountCacheKey}
    B -- success --> C[Use storage token]
    B -- throws --> D{fallbackToken present?}
    D -- no --> E[Rethrow → download fails]
    D -- yes --> F[Use in-memory token from AuthorizationInterceptors]
    F --> G[persistTokenOIDCAt accountCacheKey — fire & forget]
    G -- success --> H[Box healed at correct key — token survives app restart]
    G -- fails --> I[logError to Sentry]
    F --> J[Download proceeds normally]
Loading

ExportAttachmentRequest and ExportAllAttachmentsRequest carry an optional fallbackToken field, populated from AuthorizationInterceptors.currentToken at the call site. Both interactors resolve the token via _getTokenOidc(accountCacheKey, fallbackToken: request.fallbackToken).

Why persistTokenOIDCAt(accountCacheKey, ...) matters: GetStoredTokenOidcInteractor looks up the token at startup using personalAccount.id (AccountCache.id) as the Hive key. persistTokenOIDC stores under fallbackToken.tokenIdHash. These can diverge if a prior 401 token refresh updated _token in memory but _updateCurrentAccount failed before setCurrentAccount ran — leaving _token.tokenIdHash ≠ currentAccount.id. Repairing under fallbackToken.tokenIdHash would write to a key startup never reads, causing logout. persistTokenOIDCAt stores at the explicit accountCacheKey that startup will use.

persistTokenOIDCAt is fire-and-forget (not awaited) so the download is never blocked by a storage repair. On repair failure, logError with the exception is sent to Sentry.

→ Download succeeds in the current session even when storage is corrupt.
→ Box is healed at the correct key so the token survives an app restart without requiring re-login.


Layer 3 — DownloadController passes the correct exception to failure handlers

// Before
exportAttachmentFailureAction(failure);
// After
exportAttachmentFailureAction(failure.exception);

CancelDownloadFileException check works correctly; cancelled downloads no longer show a "download failed" toast.


Outcome

Scenario Before After
Box corrupt + download Fails with toast, box left corrupt Succeeds using in-memory token, box auto-repaired
Box corrupt + app restart Logout loop (every restart) No logout — token re-persisted at accountCacheKey
_token hash diverges from currentAccount.id + restart Logout No logout — repair writes at the key startup reads
Box corrupt + token refresh persistOneTokenOidc throws → request fails Token refresh succeeds, box recovered
User cancels download Spurious "download failed" toast Silent cancel (correct)

Summary by CodeRabbit

  • New Features

    • Request-based export flows for single and all-attachments downloads with optional fallback token support.
    • New request models for export operations.
    • New explicit keyed token-persistence API.
  • Enhancements

    • Improved token-cache resilience: detects corruption, clears/rebuilds cache, and forces clean re-authentication when needed.
    • Public accessor to read current in-memory OIDC token.
    • Export failures now forward the underlying exception for clearer reporting.
  • Tests

    • Expanded coverage and new test helpers/stubs for cache recovery, fallback-token repair, and export flows.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds guarded recovery around TokenOidc cache reads/writes (try/catch, safe clear), introduces MemoryTokenOidcCacheClient and StubTokenOidcCacheClient for tests, and adds tests for cache recovery and persistence. It also adds ExportAttachmentRequest and ExportAllAttachmentsRequest models, refactors ExportAttachmentInteractor and ExportAllAttachmentsInteractor to accept requests and use fallback tokens with repair persistence, updates controller wiring to build those requests, exposes AuthorizationInterceptors.currentToken, and forwards failure.exception to export failure actions.

Possibly related PRs

  • linagora/tmail-flutter#4605: Both PRs modify TokenOidcCacheManager.persistOneTokenOidc (in different ways) to change how token-cache writes/recovery/pruning are handled, so the changes are code-level related.

Suggested reviewers

  • hoangdat
  • tddang-linagora
  • codescene-delta-analysis
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'TF-4606 Fix cannot download attachment' directly addresses the main issue: enabling attachment downloads when Hive box corruption prevents OIDC token retrieval. The title captures the primary user-facing problem being resolved.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/tf-4606-cannot-download-attachment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@hoangdat

Copy link
Copy Markdown
Member

How about we should support boxItem.flush() inside insertItem as a defensive to prevent mid-write broken?

  Future<void> insertItem(
    String key,
    T newObject, {
    bool isolated = true,
    bool flushAfterWrite = false,
  }) {
    log('$runtimeType::insertItem:encryption: $encryption - key = $key - isolated = $isolated');
    return Future.sync(() async {
      if (isolated) {
        final boxItem = await openIsolatedBox();
        await boxItem.put(key, newObject);
        // Flush pending writes to reduce the risk of losing recently written
        // encrypted data if the app is terminated immediately after a write.
        // Improves durability; does NOT guarantee atomic persistence. No-op on
        // web (IndexedDB writes are already transactional).
        if (flushAfterWrite) await boxItem.flush();
      } else {
        final boxItem = await openBox();
        await boxItem.put(key, newObject);
        if (flushAfterWrite) await boxItem.flush();
      }
    }).catchError((error) {
      throw error;
    });
  }

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/features/login/data/local/token_oidc_cache_manager.dart`:
- Around line 43-72: persistOneTokenOidc currently wraps both
_tokenOidcCacheClient.insertItem(...) and _removeStaleTokens(...) in one
try/catch so any error from the initial insert triggers full box recovery;
change this so the insert is performed outside the recovery catch and only
_removeStaleTokens is wrapped to trigger recovery on failure. Concretely: call
await _tokenOidcCacheClient.insertItem(tokenOIDC.tokenIdHash, ...) first and let
AppBaseException rethrow as before; then wrap only await
_removeStaleTokens(keepKey: ...) in a try/catch that on non-AppBaseException
logs and calls _recoverBoxWithToken(tokenOIDC). Also update _recoverBoxWithToken
to not silently swallow a failed re-insert (make it propagate or surface the
error after logging) so insertItem failures aren’t ignored; refer to
persistOneTokenOidc, _removeStaleTokens, _recoverBoxWithToken, _safelyClearBox
and insertItem to locate the changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 65dfe16a-e9dc-4585-8ea3-74bacd045cd4

📥 Commits

Reviewing files that changed from the base of the PR and between 4a2d96c and 638d4a7.

📒 Files selected for processing (4)
  • lib/features/download/presentation/controllers/download_controller.dart
  • lib/features/login/data/local/token_oidc_cache_manager.dart
  • test/features/login/data/local/memory_token_oidc_cache_client.dart
  • test/features/login/data/local/token_oidc_cache_manager_test.dart

Comment thread lib/features/login/data/local/token_oidc_cache_manager.dart
@github-actions

Copy link
Copy Markdown

This PR has been deployed to https://linagora.github.io/tmail-flutter/4608.

@dab246

dab246 commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

How about we should support boxItem.flush() inside insertItem as a defensive to prevent mid-write broken?

  Future<void> insertItem(
    String key,
    T newObject, {
    bool isolated = true,
    bool flushAfterWrite = false,
  }) {
    log('$runtimeType::insertItem:encryption: $encryption - key = $key - isolated = $isolated');
    return Future.sync(() async {
      if (isolated) {
        final boxItem = await openIsolatedBox();
        await boxItem.put(key, newObject);
        // Flush pending writes to reduce the risk of losing recently written
        // encrypted data if the app is terminated immediately after a write.
        // Improves durability; does NOT guarantee atomic persistence. No-op on
        // web (IndexedDB writes are already transactional).
        if (flushAfterWrite) await boxItem.flush();
      } else {
        final boxItem = await openBox();
        await boxItem.put(key, newObject);
        if (flushAfterWrite) await boxItem.flush();
      }
    }).catchError((error) {
      throw error;
    });
  }

After analyzing, flush() unfortunately doesn't address the corruption here, and could actually make one scenario worse.

Why it doesn't fix cross-isolate race

The confirmed failure mode is two independent IsolatedHive worker isolates writing to the same .hive file concurrently — the FCM isolate spawns its own worker when the main app's worker isn't reachable via FcmIsolateNameServer. flush() only ensures worker A's data reaches disk sooner; it has no effect on worker B interleaving its writes into the same file at the same time. The two workers remain completely unaware of each other.

Why it may worsen partial write on process kill

flush() addresses durability (page cache → disk), not atomicity (write is either complete or not). Hive CE writes a token as multiple sequential syscalls: [length_prefix][...data bytes...].

Outcome on process kill
Without flush() OS buffer may be discarded entirely → old valid entry remains intact on disk
With flush() after each syscall Partial data committed to disk with certainty → length_prefix=1952 on disk, only 1949 bytes of data written → same corruption state

The proposed code's own comment acknowledges this: "does NOT guarantee atomic persistence".

API design concern

With flushAfterWrite = false as the default, existing callers get no benefit unless they explicitly opt in — so the defensive goal isn't achieved unless we also change call sites. Flipping the default to true would impose an fsync on every write across all cache clients (mailbox, email, session…) without addressing the root cause.

Correct direction

The real fix requires either removing the shared-file write path altogether or using a storage backend with OS-level atomic writes. That's tracked as a follow-up PR — migrating the OIDC token to Flutter Secure Storage (iOS Keychain / Android Keystore), where each write() is atomic by the OS and there is no shared .hive file between isolates.

@hoangdat

Copy link
Copy Markdown
Member

How about we should have the fallback token with token which in the memory (interceptor)? It means if we failed to get token in Hive, we try one more time with token in memory to download?

codescene-delta-analysis[bot]

This comment was marked as outdated.

codescene-delta-analysis[bot]

This comment was marked as outdated.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@dab246

dab246 commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

How about we should have the fallback token with token which in the memory (interceptor)? It means if we failed to get token in Hive, we try one more time with token in memory to download?

Agree. applied

codescene-delta-analysis[bot]

This comment was marked as outdated.

@dab246 dab246 marked this pull request as ready for review June 12, 2026 07:40
@dab246 dab246 changed the title [WIP] TF-4606 Fix cannot download attachment TF-4606 Fix cannot download attachment Jun 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/features/download/domain/usecase/export_attachment_interactor_test.dart (1)

80-181: ⚡ Quick win

Assert the AccountRequest contents, not just the emitted success/failure.

These cases currently pass even if the interactor builds the wrong auth material, because downloadRepository.exportAttachment(any, any, any, any, any) accepts any AccountRequest. Please add verify(...)/captureAny assertions for the OIDC stored-token path, the fallback-token path, and the basic-auth path so the tests prove the new wiring rather than only the stream shape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/features/download/domain/usecase/export_attachment_interactor_test.dart`
around lines 80 - 181, Add assertions that verify the exact AccountRequest
passed to downloadRepository.exportAttachment instead of allowing any; after
each test call to interactor.execute(buildRequest(...)) add verify/capture on
downloadRepository.exportAttachment and assert the captured AccountRequest's
auth fields: for OIDC path assert token equals OIDCFixtures.newTokenOidc, for
fallback-token path assert token equals provided fallback token when
authenticationOIDCRepository.getStoredTokenOIDC throws, and for basic-auth path
assert credentials equal AuthenticationInfoCache('user@example.com','secret')
(referencing exportAttachment, interactor.execute,
authenticationOIDCRepository.getStoredTokenOIDC,
credentialRepository.getAuthenticationInfoStored,
accountRepository.getCurrentAccount).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@lib/features/login/data/network/interceptors/authorization_interceptors.dart`:
- Line 75: The currentToken getter can leak stale OIDC tokens after
setBasicAuthorization() switches auth type; update the getter to return _token
only when _authenticationType == AuthenticationType.oidc (otherwise return null)
so ExportAttachmentRequest/ExportAllAttachmentsRequest cannot pick up an OIDC
token during basic auth; alternatively, if you prefer state mutation, clear
_token and _configOIDC inside setBasicAuthorization() after setting
_authenticationType/_authorization—pick one approach and apply it to the symbols
currentToken, _token, _authenticationType, setBasicAuthorization(), and
_configOIDC.

---

Nitpick comments:
In
`@test/features/download/domain/usecase/export_attachment_interactor_test.dart`:
- Around line 80-181: Add assertions that verify the exact AccountRequest passed
to downloadRepository.exportAttachment instead of allowing any; after each test
call to interactor.execute(buildRequest(...)) add verify/capture on
downloadRepository.exportAttachment and assert the captured AccountRequest's
auth fields: for OIDC path assert token equals OIDCFixtures.newTokenOidc, for
fallback-token path assert token equals provided fallback token when
authenticationOIDCRepository.getStoredTokenOIDC throws, and for basic-auth path
assert credentials equal AuthenticationInfoCache('user@example.com','secret')
(referencing exportAttachment, interactor.execute,
authenticationOIDCRepository.getStoredTokenOIDC,
credentialRepository.getAuthenticationInfoStored,
accountRepository.getCurrentAccount).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2d39ae13-0413-4a0b-a7ec-c13287a33fc3

📥 Commits

Reviewing files that changed from the base of the PR and between 638d4a7 and 68c0a6f.

📒 Files selected for processing (11)
  • lib/features/download/domain/model/export_all_attachments_request.dart
  • lib/features/download/domain/model/export_attachment_request.dart
  • lib/features/download/domain/usecase/export_all_attachments_interactor.dart
  • lib/features/download/domain/usecase/export_attachment_interactor.dart
  • lib/features/download/presentation/extensions/download_attachment_download_controller_extension.dart
  • lib/features/login/data/local/token_oidc_cache_manager.dart
  • lib/features/login/data/network/interceptors/authorization_interceptors.dart
  • test/features/download/domain/usecase/export_all_attachments_interactor_test.dart
  • test/features/download/domain/usecase/export_attachment_interactor_test.dart
  • test/features/login/data/local/stub_token_oidc_cache_clients.dart
  • test/features/login/data/local/token_oidc_cache_manager_test.dart
✅ Files skipped from review due to trivial changes (1)
  • test/features/download/domain/usecase/export_all_attachments_interactor_test.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/features/login/data/local/token_oidc_cache_manager.dart

Comment thread lib/features/login/data/network/interceptors/authorization_interceptors.dart Outdated
codescene-delta-analysis[bot]

This comment was marked as outdated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
lib/features/download/domain/usecase/export_all_attachments_interactor.dart (1)

72-78: ⚡ Quick win

Prefer Object over dynamic for the error parameter.

The catchError callback parameter should use Object instead of dynamic to maintain static type safety, consistent with Effective Dart guidance and this codebase's convention for exception parameters.

♻️ Suggested fix
         _authenticationOIDCRepository.persistTokenOIDC(fallbackToken).catchError(
-          (dynamic repairError) => logError(
+          (Object repairError) => logError(
             'ExportAllAttachmentsInteractor::_getTokenOidc(): '
             'failed to repair token storage | error=${repairError.runtimeType}',
             exception: repairError,
           ),
         );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/download/domain/usecase/export_all_attachments_interactor.dart`
around lines 72 - 78, The catchError callback uses a dynamic parameter; change
it to Object to follow project conventions and preserve static type safety:
update the lambda parameter in the call to
_authenticationOIDCRepository.persistTokenOIDC(fallbackToken).catchError from
(dynamic repairError) => ... to (Object repairError) => ... and ensure the
existing usage of repairError (e.g., repairError.runtimeType and exception:
repairError) still compiles with Object.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@lib/features/download/domain/usecase/export_all_attachments_interactor.dart`:
- Around line 72-78: The catchError callback uses a dynamic parameter; change it
to Object to follow project conventions and preserve static type safety: update
the lambda parameter in the call to
_authenticationOIDCRepository.persistTokenOIDC(fallbackToken).catchError from
(dynamic repairError) => ... to (Object repairError) => ... and ensure the
existing usage of repairError (e.g., repairError.runtimeType and exception:
repairError) still compiles with Object.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3b5c9315-d90a-4d89-a6c7-e6372303fd73

📥 Commits

Reviewing files that changed from the base of the PR and between 68c0a6f and ad340d6.

📒 Files selected for processing (4)
  • lib/features/download/domain/usecase/export_all_attachments_interactor.dart
  • lib/features/download/domain/usecase/export_attachment_interactor.dart
  • test/features/download/domain/usecase/export_all_attachments_interactor_test.dart
  • test/features/download/domain/usecase/export_attachment_interactor_test.dart
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/features/download/domain/usecase/export_all_attachments_interactor_test.dart
  • lib/features/download/domain/usecase/export_attachment_interactor.dart
  • test/features/download/domain/usecase/export_attachment_interactor_test.dart

codescene-delta-analysis[bot]

This comment was marked as outdated.

codescene-delta-analysis[bot]

This comment was marked as outdated.

codescene-delta-analysis[bot]

This comment was marked as outdated.

Comment thread lib/features/login/data/local/token_oidc_cache_manager.dart
Comment thread lib/features/login/data/local/token_oidc_cache_manager.dart
@dab246 dab246 requested a review from hoangdat June 12, 2026 09:53
codescene-delta-analysis[bot]

This comment was marked as outdated.

@codescene-delta-analysis codescene-delta-analysis Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our agent can fix these. Install it.

Gates Passed
3 Quality Gates Passed

Quality Gate Profile: The Bare Minimum
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.

Comment thread lib/features/login/data/local/token_oidc_cache_manager.dart
@hoangdat hoangdat merged commit 663cb61 into master Jun 12, 2026
21 checks passed
@hoangdat hoangdat deleted the bugfix/tf-4606-cannot-download-attachment branch June 12, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants