@rejourneyco/react-native 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/android/build.gradle.kts +135 -0
  2. package/android/consumer-rules.pro +10 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/main/AndroidManifest.xml +15 -0
  5. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
  6. package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
  7. package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
  8. package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
  9. package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
  10. package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
  11. package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
  12. package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
  13. package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
  14. package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
  15. package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
  16. package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
  17. package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
  18. package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
  19. package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
  20. package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
  21. package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
  22. package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
  23. package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
  24. package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
  25. package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
  26. package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
  27. package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
  28. package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
  29. package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
  30. package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
  31. package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
  32. package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
  33. package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
  34. package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
  35. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
  36. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  37. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
  38. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  39. package/ios/Capture/RJANRHandler.h +42 -0
  40. package/ios/Capture/RJANRHandler.m +328 -0
  41. package/ios/Capture/RJCaptureEngine.h +275 -0
  42. package/ios/Capture/RJCaptureEngine.m +2062 -0
  43. package/ios/Capture/RJCaptureHeuristics.h +80 -0
  44. package/ios/Capture/RJCaptureHeuristics.m +903 -0
  45. package/ios/Capture/RJCrashHandler.h +46 -0
  46. package/ios/Capture/RJCrashHandler.m +313 -0
  47. package/ios/Capture/RJMotionEvent.h +183 -0
  48. package/ios/Capture/RJMotionEvent.m +183 -0
  49. package/ios/Capture/RJPerformanceManager.h +100 -0
  50. package/ios/Capture/RJPerformanceManager.m +373 -0
  51. package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
  52. package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
  53. package/ios/Capture/RJSegmentUploader.h +146 -0
  54. package/ios/Capture/RJSegmentUploader.m +778 -0
  55. package/ios/Capture/RJVideoEncoder.h +247 -0
  56. package/ios/Capture/RJVideoEncoder.m +1036 -0
  57. package/ios/Capture/RJViewControllerTracker.h +73 -0
  58. package/ios/Capture/RJViewControllerTracker.m +508 -0
  59. package/ios/Capture/RJViewHierarchyScanner.h +215 -0
  60. package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
  61. package/ios/Capture/RJViewSerializer.h +119 -0
  62. package/ios/Capture/RJViewSerializer.m +498 -0
  63. package/ios/Core/RJConstants.h +124 -0
  64. package/ios/Core/RJConstants.m +88 -0
  65. package/ios/Core/RJLifecycleManager.h +85 -0
  66. package/ios/Core/RJLifecycleManager.m +308 -0
  67. package/ios/Core/RJLogger.h +61 -0
  68. package/ios/Core/RJLogger.m +211 -0
  69. package/ios/Core/RJTypes.h +176 -0
  70. package/ios/Core/RJTypes.m +66 -0
  71. package/ios/Core/Rejourney.h +64 -0
  72. package/ios/Core/Rejourney.mm +2495 -0
  73. package/ios/Network/RJDeviceAuthManager.h +94 -0
  74. package/ios/Network/RJDeviceAuthManager.m +967 -0
  75. package/ios/Network/RJNetworkMonitor.h +68 -0
  76. package/ios/Network/RJNetworkMonitor.m +267 -0
  77. package/ios/Network/RJRetryManager.h +73 -0
  78. package/ios/Network/RJRetryManager.m +325 -0
  79. package/ios/Network/RJUploadManager.h +267 -0
  80. package/ios/Network/RJUploadManager.m +2296 -0
  81. package/ios/Privacy/RJPrivacyMask.h +163 -0
  82. package/ios/Privacy/RJPrivacyMask.m +922 -0
  83. package/ios/Rejourney.h +63 -0
  84. package/ios/Touch/RJGestureClassifier.h +130 -0
  85. package/ios/Touch/RJGestureClassifier.m +333 -0
  86. package/ios/Touch/RJTouchInterceptor.h +169 -0
  87. package/ios/Touch/RJTouchInterceptor.m +772 -0
  88. package/ios/Utils/RJEventBuffer.h +112 -0
  89. package/ios/Utils/RJEventBuffer.m +358 -0
  90. package/ios/Utils/RJGzipUtils.h +33 -0
  91. package/ios/Utils/RJGzipUtils.m +89 -0
  92. package/ios/Utils/RJKeychainManager.h +48 -0
  93. package/ios/Utils/RJKeychainManager.m +111 -0
  94. package/ios/Utils/RJPerfTiming.h +209 -0
  95. package/ios/Utils/RJPerfTiming.m +264 -0
  96. package/ios/Utils/RJTelemetry.h +92 -0
  97. package/ios/Utils/RJTelemetry.m +320 -0
  98. package/ios/Utils/RJWindowUtils.h +66 -0
  99. package/ios/Utils/RJWindowUtils.m +133 -0
  100. package/lib/commonjs/NativeRejourney.js +40 -0
  101. package/lib/commonjs/components/Mask.js +79 -0
  102. package/lib/commonjs/index.js +1381 -0
  103. package/lib/commonjs/sdk/autoTracking.js +1259 -0
  104. package/lib/commonjs/sdk/constants.js +151 -0
  105. package/lib/commonjs/sdk/errorTracking.js +199 -0
  106. package/lib/commonjs/sdk/index.js +50 -0
  107. package/lib/commonjs/sdk/metricsTracking.js +204 -0
  108. package/lib/commonjs/sdk/navigation.js +151 -0
  109. package/lib/commonjs/sdk/networkInterceptor.js +412 -0
  110. package/lib/commonjs/sdk/utils.js +363 -0
  111. package/lib/commonjs/types/expo-router.d.js +2 -0
  112. package/lib/commonjs/types/index.js +2 -0
  113. package/lib/module/NativeRejourney.js +38 -0
  114. package/lib/module/components/Mask.js +72 -0
  115. package/lib/module/index.js +1284 -0
  116. package/lib/module/sdk/autoTracking.js +1233 -0
  117. package/lib/module/sdk/constants.js +145 -0
  118. package/lib/module/sdk/errorTracking.js +189 -0
  119. package/lib/module/sdk/index.js +12 -0
  120. package/lib/module/sdk/metricsTracking.js +187 -0
  121. package/lib/module/sdk/navigation.js +143 -0
  122. package/lib/module/sdk/networkInterceptor.js +401 -0
  123. package/lib/module/sdk/utils.js +342 -0
  124. package/lib/module/types/expo-router.d.js +2 -0
  125. package/lib/module/types/index.js +2 -0
  126. package/lib/typescript/NativeRejourney.d.ts +147 -0
  127. package/lib/typescript/components/Mask.d.ts +39 -0
  128. package/lib/typescript/index.d.ts +117 -0
  129. package/lib/typescript/sdk/autoTracking.d.ts +204 -0
  130. package/lib/typescript/sdk/constants.d.ts +120 -0
  131. package/lib/typescript/sdk/errorTracking.d.ts +32 -0
  132. package/lib/typescript/sdk/index.d.ts +9 -0
  133. package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
  134. package/lib/typescript/sdk/navigation.d.ts +33 -0
  135. package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
  136. package/lib/typescript/sdk/utils.d.ts +148 -0
  137. package/lib/typescript/types/index.d.ts +624 -0
  138. package/package.json +102 -0
  139. package/rejourney.podspec +21 -0
  140. package/src/NativeRejourney.ts +165 -0
  141. package/src/components/Mask.tsx +80 -0
  142. package/src/index.ts +1459 -0
  143. package/src/sdk/autoTracking.ts +1373 -0
  144. package/src/sdk/constants.ts +134 -0
  145. package/src/sdk/errorTracking.ts +231 -0
  146. package/src/sdk/index.ts +11 -0
  147. package/src/sdk/metricsTracking.ts +232 -0
  148. package/src/sdk/navigation.ts +157 -0
  149. package/src/sdk/networkInterceptor.ts +440 -0
  150. package/src/sdk/utils.ts +369 -0
  151. package/src/types/expo-router.d.ts +7 -0
  152. package/src/types/index.ts +739 -0
