diff --git a/Example/Example.entitlements b/Example/Example.entitlements
index 938e10d..7f0de4a 100644
--- a/Example/Example.entitlements
+++ b/Example/Example.entitlements
@@ -4,7 +4,9 @@
com.apple.developer.in-app-payments
- merchant.com.payline.prod
+ merchant.homo.sdk.app.monext.com
+ merchant.homo.sdk.monext.com
+ merchant.prod.sdk.app.monext.com
diff --git a/Sources/Monext/Presentation/PaymentMethodScreen/CardFormSection/CardForm.swift b/Sources/Monext/Presentation/PaymentMethodScreen/CardFormSection/CardForm.swift
index b8c7ddc..52579eb 100644
--- a/Sources/Monext/Presentation/PaymentMethodScreen/CardFormSection/CardForm.swift
+++ b/Sources/Monext/Presentation/PaymentMethodScreen/CardFormSection/CardForm.swift
@@ -26,110 +26,16 @@ struct CardForm: View {
var onFieldFocused: ((CardField?) -> Void)?
var body: some View {
-
VStack(spacing: 10) {
-
- FormFieldView(
- label: "Card number",
- textValue: $viewModel.cardNumber,
- errorMessage: viewModel.cardNumberError,
- formatter: CardNumberFormatter(),
- keyboardType: .numberPad,
- focusedState: $focusedField,
- focusedField: .cardNumber
- )
- .id(CardField.cardNumber)
-
- HStack {
-
- if viewModel.showExpirationDate {
- FormFieldView(
- label: "Expiry",
- textValue: $viewModel.cardExpiration,
- errorMessage: viewModel.cardExpirationError,
- formatter: CardDateFormatter(),
- keyboardType: .numberPad,
- focusedState: $focusedField,
- focusedField: .expiration
- )
- .id(CardField.expiration)
- }
-
- if viewModel.showCardCvv {
- FormFieldView(
- label: "CVV",
- textValue: $viewModel.cardCvv,
- errorMessage: viewModel.cardCvvError,
- formatter: CardCvvFormatter(),
- keyboardType: .numberPad,
- focusedState: $focusedField,
- focusedField: .cvv,
- onTappedInfoAccessory: {
- isPresentedCvvInfo = true
- }
- )
- .id(CardField.cvv)
- .modifier(CvvInfoDialog(isPresented: $isPresentedCvvInfo))
- }
- }
-
- if viewModel.showCardHolderName {
- FormFieldView(
- label: "Name on card",
- textValue: $viewModel.cardHolderName,
- errorMessage: viewModel.cardHolderNameError,
- formatter: nil,
- focusedState: $focusedField,
- focusedField: .holder
- )
- .id(CardField.holder)
- .onSubmit {
- focusedField = nil
- }
- .submitLabel(.done)
- }
-
- if let availableNetworks = viewModel.availableNetworks,
- let defaultNetwork = availableNetworks.defaultCardNetwork,
- let altNetwork = availableNetworks.altCardNetwork,
- viewModel.showNetworkPicker {
- CardNetworkSelector(
- defaultNetwork: defaultNetwork,
- altNetwork: altNetwork,
- selectedNetwork: $viewModel.selectedNetwork
- )
- }
-
- if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
- CardNetworkSelector(
- defaultNetwork: .init(network: "CB", code: "1"),
- altNetwork: .init(network: "VISA", code: "2"),
- selectedNetwork: .constant(.init(network: "CB", code: "1"))
- )
- }
-
- if viewModel.showSaveCard {
-
- ToggleButton(
- "I want to save my credit card information for later.",
- isOn: $viewModel.saveCard
- )
- }
-
+ cardNumberField
+ expirationAndCvvRow
+ holderField
+ networkPickerSection
+ saveCardToggle
CompliancyNotice()
}
.background(sessionStore.appearance.backgroundColor)
- .toolbar {
- if [CardField.cardNumber, CardField.expiration, CardField.cvv].contains(focusedField) {
- ToolbarItemGroup(placement: .keyboard) {
- Spacer()
- Button(action: nextFocus) {
- Text("Next")
- .foregroundStyle(colorScheme == .dark ? .white : .black)
- }
- }
- }
- }
+ .toolbar { keyboardToolbar }
.onChange(of: focusedField) { field in
viewModel.focusedField = field
onFieldFocused?(field)
@@ -138,20 +44,129 @@ struct CardForm: View {
focusedField = nil
}
.onChange(of: viewModel.selectedNetwork) { [oldValue = viewModel.selectedNetwork] _ in
- if oldValue != nil {
- focusedField = nil
- }
+ if oldValue != nil { focusedField = nil }
}
.onChange(of: viewModel.formValid) { isValid in
formValid = isValid
}
.onChange(of: viewModel.cardExpiration) {
- if DateFormatter.isValidCardExpiration($0) {
- nextFocus()
+ if DateFormatter.isValidCardExpiration($0) { nextFocus() }
+ }
+ }
+
+ // MARK: - Subviews
+
+ private var cardNumberField: some View {
+ FormFieldView(
+ label: "Card number",
+ textValue: $viewModel.cardNumber,
+ errorMessage: viewModel.cardNumberError,
+ formatter: CardNumberFormatter(),
+ keyboardType: .numberPad,
+ focusedState: $focusedField,
+ focusedField: .cardNumber,
+ isScreenshotProtected: true
+ )
+ .id(CardField.cardNumber)
+ }
+
+ private var expirationAndCvvRow: some View {
+ HStack {
+ if viewModel.showExpirationDate {
+ FormFieldView(
+ label: "Expiry",
+ textValue: $viewModel.cardExpiration,
+ errorMessage: viewModel.cardExpirationError,
+ formatter: CardDateFormatter(),
+ keyboardType: .numberPad,
+ focusedState: $focusedField,
+ focusedField: .expiration,
+ isScreenshotProtected: true
+ )
+ .id(CardField.expiration)
}
+ if viewModel.showCardCvv {
+ FormFieldView(
+ label: "CVV",
+ textValue: $viewModel.cardCvv,
+ errorMessage: viewModel.cardCvvError,
+ formatter: CardCvvFormatter(),
+ keyboardType: .numberPad,
+ focusedState: $focusedField,
+ focusedField: .cvv,
+ onTappedInfoAccessory: { isPresentedCvvInfo = true },
+ isScreenshotProtected: true
+ )
+ .id(CardField.cvv)
+ .modifier(CvvInfoDialog(isPresented: $isPresentedCvvInfo))
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var holderField: some View {
+ if viewModel.showCardHolderName {
+ FormFieldView(
+ label: "Name on card",
+ textValue: $viewModel.cardHolderName,
+ errorMessage: viewModel.cardHolderNameError,
+ formatter: nil,
+ focusedState: $focusedField,
+ focusedField: .holder,
+ isScreenshotProtected: true
+ )
+ .id(CardField.holder)
+ .onSubmit { focusedField = nil }
+ .submitLabel(.done)
}
}
+ @ViewBuilder
+ private var networkPickerSection: some View {
+ if let availableNetworks = viewModel.availableNetworks,
+ let defaultNetwork = availableNetworks.defaultCardNetwork,
+ let altNetwork = availableNetworks.altCardNetwork,
+ viewModel.showNetworkPicker {
+ CardNetworkSelector(
+ defaultNetwork: defaultNetwork,
+ altNetwork: altNetwork,
+ selectedNetwork: $viewModel.selectedNetwork
+ )
+ }
+ if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
+ CardNetworkSelector(
+ defaultNetwork: .init(network: "CB", code: "1"),
+ altNetwork: .init(network: "VISA", code: "2"),
+ selectedNetwork: .constant(.init(network: "CB", code: "1"))
+ )
+ }
+ }
+
+ @ViewBuilder
+ private var saveCardToggle: some View {
+ if viewModel.showSaveCard {
+ ToggleButton(
+ "I want to save my credit card information for later.",
+ isOn: $viewModel.saveCard
+ )
+ }
+ }
+
+ @ToolbarContentBuilder
+ private var keyboardToolbar: some ToolbarContent {
+ if [CardField.cardNumber, CardField.expiration, CardField.cvv].contains(focusedField) {
+ ToolbarItemGroup(placement: .keyboard) {
+ Spacer()
+ Button(action: nextFocus) {
+ Text("Next")
+ .foregroundStyle(colorScheme == .dark ? .white : .black)
+ }
+ }
+ }
+ }
+
+ // MARK: - Actions
+
private func nextFocus() {
guard let focusedField else { return }
self.focusedField = viewModel.nextFocus(focusedField)
diff --git a/Sources/Monext/Presentation/PaymentMethodScreen/PaymentMethodFormSection/AlternativePaymentMethodForm.swift b/Sources/Monext/Presentation/PaymentMethodScreen/PaymentMethodFormSection/AlternativePaymentMethodForm.swift
index 1eae01b..4ff89e7 100644
--- a/Sources/Monext/Presentation/PaymentMethodScreen/PaymentMethodFormSection/AlternativePaymentMethodForm.swift
+++ b/Sources/Monext/Presentation/PaymentMethodScreen/PaymentMethodFormSection/AlternativePaymentMethodForm.swift
@@ -89,7 +89,8 @@ struct AlternativePaymentMethodForm: View {
keyboardType: getKeyboardType(for: field),
focusedState: $focusedField,
focusedField: field.id,
- placeholder: field.placeholder
+ placeholder: field.placeholder,
+ isScreenshotProtected: field.secured ?? false
)
.padding(.vertical, 8)
default:
diff --git a/Sources/Monext/Presentation/Views/FormField/FormFieldView.swift b/Sources/Monext/Presentation/Views/FormField/FormFieldView.swift
index f4a0b07..7196435 100644
--- a/Sources/Monext/Presentation/Views/FormField/FormFieldView.swift
+++ b/Sources/Monext/Presentation/Views/FormField/FormFieldView.swift
@@ -38,6 +38,14 @@ struct FormFieldView: View {
@EnvironmentObject var sessionStore: SessionStateStore
+
+ /// Active la protection contre les screenshots et screen recordings iOS natifs.
+ /// À utiliser sur les champs sensibles : PAN, CVV, etc.
+ var isScreenshotProtected: Bool = false
+ private var shouldProtectSensitiveData: Bool {
+ isScreenshotProtected && (sessionStore.env == .production || sessionStore.env == .sandbox)
+ }
+
private var config: Appearance {
sessionStore.appearance
}
@@ -117,6 +125,11 @@ struct FormFieldView: View {
.tint(config.textfieldTextColor)
.keyboardType(keyboardType)
.textFieldStyle(MonextTextFieldStyle(config: config))
+ // Cache les champs uniquement en Production et Homologation
+ .privacySensitive(shouldProtectSensitiveData)
+ .if(shouldProtectSensitiveData) {
+ $0.screenshotProtected()
+ }
if onTappedInfoAccessory != nil {
Image(moduleImage: "ic.i.circle.filled")
@@ -191,7 +204,8 @@ struct FormFieldView: View {
keyboardType: .numberPad,
focusedState: FocusState().projectedValue,
focusedField: .cardNumber,
- placeholder: "0000 0000 0000 0000"
+ placeholder: "0000 0000 0000 0000",
+ isScreenshotProtected: true
)
.padding()
@@ -205,7 +219,8 @@ struct FormFieldView: View {
keyboardType: .numberPad,
focusedState: FocusState().projectedValue,
focusedField: .cvv,
- placeholder: "123"
+ placeholder: "123",
+ isScreenshotProtected: true
)
.padding()
diff --git a/Sources/Monext/Presentation/Views/ScreenshotProtected.swift b/Sources/Monext/Presentation/Views/ScreenshotProtected.swift
new file mode 100644
index 0000000..3079782
--- /dev/null
+++ b/Sources/Monext/Presentation/Views/ScreenshotProtected.swift
@@ -0,0 +1,92 @@
+//
+// ScreenshotProtected.swift
+// Monext
+//
+
+import SwiftUI
+import UIKit
+
+// MARK: - UIViewRepresentable
+
+private struct ScreenshotProtectedWrapper: UIViewRepresentable {
+ let content: Content
+
+ func makeUIView(context: Context) -> UIView {
+ let secureField = UITextField()
+ secureField.isSecureTextEntry = true
+ secureField.isUserInteractionEnabled = false
+
+ guard let secureView = secureField.layer.sublayers?.first?.delegate as? UIView else {
+ let hosting = UIHostingController(rootView: content)
+ hosting.view.backgroundColor = .clear
+ return hosting.view
+ }
+
+ secureView.subviews.forEach { $0.removeFromSuperview() }
+ secureView.isUserInteractionEnabled = true
+
+ let hosting = UIHostingController(rootView: content)
+ hosting.view.backgroundColor = .clear
+ hosting.view.translatesAutoresizingMaskIntoConstraints = false
+ secureView.addSubview(hosting.view)
+
+ NSLayoutConstraint.activate([
+ hosting.view.topAnchor.constraint(equalTo: secureView.topAnchor),
+ hosting.view.bottomAnchor.constraint(equalTo: secureView.bottomAnchor),
+ hosting.view.leadingAnchor.constraint(equalTo: secureView.leadingAnchor),
+ hosting.view.trailingAnchor.constraint(equalTo: secureView.trailingAnchor),
+ ])
+
+ let container = UIView()
+ container.backgroundColor = .clear
+ secureView.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(secureView)
+
+ NSLayoutConstraint.activate([
+ secureView.topAnchor.constraint(equalTo: container.topAnchor),
+ secureView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ secureView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+ secureView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+ ])
+
+ container.tag = 42
+ objc_setAssociatedObject(container, &hostingKey, hosting, .OBJC_ASSOCIATION_RETAIN)
+
+ return container
+ }
+
+ func updateUIView(_ uiView: UIView, context: Context) {}
+
+ func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? {
+ let hosting = objc_getAssociatedObject(uiView, &hostingKey) as? UIHostingController
+ let width = proposal.width ?? UIScreen.main.bounds.width
+ return hosting?.sizeThatFits(in: CGSize(width: width, height: .infinity))
+ }
+}
+
+private nonisolated(unsafe) var hostingKey: UInt8 = 0
+
+// MARK: - ViewModifier
+
+struct ScreenshotProtected: ViewModifier {
+ func body(content: Content) -> some View {
+ ScreenshotProtectedWrapper(content: content)
+ }
+}
+
+// MARK: - View extensions
+
+extension View {
+ func screenshotProtected() -> some View {
+ modifier(ScreenshotProtected())
+ }
+
+ @ViewBuilder
+ func `if`(_ condition: Bool, transform: (Self) -> some View) -> some View {
+ if condition {
+ transform(self)
+ } else {
+ self
+ }
+ }
+}