expo-secure-keypad-jsi 0.1.2 → 0.1.3

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.
@@ -105,7 +105,7 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
105
105
  private val actionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
106
106
  private val gap = 6f * density
107
107
 
108
- private var pressedIndex = -1
108
+ private val pressed = PressedKeyState(this)
109
109
 
110
110
  init {
111
111
  isClickable = true
@@ -226,7 +226,7 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
226
226
 
227
227
  for ((index, entry) in keyRects.withIndex()) {
228
228
  val (rect, key) = entry
229
- val held = pressedHighlight && index == pressedIndex
229
+ val held = pressedHighlight && index == pressed.index
230
230
  keyPaint.color = keyFill(held)
231
231
  canvas.drawRoundRect(rect, r, r, keyPaint)
232
232
  val label = labelFor(key)
@@ -257,18 +257,15 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
257
257
  override fun onTouchEvent(event: MotionEvent): Boolean {
258
258
  when (event.actionMasked) {
259
259
  MotionEvent.ACTION_DOWN -> {
260
- pressedIndex = keyRects.indexOfFirst { it.first.contains(event.x, event.y) }
261
- invalidate()
260
+ pressed.press(keyRects.indexOfFirst { it.first.contains(event.x, event.y) })
262
261
  return true
263
262
  }
264
263
  MotionEvent.ACTION_CANCEL -> {
265
- pressedIndex = -1
266
- invalidate()
264
+ pressed.clear()
267
265
  return true
268
266
  }
269
267
  MotionEvent.ACTION_UP -> {
270
- pressedIndex = -1
271
- invalidate()
268
+ pressed.release()
272
269
  val key = keyRects.firstOrNull { it.first.contains(event.x, event.y) }?.second
273
270
  if (key != null) handleKey(key)
274
271
  performClick()
@@ -297,11 +294,14 @@ class KeyboardCanvasView(context: Context) : View(context), ThemedKeypadView {
297
294
  ACTION_TOGGLE -> {
298
295
  layer = if (layer == Layer.LETTERS) Layer.SYMBOLS else Layer.LETTERS
299
296
  shift = ShiftState.OFF
297
+ // A new key set: the lit index would now point at another key.
298
+ pressed.clear()
300
299
  layoutKeys()
301
300
  invalidate()
302
301
  }
303
302
  ACTION_SYMBOL_PAGE -> {
304
303
  layer = if (layer == Layer.SYMBOLS) Layer.SYMBOLS_EXTRA else Layer.SYMBOLS
304
+ pressed.clear()
305
305
  layoutKeys()
306
306
  invalidate()
307
307
  }
@@ -64,7 +64,7 @@ class KeypadCanvasView(context: Context) : View(context), ThemedKeypadView {
64
64
  private val actionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
65
65
  private val rect = RectF()
66
66
 
67
- private var pressedCell = -1
67
+ private val pressed = PressedKeyState(this)
68
68
 
69
69
  private var cellW = 0f
70
70
  private var cellH = 0f
@@ -114,7 +114,7 @@ class KeypadCanvasView(context: Context) : View(context), ThemedKeypadView {
114
114
  rect.set(left, top, left + cellW, top + cellH)
115
115
 
116
116
  val digit = digitAtCell[cell]
117
- val held = pressedHighlight && cell == pressedCell
117
+ val held = pressedHighlight && cell == pressed.index
118
118
  val r = keyCornerRadiusDp * density
119
119
  keyPaint.color = keyFill(held)
120
120
  // Action cells have no key fill. A held one gets the pressedKeyColor
@@ -158,18 +158,15 @@ class KeypadCanvasView(context: Context) : View(context), ThemedKeypadView {
158
158
  override fun onTouchEvent(event: MotionEvent): Boolean {
159
159
  when (event.actionMasked) {
160
160
  MotionEvent.ACTION_DOWN -> {
161
- pressedCell = cellAt(event.x, event.y)
162
- invalidate()
161
+ pressed.press(cellAt(event.x, event.y))
163
162
  return true
164
163
  }
165
164
  MotionEvent.ACTION_CANCEL -> {
166
- pressedCell = -1
167
- invalidate()
165
+ pressed.clear()
168
166
  return true
169
167
  }
170
168
  MotionEvent.ACTION_UP -> {
171
- pressedCell = -1
172
- invalidate()
169
+ pressed.release()
173
170
  val cell = cellAt(event.x, event.y)
174
171
  when {
175
172
  cell in 0..11 && digitAtCell[cell] >= 0 -> {
@@ -7,6 +7,8 @@ import android.graphics.Color
7
7
  import android.graphics.Paint
8
8
  import android.graphics.RectF
9
9
  import android.graphics.Typeface
10
+ import android.os.SystemClock
11
+ import android.view.View
10
12
  import com.facebook.react.common.assets.ReactFontManager
11
13
 
12
14
  /**
@@ -55,6 +57,49 @@ internal fun pressedDim(color: Int, held: Boolean): Int =
55
57
  color
56
58
  }
57
59
 
60
+ /**
61
+ * Which key is lit, kept lit for at least [MIN_LIT_MS] after the press.
62
+ *
63
+ * When DOWN and UP land within one frame — a quick tap, or a touch the system
64
+ * held back and then released whole — unlighting on UP clears the key before a
65
+ * frame is drawn. iOS does this at the left and right screen edges (see the
66
+ * PressedKeyState twin in ios/KeypadCoreGraphicsView.swift); this keeps both
67
+ * platforms showing the same feedback. [View] guards its own pressed state the
68
+ * same way with `ViewConfiguration.getPressedStateDuration`.
69
+ */
70
+ internal class PressedKeyState(private val view: View) {
71
+ companion object {
72
+ /** About one ordinary tap, so an edge tap reads like one in the middle. */
73
+ const val MIN_LIT_MS = 100L
74
+ }
75
+
76
+ var index = -1
77
+ private set
78
+ private var litAt = 0L
79
+ private val unlight = Runnable { clear() }
80
+
81
+ fun press(index: Int) {
82
+ view.removeCallbacks(unlight)
83
+ this.index = index
84
+ litAt = SystemClock.uptimeMillis()
85
+ view.invalidate()
86
+ }
87
+
88
+ /** Finger lifted: unlit now, or once the key has been lit for [MIN_LIT_MS]. */
89
+ fun release() {
90
+ view.removeCallbacks(unlight)
91
+ val remaining = MIN_LIT_MS - (SystemClock.uptimeMillis() - litAt)
92
+ if (index >= 0 && remaining > 0) view.postDelayed(unlight, remaining) else clear()
93
+ }
94
+
95
+ /** Unlit at once: a cancelled touch, or a key set that changed under it. */
96
+ fun clear() {
97
+ view.removeCallbacks(unlight)
98
+ index = -1
99
+ view.invalidate()
100
+ }
101
+ }
102
+
58
103
  /** Key fill: [ThemedKeypadView.pressedKeyColor] while held when set, else the 70% dim. */
59
104
  internal fun ThemedKeypadView.keyFill(held: Boolean): Int =
60
105
  if (held) pressedKeyColor ?: pressedDim(keyColor, true) else keyColor
@@ -82,7 +82,7 @@ final class KeyboardCoreGraphicsView: UIView, ThemableKeypadView {
82
82
  private var dummySlots = [Int](repeating: -1, count: 5)
83
83
 
84
84
  private var keyRects: [(CGRect, Key)] = []
85
- private var pressedIndex = -1
85
+ private lazy var pressed = PressedKeyState(view: self)
86
86
  private let gap: CGFloat = 6
87
87
 
88
88
  override init(frame: CGRect) {
@@ -205,7 +205,7 @@ final class KeyboardCoreGraphicsView: UIView, ThemableKeypadView {
205
205
  for (index, entry) in keyRects.enumerated() {
206
206
  let (frame, key) = entry
207
207
  let path = UIBezierPath(roundedRect: frame, cornerRadius: keyCornerRadius)
208
- keyFill(pressedHighlight && index == pressedIndex).setFill()
208
+ keyFill(pressedHighlight && index == pressed.index).setFill()
209
209
  path.fill()
210
210
  if let label = labelFor(key) {
211
211
  let isChar = key.code >= 0
@@ -231,18 +231,15 @@ final class KeyboardCoreGraphicsView: UIView, ThemableKeypadView {
231
231
  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
232
232
  guard let touch = touches.first else { return }
233
233
  let p = touch.location(in: self)
234
- pressedIndex = keyRects.firstIndex(where: { $0.0.contains(p) }) ?? -1
235
- setNeedsDisplay()
234
+ pressed.press(keyRects.firstIndex(where: { $0.0.contains(p) }) ?? -1)
236
235
  }
237
236
 
238
237
  override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
239
- pressedIndex = -1
240
- setNeedsDisplay()
238
+ pressed.clear()
241
239
  }
242
240
 
243
241
  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
244
- pressedIndex = -1
245
- setNeedsDisplay()
242
+ pressed.release()
246
243
  guard let touch = touches.first else { return }
247
244
  let p = touch.location(in: self)
248
245
  guard let key = keyRects.first(where: { $0.0.contains(p) })?.1 else { return }
@@ -271,10 +268,13 @@ final class KeyboardCoreGraphicsView: UIView, ThemableKeypadView {
271
268
  case Code.toggle:
272
269
  layer_ = layer_ == .letters ? .symbols : .letters
273
270
  shift = .off
271
+ // A new key set: the lit index would now point at another key.
272
+ pressed.clear()
274
273
  layoutKeys()
275
274
  setNeedsDisplay()
276
275
  case Code.symbolPage:
277
276
  layer_ = layer_ == .symbols ? .symbolsExtra : .symbols
277
+ pressed.clear()
278
278
  layoutKeys()
279
279
  setNeedsDisplay()
280
280
  default:
@@ -38,7 +38,7 @@ final class KeypadCoreGraphicsView: UIView, ThemableKeypadView {
38
38
  private let clearCell = 9
39
39
  private let backspaceCell = 11
40
40
  private var digitAtCell = [Int](repeating: -1, count: 12)
41
- private var pressedCell = -1
41
+ private lazy var pressed = PressedKeyState(view: self)
42
42
  private let gap: CGFloat = 12
43
43
 
44
44
  override init(frame: CGRect) {
@@ -80,7 +80,7 @@ final class KeypadCoreGraphicsView: UIView, ThemableKeypadView {
80
80
  let row = CGFloat(cell / 3)
81
81
  let frame = CGRect(x: col * (cw + gap), y: row * (ch + gap), width: cw, height: ch)
82
82
  let digit = digitAtCell[cell]
83
- let held = pressedHighlight && cell == pressedCell
83
+ let held = pressedHighlight && cell == pressed.index
84
84
  let path = UIBezierPath(roundedRect: frame, cornerRadius: keyCornerRadius)
85
85
  // Action cells have no key fill. A held one gets the pressedKeyColor fill
86
86
  // when the theme sets it; otherwise the glyph itself carries the dim.
@@ -116,18 +116,15 @@ final class KeypadCoreGraphicsView: UIView, ThemableKeypadView {
116
116
 
117
117
  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
118
118
  guard let touch = touches.first else { return }
119
- pressedCell = cellAt(touch.location(in: self))
120
- setNeedsDisplay()
119
+ pressed.press(cellAt(touch.location(in: self)))
121
120
  }
122
121
 
123
122
  override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
124
- pressedCell = -1
125
- setNeedsDisplay()
123
+ pressed.clear()
126
124
  }
127
125
 
128
126
  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
129
- pressedCell = -1
130
- setNeedsDisplay()
127
+ pressed.release()
131
128
  guard let touch = touches.first else { return }
132
129
  let cell = cellAt(touch.location(in: self))
133
130
  guard cell >= 0, cell < 12 else { return }
@@ -168,6 +165,52 @@ protocol ThemableKeypadView: AnyObject {
168
165
  /// shows, and the fallback whenever the native CSPRNG layout is unavailable.
169
166
  let identityDigitLayout = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
170
167
 
168
+ /// Which key is lit, kept lit for at least `minimumLit` after the press.
169
+ ///
170
+ /// Near the left and right screen edges iOS holds a touch back while it decides
171
+ /// whether a system edge gesture is starting, and on a quick tap releases
172
+ /// touchesBegan and touchesEnded together. Unlighting on touchesEnded then
173
+ /// clears the key before a frame is drawn, which is why edge keys (and the edge
174
+ /// half of the wide ⌫) showed no press colour, or only sometimes. A key that is
175
+ /// held longer still lights late, once iOS lets the touch through; only the
176
+ /// host app can opt its edges out of system gestures.
177
+ final class PressedKeyState {
178
+ /// About one ordinary tap, so an edge tap reads like one in the middle.
179
+ static let minimumLit: CFTimeInterval = 0.1
180
+
181
+ private(set) var index = -1
182
+ private var litAt: CFTimeInterval = 0
183
+ private var pendingUnlight: DispatchWorkItem?
184
+ private weak var view: UIView?
185
+
186
+ init(view: UIView) { self.view = view }
187
+
188
+ func press(_ index: Int) {
189
+ pendingUnlight?.cancel()
190
+ self.index = index
191
+ litAt = CACurrentMediaTime()
192
+ view?.setNeedsDisplay()
193
+ }
194
+
195
+ /// Finger lifted: unlit now, or once the key has been lit for `minimumLit`.
196
+ func release() {
197
+ let remaining = Self.minimumLit - (CACurrentMediaTime() - litAt)
198
+ guard index >= 0, remaining > 0 else { clear(); return }
199
+ pendingUnlight?.cancel()
200
+ let work = DispatchWorkItem { [weak self] in self?.clear() }
201
+ pendingUnlight = work
202
+ DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: work)
203
+ }
204
+
205
+ /// Unlit at once: a cancelled touch, or a key set that changed under it.
206
+ func clear() {
207
+ pendingUnlight?.cancel()
208
+ pendingUnlight = nil
209
+ index = -1
210
+ view?.setNeedsDisplay()
211
+ }
212
+ }
213
+
171
214
  extension ThemableKeypadView {
172
215
  /// Key fill: `pressedKeyColor` while held when set, else the 70% dim.
173
216
  func keyFill(_ held: Bool) -> UIColor {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-secure-keypad-jsi",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Secure PIN / password keypad for Expo (New Architecture). Natively rendered shuffled keys, the entered value never enters the JS runtime, mlock'd C++ buffer, OpenSSL RSA-OAEP ciphertext only. Screen-capture blocking and device integrity are the host app's job.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",