添加家人绑定

This commit is contained in:
2026-04-17 11:32:01 +08:00
parent e4e6b84811
commit 61183e0c9a
26 changed files with 8238 additions and 37 deletions
+2
View File
@@ -10,6 +10,7 @@ static def getCurrentTime() {
android {
namespace 'ai.guiji.duix.test'
compileSdk 34
ndkVersion '25.1.8937393'
defaultConfig {
applicationId "ai.guiji.duix.test"
@@ -87,4 +88,5 @@ dependencies {
implementation project(":duix-sdk")
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.google.zxing:core:3.5.3'
}
+12
View File
@@ -37,6 +37,18 @@
android:exported="false"
android:theme="@style/Theme.DUIX.Call">
</activity>
<activity
android:name=".ui.activity.SettingsActivity"
android:exported="false"
android:theme="@style/Theme.DUIX.Test">
</activity>
<activity
android:name=".ui.activity.DeviceSettingsActivity"
android:exported="false"
android:theme="@style/Theme.DUIX.Test">
</activity>
</application>
</manifest>
@@ -9,6 +9,10 @@ import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
@@ -71,6 +75,10 @@ public class OmniRealtimeClient {
private final AtomicBoolean assistantTextFinalized = new AtomicBoolean(false);
private final StringBuilder assistantTextBuffer = new StringBuilder();
public static String getDefaultInstructions() {
return DEFAULT_INSTRUCTIONS;
}
public OmniRealtimeClient(SessionOptions sessionOptions, Callback callback) {
this.sessionOptions = sessionOptions;
this.callback = callback;
@@ -170,7 +178,15 @@ public class OmniRealtimeClient {
}
session.put("input_audio_format", "pcm");
session.put("instructions", options.getInstructions());
session.put("enable_search", options.isEnableSearch());
session.put("enable_search", options.isEnableSearch() && !options.hasTools());
if (options.hasTools()) {
JSONArray tools = new JSONArray();
for (ToolDefinition toolDefinition : options.getTools()) {
tools.put(toolDefinition.toJson());
}
session.put("tools", tools);
}
if (options.isEnableInputAudioTranscription()) {
JSONObject inputAudioTranscription = new JSONObject();
@@ -263,6 +279,16 @@ public class OmniRealtimeClient {
dispatchAssistantFinalText(event.optString("text", event.optString("transcript", "")));
break;
case "response.function_call_arguments.done":
String toolName = event.optString("name", "");
String callId = event.optString("call_id", "");
String arguments = event.optString("arguments", "{}");
Log.d(TAG, "Tool call: " + toolName + ", callId=" + callId + ", arguments=" + arguments);
if (callback != null) {
callback.onToolCallRequested(toolName, callId, arguments);
}
break;
case "response.output_item.done":
dispatchAssistantFinalTextIfNeeded(extractTextFromItem(event.optJSONObject("item")));
break;
@@ -391,6 +417,35 @@ public class OmniRealtimeClient {
return isConnected.get();
}
public boolean sendFunctionCallOutput(String callId, String output) {
if (callId == null || callId.trim().isEmpty()) {
return false;
}
try {
JSONObject item = new JSONObject();
item.put("type", "function_call_output");
item.put("call_id", callId);
item.put("output", output == null ? "" : output);
JSONObject event = new JSONObject();
event.put("event_id", nextEventId());
event.put("type", "conversation.item.create");
event.put("item", item);
if (!sendEvent(event)) {
return false;
}
JSONObject responseCreate = new JSONObject();
responseCreate.put("event_id", nextEventId());
responseCreate.put("type", "response.create");
return sendEvent(responseCreate);
} catch (JSONException e) {
Log.e(TAG, "Failed to send function_call_output: " + e.getMessage());
return false;
}
}
private void handleServerError(JSONObject event) {
String errMsg = event.optJSONObject("error") != null
? event.optJSONObject("error").optString("message", "Unknown error")
@@ -510,7 +565,7 @@ public class OmniRealtimeClient {
}
private String nextEventId() {
return "event_" + System.currentTimeMillis();
return "event_" + UUID.randomUUID().toString().replace("-", "");
}
public static class SessionOptions {
@@ -528,29 +583,48 @@ public class OmniRealtimeClient {
private final boolean enableTurnDetection;
private final boolean enableInputAudioTranscription;
private final OutputMode outputMode;
private final List<ToolDefinition> tools;
public SessionOptions(String voice, boolean enableSearch) {
this(voice, enableSearch, OutputMode.AUDIO_AND_TEXT);
}
public SessionOptions(String voice, boolean enableSearch, OutputMode outputMode) {
this(voice, enableSearch, outputMode, null, null);
}
public SessionOptions(String voice, boolean enableSearch, OutputMode outputMode,
String instructions, List<ToolDefinition> tools) {
this(DEFAULT_API_KEY, DEFAULT_BASE_URL, DEFAULT_MODEL, voice, DEFAULT_INSTRUCTIONS,
enableSearch, true, true, outputMode);
enableSearch, true, true, outputMode, tools, instructions);
}
public SessionOptions(String apiKey, String baseUrl, String model, String voice,
String instructions, boolean enableSearch, boolean enableTurnDetection,
boolean enableInputAudioTranscription, OutputMode outputMode) {
this(apiKey, baseUrl, model, voice, instructions, enableSearch,
enableTurnDetection, enableInputAudioTranscription, outputMode, null, null);
}
public SessionOptions(String apiKey, String baseUrl, String model, String voice,
String instructions, boolean enableSearch, boolean enableTurnDetection,
boolean enableInputAudioTranscription, OutputMode outputMode,
List<ToolDefinition> tools, String ignoredInstructionsOverride) {
this.apiKey = apiKey == null ? "" : apiKey;
this.baseUrl = baseUrl == null || baseUrl.trim().isEmpty() ? DEFAULT_BASE_URL : baseUrl;
this.model = model == null || model.trim().isEmpty() ? DEFAULT_MODEL : model;
this.voice = voice == null || voice.trim().isEmpty() ? "Ethan" : voice;
this.instructions = instructions == null || instructions.trim().isEmpty()
? DEFAULT_INSTRUCTIONS : instructions;
String resolvedInstructions = ignoredInstructionsOverride;
if (resolvedInstructions == null || resolvedInstructions.trim().isEmpty()) {
resolvedInstructions = instructions;
}
this.instructions = resolvedInstructions == null || resolvedInstructions.trim().isEmpty()
? DEFAULT_INSTRUCTIONS : resolvedInstructions;
this.enableSearch = enableSearch;
this.enableTurnDetection = enableTurnDetection;
this.enableInputAudioTranscription = enableInputAudioTranscription;
this.outputMode = outputMode == null ? OutputMode.AUDIO_AND_TEXT : outputMode;
this.tools = tools == null ? Collections.emptyList() : new ArrayList<>(tools);
}
public String getApiKey() {
@@ -584,6 +658,38 @@ public class OmniRealtimeClient {
public boolean isAudioOutputEnabled() {
return outputMode == OutputMode.AUDIO_AND_TEXT;
}
public boolean hasTools() {
return !tools.isEmpty();
}
public List<ToolDefinition> getTools() {
return tools;
}
}
public static class ToolDefinition {
private final String name;
private final String description;
private final JSONObject parameters;
public ToolDefinition(String name, String description, JSONObject parameters) {
this.name = name == null ? "" : name;
this.description = description == null ? "" : description;
this.parameters = parameters == null ? new JSONObject() : parameters;
}
public JSONObject toJson() throws JSONException {
JSONObject function = new JSONObject();
function.put("name", name);
function.put("description", description);
function.put("parameters", new JSONObject(parameters.toString()));
JSONObject tool = new JSONObject();
tool.put("type", "function");
tool.put("function", function);
return tool;
}
}
public interface Callback {
@@ -603,6 +709,8 @@ public class OmniRealtimeClient {
void onAssistantTranscriptDone(String transcript);
void onToolCallRequested(String toolName, String callId, String argumentsJson);
void onAssistantAudioDelta(byte[] pcmData);
void onAssistantAudioDone();
@@ -7,6 +7,7 @@ public final class DashScopeConfig {
public static final String VOICE_CLONE_URL = "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/customization";
public static final String VOICE_CLONE_MODEL = "qwen-voice-enrollment";
public static final String VOICE_CLONE_TTS_MODEL = "qwen3-tts-vc-realtime-2026-01-15";
public static final String MONITOR_BASE_URL = "https://digital-human.xn--876a.net";
public static final int AUDIO_SAMPLE_RATE = 24000;
private DashScopeConfig() {
@@ -0,0 +1,44 @@
package ai.guiji.duix.test.service
import android.content.Context
import android.net.Uri
import java.util.Locale
import java.util.UUID
class DeviceIdentityStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getOrCreateDeviceUuid(): String {
val current = prefs.getString(KEY_DEVICE_UUID, null)?.trim()?.lowercase(Locale.US)
if (!current.isNullOrBlank()) {
return current
}
val deviceUuid = UUID.randomUUID().toString().lowercase(Locale.US)
prefs.edit().putString(KEY_DEVICE_UUID, deviceUuid).apply()
return deviceUuid
}
fun getBindUrl(): String {
val deviceUuid = getOrCreateDeviceUuid()
return prefs.getString(KEY_BIND_URL, null)?.takeIf { it.isNotBlank() }
?: buildFallbackBindUrl(deviceUuid)
}
fun saveBindUrl(bindUrl: String) {
prefs.edit().putString(KEY_BIND_URL, bindUrl.trim()).apply()
}
fun buildFallbackBindUrl(deviceUuid: String = getOrCreateDeviceUuid()): String {
val normalized = deviceUuid.trim().lowercase(Locale.US)
return DashScopeConfig.MONITOR_BASE_URL.trimEnd('/') +
"/bind?deviceUuid=" + Uri.encode(normalized)
}
companion object {
private const val PREFS_NAME = "device_identity_store"
private const val KEY_DEVICE_UUID = "device_uuid"
private const val KEY_BIND_URL = "bind_url"
}
}
@@ -0,0 +1,218 @@
package ai.guiji.duix.test.service
import ai.guiji.duix.test.App
import ai.guiji.duix.test.BuildConfig
import android.content.Context
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
class FamilyBridgeService(context: Context) {
private val appContext = context.applicationContext
private val identityStore = DeviceIdentityStore(appContext)
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
fun getDeviceUuid(): String = identityStore.getOrCreateDeviceUuid()
fun getBindUrl(): String = identityStore.getBindUrl()
fun registerCurrentDevice(): DeviceRegistrationResult {
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
put("displayName", buildDisplayName())
put("appVersion", BuildConfig.VERSION_NAME)
}
val response = postJson("/api/device/register", payload)
val bindUrl = response.optString("bindUrl").ifBlank {
identityStore.buildFallbackBindUrl(getDeviceUuid())
}
identityStore.saveBindUrl(bindUrl)
return DeviceRegistrationResult(
deviceUuid = response.optString("deviceUuid", getDeviceUuid()),
bindUrl = bindUrl,
)
}
fun fetchFamilyMessages(take: Int = 5): FamilyMessageFetchResult {
val normalizedTake = take.coerceIn(1, 8)
val url = DashScopeConfig.MONITOR_BASE_URL.trimEnd('/') +
"/api/device/messages?deviceUuid=${getDeviceUuid()}&take=$normalizedTake"
val response = executeJsonRequest(
Request.Builder()
.url(url)
.get()
.build()
)
val messagesJson = response.optJSONArray("messages") ?: JSONArray()
val messages = buildList {
for (index in 0 until messagesJson.length()) {
val item = messagesJson.optJSONObject(index) ?: continue
add(
FamilyMessageItem(
id = item.optString("id"),
content = item.optString("content"),
importance = item.optString("importance"),
createdAt = item.optString("createdAt"),
)
)
}
}
return FamilyMessageFetchResult(
summaryText = response.optString("summaryText"),
unreadCount = response.optInt("unreadCount"),
messages = messages,
)
}
fun markFamilyMessagesRead(messageIds: List<String>) {
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
put("messageIds", JSONArray(messageIds))
}
postJson("/api/device/messages/read", payload)
}
fun recordUsageEvent(eventType: String, detailJson: JSONObject? = null) {
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
put("eventType", eventType)
if (detailJson != null) {
put("detailJson", detailJson.toString())
}
}
postJson("/api/device/usage", payload)
}
fun recordToolCall(
toolName: String,
arguments: JSONObject,
outputText: String,
status: String,
callId: String? = null,
sessionId: String? = null,
) {
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
put("toolName", toolName)
put("arguments", arguments)
put("outputText", outputText)
put("status", status)
if (!callId.isNullOrBlank()) {
put("callId", callId)
}
if (!sessionId.isNullOrBlank()) {
put("sessionId", sessionId)
}
}
postJson("/api/device/tool-call", payload)
}
fun uploadConversationTurns(sessionId: String?, turns: List<ConversationTurnPayload>) {
if (turns.isEmpty()) {
return
}
val payload = JSONObject().apply {
put("deviceUuid", getDeviceUuid())
if (!sessionId.isNullOrBlank()) {
put("sessionId", sessionId)
}
put(
"turns",
JSONArray().apply {
turns.forEach { turn ->
put(
JSONObject().apply {
put("role", turn.role)
put("content", turn.content)
if (!turn.metaJson.isNullOrBlank()) {
put("metaJson", turn.metaJson)
}
}
)
}
}
)
}
postJson("/api/device/conversations", payload)
}
private fun postJson(path: String, payload: JSONObject): JSONObject {
return executeJsonRequest(
Request.Builder()
.url(DashScopeConfig.MONITOR_BASE_URL.trimEnd('/') + path)
.post(payload.toString().toRequestBody(jsonMediaType))
.build()
)
}
private fun executeJsonRequest(request: Request): JSONObject {
App.getOkHttpClient().newCall(request).execute().use { response ->
val responseText = response.body?.string().orEmpty()
val json = responseText.takeIf { it.isNotBlank() }?.let(::JSONObject) ?: JSONObject()
if (!response.isSuccessful) {
val message = json.optString("error").ifBlank {
response.message.ifBlank { "请求失败" }
}
throw IllegalStateException(message)
}
return json
}
}
private fun buildDisplayName(): String {
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val model = Build.MODEL?.trim().orEmpty()
return listOf(manufacturer, model)
.filter { it.isNotBlank() }
.distinct()
.joinToString(" ")
.ifBlank { "长辈陪伴终端" }
}
data class DeviceRegistrationResult(
val deviceUuid: String,
val bindUrl: String,
)
data class FamilyMessageItem(
val id: String,
val content: String,
val importance: String,
val createdAt: String,
)
data class FamilyMessageFetchResult(
val summaryText: String,
val unreadCount: Int,
val messages: List<FamilyMessageItem>,
)
data class ConversationTurnPayload(
val role: String,
val content: String,
val metaJson: String? = null,
)
companion object {
const val EVENT_APP_OPEN = "APP_OPEN"
const val EVENT_SETTINGS_OPENED = "SETTINGS_OPENED"
const val EVENT_AI_SESSION_STARTED = "AI_SESSION_STARTED"
const val EVENT_AI_SESSION_ENDED = "AI_SESSION_ENDED"
const val EVENT_CAMERA_ENABLED = "CAMERA_ENABLED"
const val EVENT_CAMERA_DISABLED = "CAMERA_DISABLED"
const val ROLE_USER = "USER"
const val ROLE_ASSISTANT = "ASSISTANT"
const val ROLE_TOOL = "TOOL"
const val STATUS_SUCCESS = "SUCCESS"
const val STATUS_FAILURE = "FAILURE"
}
}
@@ -13,6 +13,7 @@ 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.DashScopeConfig
import ai.guiji.duix.test.service.FamilyBridgeService
import ai.guiji.duix.test.ui.adapter.MotionAdapter
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
import ai.guiji.duix.test.ui.widget.AudioSpectrumView
@@ -29,9 +30,14 @@ import android.view.View
import android.widget.CompoundButton
import android.widget.Toast
import com.bumptech.glide.Glide
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class CallActivity : BaseActivity() {
@@ -74,11 +80,15 @@ class CallActivity : BaseActivity() {
private val mainHandler = Handler(Looper.getMainLooper())
private var assistantTextBuffer = StringBuilder()
private var hasStartedPushForResponse = false
private val enableWebSearch = true
private val enableWebSearch = false
private val enableToolCalling = true
private var isCloneMode = false
private var clonedVoice = ""
private var cloneTtsModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL
private var ttsRealtimeClient: QwenTtsRealtimeClient? = null
private val familyBridgeService by lazy { FamilyBridgeService(applicationContext) }
private val bridgeExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private var currentSessionId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -96,6 +106,10 @@ class CallActivity : BaseActivity() {
clonedVoice = intent.getStringExtra("clonedVoice") ?: ""
cloneTtsModel = intent.getStringExtra("cloneTtsModel") ?: DashScopeConfig.VOICE_CLONE_TTS_MODEL
enqueueBridgeTask("设备注册失败") {
familyBridgeService.registerCurrentDevice()
}
Glide.with(mContext).load("file:///android_asset/bg/bg1.png").into(binding.ivBg)
binding.glTextureView.setEGLContextClientVersion(GL_CONTEXT_VERSION)
@@ -263,7 +277,75 @@ class CallActivity : BaseActivity() {
} else {
OmniRealtimeClient.SessionOptions.OutputMode.AUDIO_AND_TEXT
}
return OmniRealtimeClient.SessionOptions(voice, enableWebSearch, outputMode)
return OmniRealtimeClient.SessionOptions(
voice,
enableWebSearch,
outputMode,
buildRealtimeInstructions(),
buildRealtimeTools(),
)
}
private fun buildRealtimeInstructions(): String {
if (!enableToolCalling) {
return OmniRealtimeClient.getDefaultInstructions()
}
return buildString {
append(OmniRealtimeClient.getDefaultInstructions())
append("\n\n")
append("当用户明确要给儿子、女儿、老伴、家里人或亲友带话、留言、报平安时,必须调用 leave_message_for_family 工具,不要假装已经转达。\n")
append("当用户问家里人有没有留言、想听家人说了什么、想看看新消息时,必须调用 fetch_family_messages 工具,不要自己编造留言。\n")
append("工具执行结果返回后,再用温和、简短、生活化的中文向老人说明情况。")
}
}
private fun buildRealtimeTools(): List<OmniRealtimeClient.ToolDefinition> {
if (!enableToolCalling) {
return emptyList()
}
val leaveMessageParameters = JSONObject().apply {
put("type", "object")
put("properties", JSONObject().apply {
put("recipientRelation", JSONObject().apply {
put("type", "string")
put("description", "留言对象与老人的关系,例如儿子、女儿、老伴、孙女。")
})
put("message", JSONObject().apply {
put("type", "string")
put("description", "老人想让家里人知道的完整留言内容。")
})
put("importance", JSONObject().apply {
put("type", "string")
put("description", "留言紧急程度。")
put("enum", JSONArray().put("LOW").put("NORMAL").put("HIGH").put("URGENT"))
})
})
put("required", JSONArray().put("message"))
}
val fetchFamilyMessagesParameters = JSONObject().apply {
put("type", "object")
put("properties", JSONObject().apply {
put("count", JSONObject().apply {
put("type", "integer")
put("description", "想读取几条家人留言,通常 1 到 5 条。")
})
})
}
return listOf(
OmniRealtimeClient.ToolDefinition(
"leave_message_for_family",
"当老人明确要求给家里人带话、留言、报平安或请转告时使用。",
leaveMessageParameters,
),
OmniRealtimeClient.ToolDefinition(
"fetch_family_messages",
"当老人想听家人最新留言,或询问有没有新消息时使用。",
fetchFamilyMessagesParameters,
),
)
}
private fun isImageAppendError(message: String): Boolean {
@@ -284,6 +366,7 @@ class CallActivity : BaseActivity() {
super.onDestroy()
stopAIConversation()
duix?.release()
bridgeExecutor.shutdownNow()
}
private fun playPCMStream(){
@@ -359,6 +442,7 @@ class CallActivity : BaseActivity() {
binding.audioSpectrum.clear()
assistantTextBuffer.clear()
hasStartedPushForResponse = false
currentSessionId = UUID.randomUUID().toString()
binding.btnAIConversation.setImageResource(R.drawable.ic_mic_off)
binding.btnAIConversation.setBackgroundResource(R.drawable.shape_fab_ai_active)
@@ -370,7 +454,7 @@ class CallActivity : BaseActivity() {
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
isCameraEnabled = false
applyMessage("AI对话: 已连接, model=${OmniRealtimeClient.DEFAULT_MODEL}, search=on, cloneMode=$isCloneMode")
applyMessage("AI对话: 已连接, model=${OmniRealtimeClient.DEFAULT_MODEL}, search=${if (enableWebSearch) "on" else "off"}, tools=${if (enableToolCalling) "on" else "off"}, cloneMode=$isCloneMode")
streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
override fun onAudioData(pcmData: ByteArray) {
omniClient?.appendAudio(pcmData)
@@ -384,6 +468,15 @@ class CallActivity : BaseActivity() {
})
streamingRecorder?.start()
}
enqueueBridgeTask("AI会话启动记录失败") {
familyBridgeService.registerCurrentDevice()
val detail = JSONObject().apply {
put("sessionId", currentSessionId)
put("cloneMode", isCloneMode)
put("voice", voice)
}
familyBridgeService.recordUsageEvent(FamilyBridgeService.EVENT_AI_SESSION_STARTED, detail)
}
}
override fun onDisconnected() {
@@ -422,6 +515,7 @@ class CallActivity : BaseActivity() {
mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_user_said, transcript)
}
uploadConversationTurnAsync(FamilyBridgeService.ROLE_USER, transcript)
}
override fun onAssistantTextDelta(delta: String) {
@@ -437,6 +531,19 @@ class CallActivity : BaseActivity() {
assistantTextBuffer.append(transcript)
binding.tvSubtitle.text = transcript
}
uploadConversationTurnAsync(FamilyBridgeService.ROLE_ASSISTANT, transcript)
}
override fun onToolCallRequested(toolName: String, callId: String, argumentsJson: String) {
mainHandler.post {
binding.tvSubtitle.text = when (toolName) {
"leave_message_for_family" -> "我来帮您把这句话记给家里人。"
"fetch_family_messages" -> "我来帮您看看家里人的新留言。"
else -> getString(R.string.ai_call_thinking)
}
}
applyMessage("AI工具调用: $toolName, callId=$callId, arguments=$argumentsJson")
handleRealtimeToolCall(toolName, callId, argumentsJson)
}
override fun onAssistantAudioDelta(pcmData: ByteArray) {
@@ -484,6 +591,8 @@ class CallActivity : BaseActivity() {
}
private fun stopAIConversation() {
val endedSessionId = currentSessionId
currentSessionId = null
isAIConversationActive = false
stopCamera()
stopCloneSpeech()
@@ -507,6 +616,16 @@ class CallActivity : BaseActivity() {
applyMessage("AI对话: 已结束")
}
}
if (!endedSessionId.isNullOrBlank()) {
enqueueBridgeTask("AI会话结束记录失败") {
val detail = JSONObject().apply {
put("sessionId", endedSessionId)
put("cloneMode", isCloneMode)
}
familyBridgeService.recordUsageEvent(FamilyBridgeService.EVENT_AI_SESSION_ENDED, detail)
}
}
}
private fun speakWithClonedVoice(text: String) {
@@ -602,6 +721,11 @@ class CallActivity : BaseActivity() {
cameraFrameCapture?.start()
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_on)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_on)
enqueueBridgeTask("镜头开启记录失败") {
val detail = JSONObject()
currentSessionId?.let { detail.put("sessionId", it) }
familyBridgeService.recordUsageEvent(FamilyBridgeService.EVENT_CAMERA_ENABLED, detail)
}
} else {
stopCamera()
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
@@ -610,12 +734,199 @@ class CallActivity : BaseActivity() {
}
private fun stopCamera() {
val wasEnabled = isCameraEnabled || cameraFrameCapture != null
cameraFrameCapture?.stop()
cameraFrameCapture = null
isCameraEnabled = false
binding.cameraPreview.visibility = View.GONE
if (wasEnabled) {
enqueueBridgeTask("镜头关闭记录失败") {
val detail = JSONObject()
currentSessionId?.let { detail.put("sessionId", it) }
familyBridgeService.recordUsageEvent(FamilyBridgeService.EVENT_CAMERA_DISABLED, detail)
}
}
}
private fun enqueueBridgeTask(label: String, task: () -> Unit) {
if (bridgeExecutor.isShutdown) {
return
}
bridgeExecutor.execute {
try {
task()
} catch (throwable: Throwable) {
applyMessage("$label: ${throwable.message}")
}
}
}
private fun uploadConversationTurnAsync(role: String, content: String, metaJson: JSONObject? = null) {
enqueueBridgeTask("对话记录上传失败") {
uploadConversationTurnInternal(role, content, metaJson)
}
}
private fun uploadConversationTurnInternal(role: String, content: String, metaJson: JSONObject? = null) {
val sessionId = currentSessionId
val normalizedContent = content.trim()
if (sessionId.isNullOrBlank() || normalizedContent.isEmpty()) {
return
}
familyBridgeService.uploadConversationTurns(
sessionId,
listOf(
FamilyBridgeService.ConversationTurnPayload(
role = role,
content = normalizedContent,
metaJson = metaJson?.toString(),
)
)
)
}
private fun handleRealtimeToolCall(toolName: String, callId: String, argumentsJson: String) {
enqueueBridgeTask("工具调用处理失败") {
val arguments = runCatching { JSONObject(argumentsJson) }.getOrElse { JSONObject() }
val result = executeRealtimeTool(toolName, callId, arguments)
uploadConversationTurnInternal(
FamilyBridgeService.ROLE_TOOL,
result.outputText,
JSONObject().apply {
put("toolName", toolName)
put("callId", callId)
put("status", result.status)
put("arguments", arguments)
}
)
val sent = omniClient?.sendFunctionCallOutput(callId, result.outputText) == true
if (!sent) {
applyMessage("工具结果回传失败: $toolName")
}
}
}
private fun executeRealtimeTool(
toolName: String,
callId: String,
arguments: JSONObject,
): ToolExecutionResult {
return when (toolName) {
"leave_message_for_family" -> executeLeaveMessageTool(callId, arguments)
"fetch_family_messages" -> executeFetchFamilyMessagesTool(callId, arguments)
else -> {
val outputText = "这个请求我还不能直接处理,您可以换一种说法再告诉我。"
runCatching {
familyBridgeService.recordToolCall(
toolName = toolName,
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
}
}
private fun executeLeaveMessageTool(callId: String, arguments: JSONObject): ToolExecutionResult {
val message = arguments.optString("message").trim()
val relation = arguments.optString("recipientRelation").trim().ifBlank { "家里人" }
if (message.isEmpty()) {
val outputText = "我还没听清您想转告的话,您再慢慢说一遍,我帮您记下来。"
runCatching {
familyBridgeService.recordToolCall(
toolName = "leave_message_for_family",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
return ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
return runCatching {
val outputText = "已记下要转告给${relation}的话:$message"
familyBridgeService.recordToolCall(
toolName = "leave_message_for_family",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_SUCCESS,
callId = callId,
sessionId = currentSessionId,
)
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_SUCCESS)
}.getOrElse { error ->
val outputText = "暂时还没连上家人的留言通道,请稍后再试一次。"
runCatching {
familyBridgeService.recordToolCall(
toolName = "leave_message_for_family",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
applyMessage("留言工具失败: ${error.message}")
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
}
private fun executeFetchFamilyMessagesTool(callId: String, arguments: JSONObject): ToolExecutionResult {
val requestedCount = arguments.optInt("count", 3).coerceIn(1, 5)
return runCatching {
val result = familyBridgeService.fetchFamilyMessages(requestedCount)
if (result.messages.isNotEmpty()) {
familyBridgeService.markFamilyMessagesRead(result.messages.map { it.id })
}
val outputText = if (result.messages.isEmpty()) {
"家里人暂时还没有新的留言。"
} else {
buildString {
append(result.summaryText.ifBlank { "我帮您看了看,家里人有新的留言。" })
result.messages.take(requestedCount).forEachIndexed { index, message ->
append(" 第${index + 1}条:${message.content}")
}
}
}
familyBridgeService.recordToolCall(
toolName = "fetch_family_messages",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_SUCCESS,
callId = callId,
sessionId = currentSessionId,
)
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_SUCCESS)
}.getOrElse { error ->
val outputText = "暂时还没取到家人的新留言,请稍后再试一次。"
runCatching {
familyBridgeService.recordToolCall(
toolName = "fetch_family_messages",
arguments = arguments,
outputText = outputText,
status = FamilyBridgeService.STATUS_FAILURE,
callId = callId,
sessionId = currentSessionId,
)
}
applyMessage("读取家人留言失败: ${error.message}")
ToolExecutionResult(outputText, FamilyBridgeService.STATUS_FAILURE)
}
}
private data class ToolExecutionResult(
val outputText: String,
val status: String,
)
private fun showRecordDialog(){
val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{
override fun onFinish(path: String) {
@@ -0,0 +1,80 @@
package ai.guiji.duix.test.ui.activity
import ai.guiji.duix.test.R
import ai.guiji.duix.test.databinding.ActivityDeviceSettingsBinding
import ai.guiji.duix.test.service.FamilyBridgeService
import ai.guiji.duix.test.util.QrCodeBitmapFactory
import android.os.Bundle
import android.util.TypedValue
import android.widget.Toast
import org.json.JSONObject
class DeviceSettingsActivity : BaseActivity() {
private lateinit var binding: ActivityDeviceSettingsBinding
private val familyBridgeService by lazy { FamilyBridgeService(applicationContext) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDeviceSettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnBack.setOnClickListener { finish() }
binding.btnRefresh.setOnClickListener { syncDeviceRegistration(true) }
renderIdentityCard(familyBridgeService.getBindUrl())
syncDeviceRegistration(false)
Thread {
runCatching {
familyBridgeService.recordUsageEvent(
FamilyBridgeService.EVENT_SETTINGS_OPENED,
JSONObject().put("screen", "device_settings"),
)
}
}.start()
}
private fun renderIdentityCard(bindUrl: String) {
val deviceUuid = familyBridgeService.getDeviceUuid()
binding.tvDeviceUuidValue.text = deviceUuid
binding.tvBindLinkValue.text = bindUrl
val qrSize = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
220f,
resources.displayMetrics,
).toInt()
binding.ivQrCode.setImageBitmap(QrCodeBitmapFactory.create(bindUrl, qrSize))
}
private fun syncDeviceRegistration(showToast: Boolean) {
binding.btnRefresh.isEnabled = false
binding.tvSyncStatus.text = getString(R.string.device_settings_syncing)
Thread {
val result = runCatching { familyBridgeService.registerCurrentDevice() }
runOnUiThread {
binding.btnRefresh.isEnabled = true
result.onSuccess { registration ->
renderIdentityCard(registration.bindUrl)
binding.tvSyncStatus.text = getString(R.string.device_settings_sync_success)
if (showToast) {
Toast.makeText(mContext, R.string.device_settings_sync_success, Toast.LENGTH_SHORT).show()
}
}.onFailure { error ->
renderIdentityCard(familyBridgeService.getBindUrl())
binding.tvSyncStatus.text = getString(
R.string.device_settings_sync_failed,
error.message ?: getString(R.string.ai_call_error_retry),
)
if (showToast) {
Toast.makeText(
mContext,
error.message ?: getString(R.string.ai_call_error_retry),
Toast.LENGTH_SHORT,
).show()
}
}
}
}.start()
}
}
@@ -5,18 +5,21 @@ 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.FamilyBridgeService
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.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager
import org.json.JSONObject
import java.io.File
class MainActivity : BaseActivity() {
private lateinit var binding: ActivityMainBinding
private val familyBridgeService by lazy { FamilyBridgeService(applicationContext) }
private var mLoadingDialog: LoadingDialog? = null
private var mLastProgress = 0
@@ -32,6 +35,9 @@ class MainActivity : BaseActivity() {
binding.btnVoiceClone.setOnClickListener {
startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java))
}
binding.btnSettings.setOnClickListener {
startActivity(Intent(mContext, SettingsActivity::class.java))
}
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
binding.rvAvatars.adapter = AvatarAdapter(AvatarCatalog.models, object : AvatarAdapter.Callback {
@@ -40,6 +46,20 @@ class MainActivity : BaseActivity() {
play(model)
}
})
bootstrapFamilyBridge()
}
private fun bootstrapFamilyBridge() {
Thread {
runCatching { familyBridgeService.registerCurrentDevice() }
runCatching {
familyBridgeService.recordUsageEvent(
FamilyBridgeService.EVENT_APP_OPEN,
JSONObject().put("screen", "main"),
)
}
}.start()
}
private fun play(model: AvatarModel) {
@@ -0,0 +1,41 @@
package ai.guiji.duix.test.ui.activity
import ai.guiji.duix.test.BuildConfig
import ai.guiji.duix.test.databinding.ActivitySettingsBinding
import ai.guiji.duix.test.service.DashScopeConfig
import ai.guiji.duix.test.service.FamilyBridgeService
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import org.json.JSONObject
class SettingsActivity : BaseActivity() {
private lateinit var binding: ActivitySettingsBinding
private val familyBridgeService by lazy { FamilyBridgeService(applicationContext) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnBack.setOnClickListener { finish() }
binding.rowDeviceCode.setOnClickListener {
startActivity(Intent(mContext, DeviceSettingsActivity::class.java))
}
binding.tvVersionValue.text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
binding.tvServerValue.text = Uri.parse(DashScopeConfig.MONITOR_BASE_URL).host
?: DashScopeConfig.MONITOR_BASE_URL
binding.tvDeviceValue.text = familyBridgeService.getDeviceUuid()
Thread {
runCatching {
familyBridgeService.recordUsageEvent(
FamilyBridgeService.EVENT_SETTINGS_OPENED,
JSONObject().put("screen", "settings_home"),
)
}
}.start()
}
}
@@ -0,0 +1,29 @@
package ai.guiji.duix.test.util
import android.graphics.Bitmap
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
object QrCodeBitmapFactory {
fun create(content: String, size: Int): Bitmap {
val hints = hashMapOf<EncodeHintType, Any>(
EncodeHintType.MARGIN to 1,
EncodeHintType.CHARACTER_SET to "UTF-8",
)
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
val pixels = IntArray(size * size)
for (y in 0 until size) {
for (x in 0 until size) {
pixels[y * size + x] = if (matrix[x, y]) Color.BLACK else Color.WHITE
}
}
return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
setPixels(pixels, 0, size, 0, 0, size, size)
}
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF5F4338"
android:pathData="M14.71,6.29a1,1 0,0 1,0 1.41L10.41,12l4.3,4.29a1,1 0,0 1,-1.42 1.42l-5,-5a1,1 0,0 1,0 -1.42l5,-5a1,1 0,0 1,1.42 0Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF8D6E63"
android:pathData="M9.29,6.71a1,1 0,0 1,1.41 0L15,11l-4.29,4.29a1,1 0,1 1,-1.42 -1.41L12.17,11 9.29,8.12a1,1 0,0 1,0 -1.41Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF7D5D4E"
android:pathData="M19.14,12.94c0.04,-0.31 0.06,-0.63 0.06,-0.94s-0.02,-0.63 -0.06,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.4 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.3 -0.6,-0.22l-2.39,0.96c-0.5,-0.38 -1.05,-0.69 -1.66,-0.94l-0.36,-2.54C14.33,2.18 14.13,2 13.88,2h-3.76c-0.25,0 -0.46,0.18 -0.49,0.42l-0.36,2.54c-0.61,0.24 -1.17,0.56 -1.66,0.94l-2.39,-0.96c-0.23,-0.09 -0.48,0 -0.6,0.22L2.7,8.48c-0.12,0.21 -0.07,0.47 0.12,0.61l2.03,1.58c-0.04,0.31 -0.06,0.64 -0.06,0.95s0.02,0.63 0.06,0.94L2.82,14.14c-0.18,0.14 -0.23,0.4 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.3 0.6,0.22l2.39,-0.96c0.5,0.38 1.05,0.69 1.66,0.94l0.36,2.54c0.03,0.24 0.24,0.42 0.49,0.42h3.76c0.25,0 0.46,-0.18 0.49,-0.42l0.36,-2.54c0.61,-0.24 1.17,-0.56 1.66,-0.94l2.39,0.96c0.23,0.09 0.48,0 0.6,-0.22l1.92,-3.32c0.12,-0.21 0.07,-0.47 -0.12,-0.61l-2.03,-1.57ZM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5 3.5,1.57 3.5,3.5 -1.57,3.5 -3.5,3.5Z" />
</vector>
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="28dp" />
<solid android:color="@color/warm_surface" />
<stroke
android:width="1dp"
android:color="#FFE4CDBD" />
</shape>
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="18dp" />
<solid android:color="@color/warm_surface" />
<stroke
android:width="1dp"
android:color="#FFE3C7B3" />
</shape>
@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/warm_bg"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="28dp"
android:paddingEnd="18dp"
android:paddingBottom="24dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btnBack"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/device_settings_back"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_arrow_back" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/device_settings_title"
android:textColor="@color/warm_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/device_settings_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:background="@drawable/shape_warm_feature_card"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/device_settings_qr_title"
android:textColor="@color/warm_text_primary"
android:textSize="19sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/device_settings_qr_desc"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<ImageView
android:id="@+id/ivQrCode"
android:layout_width="220dp"
android:layout_height="220dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="18dp"
android:background="@drawable/shape_qr_surface"
android:padding="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/device_settings_uuid_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvDeviceUuidValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/warm_text_primary"
android:textIsSelectable="true"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/device_settings_link_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvBindLinkValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:textColor="@color/warm_text_primary"
android:textIsSelectable="true"
android:textSize="14sp" />
<TextView
android:id="@+id/tvSyncStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:lineSpacingExtra="4dp"
android:text="@string/device_settings_syncing"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnRefresh"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="16dp"
android:background="@drawable/selector_warm_primary_button"
android:elevation="0dp"
android:stateListAnimator="@null"
android:text="@string/device_settings_refresh"
android:textAllCaps="false"
android:textColor="@color/text_warm_primary_button"
android:textSize="17sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
+40 -20
View File
@@ -12,29 +12,49 @@
android:paddingTop="40dp"
android:paddingBottom="20dp">
<TextView
android:id="@+id/tv_title"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:text="@string/app_title"
android:textColor="@color/warm_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
android:paddingStart="18dp"
android:paddingEnd="18dp">
<TextView
android:id="@+id/tv_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:text="@string/app_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingEnd="68dp">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/app_title"
android:textColor="@color/warm_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/app_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
</LinearLayout>
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="top|end"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/settings_open"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_settings" />
</FrameLayout>
<LinearLayout
android:id="@+id/cardVoiceCloneEntry"
@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/warm_bg"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="28dp"
android:paddingEnd="18dp"
android:paddingBottom="24dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btnBack"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/shape_warm_icon_button"
android:contentDescription="@string/device_settings_back"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_arrow_back" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/settings_title"
android:textColor="@color/warm_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="4dp"
android:text="@string/settings_subtitle"
android:textColor="@color/warm_text_secondary"
android:textSize="15sp" />
<LinearLayout
android:id="@+id/rowDeviceCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:background="@drawable/shape_warm_feature_card"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="18dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_device_code_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="@string/settings_device_code_desc"
android:textColor="@color/warm_text_secondary"
android:textSize="14sp" />
</LinearLayout>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:src="@drawable/ic_chevron_right"
android:contentDescription="@null" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/shape_warm_feature_card"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_about_title"
android:textColor="@color/warm_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings_version_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvVersionValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings_server_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvServerValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/warm_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings_device_label"
android:textColor="@color/warm_text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvDeviceValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/warm_text_primary"
android:textIsSelectable="true"
android:textSize="15sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -60,6 +60,29 @@
<string name="voice_clone_saved">熟悉声音已经保存好了</string>
<string name="voice_clone_create_failed">熟悉声音保存失败,请稍后再试</string>
<string name="voice_clone_unavailable">请先在熟悉声音陪伴里保存一段声音</string>
<string name="device_settings_entry_title">家人连接</string>
<string name="device_settings_entry_desc">打开设备码页面后,家人扫一扫就能把这台陪伴设备绑定到自己的账号,之后可以给您留言。</string>
<string name="device_settings_entry_action">打开设备码</string>
<string name="device_settings_back">返回</string>
<string name="device_settings_title">家人连接</string>
<string name="device_settings_subtitle">把这个二维码给家里人扫一扫,绑定后他们就能给您留言,也能看看最近的陪伴记录。</string>
<string name="device_settings_qr_title">这台设备的二维码</string>
<string name="device_settings_qr_desc">家人用网页面板扫一扫,就能把这台设备连接到自己的账号。</string>
<string name="device_settings_uuid_label">设备 UUID</string>
<string name="device_settings_link_label">绑定链接</string>
<string name="device_settings_refresh">重新同步二维码</string>
<string name="device_settings_syncing">正在为您同步设备连接信息…</string>
<string name="device_settings_sync_success">设备二维码已经准备好了</string>
<string name="device_settings_sync_failed">同步失败:%1$s</string>
<string name="settings_open">打开设置</string>
<string name="settings_title">设置</string>
<string name="settings_subtitle">把设备信息、家人连接入口和当前版本都放在这里,找起来更方便。</string>
<string name="settings_device_code_title">家人连接设备码</string>
<string name="settings_device_code_desc">打开设备二维码页面后,家里人扫一扫就能把这台设备绑定到自己的账号。</string>
<string name="settings_about_title">关于这台设备</string>
<string name="settings_version_label">应用版本</string>
<string name="settings_server_label">家属面板域名</string>
<string name="settings_device_label">当前设备 UUID</string>
<string name="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string>
<string name="debug_message">调试信息</string>
+23
View File
@@ -63,6 +63,29 @@
<string name="voice_clone_saved">The familiar voice has been saved</string>
<string name="voice_clone_create_failed">Failed to save the familiar voice, please try again later</string>
<string name="voice_clone_unavailable">Please save a familiar voice first</string>
<string name="device_settings_entry_title">Family Connection</string>
<string name="device_settings_entry_desc">Open the device code page so your family can scan and connect this companion device to their own account.</string>
<string name="device_settings_entry_action">Open Device Code</string>
<string name="device_settings_back">Back</string>
<string name="device_settings_title">Family Connection</string>
<string name="device_settings_subtitle">Show this QR code to your family. After scanning, they can leave you messages and check recent usage records.</string>
<string name="device_settings_qr_title">This device QR code</string>
<string name="device_settings_qr_desc">Family members can scan this code with the web panel to bind this device.</string>
<string name="device_settings_uuid_label">Device UUID</string>
<string name="device_settings_link_label">Binding link</string>
<string name="device_settings_refresh">Refresh QR Code</string>
<string name="device_settings_syncing">Syncing device status…</string>
<string name="device_settings_sync_success">Device QR code is ready</string>
<string name="device_settings_sync_failed">Sync failed: %1$s</string>
<string name="settings_open">Open settings</string>
<string name="settings_title">Settings</string>
<string name="settings_subtitle">Keep device information, family connection entry and current version together in one place.</string>
<string name="settings_device_code_title">Family device code</string>
<string name="settings_device_code_desc">Open the QR code page for your family to scan and bind this elder device.</string>
<string name="settings_about_title">About this device</string>
<string name="settings_version_label">App version</string>
<string name="settings_server_label">Family panel domain</string>
<string name="settings_device_label">Current device UUID</string>
<string name="main_download_tips">The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service</string>
<string name="debug_message">Debug message</string>
</resources>