添加埋点与性能监控

This commit is contained in:
2026-04-15 11:52:55 +08:00
parent e4e6b84811
commit e3274a77d4
5 changed files with 821 additions and 0 deletions
@@ -0,0 +1,322 @@
package ai.guiji.duix.test.service
import ai.guiji.duix.test.App
import android.content.Context
import android.content.pm.PackageInfo
import android.os.Build
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
import java.util.TimeZone
object AnalyticsReporter {
private const val ENDPOINT = "https://digital-human.xn--876a.net/api/track"
private const val PREFS_NAME = "digital_human_monitor_analytics"
private const val INSTALLATION_ID_KEY = "installation_id"
private const val SESSION_ID_KEY = "session_id"
private const val LAST_ACTIVE_AT_KEY = "last_active_at"
private const val SESSION_TIMEOUT_MS = 30 * 60 * 1000L
private const val MAX_DURATION_MS = 2_147_483_647L
@JvmStatic
@JvmOverloads
fun trackScreen(context: Context, screenName: String, metadata: Map<String, Any?> = emptyMap()) {
trackEvent(
context = context,
name = "screen_view",
type = "navigation",
screenName = screenName,
component = screenName,
metadata = metadata
)
}
@JvmStatic
@JvmOverloads
fun trackAction(
context: Context,
name: String,
screenName: String? = null,
component: String? = null,
value: String? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
trackEvent(
context = context,
name = name,
type = "interaction",
screenName = screenName,
component = component,
value = value,
metadata = metadata
)
}
@JvmStatic
@JvmOverloads
fun trackPerformance(
context: Context,
name: String,
durationMs: Long,
screenName: String? = null,
component: String? = null,
value: String? = null,
sequence: Int? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
trackEvent(
context = context,
name = name,
type = "performance",
screenName = screenName,
component = component,
value = value,
durationMs = durationMs,
sequence = sequence,
metadata = metadata
)
}
@JvmStatic
@JvmOverloads
fun trackConversationMessage(
context: Context,
role: String,
text: String,
screenName: String? = null,
turnIndex: Int? = null,
durationMs: Long? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
val normalizedRole = role.trim().lowercase(Locale.US)
val normalizedText = text.trim().replace("\\s+".toRegex(), " ")
if (normalizedText.isBlank()) {
return
}
val eventName = if (normalizedRole == "assistant") {
"assistant_message_uploaded"
} else {
"user_message_uploaded"
}
val mergedMetadata = LinkedHashMap<String, Any?>().apply {
put("role", normalizedRole)
put("text", normalizedText)
put("textLength", normalizedText.length)
if (turnIndex != null) {
put("turnIndex", turnIndex)
}
putAll(metadata)
}
trackEvent(
context = context,
name = eventName,
type = "conversation",
screenName = screenName,
component = "chat_history",
elementText = shorten(normalizedText, 240),
durationMs = durationMs,
sequence = turnIndex,
metadata = mergedMetadata
)
}
@JvmStatic
@JvmOverloads
fun trackError(
context: Context,
name: String,
message: String,
screenName: String? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
trackEvent(
context = context,
name = name,
type = "error",
screenName = screenName,
value = message,
metadata = metadata
)
}
@JvmStatic
@JvmOverloads
fun trackEvent(
context: Context,
name: String,
type: String,
screenName: String? = null,
component: String? = null,
value: String? = null,
elementText: String? = null,
durationMs: Long? = null,
sequence: Int? = null,
metadata: Map<String, Any?> = emptyMap(),
) {
val appContext = context.applicationContext
val payload = JSONObject()
val session = buildSession(appContext)
val event = JSONObject()
event.put("name", shorten(name, 120))
event.put("type", shorten(type, 80))
event.put("occurredAt", java.time.Instant.now().toString())
screenName?.let {
event.put("pathname", screenPath(it))
event.put("pageUrl", screenUrl(it))
event.put("title", it)
}
if (!component.isNullOrBlank()) {
event.put("component", shorten(component, 120))
}
if (!value.isNullOrBlank()) {
event.put("value", shorten(value, 240))
}
if (!elementText.isNullOrBlank()) {
event.put("elementText", shorten(elementText, 240))
}
durationMs?.let {
event.put("durationMs", clampDuration(it))
}
sequence?.let {
event.put("sequence", it.coerceAtLeast(0))
}
if (metadata.isNotEmpty()) {
event.put("metadata", toJsonObject(metadata))
}
val events = JSONArray()
events.put(event)
payload.put("session", session)
payload.put("events", events)
val request = Request.Builder()
.url(ENDPOINT)
.post(payload.toString().toRequestBody(JSON_MEDIA_TYPE))
.addHeader("Content-Type", "application/json")
.build()
App.getOkHttpClient().newCall(request).enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
}
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
response.close()
}
})
}
private fun buildSession(context: Context): JSONObject {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val now = System.currentTimeMillis()
val lastActiveAt = prefs.getLong(LAST_ACTIVE_AT_KEY, 0L)
val sessionId = if (now - lastActiveAt > SESSION_TIMEOUT_MS) {
newId("android_session")
} else {
prefs.getString(SESSION_ID_KEY, null) ?: newId("android_session")
}
val installationId = prefs.getString(INSTALLATION_ID_KEY, null) ?: newId("android_install")
prefs.edit()
.putString(INSTALLATION_ID_KEY, installationId)
.putString(SESSION_ID_KEY, sessionId)
.putLong(LAST_ACTIVE_AT_KEY, now)
.apply()
val metrics = context.resources.displayMetrics
val packageInfo = packageInfo(context)
return JSONObject().apply {
put("sessionKey", sessionId)
put("platform", "ANDROID")
put("domain", "digital-human.xn--876a.net")
put("channel", "android-app")
put("visitorId", installationId)
put("locale", Locale.getDefault().toLanguageTag())
put("timezone", TimeZone.getDefault().id)
put("userAgent", "Android/${Build.VERSION.RELEASE} (${Build.BRAND} ${Build.MODEL}; API ${Build.VERSION.SDK_INT})")
put("appVersion", packageInfo?.versionName ?: "unknown")
put("deviceType", "mobile")
put("deviceBrand", Build.BRAND)
put("deviceModel", Build.MODEL)
put("osName", "Android")
put("osVersion", Build.VERSION.RELEASE)
put("screenWidth", metrics.widthPixels)
put("screenHeight", metrics.heightPixels)
}
}
private fun packageInfo(context: Context): PackageInfo? {
return try {
context.packageManager.getPackageInfo(context.packageName, 0)
} catch (_: Exception) {
null
}
}
private fun screenPath(screenName: String): String {
val normalized = screenName
.replace("Activity", "")
.replace(Regex("([a-z])([A-Z])"), "$1-$2")
.lowercase(Locale.US)
return "/android/$normalized"
}
private fun screenUrl(screenName: String): String {
return "android://digital-human${screenPath(screenName)}"
}
private fun newId(prefix: String): String {
return prefix + "_" + System.currentTimeMillis().toString(36) + "_" + (100000..999999).random()
}
private fun shorten(value: String, maxLength: Int): String {
val normalized = value.trim().replace("\\s+".toRegex(), " ")
return if (normalized.length > maxLength) normalized.take(maxLength) else normalized
}
private fun clampDuration(durationMs: Long): Long {
return durationMs.coerceIn(0L, MAX_DURATION_MS)
}
private fun toJsonObject(values: Map<String, Any?>): JSONObject {
return JSONObject().apply {
values.forEach { (key, value) ->
put(key, toJsonValue(value))
}
}
}
private fun toJsonValue(value: Any?): Any {
return when (value) {
null -> JSONObject.NULL
is JSONObject -> value
is JSONArray -> value
is Number, is Boolean, is String -> value
is Map<*, *> -> {
val jsonObject = JSONObject()
value.forEach { (entryKey, entryValue) ->
if (entryKey != null) {
jsonObject.put(entryKey.toString(), toJsonValue(entryValue))
}
}
jsonObject
}
is Iterable<*> -> {
val array = JSONArray()
value.forEach { array.put(toJsonValue(it)) }
array
}
is Array<*> -> {
val array = JSONArray()
value.forEach { array.put(toJsonValue(it)) }
array
}
else -> value.toString()
}
}
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
}
@@ -1,5 +1,6 @@
package ai.guiji.duix.test.ui.activity;
import ai.guiji.duix.test.service.AnalyticsReporter;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
@@ -59,6 +60,16 @@ public abstract class BaseActivity extends AppCompatActivity implements Handler.
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
@Override
protected void onResume() {
super.onResume();
AnalyticsReporter.trackScreen(this, analyticsScreenName());
}
protected String analyticsScreenName() {
return getClass().getSimpleName();
}
private String[] mRequestPermissions;
private int mRequestPermissionCode;
ActivityResultLauncher<String[]> permissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(),
@@ -12,6 +12,7 @@ import ai.guiji.duix.test.camera.CameraFrameCapture
import ai.guiji.duix.test.databinding.ActivityCallBinding
import ai.guiji.duix.test.realtime.OmniRealtimeClient
import ai.guiji.duix.test.realtime.QwenTtsRealtimeClient
import ai.guiji.duix.test.service.AnalyticsReporter
import ai.guiji.duix.test.service.DashScopeConfig
import ai.guiji.duix.test.ui.adapter.MotionAdapter
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
@@ -23,6 +24,7 @@ import android.opengl.GLSurfaceView
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.text.TextUtils
import android.util.Log
import android.view.View
@@ -79,6 +81,21 @@ class CallActivity : BaseActivity() {
private var clonedVoice = ""
private var cloneTtsModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL
private var ttsRealtimeClient: QwenTtsRealtimeClient? = null
private var duixInitStartedAtMs = 0L
private var aiConversationConnectStartedAtMs = 0L
private var aiConversationConnectedAtMs = 0L
private var conversationTurnIndex = 0
private var currentTurnRequestAtMs = 0L
private var currentTurnSpeechStartedAtMs = 0L
private var currentTurnSpeechStoppedAtMs = 0L
private var currentTurnFirstTextAtMs = 0L
private var currentTurnFirstAudioAtMs = 0L
private var currentTurnUserTranscript = ""
private var currentTurnAssistantTranscript = ""
private var cloneTtsStartedAtMs = 0L
private var cloneTtsFirstAudioAtMs = 0L
private var cameraEnableStartedAtMs = 0L
private var hasReportedCameraFirstFrame = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -95,6 +112,14 @@ class CallActivity : BaseActivity() {
isCloneMode = intent.getBooleanExtra("cloneMode", false)
clonedVoice = intent.getStringExtra("clonedVoice") ?: ""
cloneTtsModel = intent.getStringExtra("cloneTtsModel") ?: DashScopeConfig.VOICE_CLONE_TTS_MODEL
AnalyticsReporter.trackAction(
mContext,
name = "call_activity_opened",
screenName = analyticsScreenName(),
component = "call_screen",
value = if (isCloneMode) "clone" else "standard",
metadata = mapOf("voice" to voice)
)
Glide.with(mContext).load("file:///android_asset/bg/bg1.png").into(binding.ivBg)
@@ -214,12 +239,21 @@ class CallActivity : BaseActivity() {
// }
// })
applyMessage("start init")
duixInitStartedAtMs = SystemClock.elapsedRealtime()
duix?.init()
}
private fun initOk() {
Log.i(TAG, "init ok")
applyMessage("init ok")
AnalyticsReporter.trackPerformance(
mContext,
name = "digital_human_init_ready",
durationMs = elapsedSince(duixInitStartedAtMs),
screenName = analyticsScreenName(),
component = "renderer",
metadata = mapOf("voice" to voice)
)
runOnUiThread {
binding.btnRecord.isEnabled = true
binding.btnPlayPCM.isEnabled = true
@@ -345,13 +379,35 @@ class CallActivity : BaseActivity() {
private fun startAIConversation() {
if (duix?.isReady() != true) {
AnalyticsReporter.trackError(
mContext,
name = "ai_conversation_unavailable",
message = "model_not_ready",
screenName = analyticsScreenName()
)
Toast.makeText(mContext, "数字人未就绪", Toast.LENGTH_SHORT).show()
return
}
if (isCloneMode && clonedVoice.isBlank()) {
AnalyticsReporter.trackError(
mContext,
name = "clone_voice_unavailable",
message = "missing_cloned_voice",
screenName = analyticsScreenName()
)
Toast.makeText(mContext, R.string.voice_clone_unavailable, Toast.LENGTH_SHORT).show()
return
}
AnalyticsReporter.trackAction(
mContext,
name = "ai_conversation_connecting",
screenName = analyticsScreenName(),
component = "btnAIConversation",
value = if (isCloneMode) "clone" else "standard"
)
aiConversationConnectStartedAtMs = SystemClock.elapsedRealtime()
aiConversationConnectedAtMs = 0L
resetConversationTurnMetrics(resetCounter = true)
applyMessage("AI对话: 连接中...")
binding.tvSubtitle.visibility = View.VISIBLE
binding.tvSubtitle.text = getString(R.string.ai_call_connecting)
@@ -365,6 +421,20 @@ class CallActivity : BaseActivity() {
omniClient = OmniRealtimeClient(buildRealtimeSessionOptions(), object : OmniRealtimeClient.Callback {
override fun onConnected() {
mainHandler.post {
aiConversationConnectedAtMs = SystemClock.elapsedRealtime()
AnalyticsReporter.trackPerformance(
mContext,
name = "ai_conversation_connected",
durationMs = elapsedSince(aiConversationConnectStartedAtMs),
screenName = analyticsScreenName(),
component = "btnAIConversation",
value = if (isCloneMode) "clone" else "standard",
metadata = mapOf(
"searchEnabled" to enableWebSearch,
"cloneMode" to isCloneMode,
"model" to OmniRealtimeClient.DEFAULT_MODEL
)
)
binding.tvSubtitle.text = getString(R.string.ai_call_connected)
binding.btnCameraToggle.visibility = if (isCloneMode) View.GONE else View.VISIBLE
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
@@ -387,10 +457,27 @@ class CallActivity : BaseActivity() {
}
override fun onDisconnected() {
AnalyticsReporter.trackAction(
mContext,
name = "ai_conversation_disconnected",
screenName = analyticsScreenName(),
component = "btnAIConversation"
)
mainHandler.post { stopAIConversation() }
}
override fun onError(message: String) {
AnalyticsReporter.trackError(
mContext,
name = "ai_conversation_error",
message = message,
screenName = analyticsScreenName(),
metadata = mapOf(
"cameraEnabled" to isCameraEnabled,
"connectElapsedMs" to elapsedSince(aiConversationConnectStartedAtMs),
"turnIndex" to conversationTurnIndex.takeIf { it > 0 }
)
)
mainHandler.post {
if (isImageAppendError(message)) {
stopCamera()
@@ -407,24 +494,44 @@ class CallActivity : BaseActivity() {
stopCloneSpeech()
}
cameraFrameCapture?.requestImmediateCapture()
currentTurnSpeechStartedAtMs = SystemClock.elapsedRealtime()
mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_listening)
}
}
override fun onUserSpeechStopped() {
startConversationTurn()
mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_thinking)
}
}
override fun onUserTranscript(transcript: String) {
ensureConversationTurn()
currentTurnUserTranscript = transcript
AnalyticsReporter.trackConversationMessage(
mContext,
role = "user",
text = transcript,
screenName = analyticsScreenName(),
turnIndex = conversationTurnIndex,
durationMs = elapsedSince(currentTurnRequestAtMs),
metadata = mapOf(
"mode" to currentConversationMode(),
"speechDurationMs" to elapsedBetween(currentTurnSpeechStartedAtMs, currentTurnSpeechStoppedAtMs),
"transcriptionLatencyMs" to elapsedSince(currentTurnRequestAtMs)
)
)
mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_user_said, transcript)
}
}
override fun onAssistantTextDelta(delta: String) {
if (currentTurnFirstTextAtMs <= 0L) {
currentTurnFirstTextAtMs = SystemClock.elapsedRealtime()
}
mainHandler.post {
assistantTextBuffer.append(delta)
binding.tvSubtitle.text = assistantTextBuffer.toString()
@@ -432,6 +539,24 @@ class CallActivity : BaseActivity() {
}
override fun onAssistantTranscriptDone(transcript: String) {
ensureConversationTurn()
if (currentTurnFirstTextAtMs <= 0L) {
currentTurnFirstTextAtMs = SystemClock.elapsedRealtime()
}
currentTurnAssistantTranscript = transcript
AnalyticsReporter.trackConversationMessage(
mContext,
role = "assistant",
text = transcript,
screenName = analyticsScreenName(),
turnIndex = conversationTurnIndex,
durationMs = elapsedSince(currentTurnRequestAtMs),
metadata = mapOf(
"mode" to currentConversationMode(),
"textReadyLatencyMs" to elapsedSince(currentTurnRequestAtMs),
"firstTextLatencyMs" to elapsedSince(currentTurnRequestAtMs, currentTurnFirstTextAtMs)
)
)
mainHandler.post {
assistantTextBuffer.clear()
assistantTextBuffer.append(transcript)
@@ -443,6 +568,9 @@ class CallActivity : BaseActivity() {
if (isCloneMode) {
return
}
if (currentTurnFirstAudioAtMs <= 0L) {
currentTurnFirstAudioAtMs = SystemClock.elapsedRealtime()
}
if (!hasStartedPushForResponse) {
hasStartedPushForResponse = true
duix?.startPush()
@@ -464,6 +592,27 @@ class CallActivity : BaseActivity() {
}
override fun onResponseDone() {
ensureConversationTurn()
if (currentTurnAssistantTranscript.isBlank()) {
currentTurnAssistantTranscript = assistantTextBuffer.toString().trim()
}
AnalyticsReporter.trackPerformance(
mContext,
name = "assistant_response_done",
durationMs = elapsedSince(currentTurnRequestAtMs),
screenName = analyticsScreenName(),
component = if (isCloneMode) "clone-tts" else "audio-stream",
value = currentTurnAssistantTranscript.length.toString(),
sequence = conversationTurnIndex,
metadata = mapOf(
"mode" to currentConversationMode(),
"firstTextLatencyMs" to elapsedSince(currentTurnRequestAtMs, currentTurnFirstTextAtMs),
"firstAudioLatencyMs" to elapsedSince(currentTurnRequestAtMs, currentTurnFirstAudioAtMs),
"userTextLength" to currentTurnUserTranscript.length,
"assistantTextLength" to currentTurnAssistantTranscript.length,
"speechDurationMs" to elapsedBetween(currentTurnSpeechStartedAtMs, currentTurnSpeechStoppedAtMs)
)
)
mainHandler.post {
if (isCloneMode) {
val replyText = assistantTextBuffer.toString().trim()
@@ -475,6 +624,7 @@ class CallActivity : BaseActivity() {
hasStartedPushForResponse = false
}
assistantTextBuffer.clear()
resetConversationTurnMetrics(resetCounter = false)
}
}
})
@@ -484,6 +634,7 @@ class CallActivity : BaseActivity() {
}
private fun stopAIConversation() {
val wasActive = isAIConversationActive
isAIConversationActive = false
stopCamera()
stopCloneSpeech()
@@ -494,6 +645,21 @@ class CallActivity : BaseActivity() {
duix?.stopAudio()
assistantTextBuffer.clear()
hasStartedPushForResponse = false
if (wasActive) {
AnalyticsReporter.trackAction(
mContext,
name = "ai_conversation_stopped",
screenName = analyticsScreenName(),
component = "btnAIConversation",
metadata = mapOf(
"sessionDurationMs" to elapsedSince(aiConversationConnectedAtMs),
"turnCount" to conversationTurnIndex
)
)
}
resetConversationTurnMetrics(resetCounter = true)
aiConversationConnectStartedAtMs = 0L
aiConversationConnectedAtMs = 0L
mainHandler.post {
if (!isFinishing) {
@@ -514,6 +680,16 @@ class CallActivity : BaseActivity() {
return
}
stopCloneSpeech()
cloneTtsStartedAtMs = SystemClock.elapsedRealtime()
cloneTtsFirstAudioAtMs = 0L
AnalyticsReporter.trackAction(
mContext,
name = "clone_tts_started",
screenName = analyticsScreenName(),
component = "clone-tts",
value = text.length.toString(),
metadata = mapOf("voice" to clonedVoice)
)
ttsRealtimeClient = QwenTtsRealtimeClient(
QwenTtsRealtimeClient.SessionOptions(
voice = clonedVoice,
@@ -530,6 +706,9 @@ class CallActivity : BaseActivity() {
}
override fun onAudioDelta(pcmData: ByteArray) {
if (cloneTtsFirstAudioAtMs <= 0L) {
cloneTtsFirstAudioAtMs = SystemClock.elapsedRealtime()
}
if (!hasStartedPushForResponse) {
hasStartedPushForResponse = true
duix?.startPush()
@@ -551,14 +730,40 @@ class CallActivity : BaseActivity() {
}
override fun onSessionFinished() {
AnalyticsReporter.trackPerformance(
mContext,
name = "clone_tts_finished",
durationMs = elapsedSince(cloneTtsStartedAtMs),
screenName = analyticsScreenName(),
component = "clone-tts",
metadata = mapOf(
"voice" to clonedVoice,
"firstAudioLatencyMs" to elapsedSince(cloneTtsStartedAtMs, cloneTtsFirstAudioAtMs),
"mode" to currentConversationMode(),
"textLength" to text.length
)
)
if (hasStartedPushForResponse) {
duix?.stopPush()
hasStartedPushForResponse = false
}
ttsRealtimeClient = null
cloneTtsStartedAtMs = 0L
cloneTtsFirstAudioAtMs = 0L
}
override fun onError(message: String) {
AnalyticsReporter.trackError(
mContext,
name = "clone_tts_error",
message = message,
screenName = analyticsScreenName(),
metadata = mapOf(
"voice" to clonedVoice,
"elapsedMs" to elapsedSince(cloneTtsStartedAtMs),
"textLength" to text.length
)
)
mainHandler.post {
if (hasStartedPushForResponse) {
duix?.stopPush()
@@ -581,18 +786,44 @@ class CallActivity : BaseActivity() {
hasStartedPushForResponse = false
}
duix?.stopAudio()
cloneTtsStartedAtMs = 0L
cloneTtsFirstAudioAtMs = 0L
}
private fun toggleCamera() {
if (!isAIConversationActive || omniClient == null) return
isCameraEnabled = !isCameraEnabled
if (isCameraEnabled) {
cameraEnableStartedAtMs = SystemClock.elapsedRealtime()
hasReportedCameraFirstFrame = false
AnalyticsReporter.trackAction(
mContext,
name = "camera_enabled",
screenName = analyticsScreenName(),
component = "btnCameraToggle"
)
binding.cameraPreview.visibility = View.VISIBLE
cameraFrameCapture = CameraFrameCapture(mContext, object : CameraFrameCapture.FrameCallback {
override fun onFrame(jpegData: ByteArray) {
if (!hasReportedCameraFirstFrame) {
hasReportedCameraFirstFrame = true
AnalyticsReporter.trackPerformance(
mContext,
name = "camera_first_frame_ready",
durationMs = elapsedSince(cameraEnableStartedAtMs),
screenName = analyticsScreenName(),
component = "btnCameraToggle"
)
}
omniClient?.appendImage(jpegData)
}
override fun onError(message: String) {
AnalyticsReporter.trackError(
mContext,
name = "camera_capture_error",
message = message,
screenName = analyticsScreenName()
)
mainHandler.post {
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
stopCamera()
@@ -603,6 +834,12 @@ class CallActivity : BaseActivity() {
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_on)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_on)
} else {
AnalyticsReporter.trackAction(
mContext,
name = "camera_disabled",
screenName = analyticsScreenName(),
component = "btnCameraToggle"
)
stopCamera()
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
@@ -613,9 +850,65 @@ class CallActivity : BaseActivity() {
cameraFrameCapture?.stop()
cameraFrameCapture = null
isCameraEnabled = false
cameraEnableStartedAtMs = 0L
hasReportedCameraFirstFrame = false
binding.cameraPreview.visibility = View.GONE
}
private fun startConversationTurn() {
conversationTurnIndex += 1
currentTurnRequestAtMs = SystemClock.elapsedRealtime()
currentTurnSpeechStoppedAtMs = currentTurnRequestAtMs
currentTurnFirstTextAtMs = 0L
currentTurnFirstAudioAtMs = 0L
currentTurnUserTranscript = ""
currentTurnAssistantTranscript = ""
}
private fun ensureConversationTurn() {
if (currentTurnRequestAtMs <= 0L) {
startConversationTurn()
}
}
private fun resetConversationTurnMetrics(resetCounter: Boolean) {
currentTurnRequestAtMs = 0L
currentTurnSpeechStartedAtMs = 0L
currentTurnSpeechStoppedAtMs = 0L
currentTurnFirstTextAtMs = 0L
currentTurnFirstAudioAtMs = 0L
currentTurnUserTranscript = ""
currentTurnAssistantTranscript = ""
if (resetCounter) {
conversationTurnIndex = 0
}
}
private fun currentConversationMode(): String {
return if (isCloneMode) "clone" else "standard"
}
private fun elapsedSince(startAtMs: Long): Long {
if (startAtMs <= 0L) {
return 0L
}
return (SystemClock.elapsedRealtime() - startAtMs).coerceAtLeast(0L)
}
private fun elapsedSince(startAtMs: Long, endAtMs: Long): Long {
if (startAtMs <= 0L || endAtMs <= 0L) {
return 0L
}
return (endAtMs - startAtMs).coerceAtLeast(0L)
}
private fun elapsedBetween(startAtMs: Long, endAtMs: Long): Long {
if (startAtMs <= 0L || endAtMs <= 0L) {
return 0L
}
return (endAtMs - startAtMs).coerceAtLeast(0L)
}
private fun showRecordDialog(){
val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{
override fun onFinish(path: String) {
@@ -5,10 +5,12 @@ import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ActivityMainBinding
import ai.guiji.duix.test.model.AvatarCatalog
import ai.guiji.duix.test.model.AvatarModel
import ai.guiji.duix.test.service.AnalyticsReporter
import ai.guiji.duix.test.ui.adapter.AvatarAdapter
import ai.guiji.duix.test.ui.dialog.LoadingDialog
import android.content.Intent
import android.os.Bundle
import android.os.SystemClock
import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager
import java.io.File
@@ -19,6 +21,8 @@ class MainActivity : BaseActivity() {
private lateinit var binding: ActivityMainBinding
private var mLoadingDialog: LoadingDialog? = null
private var mLastProgress = 0
private var baseConfigDownloadStartedAtMs = 0L
private var modelDownloadStartedAtMs = 0L
private val baseConfigUrl = "https://dl.xn--876a.net/models/gj_dh_res.zip"
@@ -30,12 +34,26 @@ class MainActivity : BaseActivity() {
setContentView(binding.root)
binding.btnVoiceClone.setOnClickListener {
AnalyticsReporter.trackAction(
mContext,
name = "voice_clone_entry_tap",
screenName = analyticsScreenName(),
component = "btnVoiceClone"
)
startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java))
}
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
binding.rvAvatars.adapter = AvatarAdapter(AvatarCatalog.models, object : AvatarAdapter.Callback {
override fun onSelect(model: AvatarModel) {
AnalyticsReporter.trackAction(
mContext,
name = "avatar_selected",
screenName = analyticsScreenName(),
component = "avatar_grid",
value = model.name,
metadata = mapOf("voice" to model.voice)
)
mSelectedModel = model
play(model)
}
@@ -66,6 +84,14 @@ class MainActivity : BaseActivity() {
private fun jumpPlayPage() {
val model = mSelectedModel ?: return
AnalyticsReporter.trackAction(
mContext,
name = "call_page_opened",
screenName = analyticsScreenName(),
component = "avatar_grid",
value = model.name,
metadata = mapOf("mode" to "standard", "voice" to model.voice)
)
val intent = Intent(mContext, CallActivity::class.java)
intent.putExtra("modelUrl", model.modelUrl)
intent.putExtra("voice", model.voice)
@@ -77,6 +103,13 @@ class MainActivity : BaseActivity() {
mLoadingDialog?.dismiss()
mLoadingDialog = LoadingDialog(mContext, "正在准备资源...")
mLoadingDialog?.show()
baseConfigDownloadStartedAtMs = SystemClock.elapsedRealtime()
AnalyticsReporter.trackAction(
mContext,
name = "base_config_download_started",
screenName = analyticsScreenName(),
component = "resource_loader"
)
VirtualModelUtil.baseConfigDownload(mContext, baseConfigUrl, object :
VirtualModelUtil.ModelDownloadCallback {
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
@@ -105,6 +138,14 @@ class MainActivity : BaseActivity() {
override fun onDownloadComplete(url: String?, dir: File?) {
runOnUiThread {
AnalyticsReporter.trackPerformance(
mContext,
name = "base_config_download_completed",
durationMs = elapsedSince(baseConfigDownloadStartedAtMs),
screenName = analyticsScreenName(),
component = "resource_loader",
metadata = mapOf("url" to url)
)
mLoadingDialog?.dismiss()
checkModel()
}
@@ -112,6 +153,16 @@ class MainActivity : BaseActivity() {
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
runOnUiThread {
AnalyticsReporter.trackError(
mContext,
name = "base_config_download_failed",
message = msg ?: "unknown",
screenName = analyticsScreenName(),
metadata = mapOf(
"code" to code,
"elapsedMs" to elapsedSince(baseConfigDownloadStartedAtMs)
)
)
mLoadingDialog?.dismiss()
Toast.makeText(mContext, "资源下载失败: $msg", Toast.LENGTH_SHORT).show()
}
@@ -124,6 +175,15 @@ class MainActivity : BaseActivity() {
mLoadingDialog?.dismiss()
mLoadingDialog = LoadingDialog(mContext, "正在下载 ${model.name} ...")
mLoadingDialog?.show()
modelDownloadStartedAtMs = SystemClock.elapsedRealtime()
AnalyticsReporter.trackAction(
mContext,
name = "avatar_download_started",
screenName = analyticsScreenName(),
component = "resource_loader",
value = model.name,
metadata = mapOf("voice" to model.voice)
)
VirtualModelUtil.modelDownload(mContext, model.modelUrl, object : VirtualModelUtil.ModelDownloadCallback {
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
val progress = (current * 100 / total).toInt()
@@ -151,6 +211,15 @@ class MainActivity : BaseActivity() {
override fun onDownloadComplete(url: String?, dir: File?) {
runOnUiThread {
AnalyticsReporter.trackPerformance(
mContext,
name = "avatar_download_completed",
durationMs = elapsedSince(modelDownloadStartedAtMs),
screenName = analyticsScreenName(),
component = "resource_loader",
value = model.name,
metadata = mapOf("voice" to model.voice)
)
mLoadingDialog?.dismiss()
jumpPlayPage()
}
@@ -158,10 +227,28 @@ class MainActivity : BaseActivity() {
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
runOnUiThread {
AnalyticsReporter.trackError(
mContext,
name = "avatar_download_failed",
message = msg ?: "unknown",
screenName = analyticsScreenName(),
metadata = mapOf(
"code" to code,
"avatar" to model.name,
"elapsedMs" to elapsedSince(modelDownloadStartedAtMs)
)
)
mLoadingDialog?.dismiss()
Toast.makeText(mContext, "下载失败: $msg", Toast.LENGTH_SHORT).show()
}
}
})
}
private fun elapsedSince(startAtMs: Long): Long {
if (startAtMs <= 0L) {
return 0L
}
return (SystemClock.elapsedRealtime() - startAtMs).coerceAtLeast(0L)
}
}
@@ -7,6 +7,7 @@ import ai.guiji.duix.test.model.AvatarCatalog
import ai.guiji.duix.test.model.AvatarModel
import ai.guiji.duix.test.model.VoiceCloneProfile
import ai.guiji.duix.test.service.DashScopeConfig
import ai.guiji.duix.test.service.AnalyticsReporter
import ai.guiji.duix.test.service.VoiceCloneProfileStore
import ai.guiji.duix.test.service.VoiceCloneService
import ai.guiji.duix.test.ui.adapter.AvatarAdapter
@@ -37,6 +38,7 @@ class VoiceCloneSetupActivity : BaseActivity() {
private var isRecording = false
private var isCreatingVoice = false
private var recordStartedAtMs = 0L
private var voiceCloneCreateStartedAtMs = 0L
private val recordTicker = object : Runnable {
override fun run() {
@@ -66,6 +68,14 @@ class VoiceCloneSetupActivity : BaseActivity() {
AvatarCatalog.models,
object : AvatarAdapter.Callback {
override fun onSelect(model: AvatarModel) {
AnalyticsReporter.trackAction(
mContext,
name = "clone_avatar_selected",
screenName = analyticsScreenName(),
component = "avatar_grid",
value = model.name,
metadata = mapOf("voice" to model.voice)
)
selectedAvatar = model
avatarAdapter.setSelectedModel(model)
updateUi()
@@ -93,10 +103,24 @@ class VoiceCloneSetupActivity : BaseActivity() {
return@setOnClickListener
}
if (currentRecording != null) {
AnalyticsReporter.trackAction(
mContext,
name = "voice_sample_discarded",
screenName = analyticsScreenName(),
component = "btnSecondaryAction",
value = selectedAvatar.name
)
currentRecording?.file?.delete()
currentRecording = null
updateUi()
} else {
AnalyticsReporter.trackAction(
mContext,
name = "voice_rerecord_requested",
screenName = analyticsScreenName(),
component = "btnSecondaryAction",
value = selectedAvatar.name
)
requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_RECORD_AUDIO)
}
}
@@ -127,6 +151,13 @@ class VoiceCloneSetupActivity : BaseActivity() {
val outputFile = File(filesDir, "voice_clone/sample_${System.currentTimeMillis()}.m4a")
try {
recorder.start(outputFile)
AnalyticsReporter.trackAction(
mContext,
name = "voice_record_started",
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name
)
currentRecording = null
isRecording = true
recordStartedAtMs = SystemClock.elapsedRealtime()
@@ -135,6 +166,12 @@ class VoiceCloneSetupActivity : BaseActivity() {
mainHandler.post(recordTicker)
updateUi()
} catch (e: Exception) {
AnalyticsReporter.trackError(
mContext,
name = "voice_record_start_failed",
message = e.message ?: getString(R.string.voice_clone_record_failed),
screenName = analyticsScreenName()
)
Toast.makeText(mContext, e.message ?: getString(R.string.voice_clone_record_failed), Toast.LENGTH_SHORT).show()
recorder.release()
isRecording = false
@@ -151,10 +188,28 @@ class VoiceCloneSetupActivity : BaseActivity() {
try {
val recording = recorder.stop()
if (recording.durationMs < MIN_RECORDING_MS) {
AnalyticsReporter.trackError(
mContext,
name = "voice_record_too_short",
message = recording.durationMs.toString(),
screenName = analyticsScreenName(),
metadata = mapOf("autoStopped" to autoStopped)
)
recording.file.delete()
currentRecording = null
Toast.makeText(mContext, R.string.voice_clone_record_too_short, Toast.LENGTH_SHORT).show()
} else {
AnalyticsReporter.trackAction(
mContext,
name = "voice_record_finished",
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name,
metadata = mapOf(
"durationMs" to recording.durationMs,
"autoStopped" to autoStopped,
)
)
currentRecording = recording
if (autoStopped) {
Toast.makeText(mContext, R.string.voice_clone_record_auto_stopped, Toast.LENGTH_SHORT).show()
@@ -163,6 +218,12 @@ class VoiceCloneSetupActivity : BaseActivity() {
}
}
} catch (e: Exception) {
AnalyticsReporter.trackError(
mContext,
name = "voice_record_stop_failed",
message = e.message ?: getString(R.string.voice_clone_record_failed),
screenName = analyticsScreenName()
)
currentRecording = null
Toast.makeText(mContext, e.message ?: getString(R.string.voice_clone_record_failed), Toast.LENGTH_SHORT).show()
}
@@ -173,7 +234,16 @@ class VoiceCloneSetupActivity : BaseActivity() {
val recording = currentRecording ?: return
val oldVoice = activeProfile?.voice
isCreatingVoice = true
voiceCloneCreateStartedAtMs = SystemClock.elapsedRealtime()
updateUi()
AnalyticsReporter.trackAction(
mContext,
name = "voice_clone_create_started",
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name,
metadata = mapOf("durationMs" to recording.durationMs)
)
voiceCloneService.createVoice(
sampleFile = recording.file,
preferredName = "family_${selectedAvatar.voice}",
@@ -196,12 +266,35 @@ class VoiceCloneSetupActivity : BaseActivity() {
profileStore.save(profile)
activeProfile = profile
currentRecording = null
AnalyticsReporter.trackPerformance(
mContext,
name = "voice_clone_create_succeeded",
durationMs = elapsedSince(voiceCloneCreateStartedAtMs),
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name,
metadata = mapOf(
"voice" to cloneResult.voice,
"sampleDurationMs" to recording.durationMs
)
)
if (!oldVoice.isNullOrBlank() && oldVoice != profile.voice) {
voiceCloneService.deleteVoice(oldVoice)
}
Toast.makeText(mContext, R.string.voice_clone_saved, Toast.LENGTH_SHORT).show()
updateUi()
}.onFailure { throwable ->
AnalyticsReporter.trackError(
mContext,
name = "voice_clone_create_failed",
message = throwable.message ?: getString(R.string.voice_clone_create_failed),
screenName = analyticsScreenName(),
metadata = mapOf(
"avatar" to selectedAvatar.name,
"elapsedMs" to elapsedSince(voiceCloneCreateStartedAtMs),
"sampleDurationMs" to recording.durationMs
)
)
Toast.makeText(mContext, throwable.message ?: getString(R.string.voice_clone_create_failed), Toast.LENGTH_SHORT).show()
updateUi()
}
@@ -218,6 +311,14 @@ class VoiceCloneSetupActivity : BaseActivity() {
)
profileStore.save(profile)
activeProfile = profile
AnalyticsReporter.trackAction(
mContext,
name = "voice_clone_chat_started",
screenName = analyticsScreenName(),
component = "btnPrimaryAction",
value = selectedAvatar.name,
metadata = mapOf("clonedVoice" to profile.voice)
)
val intent = Intent(mContext, CallActivity::class.java)
intent.putExtra("modelUrl", selectedAvatar.modelUrl)
@@ -267,6 +368,13 @@ class VoiceCloneSetupActivity : BaseActivity() {
return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
}
private fun elapsedSince(startAtMs: Long): Long {
if (startAtMs <= 0L) {
return 0L
}
return (SystemClock.elapsedRealtime() - startAtMs).coerceAtLeast(0L)
}
companion object {
private const val REQUEST_RECORD_AUDIO = 11
private const val MIN_RECORDING_MS = 3_000L