diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d79ca4079..d778282c6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -101,10 +101,11 @@ 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 + # 2. Validate tag version matches pubspec.yaml version (base version only, ignoring -rc* suffix) TAG_VERSION="${GITHUB_REF_NAME#v}" TAG_VERSION="${TAG_VERSION%%-rc*}" PUBSPEC_VERSION=$(awk '/^version:/{split($2,a,"+"); print a[1]; exit}' pubspec.yaml) + PUBSPEC_VERSION="${PUBSPEC_VERSION%%-rc*}" if [ "$PUBSPEC_VERSION" != "$TAG_VERSION" ]; then echo "::error::Tag version ($TAG_VERSION) does not match pubspec version ($PUBSPEC_VERSION)." exit 1 @@ -117,7 +118,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 +128,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 +135,48 @@ 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 version matches pubspec.yaml version (base version only, ignoring -rc* suffix) + TAG_VERSION="${GITHUB_REF_NAME#v}" + TAG_VERSION="${TAG_VERSION%%-rc*}" + PUBSPEC_VERSION=$(awk '/^version:/{split($2,a,"+"); print a[1]; exit}' pubspec.yaml) + PUBSPEC_VERSION="${PUBSPEC_VERSION%%-rc*}" + if [ "$PUBSPEC_VERSION" != "$TAG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match pubspec version ($PUBSPEC_VERSION)." + exit 1 + fi + + # 3. Read the xcarchive path written by Fastlane after build_app. + # Fastlane writes lane_context[XCODEBUILD_ARCHIVE] to ios/gym_archive_path.txt. + XCARCHIVE=$(cat ios/gym_archive_path.txt 2>/dev/null) + if [ -z "$XCARCHIVE" ] || [ ! -d "$XCARCHIVE" ]; then + echo "::error::xcarchive not found. Check that Fastlane wrote gym_archive_path.txt correctly." + 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 "Uploading dSYMs from $DSYM_DIR..." + sentry-cli debug-files upload --include-sources "$DSYM_DIR" + else + echo "::error::dSYMs directory not found inside xcarchive at $DSYM_DIR." + exit 1 + fi diff --git a/core/lib/utils/sentry/sentry_config.dart b/core/lib/utils/sentry/sentry_config.dart index e2690b5f0..875de64a7 100644 --- a/core/lib/utils/sentry/sentry_config.dart +++ b/core/lib/utils/sentry/sentry_config.dart @@ -95,4 +95,24 @@ class SentryConfig { dist: sentryDist.isNotEmpty ? sentryDist : null, ); } + + 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/ios/.gitignore b/ios/.gitignore index da38b0877..e34a96caf 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -26,6 +26,9 @@ ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* Flutter/ephemeral/ +# Build artifacts generated by Fastlane +gym_archive_path.txt + # Exceptions to above rules. !default.mode1v3 !default.mode2v3 diff --git a/ios/Podfile b/ios/Podfile index b38c1ac3b..43ab7719a 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 c0802d65c..34d5afc8d 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 2a0ff96c8..969d76e47 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 068d3159d..a5324cb19 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") + return nil + } + } +} diff --git a/ios/TwakeMailNSE/Model/KeychainSharingSession.swift b/ios/TwakeMailNSE/Model/KeychainSharingSession.swift index 0f99aa31d..6556628e2 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 000000000..3783a206a --- /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 fae9d5f0e..ff69185cf 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,12 @@ 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 } 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 +49,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,14 +63,23 @@ 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() } @@ -106,7 +123,8 @@ class NotificationService: UNNotificationServiceExtension { } } } catch { - TwakeLogger.shared.log(message: "JmapClient.shared.getNewEmails: \(error)") + TwakeLogger.shared.log(message: "Error processing emails: \(error)") + SentryManager.shared.capture(error: error) self.showDefaultNotification(message: NSLocalizedString(self.newEmailDefaultMessageKey, comment: "Localizable")) return self.notify() } @@ -183,7 +201,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 +209,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") } @@ -212,6 +231,7 @@ class NotificationService: UNNotificationServiceExtension { } 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 000000000..35d44cf39 --- /dev/null +++ b/ios/TwakeMailNSE/Utils/SentryManager.swift @@ -0,0 +1,89 @@ +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) + } + } + + /// Set user context cho 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 c83b883da..80e593bdd 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -56,6 +56,10 @@ platform :ios do workspace: "Runner.xcworkspace", export_method: "app-store" ) + # Write the xcarchive path to a file so CI can locate dSYMs for Sentry upload. + # lane_context[XCODEBUILD_ARCHIVE] is set by build_app and contains the exact path. + archive_path = Actions.lane_context[SharedValues::XCODEBUILD_ARCHIVE] + File.write("gym_archive_path.txt", archive_path.to_s) if archive_path upload_to_testflight( skip_waiting_for_build_processing: true, ipa: "Runner.ipa" 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 2c3eb1240..5a5a372ba 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 4c39faf0b..d9a5ee639 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 b6d384fdb..85595cbb1 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 8e3e1712d..37336f359 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; } } + + 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'); + } + } } \ No newline at end of file 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 4e150610e..e51fcb980 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(