@salve-software/react-native-nitro-input-mask 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NitroInputMask.podspec +31 -0
- package/README.md +181 -0
- package/android/CMakeLists.txt +32 -0
- package/android/build.gradle +148 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +9 -0
- package/android/src/main/java/com/nitroinputmask/NitroInputMaskContext.kt +7 -0
- package/android/src/main/java/com/nitroinputmask/NitroInputMaskModule.kt +108 -0
- package/android/src/main/java/com/nitroinputmask/NitroInputMaskPackage.kt +29 -0
- package/android/src/main/java/com/nitroinputmask/NitroInputMaskServiceModule.kt +17 -0
- package/android/src/main/java/com/nitroinputmask/NitroInputMaskTextWatcher.kt +127 -0
- package/android/src/main/java/com/nitroinputmask/engine/CursorEngine.kt +30 -0
- package/android/src/main/java/com/nitroinputmask/engine/MaskEngine.kt +261 -0
- package/android/src/main/java/com/nitroinputmask/engine/MaskEngineFactory.kt +18 -0
- package/android/src/main/java/com/nitroinputmask/engine/MaskEngineProtocol.kt +8 -0
- package/android/src/main/java/com/nitroinputmask/masks/CreditCardMaskEngine.kt +47 -0
- package/android/src/main/java/com/nitroinputmask/masks/CustomMaskEngine.kt +16 -0
- package/android/src/main/java/com/nitroinputmask/masks/DatetimeMaskEngine.kt +38 -0
- package/android/src/main/java/com/nitroinputmask/masks/MoneyMaskEngine.kt +66 -0
- package/ios/Bridge.h +8 -0
- package/ios/NitroInputMaskDelegateProxy.swift +194 -0
- package/ios/NitroInputMaskModule.swift +114 -0
- package/ios/NitroInputMaskServiceModule.swift +11 -0
- package/ios/engine/CursorEngine.swift +52 -0
- package/ios/engine/MaskEngine.swift +221 -0
- package/ios/engine/MaskEngineFactory.swift +16 -0
- package/ios/engine/MaskEngineProtocol.swift +7 -0
- package/ios/masks/CreditCardMaskEngine.swift +57 -0
- package/ios/masks/CustomMaskEngine.swift +16 -0
- package/ios/masks/DatetimeMaskEngine.swift +46 -0
- package/ios/masks/MoneyMaskEngine.swift +66 -0
- package/lib/commonjs/classes/NitroInputMaskService/NitroInputMaskService.class.js +74 -0
- package/lib/commonjs/classes/NitroInputMaskService/NitroInputMaskService.class.js.map +1 -0
- package/lib/commonjs/classes/NitroInputMaskService/index.js +13 -0
- package/lib/commonjs/classes/NitroInputMaskService/index.js.map +1 -0
- package/lib/commonjs/classes/NitroInputMaskService/types/IApplyMaskProps.js +6 -0
- package/lib/commonjs/classes/NitroInputMaskService/types/IApplyMaskProps.js.map +1 -0
- package/lib/commonjs/classes/NitroInputMaskService/types/index.js +2 -0
- package/lib/commonjs/classes/NitroInputMaskService/types/index.js.map +1 -0
- package/lib/commonjs/classes/index.js +17 -0
- package/lib/commonjs/classes/index.js.map +1 -0
- package/lib/commonjs/components/NitroInputMask/index.js +41 -0
- package/lib/commonjs/components/NitroInputMask/index.js.map +1 -0
- package/lib/commonjs/components/NitroInputMask/types/NitroInputMaskProps.js +6 -0
- package/lib/commonjs/components/NitroInputMask/types/NitroInputMaskProps.js.map +1 -0
- package/lib/commonjs/components/NitroInputMask/types/index.js +2 -0
- package/lib/commonjs/components/NitroInputMask/types/index.js.map +1 -0
- package/lib/commonjs/components/index.js +17 -0
- package/lib/commonjs/components/index.js.map +1 -0
- package/lib/commonjs/index.js +39 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/nitro-module.js +17 -0
- package/lib/commonjs/nitro-module.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/specs/nitro-input-mask-service.nitro.js +6 -0
- package/lib/commonjs/specs/nitro-input-mask-service.nitro.js.map +1 -0
- package/lib/commonjs/specs/nitro-input-mask.nitro.js +6 -0
- package/lib/commonjs/specs/nitro-input-mask.nitro.js.map +1 -0
- package/lib/commonjs/types/CreditCardMaskOptions.js +2 -0
- package/lib/commonjs/types/CreditCardMaskOptions.js.map +1 -0
- package/lib/commonjs/types/CustomMaskOptions.js +2 -0
- package/lib/commonjs/types/CustomMaskOptions.js.map +1 -0
- package/lib/commonjs/types/DatetimeMaskOptions.js +2 -0
- package/lib/commonjs/types/DatetimeMaskOptions.js.map +1 -0
- package/lib/commonjs/types/MaskConfig.js +6 -0
- package/lib/commonjs/types/MaskConfig.js.map +1 -0
- package/lib/commonjs/types/MoneyMaskOptions.js +2 -0
- package/lib/commonjs/types/MoneyMaskOptions.js.map +1 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/commonjs/types/index.js.map +1 -0
- package/lib/module/classes/NitroInputMaskService/NitroInputMaskService.class.js +70 -0
- package/lib/module/classes/NitroInputMaskService/NitroInputMaskService.class.js.map +1 -0
- package/lib/module/classes/NitroInputMaskService/index.js +4 -0
- package/lib/module/classes/NitroInputMaskService/index.js.map +1 -0
- package/lib/module/classes/NitroInputMaskService/types/IApplyMaskProps.js +4 -0
- package/lib/module/classes/NitroInputMaskService/types/IApplyMaskProps.js.map +1 -0
- package/lib/module/classes/NitroInputMaskService/types/index.js +2 -0
- package/lib/module/classes/NitroInputMaskService/types/index.js.map +1 -0
- package/lib/module/classes/index.js +4 -0
- package/lib/module/classes/index.js.map +1 -0
- package/lib/module/components/NitroInputMask/index.js +35 -0
- package/lib/module/components/NitroInputMask/index.js.map +1 -0
- package/lib/module/components/NitroInputMask/types/NitroInputMaskProps.js +4 -0
- package/lib/module/components/NitroInputMask/types/NitroInputMaskProps.js.map +1 -0
- package/lib/module/components/NitroInputMask/types/index.js +2 -0
- package/lib/module/components/NitroInputMask/types/index.js.map +1 -0
- package/lib/module/components/index.js +4 -0
- package/lib/module/components/index.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/nitro-module.js +12 -0
- package/lib/module/nitro-module.js.map +1 -0
- package/lib/module/specs/nitro-input-mask-service.nitro.js +4 -0
- package/lib/module/specs/nitro-input-mask-service.nitro.js.map +1 -0
- package/lib/module/specs/nitro-input-mask.nitro.js +4 -0
- package/lib/module/specs/nitro-input-mask.nitro.js.map +1 -0
- package/lib/module/types/CreditCardMaskOptions.js +2 -0
- package/lib/module/types/CreditCardMaskOptions.js.map +1 -0
- package/lib/module/types/CustomMaskOptions.js +2 -0
- package/lib/module/types/CustomMaskOptions.js.map +1 -0
- package/lib/module/types/DatetimeMaskOptions.js +2 -0
- package/lib/module/types/DatetimeMaskOptions.js.map +1 -0
- package/lib/module/types/MaskConfig.js +4 -0
- package/lib/module/types/MaskConfig.js.map +1 -0
- package/lib/module/types/MoneyMaskOptions.js +2 -0
- package/lib/module/types/MoneyMaskOptions.js.map +1 -0
- package/lib/module/types/index.js +2 -0
- package/lib/module/types/index.js.map +1 -0
- package/lib/typescript/src/classes/NitroInputMaskService/NitroInputMaskService.class.d.ts +50 -0
- package/lib/typescript/src/classes/NitroInputMaskService/NitroInputMaskService.class.d.ts.map +1 -0
- package/lib/typescript/src/classes/NitroInputMaskService/index.d.ts +3 -0
- package/lib/typescript/src/classes/NitroInputMaskService/index.d.ts.map +1 -0
- package/lib/typescript/src/classes/NitroInputMaskService/types/IApplyMaskProps.d.ts +5 -0
- package/lib/typescript/src/classes/NitroInputMaskService/types/IApplyMaskProps.d.ts.map +1 -0
- package/lib/typescript/src/classes/NitroInputMaskService/types/index.d.ts +2 -0
- package/lib/typescript/src/classes/NitroInputMaskService/types/index.d.ts.map +1 -0
- package/lib/typescript/src/classes/index.d.ts +2 -0
- package/lib/typescript/src/classes/index.d.ts.map +1 -0
- package/lib/typescript/src/components/NitroInputMask/index.d.ts +5 -0
- package/lib/typescript/src/components/NitroInputMask/index.d.ts.map +1 -0
- package/lib/typescript/src/components/NitroInputMask/types/NitroInputMaskProps.d.ts +4 -0
- package/lib/typescript/src/components/NitroInputMask/types/NitroInputMaskProps.d.ts.map +1 -0
- package/lib/typescript/src/components/NitroInputMask/types/index.d.ts +2 -0
- package/lib/typescript/src/components/NitroInputMask/types/index.d.ts.map +1 -0
- package/lib/typescript/src/components/index.d.ts +2 -0
- package/lib/typescript/src/components/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/nitro-module.d.ts +5 -0
- package/lib/typescript/src/nitro-module.d.ts.map +1 -0
- package/lib/typescript/src/specs/nitro-input-mask-service.nitro.d.ts +9 -0
- package/lib/typescript/src/specs/nitro-input-mask-service.nitro.d.ts.map +1 -0
- package/lib/typescript/src/specs/nitro-input-mask.nitro.d.ts +23 -0
- package/lib/typescript/src/specs/nitro-input-mask.nitro.d.ts.map +1 -0
- package/lib/typescript/src/types/CreditCardMaskOptions.d.ts +5 -0
- package/lib/typescript/src/types/CreditCardMaskOptions.d.ts.map +1 -0
- package/lib/typescript/src/types/CustomMaskOptions.d.ts +4 -0
- package/lib/typescript/src/types/CustomMaskOptions.d.ts.map +1 -0
- package/lib/typescript/src/types/DatetimeMaskOptions.d.ts +4 -0
- package/lib/typescript/src/types/DatetimeMaskOptions.d.ts.map +1 -0
- package/lib/typescript/src/types/MaskConfig.d.ts +18 -0
- package/lib/typescript/src/types/MaskConfig.d.ts.map +1 -0
- package/lib/typescript/src/types/MoneyMaskOptions.d.ts +9 -0
- package/lib/typescript/src/types/MoneyMaskOptions.d.ts.map +1 -0
- package/lib/typescript/src/types/index.d.ts +6 -0
- package/lib/typescript/src/types/index.d.ts.map +1 -0
- package/nitro.json +40 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/NitroInputMask+autolinking.cmake +83 -0
- package/nitrogen/generated/android/NitroInputMask+autolinking.gradle +27 -0
- package/nitrogen/generated/android/NitroInputMaskOnLoad.cpp +70 -0
- package/nitrogen/generated/android/NitroInputMaskOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JHybridNitroInputMaskServiceSpec.cpp +57 -0
- package/nitrogen/generated/android/c++/JHybridNitroInputMaskServiceSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridNitroInputMaskSpec.cpp +68 -0
- package/nitrogen/generated/android/c++/JHybridNitroInputMaskSpec.hpp +66 -0
- package/nitrogen/generated/android/c++/JNitroMaskOptions.hpp +94 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinputmask/HybridNitroInputMaskServiceSpec.kt +54 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinputmask/HybridNitroInputMaskSpec.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinputmask/NitroInputMaskOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinputmask/NitroMaskOptions.kt +96 -0
- package/nitrogen/generated/ios/NitroInputMask+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NitroInputMask-Swift-Cxx-Bridge.cpp +50 -0
- package/nitrogen/generated/ios/NitroInputMask-Swift-Cxx-Bridge.hpp +124 -0
- package/nitrogen/generated/ios/NitroInputMask-Swift-Cxx-Umbrella.hpp +53 -0
- package/nitrogen/generated/ios/NitroInputMaskAutolinking.mm +41 -0
- package/nitrogen/generated/ios/NitroInputMaskAutolinking.swift +38 -0
- package/nitrogen/generated/ios/c++/HybridNitroInputMaskServiceSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNitroInputMaskServiceSpecSwift.hpp +85 -0
- package/nitrogen/generated/ios/c++/HybridNitroInputMaskSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNitroInputMaskSpecSwift.hpp +101 -0
- package/nitrogen/generated/ios/swift/HybridNitroInputMaskServiceSpec.swift +55 -0
- package/nitrogen/generated/ios/swift/HybridNitroInputMaskServiceSpec_cxx.swift +138 -0
- package/nitrogen/generated/ios/swift/HybridNitroInputMaskSpec.swift +58 -0
- package/nitrogen/generated/ios/swift/HybridNitroInputMaskSpec_cxx.swift +170 -0
- package/nitrogen/generated/ios/swift/NitroMaskOptions.swift +204 -0
- package/nitrogen/generated/shared/c++/HybridNitroInputMaskServiceSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridNitroInputMaskServiceSpec.hpp +64 -0
- package/nitrogen/generated/shared/c++/HybridNitroInputMaskSpec.cpp +24 -0
- package/nitrogen/generated/shared/c++/HybridNitroInputMaskSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/NitroMaskOptions.hpp +120 -0
- package/package.json +136 -0
- package/src/classes/NitroInputMaskService/NitroInputMaskService.class.ts +70 -0
- package/src/classes/NitroInputMaskService/index.ts +2 -0
- package/src/classes/NitroInputMaskService/types/IApplyMaskProps.ts +3 -0
- package/src/classes/NitroInputMaskService/types/index.ts +1 -0
- package/src/classes/index.ts +1 -0
- package/src/components/NitroInputMask/index.tsx +41 -0
- package/src/components/NitroInputMask/types/NitroInputMaskProps.ts +4 -0
- package/src/components/NitroInputMask/types/index.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/index.ts +3 -0
- package/src/nitro-module.ts +14 -0
- package/src/specs/nitro-input-mask-service.nitro.ts +6 -0
- package/src/specs/nitro-input-mask.nitro.ts +25 -0
- package/src/types/CreditCardMaskOptions.ts +4 -0
- package/src/types/CustomMaskOptions.ts +3 -0
- package/src/types/DatetimeMaskOptions.ts +3 -0
- package/src/types/MaskConfig.ts +10 -0
- package/src/types/MoneyMaskOptions.ts +8 -0
- package/src/types/index.ts +5 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
class NitroInputMaskDelegateProxy: NSObject, UITextFieldDelegate {
|
|
4
|
+
var engine: MaskEngineProtocol
|
|
5
|
+
weak var original: UITextFieldDelegate?
|
|
6
|
+
|
|
7
|
+
init(engine: MaskEngineProtocol, original: UITextFieldDelegate?) {
|
|
8
|
+
self.engine = engine
|
|
9
|
+
self.original = original
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
func textField(
|
|
13
|
+
_ textField: UITextField,
|
|
14
|
+
shouldChangeCharactersIn range: NSRange,
|
|
15
|
+
replacementString string: String
|
|
16
|
+
) -> Bool {
|
|
17
|
+
let current = textField.text ?? ""
|
|
18
|
+
guard let swiftRange = Range(range, in: current) else { return false }
|
|
19
|
+
let proposed = current.replacingCharacters(in: swiftRange, with: string)
|
|
20
|
+
|
|
21
|
+
let isDeletion = string.isEmpty
|
|
22
|
+
var (masked, raw) = engine.apply(input: proposed)
|
|
23
|
+
|
|
24
|
+
// Empty mask (custom with no mask string) — pass through
|
|
25
|
+
if masked.isEmpty && raw.isEmpty && !proposed.isEmpty {
|
|
26
|
+
let (_, currentRaw) = engine.apply(input: current)
|
|
27
|
+
if currentRaw.isEmpty {
|
|
28
|
+
textField.text = proposed
|
|
29
|
+
textField.sendActions(for: .editingChanged)
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Separator deletion: masking re-produces the same text because the separator
|
|
35
|
+
// is always re-inserted. If there is data, block the deletion (text unchanged,
|
|
36
|
+
// cursor moves to just before the separator). If there is no data, clear the field.
|
|
37
|
+
var isSeparatorDeletion = false
|
|
38
|
+
if isDeletion && masked == current {
|
|
39
|
+
let (_, extractedRaw) = engine.apply(input: current)
|
|
40
|
+
if extractedRaw.isEmpty {
|
|
41
|
+
masked = ""
|
|
42
|
+
} else {
|
|
43
|
+
isSeparatorDeletion = true
|
|
44
|
+
// masked stays == current; cursor will move to range.location below
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
textField.text = masked
|
|
49
|
+
|
|
50
|
+
if engine.wantsTrailingCursor {
|
|
51
|
+
let cursorPos: Int
|
|
52
|
+
if isSeparatorDeletion {
|
|
53
|
+
// Separator deleted: move to just before it so next backspace hits a real digit.
|
|
54
|
+
cursorPos = range.location
|
|
55
|
+
} else if isDeletion {
|
|
56
|
+
// If the cursor was at the trailing edge (no digits after the deleted
|
|
57
|
+
// char), stay at the end of the new string — RTL money digits shift left
|
|
58
|
+
// and the "digits before" anchor would land one position short.
|
|
59
|
+
// Otherwise use the digits-before anchor so the cursor survives a
|
|
60
|
+
// thousands separator being removed (e.g. 1.123,55 → 112,55).
|
|
61
|
+
let digitsAfterDeleted = countDigits(in: current, from: range.location + range.length)
|
|
62
|
+
|
|
63
|
+
if digitsAfterDeleted == 0 {
|
|
64
|
+
cursorPos = masked.count
|
|
65
|
+
} else {
|
|
66
|
+
let digitsBeforeRange = countDigitsUpTo(in: current, upTo: range.location)
|
|
67
|
+
cursorPos = positionAfterDigits(digitsBeforeRange, in: masked)
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
// Insertion: use "digits from end" so cursor survives magnitude changes.
|
|
71
|
+
let digitsAfterCursor = countDigits(in: current, from: range.location + range.length)
|
|
72
|
+
cursorPos = cursorPositionWithDigitsAfter(digitsAfterCursor, in: masked)
|
|
73
|
+
}
|
|
74
|
+
if let pos = textField.position(from: textField.beginningOfDocument, offset: cursorPos) {
|
|
75
|
+
textField.selectedTextRange = textField.textRange(from: pos, to: pos)
|
|
76
|
+
}
|
|
77
|
+
} else if let expandedMask = engine.expandedMask {
|
|
78
|
+
let cursorOffset: Int
|
|
79
|
+
|
|
80
|
+
if isDeletion {
|
|
81
|
+
cursorOffset = min(range.location, masked.count)
|
|
82
|
+
} else {
|
|
83
|
+
let raw = CursorEngine.offsetAfterInsertion(
|
|
84
|
+
oldMasked: current,
|
|
85
|
+
newMasked: masked,
|
|
86
|
+
mask: expandedMask,
|
|
87
|
+
at: range.location
|
|
88
|
+
)
|
|
89
|
+
// Advance past any auto-inserted literals immediately following the cursor.
|
|
90
|
+
cursorOffset = skipLeadingLiterals(at: raw, in: masked, mask: expandedMask)
|
|
91
|
+
}
|
|
92
|
+
if let pos = textField.position(from: textField.beginningOfDocument, offset: cursorOffset) {
|
|
93
|
+
textField.selectedTextRange = textField.textRange(from: pos, to: pos)
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
let endPos = masked.count
|
|
97
|
+
|
|
98
|
+
if let pos = textField.position(from: textField.beginningOfDocument, offset: endPos) {
|
|
99
|
+
textField.selectedTextRange = textField.textRange(from: pos, to: pos)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
textField.sendActions(for: .editingChanged)
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
108
|
+
textField.resignFirstResponder()
|
|
109
|
+
return original?.textFieldShouldReturn?(textField) ?? true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
override func responds(to aSelector: Selector!) -> Bool {
|
|
113
|
+
super.responds(to: aSelector) || (original?.responds(to: aSelector) ?? false)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
override func forwardingTarget(for aSelector: Selector!) -> Any? {
|
|
117
|
+
if original?.responds(to: aSelector) == true { return original }
|
|
118
|
+
return super.forwardingTarget(for: aSelector)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// MARK: - Cursor helpers
|
|
122
|
+
|
|
123
|
+
/// Advances `offset` past any consecutive literal slots in `mask`, so the
|
|
124
|
+
/// cursor lands after auto-inserted separators rather than before them.
|
|
125
|
+
private func skipLeadingLiterals(at offset: Int, in masked: String, mask: String) -> Int {
|
|
126
|
+
let dataTokens: Set<Character> = ["9", "A", "*"]
|
|
127
|
+
let maskChars = Array(mask)
|
|
128
|
+
var idx = offset
|
|
129
|
+
|
|
130
|
+
while idx < maskChars.count && !dataTokens.contains(maskChars[idx]) {
|
|
131
|
+
idx += 1
|
|
132
|
+
}
|
|
133
|
+
return min(idx, masked.count)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Counts digit characters in `text` before index `upTo`.
|
|
137
|
+
private func countDigitsUpTo(in text: String, upTo: Int) -> Int {
|
|
138
|
+
let chars = Array(text)
|
|
139
|
+
let limit = min(upTo, chars.count)
|
|
140
|
+
return chars[0..<limit].filter { $0.isNumber }.count
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Returns the position in `text` immediately after the `count`-th digit.
|
|
144
|
+
/// When `count` is 0, returns the index of the first digit (past any unit
|
|
145
|
+
/// prefix) so the cursor lands before the content, not before the unit.
|
|
146
|
+
/// Falls back to `text.count` when fewer digits exist.
|
|
147
|
+
private func positionAfterDigits(_ count: Int, in text: String) -> Int {
|
|
148
|
+
let chars = Array(text)
|
|
149
|
+
|
|
150
|
+
if count == 0 {
|
|
151
|
+
return chars.enumerated().first(where: { $0.element.isNumber })?.offset ?? 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
var found = 0
|
|
155
|
+
for (i, c) in chars.enumerated() {
|
|
156
|
+
if c.isNumber {
|
|
157
|
+
found += 1
|
|
158
|
+
if found == count { return i + 1 }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return text.count
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Returns how many digit characters exist in `text` starting from `start`.
|
|
165
|
+
private func countDigits(in text: String, from start: Int) -> Int {
|
|
166
|
+
guard start < text.count else { return 0 }
|
|
167
|
+
let chars = Array(text)
|
|
168
|
+
var count = 0
|
|
169
|
+
|
|
170
|
+
for i in start..<chars.count where chars[i].isNumber {
|
|
171
|
+
count += 1
|
|
172
|
+
}
|
|
173
|
+
return count
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Returns the cursor position in `text` such that exactly `count` digit
|
|
177
|
+
/// characters appear after it. Used for RTL money masks so the cursor
|
|
178
|
+
/// stays near the edit point after reformatting.
|
|
179
|
+
private func cursorPositionWithDigitsAfter(_ count: Int, in text: String) -> Int {
|
|
180
|
+
if count == 0 { return text.count }
|
|
181
|
+
let chars = Array(text)
|
|
182
|
+
var digitsFromEnd = 0
|
|
183
|
+
var pos = chars.count
|
|
184
|
+
|
|
185
|
+
while pos > 0 {
|
|
186
|
+
pos -= 1
|
|
187
|
+
if chars[pos].isNumber {
|
|
188
|
+
digitsFromEnd += 1
|
|
189
|
+
if digitsFromEnd == count { return pos }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return 0
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import NitroModules
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
private let attachRetries = 5
|
|
5
|
+
private let attachRetryDelay: TimeInterval = 0.05
|
|
6
|
+
|
|
7
|
+
class HybridNitroInputMaskModule: HybridNitroInputMaskSpec_base, HybridNitroInputMaskSpec_protocol {
|
|
8
|
+
private var proxies: [String: NitroInputMaskDelegateProxy] = [:]
|
|
9
|
+
|
|
10
|
+
func attach(nativeID: String, maskType: String, options: NitroMaskOptions) throws {
|
|
11
|
+
let engine = MaskEngineFactory.build(maskType: maskType, options: options)
|
|
12
|
+
attemptAttach(nativeID: nativeID, engine: engine, retries: attachRetries)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private func attemptAttach(nativeID: String, engine: MaskEngineProtocol, retries: Int) {
|
|
16
|
+
DispatchQueue.main.async { [weak self] in
|
|
17
|
+
guard let self else { return }
|
|
18
|
+
|
|
19
|
+
if let tf = self.findTextField(nativeID: nativeID) {
|
|
20
|
+
let proxy = NitroInputMaskDelegateProxy(engine: engine, original: tf.delegate)
|
|
21
|
+
tf.delegate = proxy
|
|
22
|
+
self.proxies[nativeID] = proxy
|
|
23
|
+
} else if retries > 0 {
|
|
24
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + attachRetryDelay) { [weak self] in
|
|
25
|
+
self?.attemptAttach(nativeID: nativeID, engine: engine, retries: retries - 1)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func detach(nativeID: String) throws {
|
|
32
|
+
DispatchQueue.main.async { [weak self] in
|
|
33
|
+
guard let self else { return }
|
|
34
|
+
guard let proxy = self.proxies.removeValue(forKey: nativeID) else { return }
|
|
35
|
+
|
|
36
|
+
if let tf = self.findTextField(nativeID: nativeID) {
|
|
37
|
+
tf.delegate = proxy.original
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func updateMask(nativeID: String, maskType: String, options: NitroMaskOptions) throws {
|
|
43
|
+
let engine = MaskEngineFactory.build(maskType: maskType, options: options)
|
|
44
|
+
proxies[nativeID]?.engine = engine
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func setValue(nativeID: String, rawValue: String) throws {
|
|
48
|
+
DispatchQueue.main.async { [weak self] in
|
|
49
|
+
guard let self else { return }
|
|
50
|
+
guard let proxy = self.proxies[nativeID] else { return }
|
|
51
|
+
guard let tf = self.findTextField(nativeID: nativeID) else { return }
|
|
52
|
+
|
|
53
|
+
let (masked, _) = proxy.engine.apply(input: rawValue)
|
|
54
|
+
guard tf.text != masked else { return }
|
|
55
|
+
tf.text = masked
|
|
56
|
+
|
|
57
|
+
if proxy.engine.wantsTrailingCursor {
|
|
58
|
+
let endPos = masked.count
|
|
59
|
+
if let pos = tf.position(from: tf.beginningOfDocument, offset: endPos) {
|
|
60
|
+
tf.selectedTextRange = tf.textRange(from: pos, to: pos)
|
|
61
|
+
}
|
|
62
|
+
} else if let expandedMask = proxy.engine.expandedMask {
|
|
63
|
+
CursorEngine.apply(to: tf, masked: masked, mask: expandedMask)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private func findTextField(nativeID: String) -> UITextField? {
|
|
69
|
+
let windows: [UIWindow]
|
|
70
|
+
|
|
71
|
+
if #available(iOS 15.0, *) {
|
|
72
|
+
windows = UIApplication.shared.connectedScenes
|
|
73
|
+
.compactMap { $0 as? UIWindowScene }
|
|
74
|
+
.flatMap { $0.windows }
|
|
75
|
+
} else {
|
|
76
|
+
windows = UIApplication.shared.windows
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for window in windows {
|
|
80
|
+
if let container = findView(in: window, identifier: nativeID),
|
|
81
|
+
let tf = findFirstTextField(in: container)
|
|
82
|
+
{
|
|
83
|
+
return tf
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return nil
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func findView(in view: UIView, identifier: String) -> UIView? {
|
|
90
|
+
for selName in ["nativeId", "nativeID"] {
|
|
91
|
+
let sel = NSSelectorFromString(selName)
|
|
92
|
+
if view.responds(to: sel),
|
|
93
|
+
let id = view.perform(sel)?.takeUnretainedValue() as? String,
|
|
94
|
+
id == identifier
|
|
95
|
+
{
|
|
96
|
+
return view
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for sv in view.subviews {
|
|
101
|
+
if let found = findView(in: sv, identifier: identifier) { return found }
|
|
102
|
+
}
|
|
103
|
+
return nil
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private func findFirstTextField(in view: UIView) -> UITextField? {
|
|
107
|
+
if let tf = view as? UITextField { return tf }
|
|
108
|
+
|
|
109
|
+
for sv in view.subviews {
|
|
110
|
+
if let found = findFirstTextField(in: sv) { return found }
|
|
111
|
+
}
|
|
112
|
+
return nil
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import NitroModules
|
|
2
|
+
|
|
3
|
+
class HybridNitroInputMaskServiceModule: HybridNitroInputMaskServiceSpec_base,
|
|
4
|
+
HybridNitroInputMaskServiceSpec_protocol
|
|
5
|
+
{
|
|
6
|
+
func applyMask(value: String, maskType: String, options: NitroMaskOptions) throws -> String {
|
|
7
|
+
let engine = MaskEngineFactory.build(maskType: maskType, options: options)
|
|
8
|
+
let (masked, _) = engine.apply(input: value)
|
|
9
|
+
return masked
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
enum CursorEngine {
|
|
4
|
+
static func offsetAtEnd(masked: String, mask: String) -> Int {
|
|
5
|
+
let maskChars = Array(mask)
|
|
6
|
+
var result = masked.count
|
|
7
|
+
for i in stride(from: min(masked.count, mask.count) - 1, through: 0, by: -1) {
|
|
8
|
+
let m = maskChars[i]
|
|
9
|
+
if m == "9" || m == "A" || m == "*" {
|
|
10
|
+
result = i + 1
|
|
11
|
+
break
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return result
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// Returns the cursor offset after inserting one character at `insertionPoint`
|
|
18
|
+
/// in `oldMasked`, producing `newMasked`.
|
|
19
|
+
static func offsetAfterInsertion(
|
|
20
|
+
oldMasked: String, newMasked: String, mask: String, at insertionPoint: Int
|
|
21
|
+
) -> Int {
|
|
22
|
+
let maskChars = Array(mask)
|
|
23
|
+
|
|
24
|
+
// Count data chars before insertionPoint in the old masked string
|
|
25
|
+
var dataRankBefore = 0
|
|
26
|
+
for i in 0..<min(insertionPoint, maskChars.count) {
|
|
27
|
+
let m = maskChars[i]
|
|
28
|
+
if m == "9" || m == "A" || m == "*" { dataRankBefore += 1 }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Find position after the (dataRankBefore + 1)-th data char in new masked string
|
|
32
|
+
let targetRank = dataRankBefore + 1
|
|
33
|
+
var dataCount = 0
|
|
34
|
+
for i in 0..<min(newMasked.count, maskChars.count) {
|
|
35
|
+
let m = maskChars[i]
|
|
36
|
+
if m == "9" || m == "A" || m == "*" {
|
|
37
|
+
dataCount += 1
|
|
38
|
+
if dataCount == targetRank { return i + 1 }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return newMasked.count
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static func apply(to textField: UITextField, masked: String, mask: String) {
|
|
46
|
+
let off = offsetAtEnd(masked: masked, mask: mask)
|
|
47
|
+
guard let pos = textField.position(from: textField.beginningOfDocument, offset: off) else {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
textField.selectedTextRange = textField.textRange(from: pos, to: pos)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - Slot
|
|
4
|
+
|
|
5
|
+
private enum Slot {
|
|
6
|
+
case digit
|
|
7
|
+
case letter
|
|
8
|
+
case any
|
|
9
|
+
case literal(Character)
|
|
10
|
+
case range(blockName: String, length: Int, from: Int, to: Int)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// MARK: - CompiledMask
|
|
14
|
+
|
|
15
|
+
struct CompiledMask {
|
|
16
|
+
fileprivate let slots: [Slot]
|
|
17
|
+
let expandedMask: String
|
|
18
|
+
var isEmpty: Bool { slots.isEmpty }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// MARK: - MaskEngine
|
|
22
|
+
|
|
23
|
+
enum MaskEngine {
|
|
24
|
+
|
|
25
|
+
// MARK: compile
|
|
26
|
+
|
|
27
|
+
/// Compiles a mask string into a `CompiledMask`.
|
|
28
|
+
///
|
|
29
|
+
/// Supported tokens:
|
|
30
|
+
/// - `9` — any digit
|
|
31
|
+
/// - `A` — any letter
|
|
32
|
+
/// - `*` — any alphanumeric
|
|
33
|
+
/// - `[from-to]` — integer range, e.g. `[1-12]`, `[1-31]`, `[0-23]`
|
|
34
|
+
/// - any other character — literal
|
|
35
|
+
///
|
|
36
|
+
/// Note: two `[from-to]` blocks with identical bounds share the same
|
|
37
|
+
/// `blockName` key and therefore share partial-tracking state during
|
|
38
|
+
/// `apply`. This is an acceptable trade-off; prefer distinct bounds when
|
|
39
|
+
/// designing date masks (e.g. `[1-12]/[1-31]/9999`).
|
|
40
|
+
static func compile(mask: String) -> CompiledMask {
|
|
41
|
+
var slots: [Slot] = []
|
|
42
|
+
var expandedMask = ""
|
|
43
|
+
var i = mask.startIndex
|
|
44
|
+
|
|
45
|
+
while i < mask.endIndex {
|
|
46
|
+
if mask[i] == "[" {
|
|
47
|
+
guard let closeIdx = mask[i...].firstIndex(of: "]") else {
|
|
48
|
+
// Malformed — treat "[" as a literal and advance past it.
|
|
49
|
+
slots.append(.literal("["))
|
|
50
|
+
expandedMask.append("[")
|
|
51
|
+
i = mask.index(after: i)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let content = String(mask[mask.index(after: i)..<closeIdx]) // e.g. "1-12"
|
|
56
|
+
let parts = content.split(separator: "-", maxSplits: 1)
|
|
57
|
+
if parts.count == 2, let from = Int(parts[0]), let to = Int(parts[1]) {
|
|
58
|
+
let length = max(String(from).count, String(to).count)
|
|
59
|
+
for _ in 0..<length {
|
|
60
|
+
slots.append(.range(blockName: content, length: length, from: from, to: to))
|
|
61
|
+
expandedMask.append("9")
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
// Malformed range — emit the entire "[...]" as individual literals.
|
|
65
|
+
let literal = "[" + content + "]"
|
|
66
|
+
for c in literal {
|
|
67
|
+
slots.append(.literal(c))
|
|
68
|
+
expandedMask.append(c)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
i = mask.index(after: closeIdx)
|
|
72
|
+
} else {
|
|
73
|
+
switch mask[i] {
|
|
74
|
+
case "9":
|
|
75
|
+
slots.append(.digit)
|
|
76
|
+
expandedMask.append("9")
|
|
77
|
+
|
|
78
|
+
case "A":
|
|
79
|
+
slots.append(.letter)
|
|
80
|
+
expandedMask.append("A")
|
|
81
|
+
|
|
82
|
+
case "*":
|
|
83
|
+
slots.append(.any)
|
|
84
|
+
expandedMask.append("*")
|
|
85
|
+
|
|
86
|
+
default:
|
|
87
|
+
slots.append(.literal(mask[i]))
|
|
88
|
+
expandedMask.append(mask[i])
|
|
89
|
+
}
|
|
90
|
+
i = mask.index(after: i)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return CompiledMask(slots: slots, expandedMask: expandedMask)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: apply
|
|
98
|
+
|
|
99
|
+
static func apply(input: String, compiled: CompiledMask) -> (masked: String, raw: String) {
|
|
100
|
+
if compiled.slots.isEmpty { return (input, input) }
|
|
101
|
+
|
|
102
|
+
var masked = ""
|
|
103
|
+
var raw = ""
|
|
104
|
+
let slots = compiled.slots
|
|
105
|
+
let inputChars = Array(input)
|
|
106
|
+
var ii = 0
|
|
107
|
+
var si = 0
|
|
108
|
+
var blockPartials: [String: String] = [:]
|
|
109
|
+
|
|
110
|
+
while si < slots.count && ii < inputChars.count {
|
|
111
|
+
let slot = slots[si]
|
|
112
|
+
let c = inputChars[ii]
|
|
113
|
+
|
|
114
|
+
switch slot {
|
|
115
|
+
case .digit:
|
|
116
|
+
if c.isNumber {
|
|
117
|
+
masked.append(c)
|
|
118
|
+
raw.append(c)
|
|
119
|
+
ii += 1
|
|
120
|
+
si += 1
|
|
121
|
+
} else {
|
|
122
|
+
ii += 1
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case .letter:
|
|
126
|
+
if c.isLetter {
|
|
127
|
+
masked.append(c)
|
|
128
|
+
raw.append(c)
|
|
129
|
+
ii += 1
|
|
130
|
+
si += 1
|
|
131
|
+
} else {
|
|
132
|
+
ii += 1
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case .any:
|
|
136
|
+
if c.isLetter || c.isNumber {
|
|
137
|
+
masked.append(c)
|
|
138
|
+
raw.append(c)
|
|
139
|
+
ii += 1
|
|
140
|
+
si += 1
|
|
141
|
+
} else {
|
|
142
|
+
ii += 1
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case .literal(let lc):
|
|
146
|
+
masked.append(lc)
|
|
147
|
+
if c == lc { ii += 1 }
|
|
148
|
+
si += 1
|
|
149
|
+
|
|
150
|
+
case .range(let blockName, let length, let from, let to):
|
|
151
|
+
if c.isNumber {
|
|
152
|
+
let partial = blockPartials[blockName, default: ""]
|
|
153
|
+
let newPartial = partial + String(c)
|
|
154
|
+
if isValidPrefix(partial: newPartial, from: from, to: to, length: length) {
|
|
155
|
+
masked.append(c)
|
|
156
|
+
raw.append(c)
|
|
157
|
+
blockPartials[blockName] = newPartial
|
|
158
|
+
ii += 1
|
|
159
|
+
si += 1
|
|
160
|
+
} else {
|
|
161
|
+
ii += 1
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
ii += 1
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Only emit trailing literals if some input was actually consumed.
|
|
170
|
+
if ii > 0 {
|
|
171
|
+
while si < slots.count, case .literal(let lc) = slots[si] {
|
|
172
|
+
masked.append(lc)
|
|
173
|
+
si += 1
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (masked, raw)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// MARK: extractRaw
|
|
181
|
+
|
|
182
|
+
static func extractRaw(from text: String, compiled: CompiledMask) -> String {
|
|
183
|
+
return extractRaw(from: text, mask: compiled.expandedMask)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// MARK: Private helpers
|
|
187
|
+
|
|
188
|
+
private static func extractRaw(from text: String, mask: String) -> String {
|
|
189
|
+
var raw = ""
|
|
190
|
+
let textChars = Array(text)
|
|
191
|
+
let maskChars = Array(mask)
|
|
192
|
+
var ti = 0
|
|
193
|
+
var mi = 0
|
|
194
|
+
|
|
195
|
+
while ti < textChars.count && mi < maskChars.count {
|
|
196
|
+
let m = maskChars[mi]
|
|
197
|
+
|
|
198
|
+
if m == "9" || m == "A" || m == "*" {
|
|
199
|
+
raw.append(textChars[ti])
|
|
200
|
+
ti += 1
|
|
201
|
+
mi += 1
|
|
202
|
+
} else {
|
|
203
|
+
if textChars[ti] == maskChars[mi] { ti += 1 }
|
|
204
|
+
mi += 1
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return raw
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private static func isValidPrefix(partial: String, from: Int, to: Int, length: Int) -> Bool {
|
|
212
|
+
let remaining = length - partial.count
|
|
213
|
+
guard remaining >= 0 else { return false }
|
|
214
|
+
guard
|
|
215
|
+
let partialMin = Int(partial + String(repeating: "0", count: remaining)),
|
|
216
|
+
let partialMax = Int(partial + String(repeating: "9", count: remaining))
|
|
217
|
+
else { return false }
|
|
218
|
+
|
|
219
|
+
return partialMin <= to && partialMax >= from
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import NitroModules
|
|
2
|
+
|
|
3
|
+
enum MaskEngineFactory {
|
|
4
|
+
static func build(maskType: String, options: NitroMaskOptions) -> MaskEngineProtocol {
|
|
5
|
+
switch maskType {
|
|
6
|
+
case "money":
|
|
7
|
+
return MoneyMaskEngine(options: options)
|
|
8
|
+
case "datetime":
|
|
9
|
+
return DatetimeMaskEngine(format: options.format ?? "DD/MM/YYYY")
|
|
10
|
+
case "credit-card":
|
|
11
|
+
return CreditCardMaskEngine(options: options)
|
|
12
|
+
default: // "custom"
|
|
13
|
+
return CustomMaskEngine(mask: options.mask ?? "")
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
protocol MaskEngineProtocol {
|
|
2
|
+
func apply(input: String) -> (masked: String, raw: String)
|
|
3
|
+
var wantsTrailingCursor: Bool { get }
|
|
4
|
+
/// Expanded mask string used for cursor positioning (e.g. "999.999.999-99").
|
|
5
|
+
/// Return nil if cursor should just go to end of masked string.
|
|
6
|
+
var expandedMask: String? { get }
|
|
7
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import NitroModules
|
|
2
|
+
|
|
3
|
+
struct CreditCardMaskEngine: MaskEngineProtocol {
|
|
4
|
+
private let compiled: CompiledMask
|
|
5
|
+
private let obfuscated: Bool
|
|
6
|
+
|
|
7
|
+
init(options: NitroMaskOptions) {
|
|
8
|
+
let issuer = options.issuer ?? "visa-or-mastercard"
|
|
9
|
+
self.obfuscated = options.obfuscated ?? false
|
|
10
|
+
|
|
11
|
+
let mask: String
|
|
12
|
+
switch issuer {
|
|
13
|
+
case "amex":
|
|
14
|
+
mask = "9999 999999 99999"
|
|
15
|
+
case "diners":
|
|
16
|
+
mask = "9999 999999 9999"
|
|
17
|
+
default: // visa-or-mastercard
|
|
18
|
+
mask = "9999 9999 9999 9999"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
self.compiled = MaskEngine.compile(mask: mask)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func apply(input: String) -> (masked: String, raw: String) {
|
|
25
|
+
let (masked, raw) = MaskEngine.apply(input: input, compiled: compiled)
|
|
26
|
+
|
|
27
|
+
if obfuscated && !masked.isEmpty {
|
|
28
|
+
let obfuscatedMasked = CreditCardMaskEngine.obfuscate(masked)
|
|
29
|
+
return (obfuscatedMasked, raw)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (masked, raw)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
var wantsTrailingCursor: Bool { false }
|
|
36
|
+
var expandedMask: String? { compiled.expandedMask }
|
|
37
|
+
|
|
38
|
+
// MARK: - Obfuscation
|
|
39
|
+
|
|
40
|
+
private static func obfuscate(_ masked: String) -> String {
|
|
41
|
+
guard let lastSpaceIdx = masked.lastIndex(of: " ") else {
|
|
42
|
+
// No spaces — single group, don't obfuscate
|
|
43
|
+
return masked
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let lastGroupStart = masked.index(after: lastSpaceIdx)
|
|
47
|
+
let prefix = String(masked[masked.startIndex..<lastGroupStart])
|
|
48
|
+
let lastGroup = String(masked[lastGroupStart...])
|
|
49
|
+
|
|
50
|
+
// Replace digit characters with * in prefix, keep spaces intact
|
|
51
|
+
let obfuscatedPrefix = prefix.map { c -> Character in
|
|
52
|
+
c.isNumber ? "*" : c
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return String(obfuscatedPrefix) + lastGroup
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import NitroModules
|
|
2
|
+
|
|
3
|
+
struct CustomMaskEngine: MaskEngineProtocol {
|
|
4
|
+
private let compiled: CompiledMask
|
|
5
|
+
|
|
6
|
+
init(mask: String) {
|
|
7
|
+
self.compiled = MaskEngine.compile(mask: mask)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
func apply(input: String) -> (masked: String, raw: String) {
|
|
11
|
+
return MaskEngine.apply(input: input, compiled: compiled)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
var wantsTrailingCursor: Bool { false }
|
|
15
|
+
var expandedMask: String? { compiled.expandedMask }
|
|
16
|
+
}
|