react-native-inapp-inspector 1.1.34 → 2.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 (122) hide show
  1. package/README.md +123 -101
  2. package/android/build.gradle +13 -1
  3. package/android/src/main/java/com/inappinspector/NetworkInspectorModule.kt +904 -0
  4. package/android/src/main/java/com/inappinspector/NetworkInspectorPackage.kt +17 -0
  5. package/dist/commonjs/components/ConsoleLogCard.js +57 -56
  6. package/dist/commonjs/components/CopyButton.js +10 -1
  7. package/dist/commonjs/components/ErrorBoundary.js +3 -0
  8. package/dist/commonjs/components/Inspector/AnalyticsTab.js +7 -3
  9. package/dist/commonjs/components/Inspector/BundleTab.js +20 -20
  10. package/dist/commonjs/components/Inspector/ConsoleTab.js +1 -1
  11. package/dist/commonjs/components/Inspector/CrashDetail.js +16 -12
  12. package/dist/commonjs/components/Inspector/CrashTab.js +97 -90
  13. package/dist/commonjs/components/Inspector/InspectorHeader.js +28 -24
  14. package/dist/commonjs/components/Inspector/MainScreen.js +20 -103
  15. package/dist/commonjs/components/Inspector/NetworkTab.js +143 -10
  16. package/dist/commonjs/components/Inspector/PerformanceTab.js +96 -3
  17. package/dist/commonjs/components/Inspector/ReduxDetail.js +24 -19
  18. package/dist/commonjs/components/Inspector/ReduxTab.js +30 -18
  19. package/dist/commonjs/components/Inspector/SettingsPanel.js +1578 -754
  20. package/dist/commonjs/components/Inspector/TabBar.js +6 -3
  21. package/dist/commonjs/components/LogCard.js +27 -9
  22. package/dist/commonjs/components/MetaAccordion.js +42 -0
  23. package/dist/commonjs/components/NetworkIcons.d.ts +10 -0
  24. package/dist/commonjs/components/NetworkIcons.js +53 -1
  25. package/dist/commonjs/components/Toast.js +3 -3
  26. package/dist/commonjs/components/TouchableScale.d.ts +5 -2
  27. package/dist/commonjs/components/TouchableScale.js +30 -32
  28. package/dist/commonjs/constants/index.d.ts +1 -2
  29. package/dist/commonjs/constants/index.js +4 -0
  30. package/dist/commonjs/constants/version.d.ts +1 -1
  31. package/dist/commonjs/constants/version.js +1 -1
  32. package/dist/commonjs/customHooks/analyticsLogger.d.ts +6 -0
  33. package/dist/commonjs/customHooks/analyticsLogger.js +16 -5
  34. package/dist/commonjs/customHooks/bundleAnalyzer.d.ts +2 -0
  35. package/dist/commonjs/customHooks/bundleAnalyzer.js +26 -17
  36. package/dist/commonjs/customHooks/consoleLogger.d.ts +2 -0
  37. package/dist/commonjs/customHooks/consoleLogger.js +10 -1
  38. package/dist/commonjs/customHooks/crashHandler.d.ts +2 -0
  39. package/dist/commonjs/customHooks/crashHandler.js +17 -1
  40. package/dist/commonjs/customHooks/networkLogger.d.ts +2 -0
  41. package/dist/commonjs/customHooks/networkLogger.js +12 -1
  42. package/dist/commonjs/customHooks/performanceTracker.d.ts +3 -1
  43. package/dist/commonjs/customHooks/performanceTracker.js +18 -4
  44. package/dist/commonjs/customHooks/reduxLogger.d.ts +2 -0
  45. package/dist/commonjs/customHooks/reduxLogger.js +10 -1
  46. package/dist/commonjs/helpers/index.js +10 -1
  47. package/dist/commonjs/helpers/settingsStore.d.ts +9 -0
  48. package/dist/commonjs/helpers/settingsStore.js +59 -3
  49. package/dist/commonjs/i18n/locales/en.json +96 -28
  50. package/dist/commonjs/index.d.ts +1 -0
  51. package/dist/commonjs/index.js +307 -73
  52. package/dist/commonjs/native/NativeInspector.d.ts +125 -0
  53. package/dist/commonjs/native/NativeInspector.js +313 -0
  54. package/dist/commonjs/native/NativeNetworkInspector.d.ts +22 -0
  55. package/dist/commonjs/native/NativeNetworkInspector.js +4 -0
  56. package/dist/commonjs/styles/AppColors.d.ts +28 -0
  57. package/dist/commonjs/styles/AppColors.js +30 -0
  58. package/dist/commonjs/styles/index.js +6 -6
  59. package/dist/commonjs/types/enums.d.ts +1 -1
  60. package/dist/commonjs/types/interfaces.d.ts +9 -0
  61. package/dist/esm/components/ConsoleLogCard.js +58 -57
  62. package/dist/esm/components/CopyButton.js +10 -1
  63. package/dist/esm/components/ErrorBoundary.js +3 -0
  64. package/dist/esm/components/Inspector/AnalyticsTab.js +7 -3
  65. package/dist/esm/components/Inspector/BundleTab.js +20 -20
  66. package/dist/esm/components/Inspector/ConsoleTab.js +1 -1
  67. package/dist/esm/components/Inspector/CrashDetail.js +16 -12
  68. package/dist/esm/components/Inspector/CrashTab.js +97 -90
  69. package/dist/esm/components/Inspector/InspectorHeader.js +29 -25
  70. package/dist/esm/components/Inspector/MainScreen.js +21 -71
  71. package/dist/esm/components/Inspector/NetworkTab.js +145 -12
  72. package/dist/esm/components/Inspector/PerformanceTab.js +96 -3
  73. package/dist/esm/components/Inspector/ReduxDetail.js +24 -19
  74. package/dist/esm/components/Inspector/ReduxTab.js +30 -18
  75. package/dist/esm/components/Inspector/SettingsPanel.js +1582 -758
  76. package/dist/esm/components/Inspector/TabBar.js +7 -4
  77. package/dist/esm/components/LogCard.js +27 -9
  78. package/dist/esm/components/MetaAccordion.js +42 -0
  79. package/dist/esm/components/NetworkIcons.d.ts +10 -0
  80. package/dist/esm/components/NetworkIcons.js +42 -0
  81. package/dist/esm/components/Toast.js +3 -3
  82. package/dist/esm/components/TouchableScale.d.ts +5 -2
  83. package/dist/esm/components/TouchableScale.js +31 -33
  84. package/dist/esm/constants/index.d.ts +1 -2
  85. package/dist/esm/constants/index.js +4 -0
  86. package/dist/esm/constants/version.d.ts +1 -1
  87. package/dist/esm/constants/version.js +1 -1
  88. package/dist/esm/customHooks/analyticsLogger.d.ts +6 -0
  89. package/dist/esm/customHooks/analyticsLogger.js +12 -4
  90. package/dist/esm/customHooks/bundleAnalyzer.d.ts +2 -0
  91. package/dist/esm/customHooks/bundleAnalyzer.js +23 -16
  92. package/dist/esm/customHooks/consoleLogger.d.ts +2 -0
  93. package/dist/esm/customHooks/consoleLogger.js +7 -0
  94. package/dist/esm/customHooks/crashHandler.d.ts +2 -0
  95. package/dist/esm/customHooks/crashHandler.js +14 -0
  96. package/dist/esm/customHooks/networkLogger.d.ts +2 -0
  97. package/dist/esm/customHooks/networkLogger.js +9 -0
  98. package/dist/esm/customHooks/performanceTracker.d.ts +3 -1
  99. package/dist/esm/customHooks/performanceTracker.js +15 -3
  100. package/dist/esm/customHooks/reduxLogger.d.ts +2 -0
  101. package/dist/esm/customHooks/reduxLogger.js +7 -0
  102. package/dist/esm/helpers/index.js +10 -1
  103. package/dist/esm/helpers/settingsStore.d.ts +9 -0
  104. package/dist/esm/helpers/settingsStore.js +58 -3
  105. package/dist/esm/i18n/locales/en.json +96 -28
  106. package/dist/esm/index.d.ts +1 -0
  107. package/dist/esm/index.js +279 -59
  108. package/dist/esm/native/NativeInspector.d.ts +125 -0
  109. package/dist/esm/native/NativeInspector.js +289 -0
  110. package/dist/esm/native/NativeNetworkInspector.d.ts +22 -0
  111. package/dist/esm/native/NativeNetworkInspector.js +2 -0
  112. package/dist/esm/styles/AppColors.d.ts +28 -0
  113. package/dist/esm/styles/AppColors.js +30 -0
  114. package/dist/esm/styles/index.js +6 -6
  115. package/dist/esm/types/enums.d.ts +1 -1
  116. package/dist/esm/types/interfaces.d.ts +9 -0
  117. package/ios/NetworkInspectorModule.h +6 -0
  118. package/ios/NetworkInspectorModule.m +720 -5
  119. package/package.json +15 -3
  120. package/react-native-inapp-inspector.podspec +7 -2
  121. package/android/src/main/java/com/inappinspector/NetworkInspectorModule.java +0 -97
  122. package/android/src/main/java/com/inappinspector/NetworkInspectorPackage.java +0 -28
