expo-secure-keypad-jsi 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +393 -0
  3. package/SecureKeypadJsi.podspec +45 -0
  4. package/android/CMakeLists.txt +28 -0
  5. package/android/build.gradle +58 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/JniBridge.cpp +118 -0
  8. package/android/src/main/java/expo/modules/securekeypadjsi/KeyboardCanvasView.kt +291 -0
  9. package/android/src/main/java/expo/modules/securekeypadjsi/KeypadCanvasView.kt +183 -0
  10. package/android/src/main/java/expo/modules/securekeypadjsi/PinSessionHandle.kt +54 -0
  11. package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiModule.kt +35 -0
  12. package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiView.kt +317 -0
  13. package/android/src/main/java/expo/modules/securekeypadjsi/ThemeColor.kt +27 -0
  14. package/android/src/main/java/expo/modules/securekeypadjsi/ThemedKeypadView.kt +78 -0
  15. package/build/SecureKeypad.d.ts +51 -0
  16. package/build/SecureKeypad.d.ts.map +1 -0
  17. package/build/SecureKeypad.js +51 -0
  18. package/build/SecureKeypad.js.map +1 -0
  19. package/build/SecureKeypadJsi.types.d.ts +94 -0
  20. package/build/SecureKeypadJsi.types.d.ts.map +1 -0
  21. package/build/SecureKeypadJsi.types.js +2 -0
  22. package/build/SecureKeypadJsi.types.js.map +1 -0
  23. package/build/SecureKeypadJsiView.d.ts +6 -0
  24. package/build/SecureKeypadJsiView.d.ts.map +1 -0
  25. package/build/SecureKeypadJsiView.js +10 -0
  26. package/build/SecureKeypadJsiView.js.map +1 -0
  27. package/build/index.d.ts +5 -0
  28. package/build/index.d.ts.map +1 -0
  29. package/build/index.js +5 -0
  30. package/build/index.js.map +1 -0
  31. package/common/CMakeLists.txt +49 -0
  32. package/common/include/esk/Base64.h +20 -0
  33. package/common/include/esk/Cleanse.h +17 -0
  34. package/common/include/esk/Error.h +37 -0
  35. package/common/include/esk/KeypadCore.h +69 -0
  36. package/common/include/esk/PinEncryptor.h +39 -0
  37. package/common/include/esk/PublicKeyValidator.h +37 -0
  38. package/common/include/esk/SecureBuffer.h +60 -0
  39. package/common/include/esk/SecureRandom.h +24 -0
  40. package/common/include/esk/esk_c_api.h +61 -0
  41. package/common/src/Base64.cpp +43 -0
  42. package/common/src/Error.cpp +21 -0
  43. package/common/src/KeypadCore.cpp +105 -0
  44. package/common/src/PinEncryptor.cpp +110 -0
  45. package/common/src/PublicKeyValidator.cpp +111 -0
  46. package/common/src/SecureBuffer.cpp +108 -0
  47. package/common/src/SecureRandom.cpp +42 -0
  48. package/common/src/esk_c_api.cpp +158 -0
  49. package/docs/BUILD.ko.md +33 -0
  50. package/docs/BUILD.md +36 -0
  51. package/docs/README.ko.md +286 -0
  52. package/expo-module.config.json +10 -0
  53. package/ios/EskKeypadBridge.h +35 -0
  54. package/ios/EskKeypadBridge.mm +77 -0
  55. package/ios/KeyboardCoreGraphicsView.swift +257 -0
  56. package/ios/KeypadCoreGraphicsView.swift +197 -0
  57. package/ios/SecureKeypadJsiModule.swift +26 -0
  58. package/ios/SecureKeypadJsiView.swift +278 -0
  59. package/package.json +80 -0
