@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.3 โ†’ 1.0.0-canary.5

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/.eslintrc.js CHANGED
@@ -2,4 +2,7 @@ module.exports = {
2
2
  root: true,
3
3
  extends: ['universe/native', 'universe/web'],
4
4
  ignorePatterns: ['build'],
5
+ rules: {
6
+ 'node/handle-callback-err': 'off',
7
+ },
5
8
  };
package/.releaserc ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "branches": [
3
+ "release",
4
+ {
5
+ "name": "main",
6
+ "prerelease": "canary"
7
+ }
8
+ ],
9
+ "plugins": [
10
+ "@semantic-release/commit-analyzer",
11
+ "@semantic-release/release-notes-generator",
12
+ "@semantic-release/changelog",
13
+ "@semantic-release/npm",
14
+ [
15
+ "@semantic-release/git",
16
+ {
17
+ "assets": ["package.json", "CHANGELOG.md"],
18
+ "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
19
+ }
20
+ ],
21
+ "@semantic-release/github"
22
+ ]
23
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # [1.0.0-canary.5](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.4...v1.0.0-canary.5) (2026-03-24)
2
+
3
+
4
+ ### Features
5
+
6
+ * **android:** add allowed credentials mapping ([e7134fc](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/e7134fc1e5bf09649155e38010e4ef9728580812))
7
+ * **android:** credential compatibility with system-provided clientDataHash and compact JSON ([428e96f](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/428e96f89b73cd35fdbfaa6669f39da68403e17e))
package/CONTRIBUTING.md CHANGED
@@ -50,6 +50,16 @@ The project includes an example app in the `example/` directory. You can use it
50
50
 
51
51
  For more details on how the project is structured, see the [Architecture Guide](./ARCHITECTURE.md).
52
52
 