@@ -0,0 +1,904 @@
1
+ package com.inappinspector
2
+
3
+ import android.app.ActivityManager
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.IntentFilter
7
+ import android.os.BatteryManager
8
+ import android.os.Build
9
+ import android.os.Debug
10
+ import android.os.Environment
11
+ import android.os.StatFs
12
+ import android.util.Log
13
+ import com.facebook.react.bridge.Arguments
14
+ import com.facebook.react.bridge.Promise
15
+ import com.facebook.react.bridge.ReactApplicationContext
16
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
17
+ import com.facebook.react.bridge.ReactMethod
18
+ import com.facebook.react.bridge.WritableMap
19
+ import com.facebook.react.modules.core.DeviceEventManagerModule
20
+ import java.io.PrintWriter
21
+ import java.io.StringWriter
22
+ import java.util.concurrent.Executors
23
+
24
+ class NetworkInspectorModule(private val reactContext: ReactApplicationContext) :
25
+ ReactContextBaseJavaModule(reactContext) {
26
+
27
+ companion object {
28
+ const val MODULE_NAME = "NetworkInspectorModule"
29
+ private const val TAG = "NetworkInspector"
30
+ }
31
+
32
+ private val networkExecutor = Executors.newSingleThreadExecutor { Thread(it, "InAppInspector-NetworkWorker") }
33
+ private val consoleExecutor = Executors.newSingleThreadExecutor { Thread(it, "InAppInspector-ConsoleWorker") }
34
+ private val analyticsExecutor = Executors.newSingleThreadExecutor { Thread(it, "InAppInspector-AnalyticsWorker") }
35
+ private val reduxExecutor = Executors.newSingleThreadExecutor { Thread(it, "InAppInspector-ReduxWorker") }
36
+ private val crashExecutor = Executors.newSingleThreadExecutor { Thread(it, "InAppInspector-CrashWorker") }
37
+ private val metricsExecutor = Executors.newSingleThreadExecutor { Thread(it, "InAppInspector-MetricsWorker") }
38
+
39
+ private var defaultHandler: Thread.UncaughtExceptionHandler? = null
40
+ private var isProtectionEnabled = false
41
+
42
+ override fun getName(): String {
43
+ return MODULE_NAME
44
+ }
45
+
46
+ private fun setupNativeCrashProtection() {
47
+ if (isProtectionEnabled) return
48
+
49
+ defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
50
+
51
+ // 1. Intercept all uncaught exceptions on background/worker threads (prevents OS force-close)
52
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
53
+ try {
54
+ handleNativeCrash(thread, throwable)
55
+ } catch (e: Exception) {
56
+ Log.e(TAG, "Error in native crash handler", e)
57
+ }
58
+ // Suppress defaultHandler?.uncaughtException() to prevent OS killing the process
59
+ }
60
+
61
+ // 2. Main Looper Crash Shield: Protects UI thread from dying on uncaught exceptions
62
+ try {
63
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
64
+ while (true) {
65
+ try {
66
+ android.os.Looper.loop()
67
+ } catch (e: Throwable) {
68
+ try {
69
+ handleNativeCrash(Thread.currentThread(), e)
70
+ } catch (ex: Exception) {
71
+ Log.e(TAG, "Error in Main Looper crash protection", ex)
72
+ }
73
+ }
74
+ }
75
+ }
76
+ } catch (e: Exception) {
77
+ Log.w(TAG, "Failed to start Looper crash protection", e)
78
+ }
79
+
80
+ isProtectionEnabled = true
81
+ Log.i(TAG, "Native Android Crash Shield enabled (Process kill prevention active)")
82
+ }
83
+
84
+ private fun handleNativeCrash(thread: Thread, throwable: Throwable) {
85
+ try {
86
+ val sw = StringWriter()
87
+ val pw = PrintWriter(sw)
88
+ throwable.printStackTrace(pw)
89
+ val stackTrace = sw.toString()
90
+
91
+ val params = Arguments.createMap().apply {
92
+ putString("platform", "android")
93
+ putString("error", throwable.message ?: "Unknown native exception")
94
+ putString("name", throwable.javaClass.name)
95
+ putString("stack", stackTrace)
96
+ putString("threadName", thread.name)
97
+ putDouble("timestamp", System.currentTimeMillis().toDouble())
98
+ }
99
+
100
+ if (reactContext.hasActiveReactInstance()) {
101
+ reactContext
102
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
103
+ .emit("onNativeCrash", params)
104
+ }
105
+ } catch (e: Exception) {
106
+ Log.e(TAG, "Failed to emit onNativeCrash to JS", e)
107
+ }
108
+ }
109
+
110
+ @ReactMethod
111
+ fun enableNativeCrashProtection(promise: Promise) {
112
+ try {
113
+ setupNativeCrashProtection()
114
+ promise.resolve(true)
115
+ } catch (e: Exception) {
116
+ promise.reject("CRASH_PROTECTION_ERROR", e.message, e)
117
+ }
118
+ }
119
+
120
+ @ReactMethod
121
+ fun getDeviceMetrics(promise: Promise) {
122
+ try {
123
+ val map = Arguments.createMap()
124
+
125
+ // 1. RAM / Memory Metrics
126
+ val actManager = reactContext.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
127
+ if (actManager != null) {
128
+ val memInfo = ActivityManager.MemoryInfo()
129
+ actManager.getMemoryInfo(memInfo)
130
+ map.putDouble("totalRAM", memInfo.totalMem.toDouble())
131
+ map.putDouble("freeRAM", memInfo.availMem.toDouble())
132
+ map.putDouble("usedRAM", (memInfo.totalMem - memInfo.availMem).toDouble())
133
+ map.putBoolean("isLowMemory", memInfo.lowMemory)
134
+ }
135
+
136
+ // 2. Native Heap Allocation
137
+ map.putDouble("nativeHeapAllocated", Debug.getNativeHeapAllocatedSize().toDouble())
138
+ map.putDouble("nativeHeapSize", Debug.getNativeHeapSize().toDouble())
139
+ map.putDouble("nativeHeapFree", Debug.getNativeHeapFreeSize().toDouble())
140
+
141
+ // 3. Storage Space
142
+ try {
143
+ val statFs = StatFs(Environment.getDataDirectory().path)
144
+ val freeStorage = statFs.availableBlocksLong * statFs.blockSizeLong
145
+ val totalStorage = statFs.blockCountLong * statFs.blockSizeLong
146
+ map.putDouble("freeStorage", freeStorage.toDouble())
147
+ map.putDouble("totalStorage", totalStorage.toDouble())
148
+ } catch (e: Exception) {
149
+ // Storage stat optional fallback
150
+ }
151
+
152
+ // 4. Battery Level & State
153
+ try {
154
+ val ifilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
155
+ val batteryStatus = reactContext.registerReceiver(null, ifilter)
156
+ if (batteryStatus != null) {
157
+ val level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
158
+ val scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
159
+ if (level >= 0 && scale > 0) {
160
+ map.putDouble("batteryPercent", (level * 100.0 / scale))
161
+ }
162
+ val status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
163
+ val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
164
+ status == BatteryManager.BATTERY_STATUS_FULL
165
+ map.putBoolean("isCharging", isCharging)
166
+ }
167
+ } catch (e: Exception) {
168
+ // Battery status optional fallback
169
+ }
170
+
171
+ // 5. Hardware & OS Identifiers
172
+ map.putString("deviceModel", Build.MODEL)
173
+ map.putString("deviceBrand", Build.MANUFACTURER)
174
+ map.putString("osVersion", Build.VERSION.RELEASE)
175
+ map.putInt("apiLevel", Build.VERSION.SDK_INT)
176
+ val cpuAbi = if (Build.SUPPORTED_ABIS.isNotEmpty()) Build.SUPPORTED_ABIS[0] else "unknown"
177
+ map.putString("cpuAbi", cpuAbi)
178
+
179
+ promise.resolve(map)
180
+ } catch (e: Exception) {
181
+ promise.reject("DEVICE_METRICS_ERROR", e.message, e)
182
+ }
183
+ }
184
+
185
+ private var floatingButton: InAppInspectorFloatingView? = null
186
+
187
+ private fun emitFloatingButtonPress() {
188
+ if (reactContext.hasActiveReactInstance()) {
189
+ reactContext
190
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
191
+ .emit("onFloatingButtonPress", null)
192
+ }
193
+ }
194
+
195
+ @ReactMethod
196
+ fun showFloatingButton(options: com.facebook.react.bridge.ReadableMap?, promise: Promise) {
197
+ val activity = currentActivity ?: run {
198
+ promise.resolve(false)
199
+ return
200
+ }
201
+
202
+ activity.runOnUiThread {
203
+ try {
204
+ val decorView = activity.window.decorView as? android.view.ViewGroup
205
+ if (decorView == null) {
206
+ promise.resolve(false)
207
+ return@runOnUiThread
208
+ }
209
+
210
+ val density = activity.resources.displayMetrics.density
211
+ val sizeDp = if (options != null && options.hasKey("size")) options.getDouble("size").toFloat() else 64f
212
+ val sizePx = (sizeDp * density).toInt()
213
+
214
+ if (floatingButton == null) {
215
+ floatingButton = InAppInspectorFloatingView(activity) {
216
+ emitFloatingButtonPress()
217
+ }
218
+ val params = android.widget.FrameLayout.LayoutParams(sizePx, sizePx)
219
+ val initialX = if (options != null && options.hasKey("x")) {
220
+ (options.getDouble("x") * density).toFloat()
221
+ } else {
222
+ (decorView.width - sizePx - (20 * density)).coerceAtLeast(20 * density)
223
+ }
224
+ val initialY = if (options != null && options.hasKey("y")) {
225
+ (options.getDouble("y") * density).toFloat()
226
+ } else {
227
+ (decorView.height - sizePx - (110 * density)).coerceAtLeast(110 * density)
228
+ }
229
+ floatingButton?.x = initialX
230
+ floatingButton?.y = initialY
231
+ decorView.addView(floatingButton, params)
232
+ } else {
233
+ if (floatingButton?.parent == null) {
234
+ val params = android.widget.FrameLayout.LayoutParams(sizePx, sizePx)
235
+ decorView.addView(floatingButton, params)
236
+ } else {
237
+ decorView.bringChildToFront(floatingButton)
238
+ }
239
+ }
240
+
241
+ floatingButton?.visibility = android.view.View.VISIBLE
242
+ floatingButton?.alpha = 0f
243
+ floatingButton?.animate()?.alpha(1f)?.setDuration(200)?.start()
244
+
245
+ promise.resolve(true)
246
+ } catch (e: Exception) {
247
+ Log.e(TAG, "Error showing floating button", e)
248
+ promise.reject("FLOATING_BTN_ERROR", e.message, e)
249
+ }
250
+ }
251
+ }
252
+
253
+ @ReactMethod
254
+ fun hideFloatingButton(promise: Promise) {
255
+ val activity = currentActivity ?: run {
256
+ promise.resolve(false)
257
+ return
258
+ }
259
+
260
+ activity.runOnUiThread {
261
+ try {
262
+ floatingButton?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
263
+ floatingButton?.visibility = android.view.View.GONE
264
+ }?.start()
265
+ promise.resolve(true)
266
+ } catch (e: Exception) {
267
+ promise.reject("FLOATING_BTN_ERROR", e.message, e)
268
+ }
269
+ }
270
+ }
271
+
272
+ @ReactMethod
273
+ fun setFloatingButtonBadge(hasBadge: Boolean, promise: Promise) {
274
+ val activity = currentActivity ?: run {
275
+ promise.resolve(false)
276
+ return
277
+ }
278
+
279
+ activity.runOnUiThread {
280
+ floatingButton?.setBadgeVisible(hasBadge)
281
+ promise.resolve(true)
282
+ }
283
+ }
284
+
285
+ @ReactMethod
286
+ fun setFloatingButtonPosition(x: Double, y: Double, promise: Promise) {
287
+ val activity = currentActivity ?: run {
288
+ promise.resolve(false)
289
+ return
290
+ }
291
+
292
+ activity.runOnUiThread {
293
+ val density = activity.resources.displayMetrics.density
294
+ floatingButton?.x = (x * density).toFloat()
295
+ floatingButton?.y = (y * density).toFloat()
296
+ promise.resolve(true)
297
+ }
298
+ }
299
+
300
+ private var sensorManager: android.hardware.SensorManager? = null
301
+ private var lastShakeTime = 0L
302
+ private var isFpsMonitoring = false
303
+ private var lastFrameTimeNanos = 0L
304
+ private var frameCount = 0
305
+ private var currentCalculatedFps = 60.0
306
+
307
+ private val frameCallback = object : android.view.Choreographer.FrameCallback {
308
+ override fun doFrame(frameTimeNanos: Long) {
309
+ if (!isFpsMonitoring) return
310
+ if (lastFrameTimeNanos == 0L) {
311
+ lastFrameTimeNanos = frameTimeNanos
312
+ } else {
313
+ frameCount++
314
+ val elapsedNanos = frameTimeNanos - lastFrameTimeNanos
315
+ if (elapsedNanos >= 500_000_000L) {
316
+ currentCalculatedFps = (frameCount * 1_000_000_000.0) / elapsedNanos
317
+ frameCount = 0
318
+ lastFrameTimeNanos = frameTimeNanos
319
+ }
320
+ }
321
+ android.view.Choreographer.getInstance().postFrameCallback(this)
322
+ }
323
+ }
324
+
325
+ private val shakeListener = object : android.hardware.SensorEventListener {
326
+ override fun onSensorChanged(event: android.hardware.SensorEvent) {
327
+ val x = event.values[0]
328
+ val y = event.values[1]
329
+ val z = event.values[2]
330
+ val gX = x / android.hardware.SensorManager.GRAVITY_EARTH
331
+ val gY = y / android.hardware.SensorManager.GRAVITY_EARTH
332
+ val gZ = z / android.hardware.SensorManager.GRAVITY_EARTH
333
+ val gForce = Math.sqrt((gX * gX + gY * gY + gZ * gZ).toDouble()).toFloat()
334
+ if (gForce > 2.7f) {
335
+ val now = System.currentTimeMillis()
336
+ if (now - lastShakeTime > 1000) {
337
+ lastShakeTime = now
338
+ emitDeviceShake()
339
+ }
340
+ }
341
+ }
342
+ override fun onAccuracyChanged(sensor: android.hardware.Sensor?, accuracy: Int) {}
343
+ }
344
+
345
+ init {
346
+ try {
347
+ sensorManager = reactContext.getSystemService(Context.SENSOR_SERVICE) as? android.hardware.SensorManager
348
+ val accelerometer = sensorManager?.getDefaultSensor(android.hardware.Sensor.TYPE_ACCELEROMETER)
349
+ if (accelerometer != null) {
350
+ sensorManager?.registerListener(shakeListener, accelerometer, android.hardware.SensorManager.SENSOR_DELAY_UI)
351
+ }
352
+ } catch (e: Exception) {
353
+ Log.w(TAG, "Failed to register shake sensor listener", e)
354
+ }
355
+ }
356
+
357
+ private fun emitDeviceShake() {
358
+ if (reactContext.hasActiveReactInstance()) {
359
+ reactContext
360
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
361
+ .emit("onDeviceShake", null)
362
+ }
363
+ }
364
+
365
+ @ReactMethod
366
+ fun startFpsMonitoring(promise: Promise) {
367
+ val activity = currentActivity ?: run {
368
+ promise.resolve(false)
369
+ return
370
+ }
371
+
372
+ activity.runOnUiThread {
373
+ if (!isFpsMonitoring) {
374
+ isFpsMonitoring = true
375
+ lastFrameTimeNanos = 0L
376
+ frameCount = 0
377
+ currentCalculatedFps = 60.0
378
+ android.view.Choreographer.getInstance().postFrameCallback(frameCallback)
379
+ }
380
+ promise.resolve(true)
381
+ }
382
+ }
383
+
384
+ @ReactMethod
385
+ fun stopFpsMonitoring(promise: Promise) {
386
+ val activity = currentActivity ?: run {
387
+ promise.resolve(false)
388
+ return
389
+ }
390
+
391
+ activity.runOnUiThread {
392
+ isFpsMonitoring = false
393
+ android.view.Choreographer.getInstance().removeFrameCallback(frameCallback)
394
+ promise.resolve(true)
395
+ }
396
+ }
397
+
398
+ @ReactMethod
399
+ fun getFpsMetrics(promise: Promise) {
400
+ val map = Arguments.createMap()
401
+ map.putDouble("fps", if (currentCalculatedFps > 0) currentCalculatedFps else 60.0)
402
+ map.putDouble("targetFps", 60.0)
403
+ promise.resolve(map)
404
+ }
405
+
406
+ @ReactMethod
407
+ fun getNativeStorageItem(key: String, promise: Promise) {
408
+ try {
409
+ val prefs = reactContext.getSharedPreferences("inapp_inspector_prefs", Context.MODE_PRIVATE)
410
+ val value = prefs.getString("inapp_inspector_$key", null)
411
+ promise.resolve(value)
412
+ } catch (e: Exception) {
413
+ promise.reject("STORAGE_ERROR", e.message, e)
414
+ }
415
+ }
416
+
417
+ @ReactMethod
418
+ fun setNativeStorageItem(key: String, value: String?, promise: Promise) {
419
+ try {
420
+ val prefs = reactContext.getSharedPreferences("inapp_inspector_prefs", Context.MODE_PRIVATE)
421
+ if (value == null) {
422
+ prefs.edit().remove("inapp_inspector_$key").apply()
423
+ } else {
424
+ prefs.edit().putString("inapp_inspector_$key", value).apply()
425
+ }
426
+ promise.resolve(true)
427
+ } catch (e: Exception) {
428
+ promise.reject("STORAGE_ERROR", e.message, e)
429
+ }
430
+ }
431
+
432
+ @ReactMethod
433
+ fun triggerHaptic(style: String, promise: Promise) {
434
+ try {
435
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
436
+ val vibratorManager = reactContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? android.os.VibratorManager
437
+ vibratorManager?.defaultVibrator
438
+ } else {
439
+ @Suppress("DEPRECATION")
440
+ reactContext.getSystemService(Context.VIBRATOR_SERVICE) as? android.os.Vibrator
441
+ }
442
+
443
+ if (vibrator != null && vibrator.hasVibrator()) {
444
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
445
+ val effect = when (style.lowercase()) {
446
+ "medium" -> android.os.VibrationEffect.createOneShot(20, android.os.VibrationEffect.DEFAULT_AMPLITUDE)
447
+ "heavy" -> android.os.VibrationEffect.createOneShot(35, android.os.VibrationEffect.DEFAULT_AMPLITUDE)
448
+ "success" -> {
449
+ val timings = longArrayOf(0, 15, 60, 20)
450
+ android.os.VibrationEffect.createWaveform(timings, -1)
451
+ }
452
+ "warning" -> {
453
+ val timings = longArrayOf(0, 25, 40, 25)
454
+ android.os.VibrationEffect.createWaveform(timings, -1)
455
+ }
456
+ "error" -> {
457
+ val timings = longArrayOf(0, 30, 40, 30, 40, 30)
458
+ android.os.VibrationEffect.createWaveform(timings, -1)
459
+ }
460
+ else -> android.os.VibrationEffect.createOneShot(10, 120)
461
+ }
462
+ vibrator.vibrate(effect)
463
+ } else {
464
+ @Suppress("DEPRECATION")
465
+ vibrator.vibrate(15)
466
+ }
467
+ }
468
+ promise.resolve(true)
469
+ } catch (e: Exception) {
470
+ promise.resolve(false)
471
+ }
472
+ }
473
+
474
+ @ReactMethod
475
+ fun getNativeSystemMetrics(promise: Promise) {
476
+ metricsExecutor.execute {
477
+ try {
478
+ val map = Arguments.createMap()
479
+
480
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
481
+ val powerManager = reactContext.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager
482
+ val status = powerManager?.currentThermalStatus ?: 0
483
+ val statusStr = when (status) {
484
+ android.os.PowerManager.THERMAL_STATUS_NONE -> "nominal"
485
+ android.os.PowerManager.THERMAL_STATUS_LIGHT -> "fair"
486
+ android.os.PowerManager.THERMAL_STATUS_MODERATE -> "fair"
487
+ android.os.PowerManager.THERMAL_STATUS_SEVERE -> "serious"
488
+ android.os.PowerManager.THERMAL_STATUS_CRITICAL -> "critical"
489
+ android.os.PowerManager.THERMAL_STATUS_EMERGENCY -> "critical"
490
+ android.os.PowerManager.THERMAL_STATUS_SHUTDOWN -> "critical"
491
+ else -> "nominal"
492
+ }
493
+ map.putString("thermalState", statusStr)
494
+ } else {
495
+ map.putString("thermalState", "nominal")
496
+ }
497
+
498
+ val memInfo = Debug.MemoryInfo()
499
+ Debug.getMemoryInfo(memInfo)
500
+ val residentRamMb = (memInfo.totalPss / 1024.0)
501
+ map.putDouble("residentRamMb", residentRamMb)
502
+
503
+ val actManager = reactContext.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
504
+ val actMemInfo = ActivityManager.MemoryInfo()
505
+ actManager?.getMemoryInfo(actMemInfo)
506
+ map.putDouble("totalPhysicalRamMb", (actMemInfo.totalMem / (1024.0 * 1024.0)))
507
+
508
+ map.putDouble("fps", if (currentCalculatedFps > 0) currentCalculatedFps else 60.0)
509
+ map.putInt("activeCpuCores", Runtime.getRuntime().availableProcessors())
510
+
511
+ promise.resolve(map)
512
+ } catch (e: Exception) {
513
+ promise.reject("METRICS_ERROR", e.message, e)
514
+ }
515
+ }
516
+ }
517
+
518
+ private val nativeNetworkLogs = java.util.Collections.synchronizedList(mutableListOf<String>())
519
+ private val nativeConsoleLogs = java.util.Collections.synchronizedList(mutableListOf<String>())
520
+ private val nativeAnalyticsEvents = java.util.Collections.synchronizedList(mutableListOf<String>())
521
+ private val nativeCrashRecords = java.util.Collections.synchronizedList(mutableListOf<String>())
522
+
523
+ @ReactMethod
524
+ fun pushNativeLogRecord(pageKey: String, jsonPayload: String, promise: Promise) {
525
+ if (jsonPayload.isBlank()) {
526
+ promise.resolve(false)
527
+ return
528
+ }
529
+ val (executor, store) = when (pageKey) {
530
+ "logs" -> consoleExecutor to nativeConsoleLogs
531
+ "analytics" -> analyticsExecutor to nativeAnalyticsEvents
532
+ "crash" -> crashExecutor to nativeCrashRecords
533
+ else -> networkExecutor to nativeNetworkLogs
534
+ }
535
+ executor.execute {
536
+ try {
537
+ var updated = false
538
+ val idStr = try {
539
+ val jsonObj = org.json.JSONObject(jsonPayload)
540
+ if (jsonObj.has("id")) jsonObj.get("id").toString() else null
541
+ } catch (e: Exception) {
542
+ null
543
+ }
544
+
545
+ synchronized(store) {
546
+ if (idStr != null) {
547
+ val idPattern = "\"id\":$idStr"
548
+ val idStrPattern = "\"id\":\"$idStr\""
549
+ for (i in 0 until store.size) {
550
+ val existing = store[i]
551
+ if (existing.contains(idPattern) || existing.contains(idStrPattern)) {
552
+ store[i] = jsonPayload
553
+ updated = true
554
+ break
555
+ }
556
+ }
557
+ }
558
+
559
+ if (!updated) {
560
+ store.add(0, jsonPayload)
561
+ if (store.size > 2000) {
562
+ store.removeAt(store.size - 1)
563
+ }
564
+ }
565
+ }
566
+ promise.resolve(true)
567
+ } catch (e: Exception) {
568
+ promise.resolve(false)
569
+ }
570
+ }
571
+ }
572
+
573
+ @ReactMethod
574
+ fun getNativeCachedPage(pageKey: String, offset: Int, limit: Int, query: String?, promise: Promise) {
575
+ val (executor, store) = when (pageKey) {
576
+ "logs" -> consoleExecutor to nativeConsoleLogs
577
+ "analytics" -> analyticsExecutor to nativeAnalyticsEvents
578
+ "crash" -> crashExecutor to nativeCrashRecords
579
+ else -> networkExecutor to nativeNetworkLogs
580
+ }
581
+ executor.execute {
582
+ try {
583
+ val cleanQuery = query?.trim()?.lowercase() ?: ""
584
+ val startIndex = Math.max(0, offset)
585
+ val actualLimit = if (limit > 0) limit else 50
586
+ val results = mutableListOf<String>()
587
+
588
+ synchronized(store) {
589
+ var itemsCollected = 0
590
+ for (i in startIndex until store.size) {
591
+ if (itemsCollected >= actualLimit) break
592
+ val itemJson = store[i]
593
+ if (cleanQuery.isNotEmpty()) {
594
+ if (itemJson.lowercase().contains(cleanQuery)) {
595
+ results.add(itemJson)
596
+ itemsCollected++
597
+ }
598
+ } else {
599
+ results.add(itemJson)
600
+ itemsCollected++
601
+ }
602
+ }
603
+
604
+ val jsonResult = org.json.JSONObject().apply {
605
+ put("pageKey", pageKey)
606
+ put("total", store.size)
607
+ put("offset", offset)
608
+ val jsonArr = org.json.JSONArray()
609
+ results.forEach { jsonArr.put(it) }
610
+ put("items", jsonArr)
611
+ }.toString()
612
+
613
+ promise.resolve(jsonResult)
614
+ }
615
+ } catch (e: Exception) {
616
+ promise.resolve("{\"items\":[],\"total\":0}")
617
+ }
618
+ }
619
+ }
620
+
621
+ @ReactMethod
622
+ fun addListener(eventName: String) {
623
+ // Required for React Native NativeEventEmitter
624
+ }
625
+
626
+ @ReactMethod
627
+ fun removeListeners(count: Double) {
628
+ // Required for React Native NativeEventEmitter
629
+ }
630
+ }
631
+
632
+ /**
633
+ * 100% Native Main-Thread Draggable Floating Button
634
+ * All touch processing and layout coordinates run directly in Android's UI Event Loop,
635
+ * completely decoupled and immune from JavaScript thread stalls.
636
+ */
637
+ class InAppInspectorFloatingView(
638
+ context: Context,
639
+ private val onTap: () -> Unit
640
+ ) : android.widget.FrameLayout(context) {
641
+
642
+ private var initialX = 0f
643
+ private var initialY = 0f
644
+ private var touchDownX = 0f
645
+ private var touchDownY = 0f
646
+ private var touchDownTime = 0L
647
+ private var isDragging = false
648
+ private val badgeView: android.view.View
649
+
650
+ init {
651
+ val density = context.resources.displayMetrics.density
652
+
653
+ // 1. Circular Background with Glow
654
+ val bgDrawable = android.graphics.drawable.GradientDrawable().apply {
655
+ shape = android.graphics.drawable.GradientDrawable.OVAL
656
+ setColor(android.graphics.Color.parseColor("#0F172A"))
657
+ setStroke((2.2f * density).toInt(), android.graphics.Color.parseColor("#38BDF8"))
658
+ }
659
+ background = bgDrawable
660
+ elevation = 16f * density
661
+
662
+ // 2. Center Icon (Inspector Owl Vector)
663
+ val iconView = object : android.view.View(context) {
664
+ private val bodyPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
665
+ color = android.graphics.Color.parseColor("#202E55")
666
+ style = android.graphics.Paint.Style.FILL
667
+ }
668
+ private val beamPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
669
+ color = android.graphics.Color.parseColor("#38BDF8")
670
+ style = android.graphics.Paint.Style.STROKE
671
+ strokeWidth = 4.2f
672
+ strokeJoin = android.graphics.Paint.Join.ROUND
673
+ strokeCap = android.graphics.Paint.Cap.ROUND
674
+ }
675
+ private val wingPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
676
+ color = android.graphics.Color.parseColor("#1A2545")
677
+ style = android.graphics.Paint.Style.FILL
678
+ }
679
+ private val bellyPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
680
+ color = android.graphics.Color.parseColor("#33477A")
681
+ style = android.graphics.Paint.Style.FILL
682
+ }
683
+ private val screenBgPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
684
+ color = android.graphics.Color.parseColor("#0C1426")
685
+ style = android.graphics.Paint.Style.FILL
686
+ }
687
+ private val codeLinePaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
688
+ color = android.graphics.Color.parseColor("#8FD0EC")
689
+ style = android.graphics.Paint.Style.STROKE
690
+ strokeWidth = 3f
691
+ strokeCap = android.graphics.Paint.Cap.ROUND
692
+ strokeJoin = android.graphics.Paint.Join.ROUND
693
+ }
694
+ private val eyeIrisPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
695
+ color = android.graphics.Color.parseColor("#FBBF24")
696
+ style = android.graphics.Paint.Style.FILL
697
+ }
698
+ private val eyePupilPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
699
+ color = android.graphics.Color.parseColor("#0A0E18")
700
+ style = android.graphics.Paint.Style.FILL
701
+ }
702
+ private val eyeGlintPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
703
+ color = android.graphics.Color.WHITE
704
+ style = android.graphics.Paint.Style.FILL
705
+ }
706
+ private val beakPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
707
+ color = android.graphics.Color.parseColor("#FB923C")
708
+ style = android.graphics.Paint.Style.FILL
709
+ }
710
+ private val lensHandlePaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
711
+ color = android.graphics.Color.parseColor("#38BDF8")
712
+ style = android.graphics.Paint.Style.STROKE
713
+ strokeWidth = 8.5f
714
+ strokeCap = android.graphics.Paint.Cap.ROUND
715
+ }
716
+ private val lensRingPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
717
+ color = android.graphics.Color.parseColor("#38BDF8")
718
+ style = android.graphics.Paint.Style.STROKE
719
+ strokeWidth = 7f
720
+ }
721
+ private val blushPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
722
+ color = android.graphics.Color.parseColor("#80FB7185")
723
+ style = android.graphics.Paint.Style.FILL
724
+ }
725
+
726
+ private val path = android.graphics.Path()
727
+
728
+ override fun onDraw(canvas: android.graphics.Canvas) {
729
+ super.onDraw(canvas)
730
+ val w = width.toFloat()
731
+ val scale = (w * 0.94f) / 256f
732
+ val offset = (w - (w * 0.94f)) / 2f
733
+
734
+ canvas.save()
735
+ canvas.translate(offset, offset)
736
+ canvas.scale(scale, scale)
737
+
738
+ // 1. Owl Body with Ears
739
+ path.reset()
740
+ path.moveTo(62f, 150f)
741
+ path.cubicTo(58f, 104f, 70f, 70f, 90f, 58f)
742
+ path.lineTo(98f, 42f)
743
+ path.lineTo(116f, 62f)
744
+ path.quadTo(128f, 57f, 140f, 62f)
745
+ path.lineTo(158f, 42f)
746
+ path.lineTo(166f, 58f)
747
+ path.cubicTo(186f, 70f, 198f, 104f, 194f, 150f)
748
+ path.cubicTo(198f, 180f, 184f, 204f, 152f, 212f)
749
+ path.cubicTo(140f, 216f, 116f, 216f, 104f, 212f)
750
+ path.cubicTo(72f, 204f, 58f, 180f, 62f, 150f)
751
+ path.close()
752
+ canvas.drawPath(path, bodyPaint)
753
+ canvas.drawPath(path, beamPaint)
754
+
755
+ // 2. Wings
756
+ path.reset()
757
+ path.moveTo(74f, 124f)
758
+ path.cubicTo(58f, 154f, 60f, 190f, 86f, 204f)
759
+ path.cubicTo(79f, 176f, 77f, 148f, 88f, 126f)
760
+ path.close()
761
+ canvas.drawPath(path, wingPaint)
762
+
763
+ path.reset()
764
+ path.moveTo(182f, 124f)
765
+ path.cubicTo(198f, 154f, 196f, 190f, 170f, 204f)
766
+ path.cubicTo(177f, 176f, 179f, 148f, 168f, 126f)
767
+ path.close()
768
+ canvas.drawPath(path, wingPaint)
769
+
770
+ // 3. Belly Plate
771
+ path.reset()
772
+ path.moveTo(128f, 126f)
773
+ path.cubicTo(151f, 126f, 164f, 148f, 162f, 174f)
774
+ path.cubicTo(160f, 198f, 146f, 212f, 128f, 212f)
775
+ path.cubicTo(110f, 212f, 96f, 198f, 94f, 174f)
776
+ path.cubicTo(92f, 148f, 105f, 126f, 128f, 126f)
777
+ path.close()
778
+ canvas.drawPath(path, bellyPaint)
779
+
780
+ // 4. Chest Screen `< / >`
781
+ val rectF = android.graphics.RectF(107f, 161f, 149f, 193f)
782
+ canvas.drawRoundRect(rectF, 8f, 8f, screenBgPaint)
783
+ val screenStroke = android.graphics.Paint(beamPaint).apply { strokeWidth = 2f }
784
+ canvas.drawRoundRect(rectF, 8f, 8f, screenStroke)
785
+
786
+ path.reset()
787
+ path.moveTo(122f, 170f); path.lineTo(115f, 177f); path.lineTo(122f, 184f)
788
+ path.moveTo(134f, 170f); path.lineTo(141f, 177f); path.lineTo(134f, 184f)
789
+ path.moveTo(130f, 168f); path.lineTo(126f, 186f)
790
+ canvas.drawPath(path, codeLinePaint)
791
+
792
+ // 5. Normal Right Eye
793
+ canvas.drawCircle(153f, 107f, 17f, eyeIrisPaint)
794
+ canvas.drawCircle(153f, 108f, 8.2f, eyePupilPaint)
795
+ canvas.drawCircle(156.2f, 104f, 3.1f, eyeGlintPaint)
796
+
797
+ // 6. Beak
798
+ path.reset()
799
+ path.moveTo(123.5f, 123f)
800
+ path.quadTo(128f, 121f, 132.5f, 123f)
801
+ path.quadTo(131f, 132f, 128f, 134.5f)
802
+ path.quadTo(125f, 132f, 123.5f, 123f)
803
+ path.close()
804
+ canvas.drawPath(path, beakPaint)
805
+
806
+ // 7. Magnifier Debug Left Eye
807
+ canvas.drawCircle(95f, 103f, 20f, eyeIrisPaint)
808
+ canvas.drawCircle(95f, 104f, 10f, eyePupilPaint)
809
+ canvas.drawCircle(98.6f, 100f, 3.4f, eyeGlintPaint)
810
+
811
+ // Magnifier Lens Ring & Handle
812
+ canvas.drawLine(75f, 123f, 54f, 147f, lensHandlePaint)
813
+ canvas.drawCircle(95f, 103f, 28f, lensRingPaint)
814
+
815
+ // Rosy Cheeks
816
+ canvas.drawOval(android.graphics.RectF(74f, 121f, 92f, 133f), blushPaint)
817
+ canvas.drawOval(android.graphics.RectF(158f, 116f, 176f, 128f), blushPaint)
818
+
819
+ canvas.restore()
820
+ }
821
+ }.apply {
822
+ isClickable = false
823
+ isFocusable = false
824
+ }
825
+ addView(iconView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
826
+
827
+ // 3. Status Badge Dot (Top-Right)
828
+ val badgeSize = (10f * density).toInt()
829
+ badgeView = android.view.View(context).apply {
830
+ val badgeDrawable = android.graphics.drawable.GradientDrawable().apply {
831
+ shape = android.graphics.drawable.GradientDrawable.OVAL
832
+ setColor(android.graphics.Color.parseColor("#22C55E"))
833
+ setStroke((1.5f * density).toInt(), android.graphics.Color.parseColor("#0F172A"))
834
+ }
835
+ background = badgeDrawable
836
+ visibility = GONE
837
+ }
838
+ val badgeParams = LayoutParams(badgeSize, badgeSize).apply {
839
+ gravity = android.view.Gravity.TOP or android.view.Gravity.END
840
+ topMargin = (4f * density).toInt()
841
+ rightMargin = (4f * density).toInt()
842
+ }
843
+ addView(badgeView, badgeParams)
844
+ }
845
+
846
+ fun setBadgeVisible(visible: Boolean) {
847
+ badgeView.visibility = if (visible) VISIBLE else GONE
848
+ }
849
+
850
+ override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
851
+ val parentView = parent as? android.view.ViewGroup ?: return super.onTouchEvent(event)
852
+ val density = context.resources.displayMetrics.density
853
+
854
+ when (event.actionMasked) {
855
+ android.view.MotionEvent.ACTION_DOWN -> {
856
+ parentView.bringChildToFront(this)
857
+ initialX = x
858
+ initialY = y
859
+ touchDownX = event.rawX
860
+ touchDownY = event.rawY
861
+ touchDownTime = System.currentTimeMillis()
862
+ isDragging = false
863
+ animate().scaleX(0.92f).scaleY(0.92f).setDuration(100).start()
864
+ return true
865
+ }
866
+ android.view.MotionEvent.ACTION_MOVE -> {
867
+ val dx = event.rawX - touchDownX
868
+ val dy = event.rawY - touchDownY
869
+
870
+ if (!isDragging && Math.hypot(dx.toDouble(), dy.toDouble()) > 8 * density) {
871
+ isDragging = true
872
+ }
873
+
874
+ if (isDragging) {
875
+ val minX = 10f * density
876
+ val maxX = (parentView.width - width - 10f * density).coerceAtLeast(minX)
877
+ val minY = 36f * density
878
+ val maxY = (parentView.height - height - 36f * density).coerceAtLeast(minY)
879
+
880
+ x = (initialX + dx).coerceIn(minX, maxX)
881
+ y = (initialY + dy).coerceIn(minY, maxY)
882
+ }
883
+ return true
884
+ }
885
+ android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
886
+ animate().scaleX(1.0f).scaleY(1.0f).setDuration(120).start()
887
+ val elapsed = System.currentTimeMillis() - touchDownTime
888
+ val totalDist = Math.hypot((event.rawX - touchDownX).toDouble(), (event.rawY - touchDownY).toDouble())
889
+
890
+ if (!isDragging && totalDist < 12 * density && elapsed < 350) {
891
+ onTap()
892
+ } else if (isDragging) {
893
+ // Smooth native snap to nearest horizontal side
894
+ val minX = 10f * density
895
+ val maxX = (parentView.width - width - 10f * density).coerceAtLeast(minX)
896
+ val targetX = if (x < parentView.width / 2f) minX else maxX
897
+ animate().x(targetX).setDuration(220).setInterpolator(android.view.animation.DecelerateInterpolator()).start()
898
+ }
899
+ return true
900
+ }
901
+ }
902
+ return super.onTouchEvent(event)
903
+ }
904
+ }