@@ -0,0 +1,291 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ package expo.modules.securekeypadjsi
3
+
4
+ import android.annotation.SuppressLint
5
+ import android.content.Context
6
+ import android.graphics.Canvas
7
+ import android.graphics.Color
8
+ import android.graphics.Paint
9
+ import android.graphics.RectF
10
+ import android.view.MotionEvent
11
+ import android.view.View
12
+ import java.security.SecureRandom
13
+
14
+ /**
15
+ * Full QWERTY secure keyboard drawn with [Canvas] and no child views, so key
16
+ * glyphs never become scrapeable text. Touches resolve to a final ASCII value
17
+ * here (shift and symbol layer included); the on-screen position never leaves
18
+ * this class.
19
+ *
20
+ * Following commercial secure-keyboard practice, the QWERTY arrangement is
21
+ * kept readable: the digit row is fully shuffled (order comes from the native
22
+ * core's CSPRNG), and each character row gets one blank dummy key at a random
23
+ * slot so the same character is not at the same coordinate across sessions.
24
+ */
25
+ @SuppressLint("ViewConstructor")
26
+ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
27
+
28
+ interface Listener {
29
+ /** Final ASCII character — shift/layer already applied. */
30
+ fun onKeyPressed(asciiChar: Int)
31
+ fun onBackspace()
32
+ fun onClear()
33
+ fun onDone()
34
+ fun onObscuredTouch()
35
+ }
36
+
37
+ var listener: Listener? = null
38
+
39
+ var shuffleMode: KeypadCanvasView.ShuffleMode = KeypadCanvasView.ShuffleMode.MOUNT
40
+
41
+ /** Shuffled 0..9 from the native core; identity order when shuffle is off. */
42
+ var digitsProvider: (() -> ByteArray)? = null
43
+
44
+ private val density = context.resources.displayMetrics.density
45
+
46
+ override var keyColor: Int = Color.parseColor("#1C1C1E")
47
+ override var keyTextColor: Int = Color.WHITE
48
+ override var actionTextColor: Int = Color.parseColor("#8E8E93")
49
+ override var keyCornerRadiusDp: Float = 8f
50
+ override var digitTextSizeDp: Float = 32f
51
+ override var fontFamily: String? = null
52
+
53
+ override var pressedHighlight: Boolean = true
54
+
55
+ private companion object {
56
+ const val ACTION_SHIFT = -1
57
+ const val ACTION_BACKSPACE = -2
58
+ const val ACTION_CLEAR = -3
59
+ const val ACTION_DONE = -4
60
+ const val ACTION_TOGGLE = -5
61
+ const val DUMMY = -6
62
+
63
+ const val ROW_QWERTY_TOP = "qwertyuiop"
64
+ const val ROW_QWERTY_MID = "asdfghjkl"
65
+ const val ROW_QWERTY_BOT = "zxcvbnm"
66
+ // All 32 printable ASCII specials, three rows + a short fourth row.
67
+ const val ROW_SYM_0 = "!@#$%^&*()"
68
+ const val ROW_SYM_1 = "-_=+[]{}\\|"
69
+ const val ROW_SYM_2 = ";:'\",.<>?/"
70
+ const val ROW_SYM_3 = "`~"
71
+ }
72
+
73
+ private enum class Layer { LETTERS, SYMBOLS }
74
+ private enum class ShiftState { OFF, ONE_SHOT, LOCK }
75
+
76
+ private data class Key(val code: Int, val weight: Float = 1f)
77
+
78
+ private var layer = Layer.LETTERS
79
+ private var shift = ShiftState.OFF
80
+ private var lastShiftTapMs = 0L
81
+
82
+ private val random = SecureRandom()
83
+ private var digitsOrder = identityLayout()
84
+ // One dummy slot per character row, re-drawn on reshuffle(); -1 = none.
85
+ private var dummySlots = IntArray(5) { -1 }
86
+
87
+ private var keyRects = ArrayList<Pair<RectF, Key>>(48)
88
+
89
+ private val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
90
+ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
91
+ private val actionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
92
+ private val gap = 6f * density
93
+
94
+ private var pressedIndex = -1
95
+
96
+ init {
97
+ isClickable = true
98
+ filterTouchesWhenObscured = true // tapjacking
99
+ reshuffle()
100
+ }
101
+
102
+ fun reshuffle() {
103
+ val shuffled = shuffleMode != KeypadCanvasView.ShuffleMode.OFF
104
+ digitsOrder = if (shuffled) {
105
+ digitsProvider?.invoke() ?: identityLayout()
106
+ } else {
107
+ identityLayout()
108
+ }
109
+ for (i in dummySlots.indices) {
110
+ // Slot count per row is resolved in buildRows(); draw an index generously
111
+ // and clamp there so one random pull per row suffices.
112
+ dummySlots[i] = if (shuffled) random.nextInt(Int.MAX_VALUE) else -1
113
+ }
114
+ layoutKeys()
115
+ invalidate()
116
+ }
117
+
118
+ private fun withDummy(keys: MutableList<Key>, rowIndex: Int) {
119
+ val slotSeed = dummySlots[rowIndex]
120
+ if (slotSeed < 0) return
121
+ keys.add(slotSeed % (keys.size + 1), Key(DUMMY))
122
+ }
123
+
124
+ private fun buildRows(): List<List<Key>> {
125
+ val rows = ArrayList<List<Key>>(5)
126
+ if (layer == Layer.LETTERS) {
127
+ rows.add(digitsOrder.map { Key(('0' + it.toInt()).code) })
128
+ rows.add(ArrayList<Key>(ROW_QWERTY_TOP.map { Key(it.code) }).also { withDummy(it, 0) })
129
+ rows.add(ArrayList<Key>(ROW_QWERTY_MID.map { Key(it.code) }).also { withDummy(it, 1) })
130
+ rows.add(ArrayList<Key>().also {
131
+ it.add(Key(ACTION_SHIFT, 1.5f))
132
+ val letters = ArrayList<Key>(ROW_QWERTY_BOT.map { c -> Key(c.code) })
133
+ withDummy(letters, 2)
134
+ it.addAll(letters)
135
+ it.add(Key(ACTION_BACKSPACE, 1.5f))
136
+ })
137
+ } else {
138
+ rows.add(ArrayList<Key>(ROW_SYM_0.map { Key(it.code) }).also { withDummy(it, 4) })
139
+ rows.add(ArrayList<Key>(ROW_SYM_1.map { Key(it.code) }).also { withDummy(it, 0) })
140
+ rows.add(ArrayList<Key>(ROW_SYM_2.map { Key(it.code) }).also { withDummy(it, 1) })
141
+ rows.add(ArrayList<Key>().also {
142
+ val syms = ArrayList<Key>(ROW_SYM_3.map { c -> Key(c.code, 2f) })
143
+ withDummy(syms, 3)
144
+ it.addAll(syms)
145
+ it.add(Key(ACTION_BACKSPACE, 2f))
146
+ })
147
+ }
148
+ rows.add(listOf(Key(ACTION_TOGGLE, 1f), Key(ACTION_CLEAR, 1f), Key(ACTION_DONE, 1f)))
149
+ return rows
150
+ }
151
+
152
+ private fun layoutKeys() {
153
+ keyRects = ArrayList(48)
154
+ val w = width.toFloat()
155
+ val h = height.toFloat()
156
+ if (w <= 0f || h <= 0f) return
157
+
158
+ val rows = buildRows()
159
+ val rowH = (h - gap * (rows.size - 1)) / rows.size
160
+ var top = 0f
161
+ for (row in rows) {
162
+ val totalWeight = row.sumOf { it.weight.toDouble() }.toFloat()
163
+ val unit = (w - gap * (row.size - 1)) / totalWeight
164
+ var left = 0f
165
+ for (key in row) {
166
+ val kw = unit * key.weight
167
+ keyRects.add(Pair(RectF(left, top, left + kw, top + rowH), key))
168
+ left += kw + gap
169
+ }
170
+ top += rowH + gap
171
+ }
172
+ }
173
+
174
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
175
+ super.onSizeChanged(w, h, oldw, oldh)
176
+ layoutKeys()
177
+ }
178
+
179
+ private fun labelFor(key: Key): String? = when (key.code) {
180
+ DUMMY -> null
181
+ ACTION_SHIFT -> "⇧"
182
+ ACTION_BACKSPACE -> "⌫"
183
+ ACTION_CLEAR -> "✕"
184
+ ACTION_DONE -> "⏎"
185
+ ACTION_TOGGLE -> if (layer == Layer.LETTERS) "!#1" else "abc"
186
+ else -> {
187
+ val c = key.code.toChar()
188
+ if (shift != ShiftState.OFF && c in 'a'..'z') c.uppercaseChar().toString() else c.toString()
189
+ }
190
+ }
191
+
192
+ // No background fill: see KeypadCanvasView.onDraw.
193
+ override fun onDraw(canvas: Canvas) {
194
+ textPaint.color = keyTextColor
195
+ textPaint.textSize = digitTextSizeDp * density * 0.6f
196
+ textPaint.typeface = EskFont.resolve(context, fontFamily)
197
+ actionPaint.textSize = digitTextSizeDp * density * 0.5f
198
+ val r = keyCornerRadiusDp * density
199
+
200
+ for ((index, entry) in keyRects.withIndex()) {
201
+ val (rect, key) = entry
202
+ val held = pressedHighlight && index == pressedIndex
203
+ keyPaint.color = pressedDim(keyColor, held)
204
+ canvas.drawRoundRect(rect, r, r, keyPaint)
205
+ val label = labelFor(key)
206
+ if (label != null) {
207
+ val paint = if (key.code >= 0) textPaint else actionPaint
208
+ // Shift shows as "armed" while one-shot or locked.
209
+ actionPaint.color = if (key.code == ACTION_SHIFT && shift != ShiftState.OFF) {
210
+ keyTextColor
211
+ } else {
212
+ actionTextColor
213
+ }
214
+ drawCentered(canvas, label, rect, paint)
215
+ }
216
+ }
217
+ }
218
+
219
+ /** Same tapjacking observation point as [KeypadCanvasView]. */
220
+ override fun onFilterTouchEventForSecurity(event: MotionEvent): Boolean {
221
+ val allowed = super.onFilterTouchEventForSecurity(event)
222
+ if (!allowed && event.actionMasked == MotionEvent.ACTION_DOWN) {
223
+ listener?.onObscuredTouch()
224
+ }
225
+ return allowed
226
+ }
227
+
228
+ override fun onTouchEvent(event: MotionEvent): Boolean {
229
+ when (event.actionMasked) {
230
+ MotionEvent.ACTION_DOWN -> {
231
+ pressedIndex = keyRects.indexOfFirst { it.first.contains(event.x, event.y) }
232
+ invalidate()
233
+ return true
234
+ }
235
+ MotionEvent.ACTION_CANCEL -> {
236
+ pressedIndex = -1
237
+ invalidate()
238
+ return true
239
+ }
240
+ MotionEvent.ACTION_UP -> {
241
+ pressedIndex = -1
242
+ invalidate()
243
+ val key = keyRects.firstOrNull { it.first.contains(event.x, event.y) }?.second
244
+ if (key != null) handleKey(key)
245
+ performClick()
246
+ return true
247
+ }
248
+ else -> return super.onTouchEvent(event)
249
+ }
250
+ }
251
+
252
+ private fun handleKey(key: Key) {
253
+ when (key.code) {
254
+ DUMMY -> {}
255
+ ACTION_SHIFT -> {
256
+ val now = android.os.SystemClock.uptimeMillis()
257
+ shift = when {
258
+ shift == ShiftState.OFF -> ShiftState.ONE_SHOT
259
+ shift == ShiftState.ONE_SHOT && now - lastShiftTapMs < 350L -> ShiftState.LOCK
260
+ else -> ShiftState.OFF
261
+ }
262
+ lastShiftTapMs = now
263
+ invalidate()
264
+ }
265
+ ACTION_BACKSPACE -> listener?.onBackspace()
266
+ ACTION_CLEAR -> listener?.onClear()
267
+ ACTION_DONE -> listener?.onDone()
268
+ ACTION_TOGGLE -> {
269
+ layer = if (layer == Layer.LETTERS) Layer.SYMBOLS else Layer.LETTERS
270
+ shift = ShiftState.OFF
271
+ layoutKeys()
272
+ invalidate()
273
+ }
274
+ else -> {
275
+ var c = key.code.toChar()
276
+ if (shift != ShiftState.OFF && c in 'a'..'z') c = c.uppercaseChar()
277
+ if (shift == ShiftState.ONE_SHOT) {
278
+ shift = ShiftState.OFF
279
+ invalidate()
280
+ }
281
+ listener?.onKeyPressed(c.code)
282
+ if (shuffleMode == KeypadCanvasView.ShuffleMode.PER_KEY) reshuffle()
283
+ }
284
+ }
285
+ }
286
+
287
+ override fun performClick(): Boolean {
288
+ super.performClick()
289
+ return true
290
+ }
291
+ }
@@ -0,0 +1,183 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ package expo.modules.securekeypadjsi
3
+
4
+ import android.annotation.SuppressLint
5
+ import android.content.Context
6
+ import android.graphics.Canvas
7
+ import android.graphics.Color
8
+ import android.graphics.Paint
9
+ import android.graphics.RectF
10
+ import android.view.MotionEvent
11
+ import android.view.View
12
+
13
+ /**
14
+ * Draws with [Canvas] and no child views, so digit glyphs never become
15
+ * scrapeable text. Touches resolve to a digit *value* here; the on-screen
16
+ * position never leaves this class.
17
+ */
18
+ @SuppressLint("ViewConstructor")
19
+ class KeypadCanvasView(context: Context) : View(context), ThemedKeypadView {
20
+
21
+ interface Listener {
22
+ fun onDigitPressed(digit: Int)
23
+ fun onBackspace()
24
+ fun onClear()
25
+ fun onObscuredTouch()
26
+ }
27
+
28
+ var listener: Listener? = null
29
+
30
+ enum class ShuffleMode { MOUNT, PER_KEY, OFF }
31
+
32
+ var shuffleMode: ShuffleMode = ShuffleMode.MOUNT
33
+
34
+ // Canvas draws in pixels, so every dimension below is stored in dp and scaled
35
+ // at draw time. Without that, a JS theme value would mean a different physical
36
+ // size than the identical value on iOS, which works in points.
37
+ private val density = context.resources.displayMetrics.density
38
+
39
+ override var keyColor: Int = Color.parseColor("#1C1C1E")
40
+ override var keyTextColor: Int = Color.WHITE
41
+ override var actionTextColor: Int = Color.parseColor("#8E8E93")
42
+ override var keyCornerRadiusDp: Float = 12f
43
+ override var digitTextSizeDp: Float = 32f
44
+ override var fontFamily: String? = null
45
+
46
+ override var pressedHighlight: Boolean = true
47
+
48
+ var layoutProvider: (() -> ByteArray)? = null
49
+
50
+ // 3 cols x 4 rows. Digits occupy 0..8 and 10; 9 = clear, 11 = backspace.
51
+ private val digitCells = intArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 10)
52
+ private val clearCell = 9
53
+ private val backspaceCell = 11
54
+
55
+ // Indexed by cell; -1 marks an action cell.
56
+ private val digitAtCell = IntArray(12) { -1 }
57
+
58
+ private val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
59
+ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
60
+ private val actionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
61
+ private val rect = RectF()
62
+
63
+ private var pressedCell = -1
64
+
65
+ private var cellW = 0f
66
+ private var cellH = 0f
67
+ private val gap = 12f * density
68
+
69
+ init {
70
+ isClickable = true
71
+ filterTouchesWhenObscured = true // tapjacking
72
+ reshuffle()
73
+ }
74
+
75
+ fun reshuffle() {
76
+ val layout = if (shuffleMode == ShuffleMode.OFF) {
77
+ identityLayout()
78
+ } else {
79
+ layoutProvider?.invoke() ?: identityLayout()
80
+ }
81
+ for (i in digitAtCell.indices) digitAtCell[i] = -1
82
+ for (slot in digitCells.indices) {
83
+ digitAtCell[digitCells[slot]] = layout[slot].toInt()
84
+ }
85
+ invalidate()
86
+ }
87
+
88
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
89
+ super.onSizeChanged(w, h, oldw, oldh)
90
+ // Same guard as KeyboardCanvasView.layoutKeys: a 0-size pass (hidden tab,
91
+ // first layout) must not leave negative cell sizes behind.
92
+ if (w <= 0 || h <= 0) { cellW = 0f; cellH = 0f; return }
93
+ cellW = (w - gap * 2) / 3f
94
+ cellH = (h - gap * 3) / 4f
95
+ }
96
+
97
+ // No background fill: the host view's `style` carries it, so the gaps
98
+ // between keys show whatever is behind the keypad.
99
+ override fun onDraw(canvas: Canvas) {
100
+ textPaint.color = keyTextColor
101
+ textPaint.textSize = digitTextSizeDp * density
102
+ textPaint.typeface = EskFont.resolve(context, fontFamily)
103
+ actionPaint.textSize = digitTextSizeDp * density * 0.7f
104
+
105
+ for (cell in 0 until 12) {
106
+ val col = cell % 3
107
+ val row = cell / 3
108
+ val left = col * (cellW + gap)
109
+ val top = row * (cellH + gap)
110
+ rect.set(left, top, left + cellW, top + cellH)
111
+
112
+ val digit = digitAtCell[cell]
113
+ val held = pressedHighlight && cell == pressedCell
114
+ keyPaint.color = pressedDim(keyColor, held)
115
+ // Action cells have no key fill, so the glyph itself carries the dim.
116
+ actionPaint.color = pressedDim(actionTextColor, held)
117
+ when {
118
+ digit >= 0 -> {
119
+ val r = keyCornerRadiusDp * density
120
+ canvas.drawRoundRect(rect, r, r, keyPaint)
121
+ drawCentered(canvas, digit.toString(), rect, textPaint)
122
+ }
123
+ cell == backspaceCell -> drawCentered(canvas, "⌫", rect, actionPaint)
124
+ cell == clearCell -> drawCentered(canvas, "✕", rect, actionPaint)
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The only place a tapjacking attempt is observable: filterTouchesWhenObscured
131
+ * drops the event before [onTouchEvent] runs. ACTION_DOWN only, or one gesture
132
+ * would emit a finding per event.
133
+ */
134
+ override fun onFilterTouchEventForSecurity(event: MotionEvent): Boolean {
135
+ val allowed = super.onFilterTouchEventForSecurity(event)
136
+ if (!allowed && event.actionMasked == MotionEvent.ACTION_DOWN) {
137
+ listener?.onObscuredTouch()
138
+ }
139
+ return allowed
140
+ }
141
+
142
+ private fun cellAt(x: Float, y: Float): Int {
143
+ val col = (x / (cellW + gap)).toInt().coerceIn(0, 2)
144
+ val row = (y / (cellH + gap)).toInt().coerceIn(0, 3)
145
+ return row * 3 + col
146
+ }
147
+
148
+ override fun onTouchEvent(event: MotionEvent): Boolean {
149
+ when (event.actionMasked) {
150
+ MotionEvent.ACTION_DOWN -> {
151
+ pressedCell = cellAt(event.x, event.y)
152
+ invalidate()
153
+ return true
154
+ }
155
+ MotionEvent.ACTION_CANCEL -> {
156
+ pressedCell = -1
157
+ invalidate()
158
+ return true
159
+ }
160
+ MotionEvent.ACTION_UP -> {
161
+ pressedCell = -1
162
+ invalidate()
163
+ val cell = cellAt(event.x, event.y)
164
+ when {
165
+ cell in 0..11 && digitAtCell[cell] >= 0 -> {
166
+ listener?.onDigitPressed(digitAtCell[cell])
167
+ if (shuffleMode == ShuffleMode.PER_KEY) reshuffle()
168
+ }
169
+ cell == backspaceCell -> listener?.onBackspace()
170
+ cell == clearCell -> listener?.onClear()
171
+ }
172
+ performClick()
173
+ return true
174
+ }
175
+ else -> return super.onTouchEvent(event)
176
+ }
177
+ }
178
+
179
+ override fun performClick(): Boolean {
180
+ super.performClick()
181
+ return true
182
+ }
183
+ }
@@ -0,0 +1,54 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ package expo.modules.securekeypadjsi
3
+
4
+ /**
5
+ * Owns the native pointer for its lifetime — call [destroy] exactly once, on
6
+ * the UI thread. `external` names must match the symbols in JniBridge.cpp.
7
+ */
8
+ class PinSessionHandle(minLength: Int, maxLength: Int, keypadType: Int = TYPE_DIGIT) {
9
+ companion object {
10
+ const val TYPE_DIGIT = 0
11
+ const val TYPE_FULL = 1
12
+
13
+ init {
14
+ System.loadLibrary("expo-secure-keypad-jsi")
15
+ }
16
+ }
17
+
18
+ private var ptr: Long = nativeCreate(minLength, maxLength, keypadType)
19
+
20
+ /** null on success, else an error-code string (e.g. "ERR_WEAK_KEY"). */
21
+ fun arm(pem: String): String? = if (ptr != 0L) nativeArm(ptr, pem) else "ERR_INTERNAL"
22
+
23
+ fun press(digit: Int): Int = if (ptr != 0L) nativePress(ptr, digit) else 0
24
+
25
+ /** Final ASCII char (view resolves shift/layer first). Out-of-charset is ignored. */
26
+ fun pressKey(asciiChar: Int): Int = if (ptr != 0L) nativePressKey(ptr, asciiChar) else 0
27
+ fun backspace(): Int = if (ptr != 0L) nativeBackspace(ptr) else 0
28
+ fun clear() { if (ptr != 0L) nativeClear(ptr) }
29
+
30
+ /** Envelope JSON starting with '{', or an "ERR_..." string. */
31
+ fun submit(): String = if (ptr != 0L) nativeSubmit(ptr) else "ERR_INTERNAL"
32
+
33
+ fun shuffledLayout(): ByteArray =
34
+ if (ptr != 0L) nativeShuffledLayout(ptr) else identityLayout()
35
+
36
+ fun onBackground() { if (ptr != 0L) nativeOnBackground(ptr) }
37
+
38
+ fun destroy() {
39
+ val p = ptr
40
+ ptr = 0L
41
+ if (p != 0L) nativeDestroy(p)
42
+ }
43
+
44
+ private external fun nativeCreate(minLength: Int, maxLength: Int, keypadType: Int): Long
45
+ private external fun nativeDestroy(ptr: Long)
46
+ private external fun nativeArm(ptr: Long, pem: String): String?
47
+ private external fun nativePress(ptr: Long, digit: Int): Int
48
+ private external fun nativePressKey(ptr: Long, asciiChar: Int): Int
49
+ private external fun nativeBackspace(ptr: Long): Int
50
+ private external fun nativeClear(ptr: Long)
51
+ private external fun nativeSubmit(ptr: Long): String
52
+ private external fun nativeShuffledLayout(ptr: Long): ByteArray
53
+ private external fun nativeOnBackground(ptr: Long)
54
+ }
@@ -0,0 +1,35 @@
1
+ // Copyright (c) 2026 expo-secure-keypad-jsi contributors. MIT License.
2
+ package expo.modules.securekeypadjsi
3
+
4
+ import expo.modules.kotlin.modules.Module
5
+ import expo.modules.kotlin.modules.ModuleDefinition
6
+
7
+ class SecureKeypadJsiModule : Module() {
8
+ override fun definition() = ModuleDefinition {
9
+ Name("SecureKeypadJsi")
10
+
11
+ // Runs on the UI thread when the host activity pauses. Views wipe their
12
+ // buffers here; iOS does the same from
13
+ // UIApplication.didEnterBackgroundNotification.
14
+ OnActivityEntersBackground {
15
+ SecureKeypadJsiView.onActivityEntersBackground()
16
+ }
17
+
18
+ View(SecureKeypadJsiView::class) {
19
+ Events("onDigitCountChanged", "onComplete", "onError")
20
+
21
+ Prop("publicKey") { view: SecureKeypadJsiView, pem: String -> view.setPublicKeyPem(pem) }
22
+ Prop("keypadType") { view: SecureKeypadJsiView, v: String -> view.setKeypadType(v) }
23
+ Prop("minLength") { view: SecureKeypadJsiView, v: Int -> view.setMinLength(v) }
24
+ Prop("maxLength") { view: SecureKeypadJsiView, v: Int -> view.setMaxLength(v) }
25
+ Prop("shuffle") { view: SecureKeypadJsiView, v: String -> view.setShuffle(v) }
26
+ Prop("autoSubmit") { view: SecureKeypadJsiView, v: Boolean -> view.setAutoSubmit(v) }
27
+ Prop("theme") { view: SecureKeypadJsiView, v: Map<String, Any?> -> view.setTheme(v) }
28
+
29
+ AsyncFunction("clear") { view: SecureKeypadJsiView -> view.clearPin() }
30
+ AsyncFunction("submit") { view: SecureKeypadJsiView -> view.submit() }
31
+
32
+ OnViewDestroys { view: SecureKeypadJsiView -> view.dispose() }
33
+ }
34
+ }
35
+ }