@momo-kits/native-kits 0.162.1-debug → 0.162.1-gif.1-debug

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 (26) hide show
  1. package/compose/build.gradle.kts +5 -1
  2. package/compose/build.gradle.kts.backup +63 -59
  3. package/compose/src/androidMain/kotlin/vn/momo/kits/fpsmonitor/FPSGraphView.kt +250 -0
  4. package/compose/src/androidMain/kotlin/vn/momo/kits/fpsmonitor/FPSMonitor.kt +249 -0
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/fpsmonitor/FPSOverlayView.kt +99 -0
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +33 -17
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +4 -0
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +1 -1
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +28 -16
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +46 -10
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +4 -2
  12. package/gradle/libs.versions.toml +5 -0
  13. package/gradle.properties +1 -1
  14. package/ios/FPSMonitor/FPSMonitor.swift +562 -0
  15. package/package.json +1 -1
  16. package/publish.sh +2 -2
  17. package/example/ios/Example.xcodeproj/xcuserdata/huynhdung.xcuserdatad/xcschemes/xcschememanagement.plist +0 -14
  18. package/example/ios/Example.xcworkspace/xcuserdata/huynhdung.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
  19. package/example/ios/Example.xcworkspace/xcuserdata/huynhdung.xcuserdatad/xcschemes/xcschememanagement.plist +0 -5
  20. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/huynhdung.xcuserdatad/xcschemes/MoMoUIKits.xcscheme +0 -58
  21. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/huynhdung.xcuserdatad/xcschemes/Pods-Example.xcscheme +0 -58
  22. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/huynhdung.xcuserdatad/xcschemes/SDWebImage.xcscheme +0 -58
  23. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/huynhdung.xcuserdatad/xcschemes/SDWebImageSwiftUI.xcscheme +0 -58
  24. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/huynhdung.xcuserdatad/xcschemes/SkeletonUI.xcscheme +0 -58
  25. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/huynhdung.xcuserdatad/xcschemes/xcschememanagement.plist +0 -46
  26. package/local.properties +0 -8
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.161.0-debug"
43
+ version = "0.162.1-gif.1-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -74,6 +74,10 @@ kotlin {
74
74
  implementation(libs.kotlinx.datetime)
75
75
  api(libs.native.max.api)
76
76
  implementation(libs.compottie)
77
+ implementation(libs.sketch.compose)
78
+ implementation(libs.sketch.http)
79
+ implementation(libs.sketch.animated.core)
80
+ implementation(libs.sketch.animated.gif)
77
81
  }
