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.
- package/LICENSE +21 -0
- package/README.md +393 -0
- package/SecureKeypadJsi.podspec +45 -0
- package/android/CMakeLists.txt +28 -0
- package/android/build.gradle +58 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/JniBridge.cpp +118 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/KeyboardCanvasView.kt +291 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/KeypadCanvasView.kt +183 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/PinSessionHandle.kt +54 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiModule.kt +35 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/SecureKeypadJsiView.kt +317 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/ThemeColor.kt +27 -0
- package/android/src/main/java/expo/modules/securekeypadjsi/ThemedKeypadView.kt +78 -0
- package/build/SecureKeypad.d.ts +51 -0
- package/build/SecureKeypad.d.ts.map +1 -0
- package/build/SecureKeypad.js +51 -0
- package/build/SecureKeypad.js.map +1 -0
- package/build/SecureKeypadJsi.types.d.ts +94 -0
- package/build/SecureKeypadJsi.types.d.ts.map +1 -0
- package/build/SecureKeypadJsi.types.js +2 -0
- package/build/SecureKeypadJsi.types.js.map +1 -0
- package/build/SecureKeypadJsiView.d.ts +6 -0
- package/build/SecureKeypadJsiView.d.ts.map +1 -0
- package/build/SecureKeypadJsiView.js +10 -0
- package/build/SecureKeypadJsiView.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/common/CMakeLists.txt +49 -0
- package/common/include/esk/Base64.h +20 -0
- package/common/include/esk/Cleanse.h +17 -0
- package/common/include/esk/Error.h +37 -0
- package/common/include/esk/KeypadCore.h +69 -0
- package/common/include/esk/PinEncryptor.h +39 -0
- package/common/include/esk/PublicKeyValidator.h +37 -0
- package/common/include/esk/SecureBuffer.h +60 -0
- package/common/include/esk/SecureRandom.h +24 -0
- package/common/include/esk/esk_c_api.h +61 -0
- package/common/src/Base64.cpp +43 -0
- package/common/src/Error.cpp +21 -0
- package/common/src/KeypadCore.cpp +105 -0
- package/common/src/PinEncryptor.cpp +110 -0
- package/common/src/PublicKeyValidator.cpp +111 -0
- package/common/src/SecureBuffer.cpp +108 -0
- package/common/src/SecureRandom.cpp +42 -0
- package/common/src/esk_c_api.cpp +158 -0
- package/docs/BUILD.ko.md +33 -0
- package/docs/BUILD.md +36 -0
- package/docs/README.ko.md +286 -0
- package/expo-module.config.json +10 -0
- package/ios/EskKeypadBridge.h +35 -0
- package/ios/EskKeypadBridge.mm +77 -0
- package/ios/KeyboardCoreGraphicsView.swift +257 -0
- package/ios/KeypadCoreGraphicsView.swift +197 -0
- package/ios/SecureKeypadJsiModule.swift +26 -0
- package/ios/SecureKeypadJsiView.swift +278 -0
- 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
|
+
}
|