53
+ ### Testing
54
+
55
+ Before submitting a Pull Request, ensure that both JavaScript and Native tests pass:
56
+
57
+ ```bash
58
+ npm run test:all
59
+ ```
60
+
61
+ For more details on the testing strategy, refer to the [Testing section in README.md](./README.md#๐Ÿงช-testing).
62
+
53
63
  ## Submitting a Pull Request
54
64
 
55
65
  1. Create a new branch for your feature or bugfix:
package/README.md CHANGED
@@ -1,6 +1,12 @@
1
- # ๐Ÿ”‘ react-native-passkey-autofill
1
+ # @algorandfoundation/react-native-passkey-autofill
2
2
 
3
- Passkey AutoFill for React Native using DP256.
3
+ <p align="center">
4
+ <img src="./example/assets/banner.png" width="100%" />
5
+ </p>
6
+
7
+ <p align="center">
8
+ Passkey AutoFill for React Native using DP256.
9
+ </p>
4
10
 
5
11
  # ๐Ÿš€ Get Started
6
12
 
@@ -9,7 +15,7 @@ For bare React Native projects, you must ensure that you have [installed and con
9
15
  ### Add the package to your npm dependencies
10
16
 
11
17
  ```bash
12
- npm install react-native-passkey-autofill
18
+ npm install @algorandfoundation/react-native-passkey-autofill
13
19
  ```
14
20
 
15
21
  ### Configure for Android
@@ -25,7 +31,7 @@ If you are using Expo, you can configure the plugin in your `app.json` or `app.c
25
31
  "expo": {
26
32
  "plugins": [
27
33
  [
28
- "react-native-passkey-autofill",
34
+ "@algorandfoundation/react-native-passkey-autofill",
29
35
  {
30
36
  "site": "https://your-fido-server.com",
31
37
  "label": "My Custom Credential Provider"
@@ -60,17 +66,49 @@ await ReactNativePasskeyAutofill.configureIntentActions(
60
66
  await ReactNativePasskeyAutofill.clearCredentials();
61
67
  ```
62
68
 
69
+ ## ๐Ÿงช Testing
70
+
71
+ The project is set up with a comprehensive testing approach covering both JavaScript and Native (Kotlin) sides.
72
+
73
+ ### JavaScript Tests
74
+ Run unit tests for the TypeScript module using Jest:
75
+ ```bash
76
+ npm test
77
+ ```
78
+
79
+ ### Native Android Tests
80
+ Run unit tests for the Kotlin code using JUnit and Robolectric. These tests are executed via the example app's Gradle wrapper:
81
+ ```bash
82
+ npm run test:android
83
+ ```
84
+
85
+ ### All Tests
86
+ Run both JS and Native tests:
87
+ ```bash
88
+ npm run test:all
89
+ ```
90
+
91
+ ### Continuous Integration (CI)
92
+ The project includes a GitHub Actions workflow that automatically runs linting, JS tests, and Native Android tests on every push and pull request to the `main` or `release` branches. You can find the configuration in `.github/workflows/ci.yml`.
93
+
94
+ ### Integration Testing (E2E)
95
+ For full end-to-end testing that covers the bridge between JavaScript and Native, we recommend using [Maestro](https://maestro.mobile.dev/). It provides a clean, YAML-based way to automate UI flows and verify the integration of the Passkey Autofill service with the system UI.
96
+
97
+ ## ๐Ÿ“ฑ Example App
98
+
99
+ The [example](./example) app demonstrates how to integrate this module with a full wallet implementation.
100
+
63
101
  # ๐Ÿค Contributing
64
102
 
65
103
  Contributions are very welcome! Please refer to guidelines described in the [contributing guide](./CONTRIBUTING.md).
66
104
 
67
105
  # ๐Ÿ’– Acknowledgements
68
106
 
69
- This has been the commination of many different efforts and ideas. We would like to thank the following individuals and organizations for their contributions:
107
+ This has been the culmination of many different efforts and ideas. We would like to thank the following individuals and organizations for their contributions:
70
108
 
71
109
  - [Bruno Martins](https://github.com/bmartins) the architect at [Algorand Foundation](https://github.com/algorandfoundation) for conceptualizing and guiding the project.
72
110
  - [HashMapsData2Value](https://github.com/HashMapsData2Value) for his guidance and support in DP256 and XHD and his work on the native autofill libraries.
73
- - [Will Beaumont](https://github.com/mjbeau) for working though integration within the Pera wallet
111
+ - [Will Beaumont](https://github.com/mjbeau) for working through integration within the Pera wallet
74
112
  - [Michael T Chuang](https://github.com/michaeltchuang) for his work in KMP integrations and client libraries.
75
113
 
76
114
  # ๐Ÿ“„ License
@@ -27,6 +27,7 @@ if (useManagedAndroidSdkVersions) {
27
27
  defaultConfig {
28
28
  minSdkVersion safeExtGet("minSdkVersion", 24)
29
29
  targetSdkVersion safeExtGet("targetSdkVersion", 36)
30
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
30
31
  }
31
32
  }
32
33
  }
@@ -43,6 +44,12 @@ android {
43
44
  kotlinOptions {
44
45
  freeCompilerArgs += ["-opt-in=kotlin.time.ExperimentalTime", "-opt-in=kotlin.RequiresOptIn"]
45
46
  }
47
+ testOptions {
48
+ unitTests.includeAndroidResources = true
49
+ }
50
+ packagingOptions {
51
+ pickFirst '**/libc++_shared.so'
52
+ }
46
53
  }
47
54
 
48
55
  dependencies {
@@ -59,6 +66,17 @@ dependencies {
59
66
  implementation fileTree(dir: 'libs', include: ['*.jar'])
60
67
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
61
68
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
69
+
70
+ // Testing dependencies
71
+ testImplementation "junit:junit:4.13.2"
72
+ testImplementation "org.robolectric:robolectric:4.11.1"
73
+ testImplementation "io.mockk:mockk:1.13.8"
74
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0"
75
+
76
+ androidTestImplementation "androidx.test:runner:1.5.2"
77
+ androidTestImplementation "androidx.test:rules:1.5.0"
78
+ androidTestImplementation "androidx.test.ext:junit:1.1.5"
79
+ androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
62
80
  }
63
81
 
64
82
  repositories {
@@ -6,7 +6,6 @@
6
6
  android:enabled="true"
7
7
  android:exported="true"
8
8
  android:label="@string/passkey_autofill_label"
9
- android:icon="@mipmap/ic_launcher"
10
9
  android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
11
10
  tools:targetApi="upside_down_cake">
12
11
  <intent-filter>
@@ -6,7 +6,12 @@ import android.content.Intent
6
6
  import android.os.Build
7
7
  import android.os.Bundle
8
8
  import android.util.Log
9
+ import android.graphics.Color
10
+ import android.graphics.Typeface
11
+ import android.view.Gravity
9
12
  import android.widget.Button
13
+ import android.widget.ImageView
14
+ import android.widget.LinearLayout
10
15
  import android.widget.TextView
11
16
  import androidx.annotation.RequiresApi
12
17
  import androidx.appcompat.app.AppCompatActivity
@@ -31,61 +36,174 @@ class CreatePasskeyActivity : AppCompatActivity() {
31
36
  const val TAG = "CreatePasskeyActivity"
32
37
  }
33
38
 
34
- private var origin: String = "unknown-origin"
39
+ private var origin: String = "unknown-origin" // Derivation origin (rpId or hash)
40
+ private var displayOrigin: String = "unknown-origin"
35
41
  private var userHandle: String = "unknown-user"
36
42
  private var userName: String = "User"
37
43
  private var userId: String = "unknown-id"
44
+ private var bundleRequestJson: String? = null
38
45
  private var request: ProviderCreateCredentialRequest? = null
39
46
 
40
47
  override fun onCreate(savedInstanceState: Bundle?) {
41
48
  super.onCreate(savedInstanceState)
42
49
 
43
50
  request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
51
+ val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
52
+
53
+ if (credentialData != null) {
54
+ bundleRequestJson = credentialData.getString("requestJson")
55
+ }
56
+
44
57
  if (request != null) {
45
58
  try {
59
+ val systemOrigin = credentialRepository.getOrigin(request!!.callingAppInfo)
60
+ origin = systemOrigin
61
+ displayOrigin = systemOrigin
62
+
46
63
  val publicKeyRequest = request!!.callingRequest as CreatePublicKeyCredentialRequest
47
- val requestJson = JSONObject(publicKeyRequest.requestJson)
64
+ val rawJson = bundleRequestJson ?: publicKeyRequest.requestJson
65
+ val requestJson = JSONObject(rawJson)
66
+ val pkJson = if (requestJson.has("publicKey")) requestJson.getJSONObject("publicKey") else requestJson
48
67
 
49
68
  // Use RP ID as origin for derivation if available, otherwise fallback to app signature
50
- val rpId = requestJson.optJSONObject("rp")?.optString("id")
51
- origin = rpId ?: credentialRepository.getOrigin(request!!.callingAppInfo)
69
+ val rpId = pkJson.optJSONObject("rp")?.optString("id")
70
+ if (rpId != null && rpId.isNotEmpty()) {
71
+ origin = rpId
72
+ displayOrigin = rpId
73
+ }
52
74
 
53
- val userJson = requestJson.optJSONObject("user")
54
- userHandle = userJson?.optString("name") ?: "unknown-user"
55
- userName = userJson?.optString("displayName") ?: userHandle
56
- userId = userJson?.optString("id") ?: "unknown-id"
75
+ userHandle = pkJson.optJSONObject("user")?.optString("name") ?: "unknown-user"
76
+ userName = pkJson.optJSONObject("user")?.optString("displayName") ?: userHandle
77
+ userId = pkJson.optJSONObject("user")?.optString("id") ?: "unknown-id"
57
78
  } catch (e: Exception) {
58
79
  Log.e(TAG, "Error parsing request", e)
59
80
  }
60
81
  }
61
82
 
62
- // Simple UI for demonstration
63
- val layout = android.widget.LinearLayout(this).apply {
64
- orientation = android.widget.LinearLayout.VERTICAL
65
- setPadding(50, 50, 50, 50)
83
+ // Improved UI
84
+ val layout = LinearLayout(this).apply {
85
+ val padding = (32 * resources.displayMetrics.density).toInt()
86
+ orientation = LinearLayout.VERTICAL
87
+ setPadding(padding, padding, padding, padding)
88
+ gravity = Gravity.CENTER_HORIZONTAL
89
+ setBackgroundColor(Color.WHITE)
66
90
  }
67
91
 
92
+ // Header with App Icon and Provider Label
93
+ try {
94
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
95
+ val appIcon = packageManager.getApplicationIcon(appInfo)
96
+ val appLabel = packageManager.getApplicationLabel(appInfo)
97
+
98
+ val density = resources.displayMetrics.density
99
+ val iconSize = (48 * density).toInt()
100
+ val iconMargin = (12 * density).toInt()
101
+ val headerPadding = (40 * density).toInt()
102
+
103
+ val header = LinearLayout(this).apply {
104
+ orientation = LinearLayout.HORIZONTAL
105
+ gravity = Gravity.CENTER_VERTICAL
106
+ setPadding(0, 0, 0, headerPadding)
107
+ }
108
+
109
+ val iconView = ImageView(this).apply {
110
+ setImageDrawable(appIcon)
111
+ layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
112
+ marginEnd = iconMargin
113
+ }
114
+ }
115
+ header.addView(iconView)
116
+
117
+ val providerLabel = TextView(this).apply {
118
+ text = appLabel
119
+ textSize = 16f
120
+ typeface = Typeface.DEFAULT_BOLD
121
+ setTextColor(Color.DKGRAY)
122
+ }
123
+ header.addView(providerLabel)
124
+ layout.addView(header)
125
+ } catch (e: Exception) {
126
+ // Fallback if app info cannot be loaded
127
+ }
128
+
68
129
  val title = TextView(this).apply {
69
- text = "Create New Passkey"
70
- textSize = 24f
71
- setPadding(0, 0, 0, 50)
130
+ text = "Create Passkey"
131
+ textSize = 28f
132
+ typeface = Typeface.DEFAULT_BOLD
133
+ setPadding(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
134
+ setTextColor(Color.BLACK)
72
135
  }
73
136
  layout.addView(title)
74
137
 
75
- val info = TextView(this).apply {
76
- text = "For: $userName\nAt: $origin"
77
- setPadding(0, 0, 0, 50)
138
+ val description = TextView(this).apply {
139
+ text = "Create a new passkey for $displayOrigin"
140
+ textSize = 16f
141
+ setPadding(0, 0, 0, (40 * resources.displayMetrics.density).toInt())
142
+ gravity = Gravity.CENTER_HORIZONTAL
143
+ setTextColor(Color.GRAY)
144
+ }
145
+ layout.addView(description)
146
+
147
+ val userInfoContainer = LinearLayout(this).apply {
148
+ orientation = LinearLayout.VERTICAL
149
+ setPadding(0, 0, 0, (50 * resources.displayMetrics.density).toInt())
150
+ gravity = Gravity.CENTER_HORIZONTAL
151
+ }
152
+
153
+ val userLabel = TextView(this).apply {
154
+ text = "ACCOUNT"
155
+ textSize = 12f
156
+ typeface = Typeface.DEFAULT_BOLD
157
+ setTextColor(Color.GRAY)
158
+ setPadding(0, 0, 0, (4 * resources.displayMetrics.density).toInt())
78
159
  }
79
- layout.addView(info)
160
+ userInfoContainer.addView(userLabel)
161
+
162
+ val userValue = TextView(this).apply {
163
+ text = userName
164
+ textSize = 20f
165
+ typeface = Typeface.DEFAULT_BOLD
166
+ setTextColor(Color.BLACK)
167
+ }
168
+ userInfoContainer.addView(userValue)
169
+
170
+ if (userHandle != userName) {
171
+ val handleValue = TextView(this).apply {
172
+ text = userHandle
173
+ textSize = 14f
174
+ setTextColor(Color.GRAY)
175
+ }
176
+ userInfoContainer.addView(handleValue)
177
+ }
178
+ layout.addView(userInfoContainer)
80
179
 
81
180
  val confirmButton = Button(this).apply {
82
181
  text = "Create Passkey"
83
182
  setOnClickListener {
84
183
  handleCreation()
85
184
  }
185
+ layoutParams = LinearLayout.LayoutParams(
186
+ LinearLayout.LayoutParams.MATCH_PARENT,
187
+ LinearLayout.LayoutParams.WRAP_CONTENT
188
+ ).apply {
189
+ setMargins(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
190
+ }
86
191
  }
87
192
  layout.addView(confirmButton)
88
193
 
194
+ val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
195
+ text = "Cancel"
196
+ setOnClickListener {
197
+ setResult(RESULT_CANCELED)
198
+ finish()
199
+ }
200
+ layoutParams = LinearLayout.LayoutParams(
201
+ LinearLayout.LayoutParams.MATCH_PARENT,
202
+ LinearLayout.LayoutParams.WRAP_CONTENT
203
+ )
204
+ }
205
+ layout.addView(cancelButton)
206
+
89
207
  setContentView(layout)
90
208
  }
91
209
 
@@ -93,8 +211,25 @@ class CreatePasskeyActivity : AppCompatActivity() {
93
211
  private fun handleCreation() {
94
212
  try {
95
213
  val req = request ?: throw IllegalStateException("No request found")
96
- val publicKeyRequest = req.callingRequest as CreatePublicKeyCredentialRequest
97
- val requestOptions = PublicKeyCredentialCreationOptions(publicKeyRequest.requestJson)
214
+
215
+ // Prefer using the request JSON from the bundle if available
216
+ val rawRequestJson = bundleRequestJson ?: run {
217
+ val publicKeyRequest = req.callingRequest as CreatePublicKeyCredentialRequest
218
+ publicKeyRequest.requestJson
219
+ }
220
+
221
+ val requestJson = JSONObject(rawRequestJson)
222
+ val passkeyReqJson = if (requestJson.has("publicKey")) {
223
+ requestJson.getJSONObject("publicKey").toString()
224
+ } else {
225
+ rawRequestJson
226
+ }
227
+ val requestOptions = try {
228
+ PublicKeyCredentialCreationOptions(passkeyReqJson)
229
+ } catch (e: org.json.JSONException) {
230
+ Log.e(TAG, "Invalid passkey creation request JSON: $passkeyReqJson")
231
+ throw e
232
+ }
98
233
 
99
234
  val keyPair: KeyPair = credentialRepository.createDeterministicKeyPair(this, origin, userHandle)
100
235
  val credentialId = credentialRepository.generateCredentialId(keyPair)
@@ -131,25 +266,34 @@ class CreatePasskeyActivity : AppCompatActivity() {
131
266
  )
132
267
 
133
268
  val resultIntent = Intent()
134
- val responseJson = JSONObject(fidoCredential.json())
135
- val passkeyDetails = JSONObject()
136
-
137
- val ecPubKey = keyPair.public as java.security.interfaces.ECPublicKey
138
- val ecPrivKey = keyPair.private as java.security.interfaces.ECPrivateKey
139
-
140
- fun to32(bi: java.math.BigInteger): ByteArray {
141
- var ba = bi.toByteArray()
142
- if (ba.size < 32) {
143
- ba = ByteArray(32 - ba.size) + ba
269
+ val fullJson = JSONObject(fidoCredential.json())
270
+ val respJson = fullJson.getJSONObject("response")
271
+
272
+ // Ensure compact clientDataJSON for registration too
273
+ val sanitizedOrigin = credentialRepository.getOrigin(req.callingAppInfo).replace(Regex("/$"), "")
274
+ val challenge = if (requestJson.has("publicKey")) {
275
+ requestJson.getJSONObject("publicKey").getString("challenge")
276
+ } else {
277
+ requestJson.getString("challenge")
278
+ }
279
+
280
+ val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
281
+ "{\"type\":\"webauthn.create\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
282
+ } else {
283
+ val json = JSONObject()
284
+ json.put("type", "webauthn.create")
285
+ json.put("challenge", challenge)
286
+ json.put("origin", sanitizedOrigin)
287
+ json.put("crossOrigin", false)
288
+ if (sanitizedOrigin.startsWith("android:apk-key-hash:")) {
289
+ json.put("androidPackageName", req.callingAppInfo.packageName)
144
290
  }
145
- return ba.copyOfRange(ba.size - 32, ba.size)
291
+ json.toString()
146
292
  }
147
-
148
- val x = to32(ecPubKey.w.affineX)
149
- val y = to32(ecPubKey.w.affineY)
150
- val s = to32(ecPrivKey.s)
293
+ val clientDataJSONb64 = AndroidBase64.encodeToString(clientDataJSONString.toByteArray(), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
294
+ respJson.put("clientDataJSON", clientDataJSONb64)
151
295
 
152
- val createResponse = CreatePublicKeyCredentialResponse(responseJson.toString())
296
+ val createResponse = CreatePublicKeyCredentialResponse(fullJson.toString())
153
297
 
154
298
  PendingIntentHandler.setCreateCredentialResponse(
155
299
  resultIntent,
@@ -6,7 +6,12 @@ import android.content.Intent
6
6
  import android.os.Build
7
7
  import android.os.Bundle
8
8
  import android.util.Log
9
+ import android.graphics.Color
10
+ import android.graphics.Typeface
11
+ import android.view.Gravity
9
12
  import android.widget.Button
13
+ import android.widget.ImageView
14
+ import android.widget.LinearLayout
10
15
  import android.widget.TextView
11
16
  import androidx.annotation.RequiresApi
12
17
  import androidx.appcompat.app.AppCompatActivity
@@ -34,8 +39,10 @@ class GetPasskeyActivity : AppCompatActivity() {
34
39
  }
35
40
 
36
41
  private var origin: String = "unknown-origin"
42
+ private var displayOrigin: String = "unknown-origin"
37
43
  private var userHandle: String = "unknown-user"
38
44
  private var credentialIdEnc: String? = null
45
+ private var bundleRequestJson: String? = null
39
46
  private var request: ProviderGetCredentialRequest? = null
40
47
 
41
48
  override fun onCreate(savedInstanceState: Bundle?) {
@@ -44,39 +51,147 @@ class GetPasskeyActivity : AppCompatActivity() {
44
51
  request = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
45
52
  val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
46
53
 
47
- if (request != null && credentialData != null) {
48
- origin = credentialRepository.getOrigin(request!!.callingAppInfo)
54
+ if (credentialData != null) {
49
55
  credentialIdEnc = credentialData.getString("credentialId")
50
56
  userHandle = credentialData.getString("userHandle") ?: "unknown-user"
57
+ bundleRequestJson = credentialData.getString("requestJson")
58
+ }
59
+
60
+ if (request != null) {
61
+ origin = credentialRepository.getOrigin(request!!.callingAppInfo)
62
+ displayOrigin = origin
63
+
64
+ // Try to extract rpId for better display
65
+ val rawJson = bundleRequestJson ?: (request?.credentialOptions?.get(0) as? GetPublicKeyCredentialOption)?.requestJson
66
+ if (rawJson != null) {
67
+ try {
68
+ val jsonObj = JSONObject(rawJson)
69
+ val pkJson = if (jsonObj.has("publicKey")) jsonObj.getJSONObject("publicKey") else jsonObj
70
+ val rpId = pkJson.optString("rpId")
71
+ if (rpId.isNotEmpty()) {
72
+ displayOrigin = rpId
73
+ }
74
+ } catch (e: Exception) {
75
+ // ignore
76
+ }
77
+ }
51
78
  }
52
79
 
53
- // Simple UI
54
- val layout = android.widget.LinearLayout(this).apply {
55
- orientation = android.widget.LinearLayout.VERTICAL
56
- setPadding(50, 50, 50, 50)
80
+ // Improved UI
81
+ val layout = LinearLayout(this).apply {
82
+ val padding = (32 * resources.displayMetrics.density).toInt()
83
+ orientation = LinearLayout.VERTICAL
84
+ setPadding(padding, padding, padding, padding)
85
+ gravity = Gravity.CENTER_HORIZONTAL
86
+ setBackgroundColor(Color.WHITE)
87
+ }
88
+
89
+ // Header with App Icon and Provider Label
90
+ try {
91
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
92
+ val appIcon = packageManager.getApplicationIcon(appInfo)
93
+ val appLabel = packageManager.getApplicationLabel(appInfo)
94
+
95
+ val density = resources.displayMetrics.density
96
+ val iconSize = (48 * density).toInt()
97
+ val iconMargin = (12 * density).toInt()
98
+ val headerPadding = (40 * density).toInt()
99
+
100
+ val header = LinearLayout(this).apply {
101
+ orientation = LinearLayout.HORIZONTAL
102
+ gravity = Gravity.CENTER_VERTICAL
103
+ setPadding(0, 0, 0, headerPadding)
104
+ }
105
+
106
+ val iconView = ImageView(this).apply {
107
+ setImageDrawable(appIcon)
108
+ layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
109
+ marginEnd = iconMargin
110
+ }
111
+ }
112
+ header.addView(iconView)
113
+
114
+ val providerLabel = TextView(this).apply {
115
+ text = appLabel
116
+ textSize = 16f
117
+ typeface = Typeface.DEFAULT_BOLD
118
+ setTextColor(Color.DKGRAY)
119
+ }
120
+ header.addView(providerLabel)
121
+ layout.addView(header)
122
+ } catch (e: Exception) {
123
+ // Fallback if app info cannot be loaded
57
124
  }
58
125
 
59
126
  val title = TextView(this).apply {
60
127
  text = "Use Passkey"
61
- textSize = 24f
62
- setPadding(0, 0, 0, 50)
128
+ textSize = 28f
129
+ typeface = Typeface.DEFAULT_BOLD
130
+ setPadding(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
131
+ setTextColor(Color.BLACK)
63
132
  }
64
133
  layout.addView(title)
65
134
 
66
- val info = TextView(this).apply {
67
- text = "User: $userHandle\nAt: $origin"
68
- setPadding(0, 0, 0, 50)
135
+ val description = TextView(this).apply {
136
+ text = "Sign in to $displayOrigin"
137
+ textSize = 16f
138
+ setPadding(0, 0, 0, (40 * resources.displayMetrics.density).toInt())
139
+ gravity = Gravity.CENTER_HORIZONTAL
140
+ setTextColor(Color.GRAY)
141
+ }
142
+ layout.addView(description)
143
+
144
+ val userInfoContainer = LinearLayout(this).apply {
145
+ orientation = LinearLayout.VERTICAL
146
+ setPadding(0, 0, 0, (50 * resources.displayMetrics.density).toInt())
147
+ gravity = Gravity.CENTER_HORIZONTAL
148
+ }
149
+
150
+ val userLabel = TextView(this).apply {
151
+ text = "ACCOUNT"
152
+ textSize = 12f
153
+ typeface = Typeface.DEFAULT_BOLD
154
+ setTextColor(Color.GRAY)
155
+ setPadding(0, 0, 0, (4 * resources.displayMetrics.density).toInt())
69
156
  }
70
- layout.addView(info)
157
+ userInfoContainer.addView(userLabel)
158
+
159
+ val userValue = TextView(this).apply {
160
+ text = userHandle
161
+ textSize = 20f
162
+ typeface = Typeface.DEFAULT_BOLD
163
+ setTextColor(Color.BLACK)
164
+ }
165
+ userInfoContainer.addView(userValue)
166
+ layout.addView(userInfoContainer)
71
167
 
72
168
  val confirmButton = Button(this).apply {
73
169
  text = "Sign In"
74
170
  setOnClickListener {
75
171
  handleAssertion()
76
172
  }
173
+ layoutParams = LinearLayout.LayoutParams(
174
+ LinearLayout.LayoutParams.MATCH_PARENT,
175
+ LinearLayout.LayoutParams.WRAP_CONTENT
176
+ ).apply {
177
+ setMargins(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
178
+ }
77
179
  }
78
180
  layout.addView(confirmButton)
79
181
 
182
+ val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
183
+ text = "Cancel"
184
+ setOnClickListener {
185
+ setResult(RESULT_CANCELED)
186
+ finish()
187
+ }
188
+ layoutParams = LinearLayout.LayoutParams(
189
+ LinearLayout.LayoutParams.MATCH_PARENT,
190
+ LinearLayout.LayoutParams.WRAP_CONTENT
191
+ )
192
+ }
193
+ layout.addView(cancelButton)
194
+
80
195
  setContentView(layout)
81
196
  }
82
197
 
@@ -84,19 +199,61 @@ class GetPasskeyActivity : AppCompatActivity() {
84
199
  private fun handleAssertion() {
85
200
  try {
86
201
  val req = request ?: throw IllegalStateException("No request found")
87
- val option = req.credentialOptions[0] as GetPublicKeyCredentialOption
88
- val requestOptions = PublicKeyCredentialRequestOptions(option.requestJson)
202
+
203
+ // Prefer using the request JSON from the bundle if available, as it's specifically for this entry
204
+ val rawRequestJson = bundleRequestJson ?: run {
205
+ val option = req.credentialOptions[0] as GetPublicKeyCredentialOption
206
+ option.requestJson
207
+ }
208
+
209
+ val requestJson = JSONObject(rawRequestJson)
210
+ val passkeyReqJson = if (requestJson.has("publicKey")) {
211
+ requestJson.getJSONObject("publicKey").toString()
212
+ } else {
213
+ rawRequestJson
214
+ }
215
+ val requestOptions = try {
216
+ PublicKeyCredentialRequestOptions(passkeyReqJson)
217
+ } catch (e: org.json.JSONException) {
218
+ Log.e(TAG, "Invalid passkey request JSON: $passkeyReqJson")
219
+ throw e
220
+ }
89
221
 
90
222
  val credId = AndroidBase64.decode(credentialIdEnc!!, AndroidBase64.DEFAULT)
91
223
 
92
- val requestJson = JSONObject(option.requestJson)
93
- val challenge = requestJson.getString("challenge")
224
+ val passkeyRequestJsonObj = JSONObject(passkeyReqJson)
225
+ val challenge = if (passkeyRequestJsonObj.has("challenge")) {
226
+ passkeyRequestJsonObj.getString("challenge")
227
+ } else {
228
+ throw org.json.JSONException("No value for challenge in requestJson: $passkeyReqJson")
229
+ }
94
230
  val sanitizedOrigin = origin.replace(Regex("/$"), "")
95
231
 
96
- val clientDataHash = option.requestData.getByteArray("androidx.credentials.BUNDLE_KEY_CLIENT_DATA_HASH")
97
- ?: MessageDigest.getInstance("SHA-256").digest(
98
- "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}".toByteArray(Charsets.UTF_8)
99
- )
232
+ // Look for system-provided clientDataHash (e.g. from Chrome)
233
+ val systemClientDataHash = run {
234
+ val option = req.credentialOptions.find { opt ->
235
+ opt is GetPublicKeyCredentialOption && (opt.requestJson == rawRequestJson ||
236
+ try { JSONObject(opt.requestJson).toString() == JSONObject(rawRequestJson!!).toString() } catch(e: Exception) { false })
237
+ } as? GetPublicKeyCredentialOption
238
+ option?.requestData?.getByteArray("androidx.credentials.BUNDLE_KEY_CLIENT_DATA_HASH")
239
+ }
240
+
241
+ val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
242
+ // Compact JSON for web origins to match browser hashing (no spaces, specific order)
243
+ "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
244
+ } else {
245
+ val json = JSONObject()
246
+ json.put("type", "webauthn.get")
247
+ json.put("challenge", challenge)
248
+ json.put("origin", sanitizedOrigin)
249
+ json.put("crossOrigin", false)
250
+ if (sanitizedOrigin.startsWith("android:apk-key-hash:")) {
251
+ json.put("androidPackageName", req.callingAppInfo.packageName)
252
+ }
253
+ json.toString()
254
+ }
255
+
256
+ val clientDataHash = systemClientDataHash ?: MessageDigest.getInstance("SHA-256").digest(clientDataJSONString.toByteArray(Charsets.UTF_8))
100
257
 
101
258
  val dbCred = credentialRepository.getCredential(this, credId)
102
259
  ?: throw IllegalStateException("Credential not found")
@@ -104,7 +261,7 @@ class GetPasskeyActivity : AppCompatActivity() {
104
261
  val response = AuthenticatorAssertionResponse(
105
262
  requestOptions = requestOptions,
106
263
  credentialId = credId,
107
- origin = credentialRepository.getOrigin(req.callingAppInfo),
264
+ origin = sanitizedOrigin,
108
265
  up = true,
109
266
  uv = true,
110
267
  be = true,
@@ -125,13 +282,23 @@ class GetPasskeyActivity : AppCompatActivity() {
125
282
  authenticatorAttachment = "platform"
126
283
  )
127
284
 
128
- // Manual addition of clientDataJSON if needed by some RPs, following reference
129
- val clientDataJSONb64 = getClientDataJSONb64(origin, challenge)
285
+ // Manual addition of clientDataJSON and signature to the response JSON
286
+ val clientDataJSONb64 = AndroidBase64.encodeToString(clientDataJSONString.toByteArray(), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
287
+ val signatureb64 = AndroidBase64.encodeToString(response.signature, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
288
+
289
+ val fullJson = JSONObject(fidoCredential.json())
290
+ val respJson = fullJson.getJSONObject("response")
291
+ respJson.put("clientDataJSON", clientDataJSONb64)
292
+ respJson.put("signature", signatureb64)
130
293
 
131
- val delimiter = "response\":{"
132
- val credentialJson = fidoCredential.json().substringBeforeLast(delimiter) + delimiter +
133
- "\"clientDataJSON\":\"$clientDataJSONb64\"," +
134
- fidoCredential.json().substringAfterLast(delimiter)
294
+ // Add clientExtensionResults as seen in the example
295
+ val clientExtensionResults = JSONObject()
296
+ val credProps = JSONObject()
297
+ credProps.put("rk", true)
298
+ clientExtensionResults.put("credProps", credProps)
299
+ fullJson.put("clientExtensionResults", clientExtensionResults)
300
+
301
+ val credentialJson = fullJson.toString()
135
302
 
136
303
  val resultIntent = Intent()
137
304
  val passkeyCredential = PublicKeyCredential(credentialJson)
@@ -149,9 +316,4 @@ class GetPasskeyActivity : AppCompatActivity() {
149
316
  finish()
150
317
  }
151
318
 
152
- private fun getClientDataJSONb64(origin: String, challenge: String): String {
153
- val sanitizedOrigin = origin.replace(Regex("/$"), "")
154
- val jsonString = "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
155
- return AndroidBase64.encodeToString(jsonString.toByteArray(), AndroidBase64.NO_WRAP)
156
- }
157
319
  }
@@ -26,8 +26,10 @@ import androidx.credentials.provider.ProviderClearCredentialStateRequest
26
26
  import androidx.credentials.provider.PublicKeyCredentialEntry
27
27
  import co.algorand.passkeyautofill.credentials.CredentialRepository
28
28
  import co.algorand.passkeyautofill.credentials.Credential
29
+ import co.algorand.passkeyautofill.utils.PasskeyUtils
29
30
  import com.tencent.mmkv.MMKV
30
31
  import kotlinx.coroutines.*
32
+ import org.json.JSONArray
31
33
  import org.json.JSONObject
32
34
 
33
35
  @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -127,7 +129,7 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
127
129
  /**
128
130
  * Fake a list of available PublicKeyCredential Entries
129
131
  */
130
- private fun processGetCredentialRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse{
132
+ private fun processGetCredentialRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse {
131
133
  // Ensure we have a master key available. If not, we can't sign anything, so don't show entries.
132
134
  if (!credentialRepository.isMasterKeyAvailable(this)) {
133
135
  return BeginGetCredentialResponse(emptyList())
@@ -138,42 +140,66 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
138
140
  }
139
141
 
140
142
  val action = credentialRepository.getGetPasskeyAction(this) ?: DEFAULT_GET_PASSKEY_ACTION
143
+ val allEntries = mutableListOf<PublicKeyCredentialEntry>()
141
144
 
142
- val entries = credentials.mapNotNull { credential ->
143
- try {
144
- // Find suitable option for this entry
145
- val option = request.beginGetCredentialOptions.find { opt ->
146
- if (opt !is BeginGetPublicKeyCredentialOption) return@find false
147
-
148
- // In a real app, we'd check if this credential's origin matches the request's origin
149
- // For now, we return all credentials that match the requested type.
150
- true
151
- } as? BeginGetPublicKeyCredentialOption
152
-
153
- if (option == null) {
154
- return@mapNotNull null
155
- }
145
+ for (option in request.beginGetCredentialOptions) {
146
+ if (option !is BeginGetPublicKeyCredentialOption) continue
156
147
 
157
- val data = Bundle()
158
- data.putString("credentialId", credential.credentialId)
159
- data.putString("userHandle", credential.userHandle)
160
- data.putString("requestJson", option.requestJson)
161
-
162
- PublicKeyCredentialEntry.Builder(
163
- this@PasskeyAutofillCredentialProviderService,
164
- credential.userHandle,
165
- createNewPendingIntent(action, GET_PASSKEY_INTENT, data),
166
- option
167
- )
168
- .setIcon(Icon.createWithResource(this@PasskeyAutofillCredentialProviderService, android.R.drawable.ic_lock_lock))
169
- .build()
148
+ val requestJsonStr = option.requestJson
149
+ var allowCredentials: JSONArray? = null
150
+ try {
151
+ val json = JSONObject(requestJsonStr)
152
+ val pk = if (json.has("publicKey")) json.getJSONObject("publicKey") else json
153
+ allowCredentials = pk.optJSONArray("allowCredentials")
170
154
  } catch (e: Exception) {
171
- Log.e(TAG, "Error building PublicKeyCredentialEntry", e)
155
+ Log.e(TAG, "Error parsing requestJson", e)
156
+ }
157
+
158
+ val allowedIds = if (allowCredentials != null && allowCredentials.length() > 0) {
159
+ val ids = mutableSetOf<String>()
160
+ for (i in 0 until allowCredentials.length()) {
161
+ ids.add(allowCredentials.getJSONObject(i).getString("id"))
162
+ }
163
+ ids
164
+ } else {
172
165
  null
173
166
  }
167
+
168
+ for (credential in credentials) {
169
+ if (allowedIds != null) {
170
+ val isAllowed = allowedIds.any { allowedId ->
171
+ allowedId == credential.credentialId ||
172
+ PasskeyUtils.normalizeBase64(allowedId) == PasskeyUtils.normalizeBase64(credential.credentialId)
173
+ }
174
+ if (!isAllowed) continue
175
+ }
176
+
177
+ try {
178
+ val data = Bundle()
179
+ data.putString("credentialId", credential.credentialId)
180
+ data.putString("userHandle", credential.userHandle)
181
+ data.putString("requestJson", option.requestJson)
182
+
183
+ // Use a unique requestCode for each PendingIntent to avoid data being overwritten
184
+ val uniqueRequestCode = (credential.credentialId + option.requestJson).hashCode()
185
+
186
+ val entry = PublicKeyCredentialEntry.Builder(
187
+ this,
188
+ credential.userHandle,
189
+ createNewPendingIntent(action, uniqueRequestCode, data),
190
+ option
191
+ )
192
+ .setIcon(Icon.createWithResource(this, android.R.drawable.ic_lock_lock))
193
+ .build()
194
+ allEntries.add(entry)
195
+ } catch (e: Exception) {
196
+ Log.e(TAG, "Error building PublicKeyCredentialEntry", e)
197
+ }
198
+ }
174
199
  }
175
- return BeginGetCredentialResponse(entries)
200
+ return BeginGetCredentialResponse(allEntries)
176
201
  }
202
+
177
203
  override fun onClearCredentialStateRequest(
178
204
  request: ProviderClearCredentialStateRequest,
179
205
  cancellationSignal: CancellationSignal,
@@ -186,12 +212,17 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
186
212
  private fun createNewPendingIntent(action: String, requestCode: Int, extra: Bundle?): PendingIntent{
187
213
  MMKV.initialize(this)
188
214
  val intent = Intent(action)
189
- // Set component to be explicit to this library's activities
190
- val componentName = when(requestCode) {
191
- GET_PASSKEY_INTENT -> android.content.ComponentName(packageName, "co.algorand.passkeyautofill.GetPasskeyActivity")
192
- CREATE_PASSKEY_INTENT -> android.content.ComponentName(packageName, "co.algorand.passkeyautofill.CreatePasskeyActivity")
215
+
216
+ // Determine component based on action instead of requestCode
217
+ val getAction = credentialRepository.getGetPasskeyAction(this) ?: DEFAULT_GET_PASSKEY_ACTION
218
+ val createAction = credentialRepository.getCreatePasskeyAction(this) ?: DEFAULT_CREATE_PASSKEY_ACTION
219
+
220
+ val componentName = when(action) {
221
+ getAction -> android.content.ComponentName(packageName, "co.algorand.passkeyautofill.GetPasskeyActivity")
222
+ createAction -> android.content.ComponentName(packageName, "co.algorand.passkeyautofill.CreatePasskeyActivity")
193
223
  else -> null
194
224
  }
225
+
195
226
  if (componentName != null) {
196
227
  intent.component = componentName
197
228
  } else {
@@ -0,0 +1,7 @@
1
+ package co.algorand.passkeyautofill.utils
2
+
3
+ object PasskeyUtils {
4
+ fun normalizeBase64(s: String): String {
5
+ return s.replace("-", "+").replace("_", "/").replace("=", "")
6
+ }
7
+ }
@@ -10,26 +10,25 @@ public class ReactNativePasskeyAutofillModule: Module {
10
10
  // The module will be accessible from `requireNativeModule('ReactNativePasskeyAutofill')` in JavaScript.
11
11
  Name("ReactNativePasskeyAutofill")
12
12
 
13
- // Defines constant property on the module.
14
- Constant("PI") {
15
- Double.pi
13
+ AsyncFunction("setMasterKey") { (secret: String) in
14
+ // TODO: Implement for iOS
16
15
  }
17
16
 
18
- // Defines event names that the module can send to JavaScript.
19
- Events("onChange")
17
+ AsyncFunction("setHdRootKeyId") { (id: String) in
18
+ // TODO: Implement for iOS
19
+ }
20
+
21
+ AsyncFunction("getHdRootKeyId") { () -> String? in
22
+ // TODO: Implement for iOS
23
+ return nil
24
+ }
20
25
 
21
- // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
22
- Function("hello") {
23
- return "Hello world! ๐Ÿ‘‹"
26
+ AsyncFunction("clearCredentials") {
27
+ // TODO: Implement for iOS
24
28
  }
25
29
 
26
- // Defines a JavaScript function that always returns a Promise and whose native code
27
- // is by default dispatched on the different thread than the JavaScript runtime runs on.
28
- AsyncFunction("setValueAsync") { (value: String) in
29
- // Send an event to JavaScript.
30
- self.sendEvent("onChange", [
31
- "value": value
32
- ])
30
+ AsyncFunction("configureIntentActions") { (getPasskeyAction: String, createPasskeyAction: String) in
31
+ // TODO: Implement for iOS
33
32
  }
34
33
 
35
34
  // Enables the module to be used as a native view. Definition components that are accepted as part of the
package/lefthook.yml ADDED
@@ -0,0 +1,13 @@
1
+ # Lefthook configuration
2
+ # Refer to https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md
3
+
4
+ pre-commit:
5
+ commands:
6
+ lint:
7
+ run: npm run lint
8
+
9
+ pre-push:
10
+ parallel: true
11
+ commands:
12
+ test:
13
+ run: npm run test:all
package/package.json CHANGED
@@ -1,18 +1,22 @@
1
1
  {
2
2
  "name": "@algorandfoundation/react-native-passkey-autofill",
3
- "version": "1.0.0-canary.3",
3
+ "version": "1.0.0-canary.5",
4
4
  "description": "Passkey AutoFill React Native",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
7
7
  "publishConfig": {
8
- "access": "public"
8
+ "access": "public",
9
+ "provenance": true
9
10
  },
10
11
  "scripts": {
11
12
  "build": "expo-module build",
12
13
  "clean": "expo-module clean",
13
- "lint": "expo-module lint",
14
- "test": "expo-module test",
15
- "prepare": "expo-module prepare",
14
+ "lint": "ESLINT_USE_FLAT_CONFIG=false expo-module lint",
15
+ "test": "CI=true expo-module test",
16
+ "test:android": "cd example/android && ./gradlew :algorandfoundation-react-native-passkey-autofill:testDebugUnitTest --no-daemon",
17
+ "test:android:instrumented": "cd example/android && ./gradlew :algorandfoundation-react-native-passkey-autofill:connectedDebugAndroidTest --no-daemon",
18
+ "test:all": "npm run test && npm run test:android",
19
+ "prepare": "expo-module prepare && npx lefthook install",
16
20
  "prepublishOnly": "expo-module prepublishOnly",
17
21
  "expo-module": "expo-module",
18
22
  "open:ios": "xed example/ios",
@@ -24,22 +28,38 @@
24
28
  "@algorandfoundation/react-native-passkey-autofill",
25
29
  "ReactNativePasskeyAutofill"
26
30
  ],
27
- "repository": "https://github.com/algorandfoundation/react-native-passkey-autofill",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+ssh://git@github.com/algorandfoundation/react-native-passkey-autofill.git"
34
+ },
28
35
  "bugs": {
29
36
  "url": "https://github.com/algorandfoundation/react-native-passkey-autofill/issues"
30
37
  },
31
- "author": "Algorand Foundation <michael.feher@algorand.foundation> (https://github.com/algorandfoundation)",
38
+ "author": "Algorand Foundation",
32
39
  "license": "Apache-2.0",
33
40
  "homepage": "https://github.com/algorandfoundation/react-native-passkey-autofill#readme",
34
41
  "dependencies": {
35
42
  "@algorandfoundation/react-native-keystore": "^1.0.0-canary.6"
36
43
  },
44
+ "jest": {
45
+ "preset": "ts-jest",
46
+ "testEnvironment": "node",
47
+ "testMatch": [
48
+ "<rootDir>/src/**/__tests__/**/*.test.ts"
49
+ ]
50
+ },
37
51
  "devDependencies": {
38
52
  "@expo/config-plugins": "^9.0.0",
53
+ "@semantic-release/changelog": "^6.0.3",
54
+ "@semantic-release/git": "^10.0.1",
55
+ "@semantic-release/github": "^12.0.6",
56
+ "@semantic-release/npm": "^13.1.5",
39
57
  "@types/react": "~19.1.1",
40
58
  "expo": "^55.0.1",
41
59
  "expo-module-scripts": "^55.0.2",
42
- "react-native": "0.82.1"
60
+ "lefthook": "^2.1.4",
61
+ "react-native": "0.82.1",
62
+ "semantic-release": "^25.0.3"
43
63
  },
44
64
  "peerDependencies": {
45
65
  "expo": "*",