TF-4269 Integrate Sentry into iOS NSE and automate dSYM upload in CI

This commit is contained in:
dab246
2026-04-21 11:51:36 +07:00
parent 4505c50302
commit bab9d4f24c
17 changed files with 412 additions and 18 deletions
+45 -4
View File
@@ -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
+20
View File
@@ -95,4 +95,24 @@ class SentryConfig {
dist: sentryDist.isNotEmpty ? sentryDist : null,
);
}
static const String sentryConfigKeyChain = 'sentry_config_data';
Map<String, dynamic> 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,
};
}
}
+3
View File
@@ -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
+12
View File
@@ -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)
+3 -2
View File
@@ -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
+44
View File
@@ -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 = "<group>"; };
4D124E74293A67D900BA5186 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
84EC933B2F236E7800A0EDC7 /* SentryConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryConfig.swift; sourceTree = "<group>"; };
84EC933D2F23705D00A0EDC7 /* SentryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryManager.swift; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
F522E8852C0EE8B600DDA35B /* CoreUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreUtils.swift; sourceTree = "<group>"; };
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 = "<group>";
@@ -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 = "<group>";
@@ -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;
@@ -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
}
}
}
@@ -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
}
}
+47
View File
@@ -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
}
+24 -4
View File
@@ -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
}
@@ -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)
}
}
+4
View File
@@ -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"
@@ -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<SentryConfigurationCacheManager>());
_sentryEcosystem = SentryEcosystem(
getBinding<SentryConfigurationCacheManager>(),
getBinding<IOSSharingManager>(),
);
_registerReceivingFileSharingStream();
_registerDeepLinks();
}
@@ -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<void> _saveSentryConfigToKeychain(SentryConfig sentryConfig) async {
await _iosSharingManager?.saveSentryConfigToKeychain(sentryConfig);
}
}
@@ -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<void> save(KeychainSharingSession keychainSharingSession) => _secureStorage.write(
Future<void> saveSharingSession(KeychainSharingSession keychainSharingSession) => _secureStorage.write(
key: keychainSharingSession.accountId.asString,
value: jsonEncode(keychainSharingSession.toJson()),
);
Future<void> saveSentryConfig(SentryConfig sentryConfig) => _secureStorage.write(
key: SentryConfig.sentryConfigKeyChain,
value: jsonEncode(sentryConfig.toJson()),
);
Future<bool> isSessionExist(AccountId accountId) =>
_secureStorage.containsKey(key: accountId.asString);
+14 -3
View File
@@ -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<bool> 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<void> saveSentryConfigToKeychain(SentryConfig sentryConfig) async {
log('IOSSharingManager::saveSentryConfigToKeychain: START');
try {
await _keychainSharingManager.saveSentryConfig(sentryConfig);
log('IOSSharingManager::saveSentryConfigToKeychain: COMPLETED');
} catch (e) {
logWarning('IOSSharingManager::saveSentryConfigToKeychain: Exception: $e');
}
}
}
@@ -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<String, dynamic>;
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(