diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d79ca4079e..9da107b32a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -101,12 +101,15 @@ jobs: # 1. Install Sentry CLI curl -sL https://sentry.io/get-cli/ | SENTRY_CLI_VERSION="2.42.2" bash - # 2. Validate tag version matches pubspec.yaml version - TAG_VERSION="${GITHUB_REF_NAME#v}" - TAG_VERSION="${TAG_VERSION%%-rc*}" + # 2. Validate tag base version matches pubspec.yaml base version (ignoring -rc* suffix). + # pubspec version is always X.Y.Z (no -rc suffix) — RC distinction lives only in the git tag. + # SENTRY_RELEASE = pubspec version, matching exactly what the app reports at runtime. + TAG_BASE="${GITHUB_REF_NAME#v}" + TAG_BASE="${TAG_BASE%%-rc*}" PUBSPEC_VERSION=$(awk '/^version:/{split($2,a,"+"); print a[1]; exit}' pubspec.yaml) - if [ "$PUBSPEC_VERSION" != "$TAG_VERSION" ]; then - echo "::error::Tag version ($TAG_VERSION) does not match pubspec version ($PUBSPEC_VERSION)." + PUBSPEC_BASE="${PUBSPEC_VERSION%%-rc*}" + if [ "$PUBSPEC_BASE" != "$TAG_BASE" ]; then + echo "::error::Tag version ($TAG_BASE) does not match pubspec version ($PUBSPEC_BASE)." exit 1 fi SENTRY_RELEASE="$PUBSPEC_VERSION" @@ -117,7 +120,6 @@ jobs: # 4. Upload ProGuard Mapping MAPPING_FILE="build/app/outputs/mapping/release/mapping.txt" - if [ -f "$MAPPING_FILE" ]; then echo "Found mapping.txt, uploading ProGuard mapping..." sentry-cli upload-proguard "$MAPPING_FILE" @@ -128,7 +130,6 @@ jobs: # 5. Upload Dart Debug Symbols (Native Dart Stacktrace) SYMBOLS_DIR="build/app/outputs/symbols" - if [ -d "$SYMBOLS_DIR" ]; then echo "Uploading Dart debug symbols from $SYMBOLS_DIR..." sentry-cli debug-files upload "$SYMBOLS_DIR" @@ -136,6 +137,54 @@ jobs: echo "::error::Symbols directory not found at $SYMBOLS_DIR. Check your Fastfile build arguments." exit 1 fi - + # 6. Finalize sentry-cli releases finalize "$SENTRY_RELEASE" + + # --- UPLOAD DSYM TO SENTRY FOR IOS --- + # sentry-cli debug-files upload works independently of the release lifecycle, + # so no releases new/finalize needed here — avoids race condition with Android leg. + - name: Upload iOS dSYMs to Sentry + if: matrix.os == 'ios' + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + # 1. Install Sentry CLI + curl -sL https://sentry.io/get-cli/ | SENTRY_CLI_VERSION="2.42.2" bash + + # 2. Validate tag base version matches pubspec.yaml base version (ignoring -rc* suffix). + # pubspec version is always X.Y.Z (no -rc suffix) — RC distinction lives only in the git tag. + # SENTRY_RELEASE = pubspec version, matching exactly what the app reports at runtime. + TAG_BASE="${GITHUB_REF_NAME#v}" + TAG_BASE="${TAG_BASE%%-rc*}" + PUBSPEC_VERSION=$(awk '/^version:/{split($2,a,"+"); print a[1]; exit}' pubspec.yaml) + PUBSPEC_BASE="${PUBSPEC_VERSION%%-rc*}" + if [ "$PUBSPEC_BASE" != "$TAG_BASE" ]; then + echo "::error::Tag version ($TAG_BASE) does not match pubspec version ($PUBSPEC_BASE)." + exit 1 + fi + SENTRY_RELEASE="$PUBSPEC_VERSION" + echo "Processing Sentry Release: $SENTRY_RELEASE" + + # 3. Find the xcarchive built by Fastlane. + # Xcode always archives to ~/Library/Developer/Xcode/Archives — pick the most recent one. + XCARCHIVE=$(find "$HOME/Library/Developer/Xcode/Archives" -name "Runner*.xcarchive" -maxdepth 3 2>/dev/null | sort -r | head -1) + if [ -z "$XCARCHIVE" ] || [ ! -d "$XCARCHIVE" ]; then + echo "::error::Runner xcarchive not found in ~/Library/Developer/Xcode/Archives" + exit 1 + fi + echo "Found xcarchive: $XCARCHIVE" + + # 4. Upload dSYMs (Native code: Swift, Objective-C, C++) + DSYM_DIR="$XCARCHIVE/dSYMs" + if [ -d "$DSYM_DIR" ]; then + echo "dSYM contents:" + ls -la "$DSYM_DIR" + echo "Uploading dSYMs from $DSYM_DIR..." + sentry-cli debug-files upload --include-sources --log-level info "$DSYM_DIR" + else + echo "::error::dSYMs directory not found inside xcarchive at $DSYM_DIR." + exit 1 + fi diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 811d7d07e3..1aaf866c5a 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -30,10 +30,12 @@ platform :android do track: track, json_key_data: ENV["PLAY_STORE_CONFIG_JSON"] )[0].to_i - build_name = last_git_tag.gsub("v", "").split("-").first + pubspec = File.read("../pubspec.yaml") + sentry_release = pubspec.match(/^version:\s+(\S+)/)[1].split("+").first + build_name = sentry_release.split("-").first # Add: --obfuscate --split-debug-info=build/app/outputs/symbols # Note: We run `cd ..` first, so paths are relative to repository root. - sh "cd .. && flutter build appbundle --verbose --release --obfuscate --split-debug-info=build/app/outputs/symbols --dart-define=SERVER_URL=$SERVER_URL --build-number=#{latest_build_number+1} --build-name=#{build_name}" + sh "cd .. && flutter build appbundle --verbose --release --obfuscate --split-debug-info=build/app/outputs/symbols --dart-define=SERVER_URL=$SERVER_URL --dart-define=SENTRY_RELEASE=#{sentry_release} --build-number=#{latest_build_number+1} --build-name=#{build_name}" upload_to_play_store( json_key_data: ENV["PLAY_STORE_CONFIG_JSON"], track: track, diff --git a/core/lib/utils/sentry/sentry_config.dart b/core/lib/utils/sentry/sentry_config.dart index e2690b5f06..aaa9a76602 100644 --- a/core/lib/utils/sentry/sentry_config.dart +++ b/core/lib/utils/sentry/sentry_config.dart @@ -78,21 +78,57 @@ class SentryConfig { && sentryEnvironment.trim().isNotEmpty; if (!isConfigValid) return null; - final appVersion = await ApplicationManager().getAppVersion(); + final release = await _resolveRelease(); const sentryDist = String.fromEnvironment('SENTRY_DIST'); logTrace( 'SentryConfig::load: sentryDist is $sentryDist, ' - 'appVersion is $appVersion', + 'release is $release', webConsoleEnabled: true, ); return SentryConfig( dsn: sentryDSN, environment: sentryEnvironment, - release: appVersion, + release: release, isAvailable: isAvailable, dist: sentryDist.isNotEmpty ? sentryDist : null, ); } + + /// Resolves the Sentry release string. + /// + /// Priority: + /// 1. `--dart-define=SENTRY_RELEASE=` injected at build time by + /// Fastlane/CI — guarantees the same string the CI uses to upload symbols. + /// 2. `PackageInfo.version` as fallback (local dev / non-release builds). + /// + /// Why dart-define takes priority: iOS CFBundleShortVersionString strips + /// pre-release suffixes (e.g. "0.28.3-rc09" → "0.28.3"), so PackageInfo + /// returns a different value than what CI tags the symbols with. + static Future _resolveRelease() async { + const dartDefineRelease = String.fromEnvironment('SENTRY_RELEASE'); + if (dartDefineRelease.isNotEmpty) return dartDefineRelease; + return ApplicationManager().getAppVersion(); + } + + static const String sentryConfigKeyChain = 'sentry_config_data'; + + Map toJson() { + return { + 'dsn': dsn, + 'environment': environment, + 'release': release, + if (dist != null) 'dist': dist, + 'tracesSampleRate': tracesSampleRate, + 'profilesSampleRate': profilesSampleRate, + 'sessionSampleRate': sessionSampleRate, + 'onErrorSampleRate': onErrorSampleRate, + 'enableLogs': enableLogs, + 'isDebug': isDebug, + 'attachScreenshot': attachScreenshot, + 'isAvailable': isAvailable, + 'enableFramesTracking': enableFramesTracking, + }; + } } diff --git a/docs/adr/0084-sentry-release-version-strategy.md b/docs/adr/0084-sentry-release-version-strategy.md new file mode 100644 index 0000000000..fe2391fe11 --- /dev/null +++ b/docs/adr/0084-sentry-release-version-strategy.md @@ -0,0 +1,45 @@ +# 0084. Sentry Release Version Strategy (Mobile) + +Date: 2026-04-22 + +## Status + +Accepted + +## Context + +> **Scope: iOS and Android mobile only.** Web uses a separate Sentry project and version injection mechanism. + +Sentry requires the release version reported by the app to exactly match the release version used when uploading debug symbols (dSYMs / source maps). A mismatch breaks symbolication — crash stack traces become unreadable. + +There are three version sources that must stay in sync: + +1. **Git tag** — Fastlane reads this via `last_git_tag` and injects it as `SENTRY_RELEASE` into the app via `dart-define` (base64-encoded `DART_DEFINES`) +2. **`pubspec.yaml` version** — used by CI only for base-version validation; **not** used as `SENTRY_RELEASE` +3. **`CFBundleShortVersionString` (iOS)** — derived from pubspec; Apple requires `X.Y.Z` format only — no `-rc` suffix allowed + +The challenge: RC builds use a `-rcXX` suffix in the tag (e.g. `v0.28.5-rc01`), but Apple rejects that suffix in `CFBundleShortVersionString`. + +## Decision + +**Rule: pubspec version = git tag base version (no `-rc` suffix). The pubspec version is used as `SENTRY_RELEASE` in both the app and CI.** + +| Source | RC build example | Final release example | +|---|---|---| +| Git tag | `v0.28.5-rc01` | `v0.28.5` | +| pubspec version | `0.28.5` | `0.28.5` | +| App Store / CFBundleShortVersionString | `0.28.5` ✅ | `0.28.5` ✅ | +| SENTRY_RELEASE (app + CI) | `0.28.5` | `0.28.5` | + +### What breaks if you deviate + +| Mistake | Result | +|---|---| +| pubspec = `0.28.5-rc01`, tag = `v0.28.5-rc01` | Apple rejects the iOS build (`CFBundleShortVersionString` must be `X.Y.Z`) | +| pubspec = `0.28.5-rc01`, tag = `v0.28.5` | Validation passes but app reports `"0.28.5"`, CI uploads to `"0.28.5-rc01"` → mismatch | + +## Consequences + +- pubspec always stays App Store-safe (`X.Y.Z`, no suffix) +- RC distinction lives only in the git tag +- CI validates base version consistency; pubspec version drives `SENTRY_RELEASE` diff --git a/ios/Podfile b/ios/Podfile index b38c1ac3b0..43ab7719ab 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -38,6 +38,18 @@ target 'Runner' do end end +target 'TwakeMailNSE' do + use_frameworks! + # Must use HybridSDK subspec at the same version as sentry_flutter requires. + # sentry_flutter 9.8.0 depends on Sentry/HybridSDK (= 8.56.2). + # Using plain `pod 'Sentry'` (Core) causes a version conflict and missing PrivateSentrySDKOnly errors. + # + # UPGRADE NOTE: When bumping sentry_flutter, update this version to match the new + # Sentry/HybridSDK constraint declared in Podfile.lock under DEPENDENCIES. + # Verify with: grep 'Sentry/HybridSDK' ios/Podfile.lock + pod 'Sentry/HybridSDK', '8.56.2' +end + post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c0802d65c3..34d5afc8dd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -262,6 +262,7 @@ DEPENDENCIES: - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`) - printing (from `.symlinks/plugins/printing/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) + - Sentry/HybridSDK (= 8.56.2) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -418,7 +419,7 @@ SPEC CHECKSUMS: OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 package_info_plus: 580e9a5f1b6ca5594e7c9ed5f92d1dfb2a66b5e1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - patrol: 5df5d241d7f95f0df12a6906bbf45acb43a1e537 + patrol: cea8074f183a2a4232d0ebd10569ae05149ada42 pdfrx: 310e84d01e06fd2af26e16507a0e48c27e99195c permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 @@ -438,6 +439,6 @@ SPEC CHECKSUMS: UniversalDetector2: 7c9ffd935cf050eeb19edf7e90f6febe3743a1af url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: 40b12ce0bc437886ee4f4050970375d7d253708d +PODFILE CHECKSUM: 01821a4f5c4164186c3bcdf0b69845d9f6e8604e COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 2a0ff96c80..969d76e477 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,9 +11,12 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 756BBC4C51FCB44047AB39E4 /* Pods_TeamMailShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 143BD7B2CF181BA69CDE351A /* Pods_TeamMailShareExtension.framework */; }; + 84EC933C2F236E8300A0EDC7 /* SentryConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EC933B2F236E7800A0EDC7 /* SentryConfig.swift */; }; + 84EC933E2F23705E00A0EDC7 /* SentryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EC933D2F23705D00A0EDC7 /* SentryManager.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B561AD69C2BE6DF40BF29406 /* Pods_TwakeMailNSE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA243B93F65E4E3CD7DC0348 /* Pods_TwakeMailNSE.framework */; }; CDFECA8C54311B749F044831 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5810EDDA99BEFEACD742F507 /* Pods_Runner.framework */; }; F522E87F2C0EE23400DDA35B /* AuthenticationSSOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F522E87E2C0EE23400DDA35B /* AuthenticationSSOTests.swift */; }; F522E8812C0EE3F200DDA35B /* AuthenticationSSO.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E7D8832B3877050009BB8A /* AuthenticationSSO.swift */; }; @@ -126,10 +129,14 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 4D124E74293A67D900BA5186 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; 5810EDDA99BEFEACD742F507 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5E35AF242935B79C2DCACB65 /* Pods-TwakeMailNSE.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TwakeMailNSE.debug.xcconfig"; path = "Target Support Files/Pods-TwakeMailNSE/Pods-TwakeMailNSE.debug.xcconfig"; sourceTree = ""; }; 631346BB444C71671599207F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 6B7C6E49525344DE515F7373 /* Pods-TwakeMailNSE.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TwakeMailNSE.release.xcconfig"; path = "Target Support Files/Pods-TwakeMailNSE/Pods-TwakeMailNSE.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 84EC933B2F236E7800A0EDC7 /* SentryConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryConfig.swift; sourceTree = ""; }; + 84EC933D2F23705D00A0EDC7 /* SentryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryManager.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -137,8 +144,10 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AF31502A83CA492E27C36A7B /* Pods-TwakeMailNSE.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TwakeMailNSE.profile.xcconfig"; path = "Target Support Files/Pods-TwakeMailNSE/Pods-TwakeMailNSE.profile.xcconfig"; sourceTree = ""; }; B2EAFF659572E6B9F5AFAAF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; BC08294F5AE09BF8CC592AD1 /* Pods-TeamMailShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TeamMailShareExtension.release.xcconfig"; path = "Target Support Files/Pods-TeamMailShareExtension/Pods-TeamMailShareExtension.release.xcconfig"; sourceTree = ""; }; + EA243B93F65E4E3CD7DC0348 /* Pods_TwakeMailNSE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TwakeMailNSE.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F522E87E2C0EE23400DDA35B /* AuthenticationSSOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationSSOTests.swift; sourceTree = ""; }; F522E8852C0EE8B600DDA35B /* CoreUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreUtils.swift; sourceTree = ""; }; F52F992D27FD6EB900346091 /* TeamMailShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TeamMailShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -218,6 +227,7 @@ files = ( F5630C812B2CEBC0003CC0FD /* KeychainAccess in Frameworks */, F53D1E632B2DE80200051FD0 /* Alamofire in Frameworks */, + B561AD69C2BE6DF40BF29406 /* Pods_TwakeMailNSE.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -229,6 +239,7 @@ children = ( 5810EDDA99BEFEACD742F507 /* Pods_Runner.framework */, 143BD7B2CF181BA69CDE351A /* Pods_TeamMailShareExtension.framework */, + EA243B93F65E4E3CD7DC0348 /* Pods_TwakeMailNSE.framework */, ); name = Frameworks; sourceTree = ""; @@ -393,6 +404,7 @@ F5630C792B2CE133003CC0FD /* Model */ = { isa = PBXGroup; children = ( + 84EC933B2F236E7800A0EDC7 /* SentryConfig.swift */, F5630C7A2B2CE148003CC0FD /* KeychainSharingSession.swift */, F5630C842B2CF50C003CC0FD /* TypeName.swift */, ); @@ -402,6 +414,7 @@ F5630C7C2B2CE33A003CC0FD /* Utils */ = { isa = PBXGroup; children = ( + 84EC933D2F23705D00A0EDC7 /* SentryManager.swift */, F5630C7D2B2CE359003CC0FD /* InfoPlistReader.swift */, F5BBBF542B2EEF3D007930E1 /* BundleExtension.swift */, F5630C862B2D0387003CC0FD /* PayloadParser.swift */, @@ -491,6 +504,9 @@ 07753111B751BFDB3187FE6F /* Pods-TeamMailShareExtension.debug.xcconfig */, BC08294F5AE09BF8CC592AD1 /* Pods-TeamMailShareExtension.release.xcconfig */, 1FB76FA91BBCB2BF7B08705B /* Pods-TeamMailShareExtension.profile.xcconfig */, + 5E35AF242935B79C2DCACB65 /* Pods-TwakeMailNSE.debug.xcconfig */, + 6B7C6E49525344DE515F7373 /* Pods-TwakeMailNSE.release.xcconfig */, + AF31502A83CA492E27C36A7B /* Pods-TwakeMailNSE.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -564,6 +580,7 @@ isa = PBXNativeTarget; buildConfigurationList = F5D4EA0B2B2ABF090090DDFC /* Build configuration list for PBXNativeTarget "TwakeMailNSE" */; buildPhases = ( + 0F3B486617C7C494D3DB7CF9 /* [CP] Check Pods Manifest.lock */, F5D4E9FC2B2ABF090090DDFC /* Sources */, F5D4E9FD2B2ABF090090DDFC /* Frameworks */, F5D4E9FE2B2ABF090090DDFC /* Resources */, @@ -674,6 +691,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0F3B486617C7C494D3DB7CF9 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-TwakeMailNSE-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 2A73AAD0923EF3B729E8973A /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -827,6 +866,7 @@ files = ( F5BBBF512B2EEC37007930E1 /* NetworkExceptions.swift in Sources */, F53D1E7F2B2E3C2600051FD0 /* JmapConstants.swift in Sources */, + 84EC933C2F236E8300A0EDC7 /* SentryConfig.swift in Sources */, F5E7D8822B3876F60009BB8A /* AuthenticationCredential.swift in Sources */, F5D4EA032B2ABF090090DDFC /* NotificationService.swift in Sources */, F522E8872C0EE8B600DDA35B /* CoreUtils.swift in Sources */, @@ -838,6 +878,7 @@ F53D1E662B2DE8B800051FD0 /* AlamofireService.swift in Sources */, F5BBBF532B2EECAA007930E1 /* JmapExceptions.swift in Sources */, F53D1E882B2E4B3B00051FD0 /* AuthenticationType.swift in Sources */, + 84EC933E2F23705E00A0EDC7 /* SentryManager.swift in Sources */, F53D1E6C2B2E208C00051FD0 /* Email.swift in Sources */, F5630C872B2D0387003CC0FD /* PayloadParser.swift in Sources */, F5E7D8862B3877390009BB8A /* TokenResponse.swift in Sources */, @@ -1419,6 +1460,7 @@ }; F5D4EA082B2ABF090090DDFC /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5E35AF242935B79C2DCACB65 /* Pods-TwakeMailNSE.debug.xcconfig */; buildSettings = { APP_GROUP_ID = group.com.linagora.teammail; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -1466,6 +1508,7 @@ }; F5D4EA092B2ABF090090DDFC /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 6B7C6E49525344DE515F7373 /* Pods-TwakeMailNSE.release.xcconfig */; buildSettings = { APP_GROUP_ID = group.com.linagora.teammail; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -1510,6 +1553,7 @@ }; F5D4EA0A2B2ABF090090DDFC /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AF31502A83CA492E27C36A7B /* Pods-TwakeMailNSE.profile.xcconfig */; buildSettings = { APP_GROUP_ID = group.com.linagora.teammail; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; diff --git a/ios/TwakeMailNSE/Keychain/KeychainController.swift b/ios/TwakeMailNSE/Keychain/KeychainController.swift index 068d3159d7..f21e6de439 100644 --- a/ios/TwakeMailNSE/Keychain/KeychainController.swift +++ b/ios/TwakeMailNSE/Keychain/KeychainController.swift @@ -58,3 +58,21 @@ class KeychainController: KeychainControllerDelegate { } catch {} } } + +extension KeychainController { + /// The key used in Dart to store the Sentry configuration JSON + private var sentryConfigKey: String { "sentry_config_data" } + + /// Retrieves and decodes the SentryConfig from Keychain + func retrieveSentryConfig() -> SentryConfig? { + do { + guard let configData = try keychain.getData(sentryConfigKey) else { + return nil + } + return try JSONDecoder().decode(SentryConfig.self, from: configData) + } catch { + TwakeLogger.shared.log(message: "SentryConfig could not be decoded from Keychain: \(error)") + return nil + } + } +} diff --git a/ios/TwakeMailNSE/Model/KeychainSharingSession.swift b/ios/TwakeMailNSE/Model/KeychainSharingSession.swift index 0f99aa31d7..6556628e2a 100644 --- a/ios/TwakeMailNSE/Model/KeychainSharingSession.swift +++ b/ios/TwakeMailNSE/Model/KeychainSharingSession.swift @@ -1,4 +1,5 @@ import Foundation +import Sentry struct KeychainSharingSession: Codable { let accountId: String @@ -61,4 +62,12 @@ extension KeychainSharingSession { } return nil } + + var sentryUser: User { + let user = User() + user.userId = self.accountId + user.email = self.userName + user.username = self.userName + return user + } } diff --git a/ios/TwakeMailNSE/Model/SentryConfig.swift b/ios/TwakeMailNSE/Model/SentryConfig.swift new file mode 100644 index 0000000000..3783a206ad --- /dev/null +++ b/ios/TwakeMailNSE/Model/SentryConfig.swift @@ -0,0 +1,47 @@ +import Foundation + +struct SentryConfig: Codable { + /// DSN (Data Source Name) endpoint for the Sentry project + let dsn: String + + /// Running environment (production/staging/dev) + let environment: String + + /// Current app release version + let release: String + + /// Distribution (e.g. Git SHA). Must match --dist used when uploading symbols to Sentry. + /// Optional: only set when the main app passes it via --dart-define=SENTRY_DIST. + let dist: String? + + /// Performance monitoring: Set to 1.0 to capture 100% of transactions for tracing. + /// High values in NSE might impact extension memory limit (24MB). + let tracesSampleRate: Double + + /// Optional profiling sample rate. + let profilesSampleRate: Double + + /// Release Health: The sampling rate for sessions (0.0 to 1.0). + let sessionSampleRate: Double + + /// Error tracking: The sampling rate for errors (0.0 to 1.0). + /// If set to 0.1, only 10% of errors are sent. + let onErrorSampleRate: Double + + /// Enable logs to be sent to Sentry (or internal console logging). + let enableLogs: Bool + + /// Debug logs during development. + let isDebug: Bool + + /// Automatically attaches a screenshot when capturing an error. + /// Ignored in NSE as there is no UI to screenshot. + let attachScreenshot: Bool + + /// Master switch to check if Sentry integration is allowed/available. + let isAvailable: Bool + + /// Performance: Tracks UI rendering performance. + /// Ignored in NSE as there is no UI rendering. + let enableFramesTracking: Bool +} diff --git a/ios/TwakeMailNSE/NotificationService.swift b/ios/TwakeMailNSE/NotificationService.swift index fae9d5f0eb..ffe9e09c5f 100644 --- a/ios/TwakeMailNSE/NotificationService.swift +++ b/ios/TwakeMailNSE/NotificationService.swift @@ -1,4 +1,5 @@ import UserNotifications +import Sentry import SwiftUI class NotificationService: UNNotificationServiceExtension { @@ -13,6 +14,10 @@ class NotificationService: UNNotificationServiceExtension { accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + + SentryManager.shared.configure(with: keychainController) + SentryManager.shared.clearUser() + handler = contentHandler modifiedContent = (request.content.mutableCopy() as? UNMutableNotificationContent) @@ -22,10 +27,14 @@ class NotificationService: UNNotificationServiceExtension { if isAppActive == true { self.modifiedContent?.userInfo = request.content.userInfo.merging(["data": request.content.userInfo], uniquingKeysWith: {(_, new) in new}) contentHandler(self.modifiedContent ?? request.content) + return } - + + SentryManager.shared.addBreadcrumb(message: "NSE: Received push notification", level: .info) + guard let payloadData = request.content.userInfo as? [String: Any], !keychainController.retrieveSharingSessions().isEmpty else { + SentryManager.shared.capture(message: "NSE: Payload invalid or No Session found in Keychain") self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable")) return self.notify() } @@ -42,6 +51,7 @@ class NotificationService: UNNotificationServiceExtension { override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + SentryManager.shared.capture(message: "NSE: Service Extension Time Expired (Timeout)", flushTimeout: 0.3) self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable")) self.notify() } @@ -55,20 +65,35 @@ class NotificationService: UNNotificationServiceExtension { let mapStateChanges: [String: [TypeName: String]] = PayloadParser.shared.parsingPayloadNotification(payloadData: payloadData) if (mapStateChanges.isEmpty) { + SentryManager.shared.capture(message: "NSE: Payload parsing returned empty state changes") self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable")) return self.notify() } else { guard let currentAccountId = mapStateChanges.keys.first, let keychainSharingSession = keychainController.retrieveSharingSessionFromKeychain(accountId: currentAccountId), - keychainSharingSession.tokenOIDC != nil || keychainSharingSession.basicAuth != nil, - let listStateOfAccount = mapStateChanges[currentAccountId], + keychainSharingSession.tokenOIDC != nil || keychainSharingSession.basicAuth != nil else { + SentryManager.shared.capture(message: "NSE: Session missing or invalid credential for account: \(mapStateChanges.keys.first ?? "unknown")") + self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable")) + return self.notify() + } + + SentryManager.shared.setSentryUser(keychainSharingSession.sentryUser) + + guard let listStateOfAccount = mapStateChanges[currentAccountId], let newEmailDeliveryState = listStateOfAccount[TypeName.emailDelivery] else { + SentryManager.shared.capture(message: "NSE: Missing emailDelivery state in payload") self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable")) return self.notify() } - - guard let oldEmailDeliveryState = keychainSharingSession.emailDeliveryState ?? keychainSharingSession.emailState, - newEmailDeliveryState != oldEmailDeliveryState else { + + guard let oldEmailDeliveryState = keychainSharingSession.emailDeliveryState ?? keychainSharingSession.emailState else { + SentryManager.shared.capture(message: "NSE: No stored email state for account: \(currentAccountId)") + self.showDefaultNotification(message: NSLocalizedString(self.newEmailDefaultMessageKey, comment: "Localizable")) + return self.notify() + } + + guard newEmailDeliveryState != oldEmailDeliveryState else { + SentryManager.shared.capture(message: "NSE: Email delivery state unchanged, skipping fetch") self.showDefaultNotification(message: NSLocalizedString(self.newEmailDefaultMessageKey, comment: "Localizable")) return self.notify() } @@ -84,32 +109,29 @@ class NotificationService: UNNotificationServiceExtension { oidcScopes: keychainSharingSession.oidcScopes, isTWP: keychainSharingSession.isTWP, onComplete: { (emails, errors) in - do { - if emails.isEmpty { - self.showDefaultNotification(message: NSLocalizedString(self.newEmailDefaultMessageKey, comment: "Localizable")) - return self.notify() - } else { - self.keychainController.updateEmailDeliveryStateToKeychain( - accountId: keychainSharingSession.accountId, - newEmailDeliveryState: newEmailDeliveryState - ) - - let mailboxIdsBlockNotification = keychainSharingSession.mailboxIdsBlockNotification ?? [] - - if (mailboxIdsBlockNotification.isEmpty) { - return self.showListNotification(emails: emails) - } else { - let emailFiltered = self.filterEmailsToPushNotification( - emails: emails, - mailboxIdsBlockNotification: mailboxIdsBlockNotification) - return self.showListNotification(emails: emailFiltered) - } - } - } catch { - TwakeLogger.shared.log(message: "JmapClient.shared.getNewEmails: \(error)") + errors.forEach { SentryManager.shared.capture(error: $0) } + + if emails.isEmpty { + SentryManager.shared.capture(message: "NSE: getNewEmails returned empty list") self.showDefaultNotification(message: NSLocalizedString(self.newEmailDefaultMessageKey, comment: "Localizable")) return self.notify() } + + self.keychainController.updateEmailDeliveryStateToKeychain( + accountId: keychainSharingSession.accountId, + newEmailDeliveryState: newEmailDeliveryState + ) + + let mailboxIdsBlockNotification = keychainSharingSession.mailboxIdsBlockNotification ?? [] + + if mailboxIdsBlockNotification.isEmpty { + return self.showListNotification(emails: emails) + } else { + let emailFiltered = self.filterEmailsToPushNotification( + emails: emails, + mailboxIdsBlockNotification: mailboxIdsBlockNotification) + return self.showListNotification(emails: emailFiltered) + } } ) } @@ -128,6 +150,11 @@ class NotificationService: UNNotificationServiceExtension { } private func showListNotification(emails: [Email]) { + guard !emails.isEmpty else { + SentryManager.shared.capture(message: "NSE: All emails filtered by block list, no notification shown") + return self.notify() + } + for email in emails { if (email.id == emails.last?.id) { self.showModifiedNotification(title: email.getSenderName(), @@ -183,7 +210,7 @@ class NotificationService: UNNotificationServiceExtension { content.userInfo = userInfo // Create a notification trigger - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false) // Create a notification request let request = UNNotificationRequest(identifier: notificationId, content: content, trigger: trigger) @@ -191,6 +218,7 @@ class NotificationService: UNNotificationServiceExtension { UNUserNotificationCenter.current().add(request) { error in if let error = error { TwakeLogger.shared.log(message: "Error scheduling notification: \(error.localizedDescription)") + SentryManager.shared.capture(error: error) } else { TwakeLogger.shared.log(message: "Notification scheduled successfully") } @@ -207,11 +235,13 @@ class NotificationService: UNNotificationServiceExtension { } private func discard() { + SentryManager.shared.capture(message: "NSE: modifiedContent nil, notification discarded") handler?(UNNotificationContent()) cleanUp() } private func cleanUp() { + SentryManager.shared.clearUser() handler = nil modifiedContent = nil } diff --git a/ios/TwakeMailNSE/Utils/SentryManager.swift b/ios/TwakeMailNSE/Utils/SentryManager.swift new file mode 100644 index 0000000000..0cf55cefaa --- /dev/null +++ b/ios/TwakeMailNSE/Utils/SentryManager.swift @@ -0,0 +1,102 @@ +import Foundation +import Sentry + +class SentryManager { + + /// Singleton instance for easy access + static let shared = SentryManager() + + /// Internal flag to prevent multiple initializations + private var isInitialized: Bool = false + + private init() {} + + /// Configures Sentry using the config stored in Keychain. + func configure(with keychainController: KeychainController) { + // Prevent re-initialization + if isInitialized { return } + + // Retrieve config and validate 'isAvailable' and DSN presence + guard let config = keychainController.retrieveSentryConfig(), + config.isAvailable, + !config.dsn.isEmpty else { + TwakeLogger.shared.log(message: "Sentry is disabled or config is missing") + return + } + + // Start Sentry SDK with options mapped from the config + SentrySDK.start { options in + options.dsn = config.dsn + options.environment = config.environment + options.releaseName = config.release + options.dist = config.dist + options.debug = config.isDebug + // Map enableLogs to diagnostic level if needed + options.diagnosticLevel = config.isDebug ? .debug : .none + // Maps 'onErrorSampleRate' (Dart) to 'sampleRate' (iOS). + // tracesSampleRate, profilesSampleRate, sessionSampleRate are intentionally not applied: + // NSE has no UI and its lifecycle is too short for performance/session tracking. + options.sampleRate = NSNumber(value: config.onErrorSampleRate) + // Disable App Hang tracking: NSE execution is short, this causes false positives. + options.enableAppHangTracking = false + // Disable Watchdog tracking: Prevent OOM reports specific to extensions. + options.enableWatchdogTerminationTracking = false + // Disable UI/Interaction tracing: NSE has no UI. + options.enableUserInteractionTracing = false + options.enableAutoPerformanceTracing = false + options.enablePreWarmedAppStartTracing = false + } + + isInitialized = true + TwakeLogger.shared.log(message: "Sentry has been successfully initialized.") + } + + /// Safely captures an error if Sentry is initialized. + /// - Parameter flushTimeout: If provided, blocks until events are sent or the timeout elapses. + /// Use in critical paths (e.g. serviceExtensionTimeWillExpire) where the process may be + /// suspended before Sentry flushes its queue. + func capture(error: Error, flushTimeout: TimeInterval? = nil) { + guard isInitialized else { return } + SentrySDK.capture(error: error) + if let flushTimeout { + SentrySDK.flush(timeout: flushTimeout) + } + } + + /// Safely captures a message if Sentry is initialized. + /// - Parameter flushTimeout: If provided, blocks until events are sent or the timeout elapses. + /// Use in critical paths (e.g. serviceExtensionTimeWillExpire) where the process may be + /// suspended before Sentry flushes its queue. + func capture(message: String, flushTimeout: TimeInterval? = nil) { + guard isInitialized else { return } + SentrySDK.capture(message: message) + if let flushTimeout { + SentrySDK.flush(timeout: flushTimeout) + } + } + + /// Adds a breadcrumb to the current Sentry scope. + /// Breadcrumbs are accumulated in-memory and automatically attached to the next + /// captured error event. Use this for diagnostic checkpoints that provide context + /// for real errors — they are NOT sent as standalone events. + func addBreadcrumb(message: String, category: String = "nse", level: SentryLevel = .info) { + guard isInitialized else { return } + let crumb = Breadcrumb() + crumb.message = message + crumb.category = category + crumb.level = level + SentrySDK.addBreadcrumb(crumb) + } + + /// Set user context for Sentry + func setSentryUser(_ user: User) { + guard isInitialized else { return } + SentrySDK.setUser(user) + } + + /// Clear user + func clearUser() { + guard isInitialized else { return } + SentrySDK.setUser(nil) + } +} diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index c83b883da1..a19714422e 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -50,11 +50,20 @@ platform :ios do increment_version_number( version_number: last_git_tag.gsub("v", "").split("-").first ) + # Pass the pubspec version (X.Y.Z, no -rc suffix) to the Dart layer as SENTRY_RELEASE. + # Read from pubspec.yaml (same source as CI release.yaml) to guarantee consistency. + # This is separate from increment_version_number (which stays numeric for + # App Store compatibility). Each dart-define must be base64-encoded. + require 'base64' + pubspec = File.read("../pubspec.yaml") + sentry_release = pubspec.match(/^version:\s+(\S+)/)[1].split("+").first + sentry_release_define = Base64.strict_encode64("SENTRY_RELEASE=#{sentry_release}") build_app( scheme: "Runner", configuration: "Release", workspace: "Runner.xcworkspace", - export_method: "app-store" + export_method: "app-store", + xcargs: "DART_DEFINES=#{sentry_release_define}", ) upload_to_testflight( skip_waiting_for_build_processing: true, diff --git a/lib/features/mailbox_dashboard/domain/linagora_ecosystem/sentry_config_linagora_ecosystem.dart b/lib/features/mailbox_dashboard/domain/linagora_ecosystem/sentry_config_linagora_ecosystem.dart index 7f13757b2c..43ba8f6bfc 100644 --- a/lib/features/mailbox_dashboard/domain/linagora_ecosystem/sentry_config_linagora_ecosystem.dart +++ b/lib/features/mailbox_dashboard/domain/linagora_ecosystem/sentry_config_linagora_ecosystem.dart @@ -41,12 +41,15 @@ class SentryConfigLinagoraEcosystem extends LinagoraEcosystemProperties { extension SentryConfigLinagoraEcosystemExtension on SentryConfigLinagoraEcosystem { Future toSentryConfig() async { - final appVersion = await ApplicationManager().getAppVersion(); + const dartDefineRelease = String.fromEnvironment('SENTRY_RELEASE'); + final release = dartDefineRelease.isNotEmpty + ? dartDefineRelease + : await ApplicationManager().getAppVersion(); return SentryConfig( dsn: dsn ?? '', environment: environment ?? '', - release: appVersion, + release: release, isAvailable: enabled ?? false, ); } diff --git a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart index 2c3eb1240e..5a5a372ba1 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -234,6 +234,7 @@ import 'package:tmail_ui_user/main/universal_import/html_stub.dart' as html; import 'package:tmail_ui_user/main/utils/app_config.dart'; import 'package:tmail_ui_user/main/utils/email_receive_manager.dart'; import 'package:tmail_ui_user/main/utils/ios_notification_manager.dart'; +import 'package:tmail_ui_user/main/utils/ios_sharing_manager.dart'; import 'package:tmail_ui_user/main/utils/toast_manager.dart'; import 'package:uuid/uuid.dart'; @@ -406,7 +407,10 @@ class MailboxDashBoardController extends ReloadableController @override void onInit() { if (PlatformInfo.isMobile) { - _sentryEcosystem = SentryEcosystem(getBinding()); + _sentryEcosystem = SentryEcosystem( + getBinding(), + getBinding(), + ); _registerReceivingFileSharingStream(); _registerDeepLinks(); } diff --git a/lib/features/mailbox_dashboard/presentation/sentry_ecosystem.dart b/lib/features/mailbox_dashboard/presentation/sentry_ecosystem.dart index 4c39faf0b1..d9a5ee639c 100644 --- a/lib/features/mailbox_dashboard/presentation/sentry_ecosystem.dart +++ b/lib/features/mailbox_dashboard/presentation/sentry_ecosystem.dart @@ -1,18 +1,21 @@ import 'package:core/presentation/extensions/string_extension.dart'; import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/platform_info.dart'; import 'package:core/utils/sentry/sentry_config.dart'; import 'package:core/utils/sentry/sentry_manager.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:tmail_ui_user/features/caching/extensions/sentry_cache_extensions.dart'; import 'package:tmail_ui_user/features/caching/manager/sentry_configuration_cache_manager.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/linagora_ecosystem/sentry_config_linagora_ecosystem.dart'; +import 'package:tmail_ui_user/main/utils/ios_sharing_manager.dart'; class SentryEcosystem { final SentryConfigurationCacheManager? _cacheManager; + final IOSSharingManager? _iosSharingManager; SentryUser? _sentryUser; - SentryEcosystem(this._cacheManager); + SentryEcosystem(this._cacheManager, this._iosSharingManager); void initUser(SentryUser? user) { _sentryUser = user; @@ -40,6 +43,10 @@ class SentryEcosystem { _applyUser(); await _cacheData(sentryConfig, _sentryUser); + + if (PlatformInfo.isIOS) { + await _saveSentryConfigToKeychain(sentryConfig); + } } void _applyUser() { @@ -64,4 +71,8 @@ class SentryEcosystem { await _cacheManager!.clearSentryConfiguration().catchError((_) {}); } } + + Future _saveSentryConfigToKeychain(SentryConfig sentryConfig) async { + await _iosSharingManager?.saveSentryConfigToKeychain(sentryConfig); + } } diff --git a/lib/features/push_notification/data/keychain/keychain_sharing_manager.dart b/lib/features/push_notification/data/keychain/keychain_sharing_manager.dart index b6d384fdbb..85595cbb19 100644 --- a/lib/features/push_notification/data/keychain/keychain_sharing_manager.dart +++ b/lib/features/push_notification/data/keychain/keychain_sharing_manager.dart @@ -1,6 +1,7 @@ import 'dart:convert'; +import 'package:core/utils/sentry/sentry_config.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:model/extensions/account_id_extensions.dart'; @@ -11,11 +12,16 @@ class KeychainSharingManager { KeychainSharingManager(this._secureStorage); - Future save(KeychainSharingSession keychainSharingSession) => _secureStorage.write( + Future saveSharingSession(KeychainSharingSession keychainSharingSession) => _secureStorage.write( key: keychainSharingSession.accountId.asString, value: jsonEncode(keychainSharingSession.toJson()), ); + Future saveSentryConfig(SentryConfig sentryConfig) => _secureStorage.write( + key: SentryConfig.sentryConfigKeyChain, + value: jsonEncode(sentryConfig.toJson()), + ); + Future isSessionExist(AccountId accountId) => _secureStorage.containsKey(key: accountId.asString); diff --git a/lib/main/utils/ios_sharing_manager.dart b/lib/main/utils/ios_sharing_manager.dart index 8e3e1712d0..c148f37f73 100644 --- a/lib/main/utils/ios_sharing_manager.dart +++ b/lib/main/utils/ios_sharing_manager.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/sentry/sentry_config.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/user_name.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; @@ -103,7 +104,7 @@ class IOSSharingManager { isTWP: tokenRecords?.isTWP ?? false, ); - await _keychainSharingManager.save(keychainSharingSession); + await _keychainSharingManager.saveSharingSession(keychainSharingSession); log('IOSSharingManager::_saveKeyChainSharingSession: COMPLETED'); } catch (e) { @@ -194,7 +195,7 @@ class IOSSharingManager { return; } final newKeychain = keychainSharingStored.updating(emailState: newEmailState); - await _keychainSharingManager.save(newKeychain); + await _keychainSharingManager.saveSharingSession(newKeychain); } Future isExistMailboxIdsBlockNotificationInKeyChain(AccountId accountId) async { @@ -217,7 +218,7 @@ class IOSSharingManager { return; } final newKeychain = keychainSharingStored.updating(mailboxIdsBlockNotification: mailboxIds); - await _keychainSharingManager.save(newKeychain); + await _keychainSharingManager.saveSharingSession(newKeychain); } catch (e) { logWarning('IOSSharingManager::updateMailboxIdsBlockNotificationInKeyChain: Exception = $e'); } @@ -240,4 +241,14 @@ class IOSSharingManager { return null; } } -} \ No newline at end of file + + Future saveSentryConfigToKeychain(SentryConfig sentryConfig) async { + log('IOSSharingManager::saveSentryConfigToKeychain: START'); + try { + await _keychainSharingManager.saveSentryConfig(sentryConfig); + log('IOSSharingManager::saveSentryConfigToKeychain: COMPLETED'); + } catch (e) { + logWarning('IOSSharingManager::saveSentryConfigToKeychain: Exception: $e'); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index f13c4ac4e0..6e9d5605dd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,8 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.28.3 - +version: 0.28.4 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/test/features/push_notification/data/keychain/keychain_sharing_manager_test.dart b/test/features/push_notification/data/keychain/keychain_sharing_manager_test.dart index 4e150610e7..e51fcb9806 100644 --- a/test/features/push_notification/data/keychain/keychain_sharing_manager_test.dart +++ b/test/features/push_notification/data/keychain/keychain_sharing_manager_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:core/utils/sentry/sentry_config.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; @@ -13,6 +14,59 @@ void main() { late KeychainSharingManager keychainSharingManager; late FlutterSecureStorage flutterSecureStorage; + group('KeychainSharingManager:saveSentryConfig', () { + setUp(() { + flutterSecureStorage = const FlutterSecureStorage(); + keychainSharingManager = KeychainSharingManager(flutterSecureStorage); + FlutterSecureStorage.setMockInitialValues({}); + }); + + test('WHEN saveSentryConfig called \n' + 'THEN stores JSON under sentryConfigKeyChain key', () async { + final config = SentryConfig( + dsn: 'https://test@sentry.io/123', + environment: 'production', + release: '1.0.0', + ); + + await keychainSharingManager.saveSentryConfig(config); + + final stored = await flutterSecureStorage.read( + key: SentryConfig.sentryConfigKeyChain, + ); + expect(stored, isNotNull); + final decoded = jsonDecode(stored!) as Map; + expect(decoded['dsn'], equals('https://test@sentry.io/123')); + expect(decoded['environment'], equals('production')); + expect(decoded['release'], equals('1.0.0')); + }); + + test('WHEN SentryConfig has no dist \n' + 'THEN toJson does not include dist key', () { + final config = SentryConfig( + dsn: 'https://test@sentry.io/123', + environment: 'staging', + release: '1.0.0', + ); + + final json = config.toJson(); + expect(json.containsKey('dist'), isFalse); + }); + + test('WHEN SentryConfig has dist \n' + 'THEN toJson includes dist key', () { + final config = SentryConfig( + dsn: 'https://test@sentry.io/123', + environment: 'staging', + release: '1.0.0', + dist: 'abc123', + ); + + final json = config.toJson(); + expect(json['dist'], equals('abc123')); + }); + }); + group('KeychainSharingManager:save for the same accountId', () { setUp(() { flutterSecureStorage = const FlutterSecureStorage(); @@ -36,7 +90,7 @@ void main() { }); // act - await keychainSharingManager.save(keychainSharingSession); + await keychainSharingManager.saveSharingSession(keychainSharingSession); // assert final keychainSession = await keychainSharingManager.getSharingSession(AccountId(Id( @@ -79,7 +133,7 @@ void main() { ); // act - await keychainSharingManager.save(keychainSharingSession2); + await keychainSharingManager.saveSharingSession(keychainSharingSession2); // assert final keychainSession = await keychainSharingManager.getSharingSession(AccountId(Id(