@@ -0,0 +1,747 @@
1
+ /**
2
+ * Device authentication and token management using ECDSA keypairs.
3
+ * Ported from iOS RJDeviceAuthManager with proper cryptographic authentication.
4
+ */
5
+ package com.rejourney.network
6
+
7
+ import android.content.Context
8
+ import android.content.SharedPreferences
9
+ import android.os.Build
10
+ import android.security.keystore.KeyGenParameterSpec
11
+ import android.security.keystore.KeyProperties
12
+ import android.util.Base64
13
+ import androidx.security.crypto.EncryptedSharedPreferences
14
+ import androidx.security.crypto.MasterKey
15
+ import com.rejourney.core.Constants
16
+ import com.rejourney.core.Logger
17
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
18
+ import okhttp3.*
19
+ import okhttp3.MediaType.Companion.toMediaType
20
+ import okhttp3.RequestBody.Companion.toRequestBody
21
+ import org.json.JSONObject
22
+ import java.io.IOException
23
+ import java.security.*
24
+ import java.security.spec.ECGenParameterSpec
25
+ import java.util.concurrent.TimeUnit
26
+
27
+ /**
28
+ * Listener interface for authentication failures.
29
+ * Implementations should stop recording and notify the user.
30
+ */
31
+ interface AuthFailureListener {
32
+ /**
33
+ * Called when authentication fails due to security errors (403/404).
34
+ * @param errorCode HTTP status code (403 = package name mismatch, 404 = project not found)
35
+ * @param errorMessage Human-readable error message
36
+ * @param domain Error domain for categorization
37
+ */
38
+ fun onAuthenticationFailure(errorCode: Int, errorMessage: String, domain: String)
39
+ }
40
+
41
+ class DeviceAuthManager private constructor(private val context: Context) {
42
+
43
+ companion object {
44
+ @Volatile
45
+ private var instance: DeviceAuthManager? = null
46
+
47
+ private const val PREFS_NAME = "rejourney_device_auth"
48
+ private const val KEY_CREDENTIAL_ID = "credential_id"
49
+ private const val KEY_UPLOAD_TOKEN = "upload_token"
50
+ private const val KEY_TOKEN_EXPIRY = "token_expiry"
51
+ private const val KEY_API_URL = "api_url"
52
+ private const val KEY_PROJECT_PUBLIC_KEY = "project_public_key"
53
+ private const val KEY_BUNDLE_ID = "bundle_id"
54
+ private const val KEY_PLATFORM = "platform"
55
+ private const val KEY_SDK_VERSION = "sdk_version"
56
+
57
+ private const val KEYSTORE_ALIAS = "com.rejourney.devicekey"
58
+ private const val ANDROID_KEYSTORE = "AndroidKeyStore"
59
+
60
+ // Rate limiting constants for auto-registration
61
+ private const val AUTH_COOLDOWN_BASE_MS = 5000L // 5 second base cooldown
62
+ private const val AUTH_COOLDOWN_MAX_MS = 300000L // 5 minute max cooldown
63
+ private const val AUTH_MAX_CONSECUTIVE_FAILURES = 10
64
+
65
+ fun getInstance(context: Context): DeviceAuthManager {
66
+ return instance ?: synchronized(this) {
67
+ instance ?: DeviceAuthManager(context.applicationContext).also { instance = it }
68
+ }
69
+ }
70
+ }
71
+
72
+ // Listener for authentication failures (set by RejourneyModuleImpl)
73
+ var authFailureListener: AuthFailureListener? = null
74
+
75
+ // Use shared client (SSL pinning removed)
76
+ private val client = HttpClientProvider.shared
77
+
78
+ private val prefs: SharedPreferences by lazy {
79
+ try {
80
+ val masterKey = MasterKey.Builder(context)
81
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
82
+ .build()
83
+
84
+ EncryptedSharedPreferences.create(
85
+ context,
86
+ PREFS_NAME,
87
+ masterKey,
88
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
89
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
90
+ )
91
+ } catch (e: Exception) {
92
+ Logger.warning("Failed to create encrypted prefs, using standard prefs: ${e.message}")
93
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
94
+ }
95
+ }
96
+
97
+ init {
98
+ loadStoredRegistrationInfo()
99
+ }
100
+
101
+ private var apiUrl: String = ""
102
+ private var projectPublicKey: String = ""
103
+ private var storedBundleId: String = ""
104
+ private var storedPlatform: String = ""
105
+ private var storedSdkVersion: String = ""
106
+
107
+ // Rate limiting for auto-registration
108
+ @Volatile private var lastFailedRegistrationTime: Long = 0
109
+ @Volatile private var consecutiveFailures: Int = 0
110
+ @Volatile private var registrationInProgress: Boolean = false
111
+ private val pendingTokenCallbacks = mutableListOf<(Boolean, String?, Int, String?) -> Unit>()
112
+
113
+ private fun loadStoredRegistrationInfo() {
114
+ apiUrl = prefs.getString(KEY_API_URL, "")?.takeIf { it.startsWith("http") } ?: ""
115
+ projectPublicKey = prefs.getString(KEY_PROJECT_PUBLIC_KEY, "") ?: ""
116
+ storedBundleId = prefs.getString(KEY_BUNDLE_ID, "") ?: ""
117
+ storedPlatform = prefs.getString(KEY_PLATFORM, "") ?: ""
118
+ storedSdkVersion = prefs.getString(KEY_SDK_VERSION, "") ?: ""
119
+ }
120
+
121
+ /**
122
+ * Register device with the dashboard server.
123
+ * Generates ECDSA P-256 keypair if needed and stores in Android Keystore.
124
+ */
125
+ fun registerDevice(
126
+ projectKey: String,
127
+ bundleId: String,
128
+ platform: String,
129
+ sdkVersion: String,
130
+ apiUrl: String,
131
+ callback: (success: Boolean, credentialId: String?, error: String?) -> Unit
132
+ ) {
133
+ this.apiUrl = apiUrl
134
+ this.projectPublicKey = projectKey
135
+ this.storedBundleId = bundleId
136
+ this.storedPlatform = platform
137
+ this.storedSdkVersion = sdkVersion
138
+ prefs.edit()
139
+ .putString(KEY_API_URL, apiUrl)
140
+ .putString(KEY_PROJECT_PUBLIC_KEY, projectKey)
141
+ .putString(KEY_BUNDLE_ID, bundleId)
142
+ .putString(KEY_PLATFORM, platform)
143
+ .putString(KEY_SDK_VERSION, sdkVersion)
144
+ .apply()
145
+
146
+ // Check if already registered
147
+ val existingCredentialId = prefs.getString(KEY_CREDENTIAL_ID, null)
148
+ if (!existingCredentialId.isNullOrEmpty()) {
149
+ Logger.debug("Device already registered with credential: $existingCredentialId")
150
+ callback(true, existingCredentialId, null)
151
+ return
152
+ }
153
+
154
+ // Generate or load ECDSA keypair
155
+ val publicKeyPEM = try {
156
+ getOrCreatePublicKeyPEM()
157
+ } catch (e: Exception) {
158
+ Logger.error("Failed to generate ECDSA keypair", e)
159
+ callback(false, null, "Failed to generate keypair: ${e.message}")
160
+ return
161
+ }
162
+
163
+ if (publicKeyPEM == null) {
164
+ callback(false, null, "Failed to export public key")
165
+ return
166
+ }
167
+
168
+ // Register with backend
169
+ val requestBody = JSONObject().apply {
170
+ put("projectPublicKey", projectKey)
171
+ put("bundleId", bundleId)
172
+ put("platform", platform)
173
+ put("sdkVersion", sdkVersion)
174
+ put("devicePublicKey", publicKeyPEM)
175
+ }
176
+
177
+ Logger.debug("Registering device with backend...")
178
+
179
+ val request = Request.Builder()
180
+ .url("$apiUrl/api/devices/register")
181
+ .post(requestBody.toString().toRequestBody("application/json".toMediaType()))
182
+ .build()
183
+
184
+ client.newCall(request).enqueue(object : Callback {
185
+ override fun onFailure(call: Call, e: IOException) {
186
+ Logger.error("Device registration failed", e)
187
+ callback(false, null, e.message)
188
+ }
189
+
190
+ override fun onResponse(call: Call, response: Response) {
191
+ response.use {
192
+ if (it.isSuccessful) {
193
+ try {
194
+ val json = JSONObject(it.body?.string() ?: "{}")
195
+ val credentialId = json.optString("deviceCredentialId")
196
+
197
+ prefs.edit().putString(KEY_CREDENTIAL_ID, credentialId).apply()
198
+
199
+ Logger.debug("Device registered: $credentialId")
200
+ callback(true, credentialId, null)
201
+ } catch (e: Exception) {
202
+ Logger.error("Failed to parse registration response", e)
203
+ callback(false, null, "Failed to parse response")
204
+ }
205
+ } else {
206
+ val errorBody = it.body?.string() ?: ""
207
+ val errorCode = it.code
208
+ Logger.error("Registration failed: $errorCode - $errorBody")
209
+
210
+ when (errorCode) {
211
+ 403 -> {
212
+ // Package name mismatch or access forbidden
213
+ Logger.error("SECURITY: Package name mismatch or access forbidden")
214
+ val errorMessage = parseErrorMessage(errorBody)
215
+ ?: "Package name mismatch. The app package name does not match the project configuration."
216
+ clearCredentials()
217
+ authFailureListener?.onAuthenticationFailure(403, errorMessage, "RJDeviceAuth")
218
+ callback(false, null, errorMessage)
219
+ }
220
+ 404 -> {
221
+ // Project not found - invalid project key
222
+ Logger.error("SECURITY: Project not found. Invalid project key.")
223
+ val errorMessage = parseErrorMessage(errorBody) ?: "Project not found. Invalid project key."
224
+ clearCredentials()
225
+ authFailureListener?.onAuthenticationFailure(404, errorMessage, "RJDeviceAuth")
226
+ callback(false, null, errorMessage)
227
+ }
228
+ else -> {
229
+ callback(false, null, "Registration failed: $errorCode")
230
+ }
231
+ }
232
+ }
233
+ }
234
+ }
235
+ })
236
+ }
237
+
238
+ /**
239
+ * Get upload token via challenge-response authentication.
240
+ */
241
+ fun getUploadToken(
242
+ callback: (success: Boolean, token: String?, expiresIn: Int, error: String?) -> Unit
243
+ ) {
244
+ // Check cached token
245
+ val cachedToken = prefs.getString(KEY_UPLOAD_TOKEN, null)
246
+ val tokenExpiry = prefs.getLong(KEY_TOKEN_EXPIRY, 0)
247
+
248
+ if (!cachedToken.isNullOrEmpty() && tokenExpiry > System.currentTimeMillis() + 60_000) {
249
+ val remainingSeconds = ((tokenExpiry - System.currentTimeMillis()) / 1000).toInt()
250
+ Logger.debug("Using cached upload token (expires in $remainingSeconds seconds)")
251
+ callback(true, cachedToken, remainingSeconds, null)
252
+ return
253
+ }
254
+
255
+ val credentialId = prefs.getString(KEY_CREDENTIAL_ID, null)
256
+ if (credentialId.isNullOrEmpty()) {
257
+ Logger.warning("Cannot get upload token: device not registered")
258
+ callback(false, null, 0, "Device not registered")
259
+ return
260
+ }
261
+
262
+ val savedApiUrl = prefs.getString(KEY_API_URL, null)?.takeIf { it.startsWith("http") } ?: "https://api.rejourney.co"
263
+
264
+ // Step 1: Request challenge
265
+ requestChallenge(credentialId) { challengeSuccess, challenge, nonce, challengeError ->
266
+ if (!challengeSuccess || challenge == null || nonce == null) {
267
+ callback(false, null, 0, challengeError ?: "Failed to get challenge")
268
+ return@requestChallenge
269
+ }
270
+
271
+ // Step 2: Sign challenge
272
+ val signature = try {
273
+ signChallenge(challenge)
274
+ } catch (e: Exception) {
275
+ Logger.error("Failed to sign challenge", e)
276
+ callback(false, null, 0, "Failed to sign challenge: ${e.message}")
277
+ return@requestChallenge
278
+ }
279
+
280
+ if (signature == null) {
281
+ callback(false, null, 0, "Failed to sign challenge")
282
+ return@requestChallenge
283
+ }
284
+
285
+ // Step 3: Start session with signed challenge
286
+ startSession(credentialId, challenge, nonce, signature, callback)
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Get current upload token if valid.
292
+ */
293
+ fun getCurrentUploadToken(): String? {
294
+ val token = prefs.getString(KEY_UPLOAD_TOKEN, null)
295
+ val expiry = prefs.getLong(KEY_TOKEN_EXPIRY, 0)
296
+
297
+ return if (!token.isNullOrEmpty() && expiry > System.currentTimeMillis()) {
298
+ token
299
+ } else {
300
+ null
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Suspend function to refresh upload token for coroutine callers.
306
+ */
307
+ @OptIn(ExperimentalCoroutinesApi::class)
308
+ suspend fun refreshUploadToken(): Boolean {
309
+ return kotlinx.coroutines.suspendCancellableCoroutine { continuation ->
310
+ getUploadToken { success, _, _, _ ->
311
+ if (continuation.isActive) {
312
+ continuation.resume(success, onCancellation = { _ -> })
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Check if upload token is still valid.
320
+ */
321
+ fun hasValidUploadToken(): Boolean {
322
+ val token = prefs.getString(KEY_UPLOAD_TOKEN, null)
323
+ val expiry = prefs.getLong(KEY_TOKEN_EXPIRY, 0)
324
+ val isValid = !token.isNullOrEmpty() && expiry > System.currentTimeMillis() + 60_000
325
+ Logger.debug("[DeviceAuthManager] hasValidUploadToken: $isValid (token=${if (token.isNullOrEmpty()) "null" else "present"}, expiresIn=${(expiry - System.currentTimeMillis()) / 1000}s)")
326
+ return isValid
327
+ }
328
+
329
+ private fun canAutoRegister(): Boolean {
330
+ if (apiUrl.isBlank() || projectPublicKey.isBlank() || storedBundleId.isBlank() ||
331
+ storedPlatform.isBlank() || storedSdkVersion.isBlank()) {
332
+ loadStoredRegistrationInfo()
333
+ }
334
+ return apiUrl.isNotBlank() && projectPublicKey.isNotBlank() &&
335
+ storedBundleId.isNotBlank() && storedPlatform.isNotBlank() &&
336
+ storedSdkVersion.isNotBlank()
337
+ }
338
+
339
+ private fun drainPendingCallbacks(
340
+ success: Boolean,
341
+ token: String?,
342
+ expiresIn: Int,
343
+ error: String?
344
+ ) {
345
+ val callbacks = synchronized(pendingTokenCallbacks) {
346
+ val copy = pendingTokenCallbacks.toList()
347
+ pendingTokenCallbacks.clear()
348
+ registrationInProgress = false
349
+ copy
350
+ }
351
+ callbacks.forEach { it(success, token, expiresIn, error) }
352
+ }
353
+
354
+ fun getUploadTokenWithAutoRegister(
355
+ callback: (success: Boolean, token: String?, expiresIn: Int, error: String?) -> Unit
356
+ ) {
357
+ if (hasValidUploadToken()) {
358
+ val token = prefs.getString(KEY_UPLOAD_TOKEN, null)
359
+ val expiry = prefs.getLong(KEY_TOKEN_EXPIRY, 0)
360
+ val remainingSeconds = ((expiry - System.currentTimeMillis()) / 1000).toInt()
361
+ callback(true, token, remainingSeconds, null)
362
+ return
363
+ }
364
+
365
+ val credentialId = prefs.getString(KEY_CREDENTIAL_ID, null)
366
+ if (!credentialId.isNullOrEmpty()) {
367
+ getUploadToken(callback)
368
+ return
369
+ }
370
+
371
+ if (!canAutoRegister()) {
372
+ callback(false, null, 0, "Device not registered and auto-registration not configured")
373
+ return
374
+ }
375
+
376
+ if (consecutiveFailures >= AUTH_MAX_CONSECUTIVE_FAILURES) {
377
+ callback(false, null, 0, "Auto-registration disabled after repeated failures")
378
+ return
379
+ }
380
+
381
+ val now = System.currentTimeMillis()
382
+ if (consecutiveFailures > 0 && lastFailedRegistrationTime > 0) {
383
+ val cooldown = minOf(
384
+ AUTH_COOLDOWN_BASE_MS * (1L shl (consecutiveFailures - 1)),
385
+ AUTH_COOLDOWN_MAX_MS
386
+ )
387
+ val timeSinceFailure = now - lastFailedRegistrationTime
388
+ if (timeSinceFailure < cooldown) {
389
+ val remaining = (cooldown - timeSinceFailure) / 1000
390
+ callback(false, null, 0, "Rate limited - retry in ${remaining}s")
391
+ return
392
+ }
393
+ }
394
+
395
+ synchronized(pendingTokenCallbacks) {
396
+ pendingTokenCallbacks.add(callback)
397
+ if (registrationInProgress) {
398
+ Logger.debug("[DeviceAuthManager] Auto-registration already in progress, callback queued")
399
+ return
400
+ }
401
+ registrationInProgress = true
402
+ }
403
+
404
+ registerDevice(
405
+ projectKey = projectPublicKey,
406
+ bundleId = storedBundleId,
407
+ platform = storedPlatform,
408
+ sdkVersion = storedSdkVersion,
409
+ apiUrl = this.apiUrl
410
+ ) { success, _, error ->
411
+ if (!success) {
412
+ consecutiveFailures++
413
+ lastFailedRegistrationTime = System.currentTimeMillis()
414
+ Logger.warning("[DeviceAuthManager] Auto-registration failed: $error")
415
+ drainPendingCallbacks(false, null, 0, error ?: "Registration failed")
416
+ return@registerDevice
417
+ }
418
+
419
+ consecutiveFailures = 0
420
+ lastFailedRegistrationTime = 0
421
+
422
+ getUploadToken { tokenSuccess, token, expiresIn, tokenError ->
423
+ drainPendingCallbacks(tokenSuccess, token, expiresIn, tokenError)
424
+ }
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Ensure a valid upload token exists before proceeding with uploads.
430
+ * This is the critical fix - blocks until token is refreshed if expired/missing.
431
+ * Returns true if token is valid after this call, false if refresh failed.
432
+ */
433
+ @OptIn(ExperimentalCoroutinesApi::class)
434
+ suspend fun ensureValidToken(): Boolean {
435
+ Logger.debug("[DeviceAuthManager] ensureValidToken() called")
436
+
437
+ // Always refresh from SharedPreferences before network operations
438
+ loadStoredRegistrationInfo()
439
+
440
+ return kotlinx.coroutines.suspendCancellableCoroutine { continuation ->
441
+ getUploadTokenWithAutoRegister { success, token, expiresIn, error ->
442
+ if (continuation.isActive) {
443
+ if (success && !token.isNullOrEmpty()) {
444
+ Logger.debug("[DeviceAuthManager] Token refresh SUCCESS (expiresIn=${expiresIn}s)")
445
+ continuation.resume(true, onCancellation = { _ -> })
446
+ } else {
447
+ Logger.error("[DeviceAuthManager] Token refresh FAILED: $error")
448
+ continuation.resume(false, onCancellation = { _ -> })
449
+ }
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Get stored project public key (for recovery uploads).
457
+ */
458
+ fun getCurrentPublicKey(): String? = prefs.getString(KEY_PROJECT_PUBLIC_KEY, null)
459
+
460
+ /**
461
+ * Get stored API URL (for recovery uploads).
462
+ */
463
+ fun getCurrentApiUrl(): String? = prefs.getString(KEY_API_URL, null)
464
+
465
+ /**
466
+ * Get stored device hash (for recovery uploads).
467
+ * This is derived from the credential ID.
468
+ */
469
+ fun getCurrentDeviceHash(): String? = prefs.getString(KEY_CREDENTIAL_ID, null)
470
+
471
+ /**
472
+ * Clear stored credentials.
473
+ */
474
+ fun clearCredentials() {
475
+ prefs.edit()
476
+ .remove(KEY_CREDENTIAL_ID)
477
+ .remove(KEY_UPLOAD_TOKEN)
478
+ .remove(KEY_TOKEN_EXPIRY)
479
+ .apply()
480
+
481
+ // Also delete the private key from Keystore
482
+ try {
483
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
484
+ keyStore.load(null)
485
+ keyStore.deleteEntry(KEYSTORE_ALIAS)
486
+ Logger.debug("Cleared all device auth data")
487
+ } catch (e: Exception) {
488
+ Logger.warning("Failed to delete private key: ${e.message}")
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Parse error message from JSON response body.
494
+ */
495
+ private fun parseErrorMessage(responseBody: String): String? {
496
+ return try {
497
+ val json = JSONObject(responseBody)
498
+ val message = json.optString("message", "")
499
+ if (message.isNotBlank()) {
500
+ message
501
+ } else {
502
+ json.optString("error", "").ifBlank { null }
503
+ }
504
+ } catch (e: Exception) {
505
+ null
506
+ }
507
+ }
508
+
509
+ // ==================== ECDSA Keypair Management ====================
510
+
511
+ /**
512
+ * Get or create ECDSA P-256 keypair and return public key in PEM format.
513
+ */
514
+ private fun getOrCreatePublicKeyPEM(): String? {
515
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
516
+ keyStore.load(null)
517
+
518
+ // Try to load existing key
519
+ if (keyStore.containsAlias(KEYSTORE_ALIAS)) {
520
+ Logger.debug("Loaded existing ECDSA private key")
521
+ val certificate = keyStore.getCertificate(KEYSTORE_ALIAS)
522
+ return exportPublicKeyToPEM(certificate.publicKey)
523
+ }
524
+
525
+ // Generate new keypair
526
+ Logger.debug("Generating new ECDSA P-256 keypair")
527
+
528
+ val keyPairGenerator = KeyPairGenerator.getInstance(
529
+ KeyProperties.KEY_ALGORITHM_EC,
530
+ ANDROID_KEYSTORE
531
+ )
532
+
533
+ val parameterSpec = KeyGenParameterSpec.Builder(
534
+ KEYSTORE_ALIAS,
535
+ KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
536
+ )
537
+ .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) // P-256
538
+ .setDigests(KeyProperties.DIGEST_SHA256)
539
+ .setUserAuthenticationRequired(false) // No biometric required
540
+ .build()
541
+
542
+ keyPairGenerator.initialize(parameterSpec)
543
+ val keyPair = keyPairGenerator.generateKeyPair()
544
+
545
+ Logger.debug("Successfully generated ECDSA P-256 keypair")
546
+ return exportPublicKeyToPEM(keyPair.public)
547
+ }
548
+
549
+ /**
550
+ * Export public key to PEM format.
551
+ */
552
+ private fun exportPublicKeyToPEM(publicKey: PublicKey): String? {
553
+ return try {
554
+ // Get the raw public key bytes (X.509 SubjectPublicKeyInfo format)
555
+ val encoded = publicKey.encoded
556
+ val base64 = Base64.encodeToString(encoded, Base64.NO_WRAP)
557
+
558
+ // Wrap in PEM format
559
+ "-----BEGIN PUBLIC KEY-----\n$base64\n-----END PUBLIC KEY-----"
560
+ } catch (e: Exception) {
561
+ Logger.error("Failed to export public key", e)
562
+ null
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Sign a challenge using the private key.
568
+ */
569
+ private fun signChallenge(challenge: String): String? {
570
+ return try {
571
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
572
+ keyStore.load(null)
573
+
574
+ val privateKey = keyStore.getKey(KEYSTORE_ALIAS, null) as? PrivateKey
575
+ ?: throw Exception("Private key not found")
576
+
577
+ // Decode base64 challenge
578
+ val challengeBytes = Base64.decode(challenge, Base64.DEFAULT)
579
+
580
+ // Sign with ECDSA-SHA256
581
+ val signature = Signature.getInstance("SHA256withECDSA")
582
+ signature.initSign(privateKey)
583
+ signature.update(challengeBytes)
584
+ val signatureBytes = signature.sign()
585
+
586
+ // Return base64-encoded signature
587
+ Base64.encodeToString(signatureBytes, Base64.NO_WRAP)
588
+ } catch (e: Exception) {
589
+ Logger.error("Failed to sign challenge", e)
590
+ null
591
+ }
592
+ }
593
+
594
+ // ==================== Backend Communication ====================
595
+
596
+ /**
597
+ * Request challenge from backend.
598
+ */
599
+ private fun requestChallenge(
600
+ credentialId: String,
601
+ callback: (success: Boolean, challenge: String?, nonce: String?, error: String?) -> Unit
602
+ ) {
603
+ val requestBody = JSONObject().apply {
604
+ put("deviceCredentialId", credentialId)
605
+ }
606
+
607
+ Logger.debug("Requesting challenge from backend...")
608
+
609
+ val request = Request.Builder()
610
+ .url("$apiUrl/api/devices/challenge")
611
+ .post(requestBody.toString().toRequestBody("application/json".toMediaType()))
612
+ .build()
613
+
614
+ client.newCall(request).enqueue(object : Callback {
615
+ override fun onFailure(call: Call, e: IOException) {
616
+ Logger.error("Challenge request failed", e)
617
+ callback(false, null, null, e.message)
618
+ }
619
+
620
+ override fun onResponse(call: Call, response: Response) {
621
+ response.use {
622
+ if (it.isSuccessful) {
623
+ try {
624
+ val json = JSONObject(it.body?.string() ?: "{}")
625
+ val challenge = json.optString("challenge")
626
+ val nonce = json.optString("nonce")
627
+
628
+ Logger.debug("Received challenge from backend")
629
+ callback(true, challenge, nonce, null)
630
+ } catch (e: Exception) {
631
+ Logger.error("Failed to parse challenge response", e)
632
+ callback(false, null, null, "Failed to parse response")
633
+ }
634
+ } else {
635
+ val errorBody = it.body?.string() ?: ""
636
+ val errorCode = it.code
637
+ Logger.error("Challenge request failed: $errorCode - $errorBody")
638
+
639
+ when (errorCode) {
640
+ 403 -> {
641
+ // Access forbidden - security error
642
+ Logger.error("SECURITY: Challenge request forbidden")
643
+ val errorMessage = parseErrorMessage(errorBody) ?: "Access forbidden"
644
+ clearCredentials()
645
+ authFailureListener?.onAuthenticationFailure(403, errorMessage, "RJDeviceAuth")
646
+ callback(false, null, null, errorMessage)
647
+ }
648
+ 404 -> {
649
+ // Device credential rejected by backend (likely DB reset or invalid)
650
+ Logger.error("SECURITY: Device credential not found")
651
+ val errorMessage = parseErrorMessage(errorBody) ?: "Credential not found"
652
+ clearCredentials()
653
+ authFailureListener?.onAuthenticationFailure(404, errorMessage, "RJDeviceAuth")
654
+ callback(false, null, null, errorMessage)
655
+ }
656
+ else -> {
657
+ callback(false, null, null, "Challenge request failed: $errorCode")
658
+ }
659
+ }
660
+ }
661
+ }
662
+ }
663
+ })
664
+ }
665
+
666
+ /**
667
+ * Start session with signed challenge to get upload token.
668
+ */
669
+ private fun startSession(
670
+ credentialId: String,
671
+ challenge: String,
672
+ nonce: String,
673
+ signature: String,
674
+ callback: (success: Boolean, token: String?, expiresIn: Int, error: String?) -> Unit
675
+ ) {
676
+ val requestBody = JSONObject().apply {
677
+ put("deviceCredentialId", credentialId)
678
+ put("challenge", challenge)
679
+ put("signature", signature)
680
+ put("nonce", nonce)
681
+ }
682
+
683
+ Logger.debug("Starting session with signed challenge...")
684
+
685
+ val request = Request.Builder()
686
+ .url("$apiUrl/api/devices/start-session")
687
+ .post(requestBody.toString().toRequestBody("application/json".toMediaType()))
688
+ .build()
689
+
690
+ client.newCall(request).enqueue(object : Callback {
691
+ override fun onFailure(call: Call, e: IOException) {
692
+ Logger.error("Start-session request failed", e)
693
+ callback(false, null, 0, e.message)
694
+ }
695
+
696
+ override fun onResponse(call: Call, response: Response) {
697
+ response.use {
698
+ if (it.isSuccessful) {
699
+ try {
700
+ val json = JSONObject(it.body?.string() ?: "{}")
701
+ val token = json.optString("uploadToken")
702
+ val expiresIn = json.optInt("expiresIn", 3600)
703
+
704
+ // Cache token
705
+ prefs.edit()
706
+ .putString(KEY_UPLOAD_TOKEN, token)
707
+ .putLong(KEY_TOKEN_EXPIRY, System.currentTimeMillis() + (expiresIn * 1000L))
708
+ .apply()
709
+
710
+ Logger.debug("Got upload token (expires in $expiresIn seconds)")
711
+ callback(true, token, expiresIn, null)
712
+ } catch (e: Exception) {
713
+ Logger.error("Failed to parse start-session response", e)
714
+ callback(false, null, 0, "Failed to parse response")
715
+ }
716
+ } else {
717
+ val errorBody = it.body?.string() ?: ""
718
+ val errorCode = it.code
719
+ Logger.error("Start-session failed: $errorCode - $errorBody")
720
+
721
+ when (errorCode) {
722
+ 403 -> {
723
+ // Access forbidden - security error
724
+ Logger.error("SECURITY: Start-session forbidden")
725
+ val errorMessage = parseErrorMessage(errorBody) ?: "Access forbidden"
726
+ clearCredentials()
727
+ authFailureListener?.onAuthenticationFailure(403, errorMessage, "RJDeviceAuth")
728
+ callback(false, null, 0, errorMessage)
729
+ }
730
+ 404 -> {
731
+ // Credential or project not found
732
+ Logger.error("SECURITY: Start-session resource not found")
733
+ val errorMessage = parseErrorMessage(errorBody) ?: "Resource not found"
734
+ clearCredentials()
735
+ authFailureListener?.onAuthenticationFailure(404, errorMessage, "RJDeviceAuth")
736
+ callback(false, null, 0, errorMessage)
737
+ }
738
+ else -> {
739
+ callback(false, null, 0, "Start-session failed: $errorCode")
740
+ }
741
+ }
742
+ }
743
+ }
744
+ }
745
+ })
746
+ }
747
+ }