From e3274a77d4c59cd4432b29c5c2961943d6aea354 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Wed, 15 Apr 2026 11:52:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=9F=8B=E7=82=B9=E4=B8=8E?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../duix/test/service/AnalyticsReporter.kt | 322 ++++++++++++++++++ .../duix/test/ui/activity/BaseActivity.java | 11 + .../duix/test/ui/activity/CallActivity.kt | 293 ++++++++++++++++ .../duix/test/ui/activity/MainActivity.kt | 87 +++++ .../ui/activity/VoiceCloneSetupActivity.kt | 108 ++++++ 5 files changed, 821 insertions(+) create mode 100644 test/src/main/java/ai/guiji/duix/test/service/AnalyticsReporter.kt diff --git a/test/src/main/java/ai/guiji/duix/test/service/AnalyticsReporter.kt b/test/src/main/java/ai/guiji/duix/test/service/AnalyticsReporter.kt new file mode 100644 index 0000000..6eeb503 --- /dev/null +++ b/test/src/main/java/ai/guiji/duix/test/service/AnalyticsReporter.kt @@ -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 = 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 = 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 = 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 = 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().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 = 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 = 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): 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() +} \ No newline at end of file diff --git a/test/src/main/java/ai/guiji/duix/test/ui/activity/BaseActivity.java b/test/src/main/java/ai/guiji/duix/test/ui/activity/BaseActivity.java index a3b1597..e82aba8 100644 --- a/test/src/main/java/ai/guiji/duix/test/ui/activity/BaseActivity.java +++ b/test/src/main/java/ai/guiji/duix/test/ui/activity/BaseActivity.java @@ -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 permissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), diff --git a/test/src/main/java/ai/guiji/duix/test/ui/activity/CallActivity.kt b/test/src/main/java/ai/guiji/duix/test/ui/activity/CallActivity.kt index a315850..9ed7a52 100644 --- a/test/src/main/java/ai/guiji/duix/test/ui/activity/CallActivity.kt +++ b/test/src/main/java/ai/guiji/duix/test/ui/activity/CallActivity.kt @@ -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) { diff --git a/test/src/main/java/ai/guiji/duix/test/ui/activity/MainActivity.kt b/test/src/main/java/ai/guiji/duix/test/ui/activity/MainActivity.kt index 1cc56f2..e43f437 100644 --- a/test/src/main/java/ai/guiji/duix/test/ui/activity/MainActivity.kt +++ b/test/src/main/java/ai/guiji/duix/test/ui/activity/MainActivity.kt @@ -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) + } } diff --git a/test/src/main/java/ai/guiji/duix/test/ui/activity/VoiceCloneSetupActivity.kt b/test/src/main/java/ai/guiji/duix/test/ui/activity/VoiceCloneSetupActivity.kt index c8e0141..67dc642 100644 --- a/test/src/main/java/ai/guiji/duix/test/ui/activity/VoiceCloneSetupActivity.kt +++ b/test/src/main/java/ai/guiji/duix/test/ui/activity/VoiceCloneSetupActivity.kt @@ -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