78
82
  androidMain.dependencies {
79
83
  implementation(libs.ktor.client.okhttp)
@@ -1,5 +1,5 @@
1
- // import com.vanniktech.maven.publish.JavadocJar
2
- // import com.vanniktech.maven.publish.KotlinMultiplatform
1
+ import com.vanniktech.maven.publish.JavadocJar
2
+ import com.vanniktech.maven.publish.KotlinMultiplatform
3
3
  import org.jetbrains.kotlin.gradle.dsl.JvmTarget
4
4
 
5
5
  plugins {
@@ -7,7 +7,7 @@ plugins {
7
7
  alias(libs.plugins.android.library)
8
8
  alias(libs.plugins.compose)
9
9
  alias(libs.plugins.kotlin.compose.compiler)
10
- // alias(libs.plugins.vanniktech.mavenPublish)
10
+ alias(libs.plugins.vanniktech.mavenPublish)
11
11
  id(libs.plugins.jetbrains.kotlin.cocoapods.get().pluginId)
12
12
  kotlin("plugin.serialization")
13
13
  }
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.161.0-debug"
43
+ version = gitlabVersion
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -74,6 +74,10 @@ kotlin {
74
74
  implementation(libs.kotlinx.datetime)
75
75
  api(libs.native.max.api)
76
76
  implementation(libs.compottie)
77
+ implementation(libs.sketch.compose)
78
+ implementation(libs.sketch.http)
79
+ implementation(libs.sketch.animated.core)
80
+ implementation(libs.sketch.animated.gif)
77
81
  }
78
82
  androidMain.dependencies {
79
83
  implementation(libs.ktor.client.okhttp)
@@ -86,14 +90,14 @@ kotlin {
86
90
  }
87
91
 
88
92
  android {
89
- namespace = "vn.momo.kits.kits"
90
- compileSdk = 35
93
+ namespace = "$gitlabGroup.$gitlabArtifactId"
94
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
91
95
  compileOptions {
92
96
  sourceCompatibility = JavaVersion.VERSION_17
93
97
  targetCompatibility = JavaVersion.VERSION_17
94
98
  }
95
99
  defaultConfig {
96
- minSdk = 24
100
+ minSdk = libs.versions.android.minSdk.get().toInt()
97
101
  }
98
102
  }
99
103
 
@@ -132,55 +136,55 @@ compose {
132
136
  }
133
137
  }
134
138
 
135
- // publishing {
136
- // repositories {
137
- // maven {
138
- // name = "${gitlabRepo}Packages"
139
- // credentials {
140
- // username = gitlabUser
141
- // password = gitlabKey
142
- // }
143
- // url = uri("$gitlabUrl")
144
- // }
145
- // }
146
- // }
147
-
148
- // mavenPublishing {
149
- // configure(
150
- // KotlinMultiplatform(
151
- // javadocJar = JavadocJar.Empty(),
152
- // sourcesJar = true,
153
- // androidVariantsToPublish = listOf("release")
154
- // )
155
- // )
156
- //
157
- // repositories {
158
- // maven {
159
- // name = "${gitlabRepo}Packages"
160
- // credentials { username = gitlabUser; password = gitlabKey }
161
- // url = uri("$gitlabUrl")
162
- // }
163
- // }
164
- //
165
- // coordinates(gitlabGroup.toString(), gitlabArtifactId, gitlabVersion.toString())
166
- //
167
- // pom {
168
- // name = "$gitlabName Klib"
169
- // description = "$gitlabName"
170
- // url = "https://gitlab.mservice.com.vn"
171
- // licenses {
172
- // license {
173
- // name = "The Apache License, Version 2.0"
174
- // url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
175
- // distribution = "https://www.apache.org/licenses/LICENSE-2.0.txt"
176
- // }
177
- // }
178
- // developers {
179
- // developer {
180
- // id = "kotlin-hands-on"
181
- // name = "Kotlin Developer Advocate"
182
- // url = "https://github.com/kotlin-hands-on/"
183
- // }
184
- // }
185
- // }
186
- // }
139
+ publishing {
140
+ repositories {
141
+ maven {
142
+ name = "${gitlabRepo}Packages"
143
+ credentials {
144
+ username = gitlabUser
145
+ password = gitlabKey
146
+ }
147
+ url = uri("$gitlabUrl")
148
+ }
149
+ }
150
+ }
151
+
152
+ mavenPublishing {
153
+ configure(
154
+ KotlinMultiplatform(
155
+ javadocJar = JavadocJar.Empty(),
156
+ sourcesJar = true,
157
+ androidVariantsToPublish = listOf("release")
158
+ )
159
+ )
160
+
161
+ repositories {
162
+ maven {
163
+ name = "${gitlabRepo}Packages"
164
+ credentials { username = gitlabUser; password = gitlabKey }
165
+ url = uri("$gitlabUrl")
166
+ }
167
+ }
168
+
169
+ coordinates(gitlabGroup.toString(), gitlabArtifactId, gitlabVersion.toString())
170
+
171
+ pom {
172
+ name = "$gitlabName Klib"
173
+ description = "$gitlabName"
174
+ url = "https://gitlab.mservice.com.vn"
175
+ licenses {
176
+ license {
177
+ name = "The Apache License, Version 2.0"
178
+ url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
179
+ distribution = "https://www.apache.org/licenses/LICENSE-2.0.txt"
180
+ }
181
+ }
182
+ developers {
183
+ developer {
184
+ id = "kotlin-hands-on"
185
+ name = "Kotlin Developer Advocate"
186
+ url = "https://github.com/kotlin-hands-on/"
187
+ }
188
+ }
189
+ }
190
+ }
@@ -0,0 +1,250 @@
1
+ package vn.momo.kits.fpsmonitor
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.Rect
8
+ import android.graphics.RectF
9
+ import android.view.View
10
+ import kotlin.math.max
11
+ import kotlin.math.min
12
+
13
+ class FPSGraphView(context: Context) : View(context) {
14
+ private val fpsValues = mutableListOf<Float>()
15
+ private val cpuTimeValues = mutableListOf<Float>()
16
+ private val gpuTimeValues = mutableListOf<Float>()
17
+ private val maxPoints = 100
18
+
19
+ private var currentMetrics: FPSMetrics? = null
20
+ private var collapsed = false
21
+ private val density = resources.displayMetrics.density
22
+ private val scaledDensity = resources.displayMetrics.density * resources.configuration.fontScale
23
+
24
+ private val fpsColor = Color.rgb(0, 255, 0)
25
+ private val cpuColor = Color.rgb(255, 165, 0)
26
+ private val gpuColor = Color.rgb(0, 191, 255)
27
+ private val memColor = Color.rgb(255, 105, 180)
28
+ private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
29
+ color = Color.argb(153, 0, 0, 0)
30
+ }
31
+ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
32
+ color = Color.WHITE
33
+ textSize = 12.sp
34
+ }
35
+ private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
36
+ strokeWidth = 1.25f.dp
37
+ style = Paint.Style.STROKE
38
+ }
39
+ private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
40
+ color = Color.argb(25, 255, 255, 255)
41
+ strokeWidth = 0.5f.dp
42
+ style = Paint.Style.STROKE
43
+ }
44
+
45
+ init {
46
+ setLayerType(LAYER_TYPE_SOFTWARE, null)
47
+ }
48
+
49
+ fun toggleCollapse() {
50
+ collapsed = !collapsed
51
+ requestLayout()
52
+ invalidate()
53
+ }
54
+
55
+ fun updateMetrics(metrics: FPSMetrics) {
56
+ currentMetrics = metrics
57
+ append(fpsValues, metrics.fps)
58
+ append(cpuTimeValues, metrics.estCPUTimePerFrameMs)
59
+ append(gpuTimeValues, metrics.estGPUTimePerFrameMs)
60
+ requestLayout()
61
+ invalidate()
62
+ }
63
+
64
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
65
+ if (collapsed) {
66
+ setMeasuredDimension(72.dp.toInt(), 44.dp.toInt())
67
+ return
68
+ }
69
+
70
+ textPaint.textSize = 12.sp
71
+ val metrics = currentMetrics
72
+ val deviceInfo = metrics?.deviceInfo
73
+ val lines = listOf(
74
+ "${deviceInfo?.deviceName ?: "Device"} [${deviceInfo?.resolution ?: "0x0"}]",
75
+ "${String.format("%.1f", deviceInfo?.density ?: 1f)}x ${deviceInfo?.refreshRate?.toInt() ?: 60}Hz",
76
+ "Pre: 99.99ms [99.99 99.99]",
77
+ "GPU: 99.99ms [99.99 99.99]",
78
+ "Mem: 999.9MB",
79
+ "FPS: 99.99 [99.99 99.99]",
80
+ )
81
+ val desiredWidth = max(220.dp, lines.maxOf { textPaint.measureText(it) } + 16.dp).toInt()
82
+ val desiredHeight = 156.dp.toInt()
83
+
84
+ setMeasuredDimension(
85
+ resolveSize(desiredWidth, widthMeasureSpec),
86
+ resolveSize(desiredHeight, heightMeasureSpec),
87
+ )
88
+ }
89
+
90
+ override fun onDraw(canvas: Canvas) {
91
+ super.onDraw(canvas)
92
+ canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), 8.dp, 8.dp, backgroundPaint)
93
+
94
+ if (collapsed) {
95
+ drawCollapsed(canvas)
96
+ } else {
97
+ drawExpanded(canvas)
98
+ }
99
+ }
100
+
101
+ private fun drawCollapsed(canvas: Canvas) {
102
+ val fpsText = String.format("%.0f", currentMetrics?.fps ?: 0f)
103
+ textPaint.color = Color.WHITE
104
+ textPaint.textSize = 18.sp
105
+ val bounds = Rect()
106
+ textPaint.getTextBounds(fpsText, 0, fpsText.length, bounds)
107
+ canvas.drawText(
108
+ fpsText,
109
+ (width - bounds.width()) / 2f,
110
+ (height + bounds.height()) / 2f,
111
+ textPaint,
112
+ )
113
+ }
114
+
115
+ private fun drawExpanded(canvas: Canvas) {
116
+ val metrics = currentMetrics
117
+ val margin = 8.dp
118
+ var y = margin + 12.sp
119
+
120
+ textPaint.textSize = 12.sp
121
+ textPaint.color = Color.WHITE
122
+
123
+ val deviceInfo = metrics?.deviceInfo
124
+ drawSplit(canvas, deviceInfo?.deviceName ?: "Device", "[${deviceInfo?.resolution ?: "0x0"}]", y, Color.WHITE)
125
+ y += 15.sp
126
+ drawSplit(
127
+ canvas,
128
+ "${String.format("%.1f", deviceInfo?.density ?: 1f)}x",
129
+ "${deviceInfo?.refreshRate?.toInt() ?: 60}Hz",
130
+ y,
131
+ Color.WHITE,
132
+ )
133
+ y += 18.sp
134
+
135
+ val cpuMinMax = minMax(cpuTimeValues, metrics?.estCPUTimePerFrameMs ?: 0f)
136
+ val gpuMinMax = minMax(gpuTimeValues, metrics?.estGPUTimePerFrameMs ?: 0f)
137
+ val fpsMinMax = minMax(fpsValues, metrics?.fps ?: 0f)
138
+
139
+ drawSplit(
140
+ canvas,
141
+ "Pre: ${String.format("%.2f", metrics?.estCPUTimePerFrameMs ?: 0f)}ms",
142
+ "[${String.format("%.2f", cpuMinMax.first)} ${String.format("%.2f", cpuMinMax.second)}]",
143
+ y,
144
+ cpuColor,
145
+ )
146
+ y += 15.sp
147
+
148
+ drawSplit(
149
+ canvas,
150
+ "GPU: ${String.format("%.2f", metrics?.estGPUTimePerFrameMs ?: 0f)}ms",
151
+ "[${String.format("%.2f", gpuMinMax.first)} ${String.format("%.2f", gpuMinMax.second)}]",
152
+ y,
153
+ gpuColor,
154
+ )
155
+ y += 15.sp
156
+
157
+ drawText(canvas, "Mem: ${String.format("%.1f", metrics?.appMemoryMB ?: 0f)}MB", margin, y, memColor)
158
+ y += 15.sp
159
+
160
+ drawSplit(
161
+ canvas,
162
+ "FPS: ${String.format("%.2f", metrics?.fps ?: 0f)}",
163
+ "[${String.format("%.2f", fpsMinMax.first)} ${String.format("%.2f", fpsMinMax.second)}]",
164
+ y,
165
+ fpsColor,
166
+ )
167
+ y += 17.sp
168
+
169
+ val graphHeight = height - y - margin
170
+ if (graphHeight > 24.dp) {
171
+ drawGraph(canvas, margin, y, width - margin * 2, graphHeight)
172
+ }
173
+ }
174
+
175
+ private fun drawSplit(canvas: Canvas, left: String, right: String, y: Float, color: Int) {
176
+ val margin = 8.dp
177
+ drawText(canvas, left, margin, y, color)
178
+ textPaint.color = color
179
+ canvas.drawText(right, width - margin - textPaint.measureText(right), y, textPaint)
180
+ }
181
+
182
+ private fun drawText(canvas: Canvas, text: String, x: Float, y: Float, color: Int) {
183
+ textPaint.color = color
184
+ canvas.drawText(text, x, y, textPaint)
185
+ }
186
+
187
+ private fun drawGraph(canvas: Canvas, x: Float, y: Float, graphWidth: Float, graphHeight: Float) {
188
+ canvas.drawRect(x, y, x + graphWidth, y + graphHeight, gridPaint)
189
+ for (index in 1..3) {
190
+ val gridY = y + index * (graphHeight / 4f)
191
+ canvas.drawLine(x + 2.dp, gridY, x + graphWidth - 2.dp, gridY, gridPaint)
192
+ }
193
+
194
+ var gridX = x + 16.dp
195
+ while (gridX < x + graphWidth) {
196
+ canvas.drawLine(gridX, y, gridX, y + graphHeight, gridPaint)
197
+ gridX += 16.dp
198
+ }
199
+
200
+ linePaint.color = fpsColor
201
+ drawDataLine(canvas, fpsValues, x, y, graphWidth, graphHeight, 60f)
202
+ linePaint.color = cpuColor
203
+ drawDataLine(canvas, cpuTimeValues, x, y, graphWidth, graphHeight, 50f)
204
+ }
205
+
206
+ private fun drawDataLine(
207
+ canvas: Canvas,
208
+ values: List<Float>,
209
+ x: Float,
210
+ y: Float,
211
+ graphWidth: Float,
212
+ graphHeight: Float,
213
+ maxScale: Float,
214
+ ) {
215
+ if (values.size < 2) return
216
+ val visibleValues = values.takeLast(max((graphWidth / 2.dp).toInt(), 2))
217
+ val step = (graphWidth - 4.dp) / (visibleValues.size - 1).coerceAtLeast(1)
218
+ var previousX = x + 2.dp
219
+ var previousY = y + graphHeight * (1f - visibleValues.first().coerceIn(0f, maxScale) / maxScale)
220
+
221
+ visibleValues.drop(1).forEachIndexed { index, value ->
222
+ val pointX = x + 2.dp + (index + 1) * step
223
+ val pointY = y + graphHeight * (1f - min(max(value / maxScale, 0f), 1f))
224
+ canvas.drawLine(previousX, previousY, pointX, pointY, linePaint)
225
+ previousX = pointX
226
+ previousY = pointY
227
+ }
228
+ }
229
+
230
+ private fun append(values: MutableList<Float>, value: Float) {
231
+ if (values.size >= maxPoints) values.removeAt(0)
232
+ values.add(value)
233
+ }
234
+
235
+ private fun minMax(values: List<Float>, fallback: Float): Pair<Float, Float> {
236
+ return Pair(values.minOrNull() ?: fallback, values.maxOrNull() ?: fallback)
237
+ }
238
+
239
+ private val Int.dp: Float
240
+ get() = toFloat().dp
241
+
242
+ private val Float.dp: Float
243
+ get() = this * density
244
+
245
+ private val Int.sp: Float
246
+ get() = toFloat().sp
247
+
248
+ private val Float.sp: Float
249
+ get() = this * scaledDensity
250
+ }
@@ -0,0 +1,249 @@
1
+ package vn.momo.kits.fpsmonitor
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.os.Build
6
+ import android.os.Handler
7
+ import android.os.Looper
8
+ import android.util.DisplayMetrics
9
+ import android.view.Choreographer
10
+ import android.view.ViewGroup
11
+ import java.io.BufferedReader
12
+ import java.io.FileReader
13
+
14
+ data class FPSMetrics(
15
+ val fps: Float,
16
+ val avgFrameTimeMs: Float,
17
+ val estCPUTimePerFrameMs: Float,
18
+ val estGPUTimePerFrameMs: Float,
19
+ val appMemoryMB: Float,
20
+ val cpuUsagePercent: Float,
21
+ val deviceInfo: FPSDeviceInfo,
22
+ )
23
+
24
+ data class FPSDeviceInfo(
25
+ val deviceName: String,
26
+ val resolution: String,
27
+ val density: Float,
28
+ val refreshRate: Float,
29
+ )
30
+
31
+ object FPSMonitor {
32
+ var updateIntervalMs: Long = 200L
33
+ var fpsThreshold: Float = 30f
34
+ var ramThresholdMB: Float = 500f
35
+ var onFPSLow: ((Float) -> Unit)? = null
36
+ var onRamOverload: ((Float) -> Unit)? = null
37
+
38
+ private val mainHandler = Handler(Looper.getMainLooper())
39
+ private var appContext: Context? = null
40
+ private var choreographer: Choreographer? = null
41
+ private var frameCallback: Choreographer.FrameCallback? = null
42
+ private var overlayView: FPSOverlayView? = null
43
+ private var overlayParent: ViewGroup? = null
44
+ private var initialized = false
45
+ private var running = false
46
+ private var visible = false
47
+ private var lastTimestampMs = 0L
48
+ private var frameCount = 0
49
+ private var lastFPSAlertMs = 0L
50
+ private var cachedDeviceInfo: FPSDeviceInfo? = null
51
+ private var latestMetrics: FPSMetrics? = null
52
+
53
+ @JvmStatic
54
+ fun init(context: Context) {
55
+ if (initialized) return
56
+ appContext = context.applicationContext
57
+ ramThresholdMB = if (isEmulator()) 1500f else 500f
58
+ cachedDeviceInfo = readDeviceInfo(context)
59
+ initialized = true
60
+ }
61
+
62
+ @JvmStatic
63
+ fun startOverlay(activity: Activity) {
64
+ init(activity)
65
+ start()
66
+ showUI(activity)
67
+ }
68
+
69
+ @JvmStatic
70
+ fun start() {
71
+ ensureInit()
72
+ if (running) return
73
+ running = true
74
+ runOnMain { startChoreographer() }
75
+ }
76
+
77
+ @JvmStatic
78
+ fun stop() {
79
+ runOnMain {
80
+ hideUI()
81
+ frameCallback?.let { choreographer?.removeFrameCallback(it) }
82
+ frameCallback = null
83
+ choreographer = null
84
+ lastTimestampMs = 0L
85
+ frameCount = 0
86
+ latestMetrics = null
87
+ lastFPSAlertMs = 0L
88
+ running = false
89
+ }
90
+ }
91
+
92
+ @JvmStatic
93
+ fun showUI(activity: Activity) {
94
+ init(activity)
95
+ runOnMain {
96
+ val parent = activity.findViewById<ViewGroup>(android.R.id.content) ?: return@runOnMain
97
+ val view = overlayView ?: FPSOverlayView(activity).also { overlayView = it }
98
+
99
+ if (view.parent !== parent) {
100
+ (view.parent as? ViewGroup)?.removeView(view)
101
+ parent.addView(view, FPSOverlayView.defaultLayoutParams())
102
+ }
103
+
104
+ overlayParent = parent
105
+ view.visibility = android.view.View.VISIBLE
106
+ latestMetrics?.let { view.updateMetrics(it) }
107
+ visible = true
108
+ }
109
+ }
110
+
111
+ @JvmStatic
112
+ fun hideUI() {
113
+ runOnMain {
114
+ overlayView?.let { view ->
115
+ (view.parent as? ViewGroup)?.removeView(view)
116
+ view.visibility = android.view.View.GONE
117
+ }
118
+ overlayParent = null
119
+ visible = false
120
+ }
121
+ }
122
+
123
+ @JvmStatic
124
+ fun metricsSnapshot(): FPSMetrics? = latestMetrics
125
+
126
+ @JvmStatic
127
+ fun isRunning(): Boolean = running
128
+
129
+ private fun startChoreographer() {
130
+ choreographer = Choreographer.getInstance()
131
+ frameCallback = object : Choreographer.FrameCallback {
132
+ override fun doFrame(frameTimeNanos: Long) {
133
+ val now = System.currentTimeMillis()
134
+ if (lastTimestampMs == 0L) {
135
+ lastTimestampMs = now
136
+ choreographer?.postFrameCallback(this)
137
+ return
138
+ }
139
+
140
+ frameCount++
141
+ val delta = now - lastTimestampMs
142
+ if (delta >= updateIntervalMs) {
143
+ publishMetrics(now, delta)
144
+ lastTimestampMs = now
145
+ frameCount = 0
146
+ }
147
+
148
+ choreographer?.postFrameCallback(this)
149
+ }
150
+ }
151
+ choreographer?.postFrameCallback(frameCallback!!)
152
+ }
153
+
154
+ private fun publishMetrics(now: Long, delta: Long) {
155
+ val fps = frameCount * 1000f / delta.coerceAtLeast(1)
156
+ val frameTimeMs = delta.toFloat() / frameCount.coerceAtLeast(1)
157
+ val fpsVariation = (60f - fps.coerceAtMost(60f)) / 60f
158
+ val gpuRatio = 0.4f + fpsVariation * 0.3f
159
+ val cpuPercent = getCPUUsagePercent()
160
+ val memoryMB = getAppMemoryUsageMB()
161
+
162
+ val metrics = FPSMetrics(
163
+ fps = fps,
164
+ avgFrameTimeMs = frameTimeMs,
165
+ estCPUTimePerFrameMs = frameTimeMs * cpuPercent.coerceIn(0f, 100f) / 100f,
166
+ estGPUTimePerFrameMs = frameTimeMs * gpuRatio,
167
+ appMemoryMB = memoryMB,
168
+ cpuUsagePercent = cpuPercent,
169
+ deviceInfo = cachedDeviceInfo ?: readDeviceInfo(appContext ?: return),
170
+ )
171
+ latestMetrics = metrics
172
+
173
+ if (visible) overlayView?.updateMetrics(metrics)
174
+
175
+ if (memoryMB > ramThresholdMB) {
176
+ ramThresholdMB += 100f
177
+ onRamOverload?.invoke(memoryMB)
178
+ }
179
+ if (fps < fpsThreshold && now - lastFPSAlertMs >= 1000L) {
180
+ onFPSLow?.invoke(fps)
181
+ lastFPSAlertMs = now
182
+ }
183
+ }
184
+
185
+ private fun getAppMemoryUsageMB(): Float {
186
+ val runtime = Runtime.getRuntime()
187
+ val used = runtime.totalMemory() - runtime.freeMemory()
188
+ return used / (1024f * 1024f)
189
+ }
190
+
191
+ private fun getCPUUsagePercent(): Float {
192
+ return try {
193
+ BufferedReader(FileReader("/proc/stat")).use { reader ->
194
+ val parts = reader.readLine()
195
+ ?.split(Regex("\\s+"))
196
+ ?.filter { it.isNotBlank() }
197
+ ?: return 0f
198
+ val idle = parts.getOrNull(4)?.toLongOrNull() ?: return 0f
199
+ val total = parts.drop(1).take(7).sumOf { it.toLongOrNull() ?: 0L }
200
+ if (total <= 0) 0f else ((total - idle) * 100f) / total
201
+ }
202
+ } catch (_: Exception) {
203
+ 0f
204
+ }
205
+ }
206
+
207
+ private fun readDeviceInfo(context: Context): FPSDeviceInfo {
208
+ val displayMetrics = context.resources.displayMetrics
209
+ val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as? android.view.WindowManager
210
+ val metrics = DisplayMetrics()
211
+ @Suppress("DEPRECATION")
212
+ windowManager?.defaultDisplay?.getMetrics(metrics)
213
+
214
+ val width = if (metrics.widthPixels > 0) metrics.widthPixels else displayMetrics.widthPixels
215
+ val height = if (metrics.heightPixels > 0) metrics.heightPixels else displayMetrics.heightPixels
216
+ @Suppress("DEPRECATION")
217
+ val refreshRate = windowManager?.defaultDisplay?.refreshRate ?: 60f
218
+
219
+ return FPSDeviceInfo(
220
+ deviceName = "${Build.MANUFACTURER} ${Build.MODEL}",
221
+ resolution = "${width}x${height}",
222
+ density = displayMetrics.density,
223
+ refreshRate = refreshRate,
224
+ )
225
+ }
226
+
227
+ private fun isEmulator(): Boolean {
228
+ return Build.FINGERPRINT.startsWith("generic") ||
229
+ Build.FINGERPRINT.startsWith("unknown") ||
230
+ Build.MODEL.contains("google_sdk", ignoreCase = true) ||
231
+ Build.MODEL.contains("Emulator", ignoreCase = true) ||
232
+ Build.MODEL.contains("Android SDK built for x86", ignoreCase = true) ||
233
+ Build.MANUFACTURER.contains("Genymotion", ignoreCase = true) ||
234
+ (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
235
+ Build.PRODUCT == "google_sdk"
236
+ }
237
+
238
+ private fun ensureInit() {
239
+ check(initialized) { "FPSMonitor.init(context) must be called before use." }
240
+ }
241
+
242
+ private fun runOnMain(block: () -> Unit) {
243
+ if (Looper.myLooper() == Looper.getMainLooper()) {
244
+ block()
245
+ } else {
246
+ mainHandler.post(block)
247
+ }
248
+ }
249
+ }