添加家人绑定

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
+1 -1
View File
@@ -11,7 +11,7 @@ buildscript {
google() google()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.1.2' classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10'
} }
} }
+8 -7
View File
@@ -1,15 +1,16 @@
plugins { plugins {
id 'com.android.library' id 'com.android.library'
} }
def sdkVersionCode = (rootProject.ext.has('versionCode') ? rootProject.ext.versionCode : 1) as Integer
def sdkVersionName = (rootProject.ext.has('versionName') ? rootProject.ext.versionName : '1.0.0').toString()
android { android {
namespace 'ai.guiji.duix.sdk.client' namespace 'ai.guiji.duix.sdk.client'
compileSdk 33 compileSdk 33
ndkVersion '25.1.8937393'
defaultConfig { defaultConfig {
minSdk 24 minSdk 24
versionCode 13
versionName '4.1.1'
externalNativeBuild { externalNativeBuild {
cmake { cmake {
@@ -25,20 +26,20 @@ android {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") buildConfigField("String", "VERSION_NAME", "\"${sdkVersionName}\"")
buildConfigField('int', 'VERSION_CODE', "${defaultConfig.versionCode}") buildConfigField('int', 'VERSION_CODE', "${sdkVersionCode}")
} }
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") buildConfigField("String", "VERSION_NAME", "\"${sdkVersionName}\"")
buildConfigField('int', 'VERSION_CODE', "${defaultConfig.versionCode}") buildConfigField('int', 'VERSION_CODE', "${sdkVersionCode}")
android.libraryVariants.all { variant -> android.libraryVariants.all { variant ->
variant.outputs.all { variant.outputs.all {
outputFileName = "duix_client_sdk_${buildType.name}_${defaultConfig.versionName}.aar" outputFileName = "duix_client_sdk_${buildType.name}_${sdkVersionName}.aar"
} }
} }
} }
+1 -1
View File
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
File diff suppressed because it is too large Load Diff
+2374
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,6 +10,7 @@ static def getCurrentTime() {
android { android {
namespace 'ai.guiji.duix.test' namespace 'ai.guiji.duix.test'
compileSdk 34 compileSdk 34
ndkVersion '25.1.8937393'
defaultConfig { defaultConfig {
applicationId "ai.guiji.duix.test" applicationId "ai.guiji.duix.test"
@@ -87,4 +88,5 @@ dependencies {
implementation project(":duix-sdk") implementation project(":duix-sdk")
implementation 'com.squareup.okhttp3:okhttp:4.10.0' 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:exported="false"
android:theme="@style/Theme.DUIX.Call"> android:theme="@style/Theme.DUIX.Call">
</activity> </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> </application>
</manifest> </manifest>
@@ -9,6 +9,10 @@ import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; 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.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@@ -71,6 +75,10 @@ public class OmniRealtimeClient {
private final AtomicBoolean assistantTextFinalized = new AtomicBoolean(false); private final AtomicBoolean assistantTextFinalized = new AtomicBoolean(false);
private final StringBuilder assistantTextBuffer = new StringBuilder(); private final StringBuilder assistantTextBuffer = new StringBuilder();
public static String getDefaultInstructions() {
return DEFAULT_INSTRUCTIONS;
}
public OmniRealtimeClient(SessionOptions sessionOptions, Callback callback) { public OmniRealtimeClient(SessionOptions sessionOptions, Callback callback) {
this.sessionOptions = sessionOptions; this.sessionOptions = sessionOptions;
this.callback = callback; this.callback = callback;
@@ -170,7 +178,15 @@ public class OmniRealtimeClient {
} }
session.put("input_audio_format", "pcm"); session.put("input_audio_format", "pcm");
session.put("instructions", options.getInstructions()); 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()) { if (options.isEnableInputAudioTranscription()) {
JSONObject inputAudioTranscription = new JSONObject(); JSONObject inputAudioTranscription = new JSONObject();
@@ -263,6 +279,16 @@ public class OmniRealtimeClient {
dispatchAssistantFinalText(event.optString("text", event.optString("transcript", ""))); dispatchAssistantFinalText(event.optString("text", event.optString("transcript", "")));
break; 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": case "response.output_item.done":
dispatchAssistantFinalTextIfNeeded(extractTextFromItem(event.optJSONObject("item"))); dispatchAssistantFinalTextIfNeeded(extractTextFromItem(event.optJSONObject("item")));
break; break;
@@ -391,6 +417,35 @@ public class OmniRealtimeClient {
return isConnected.get(); 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) { private void handleServerError(JSONObject event) {
String errMsg = event.optJSONObject("error") != null String errMsg = event.optJSONObject("error") != null
? event.optJSONObject("error").optString("message", "Unknown error") ? event.optJSONObject("error").optString("message", "Unknown error")
@@ -510,7 +565,7 @@ public class OmniRealtimeClient {
} }
private String nextEventId() { private String nextEventId() {
return "event_" + System.currentTimeMillis(); return "event_" + UUID.randomUUID().toString().replace("-", "");
} }
public static class SessionOptions { public static class SessionOptions {
@@ -528,29 +583,48 @@ public class OmniRealtimeClient {
private final boolean enableTurnDetection; private final boolean enableTurnDetection;
private final boolean enableInputAudioTranscription; private final boolean enableInputAudioTranscription;
private final OutputMode outputMode; private final OutputMode outputMode;
private final List<ToolDefinition> tools;
public SessionOptions(String voice, boolean enableSearch) { public SessionOptions(String voice, boolean enableSearch) {
this(voice, enableSearch, OutputMode.AUDIO_AND_TEXT); this(voice, enableSearch, OutputMode.AUDIO_AND_TEXT);
} }
public SessionOptions(String voice, boolean enableSearch, OutputMode outputMode) { 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, 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, public SessionOptions(String apiKey, String baseUrl, String model, String voice,
String instructions, boolean enableSearch, boolean enableTurnDetection, String instructions, boolean enableSearch, boolean enableTurnDetection,
boolean enableInputAudioTranscription, OutputMode outputMode) { 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.apiKey = apiKey == null ? "" : apiKey;
this.baseUrl = baseUrl == null || baseUrl.trim().isEmpty() ? DEFAULT_BASE_URL : baseUrl; this.baseUrl = baseUrl == null || baseUrl.trim().isEmpty() ? DEFAULT_BASE_URL : baseUrl;
this.model = model == null || model.trim().isEmpty() ? DEFAULT_MODEL : model; this.model = model == null || model.trim().isEmpty() ? DEFAULT_MODEL : model;
this.voice = voice == null || voice.trim().isEmpty() ? "Ethan" : voice; this.voice = voice == null || voice.trim().isEmpty() ? "Ethan" : voice;
this.instructions = instructions == null || instructions.trim().isEmpty() String resolvedInstructions = ignoredInstructionsOverride;
? DEFAULT_INSTRUCTIONS : instructions; if (resolvedInstructions == null || resolvedInstructions.trim().isEmpty()) {
resolvedInstructions = instructions;
}
this.instructions = resolvedInstructions == null || resolvedInstructions.trim().isEmpty()
? DEFAULT_INSTRUCTIONS : resolvedInstructions;
this.enableSearch = enableSearch; this.enableSearch = enableSearch;
this.enableTurnDetection = enableTurnDetection; this.enableTurnDetection = enableTurnDetection;
this.enableInputAudioTranscription = enableInputAudioTranscription; this.enableInputAudioTranscription = enableInputAudioTranscription;
this.outputMode = outputMode == null ? OutputMode.AUDIO_AND_TEXT : outputMode; this.outputMode = outputMode == null ? OutputMode.AUDIO_AND_TEXT : outputMode;
this.tools = tools == null ? Collections.emptyList() : new ArrayList<>(tools);
} }
public String getApiKey() { public String getApiKey() {
@@ -584,6 +658,38 @@ public class OmniRealtimeClient {
public boolean isAudioOutputEnabled() { public boolean isAudioOutputEnabled() {
return outputMode == OutputMode.AUDIO_AND_TEXT; 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 { public interface Callback {
@@ -603,6 +709,8 @@ public class OmniRealtimeClient {
void onAssistantTranscriptDone(String transcript); void onAssistantTranscriptDone(String transcript);
void onToolCallRequested(String toolName, String callId, String argumentsJson);
void onAssistantAudioDelta(byte[] pcmData); void onAssistantAudioDelta(byte[] pcmData);
void onAssistantAudioDone(); 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_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_MODEL = "qwen-voice-enrollment";
public static final String VOICE_CLONE_TTS_MODEL = "qwen3-tts-vc-realtime-2026-01-15"; 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; public static final int AUDIO_SAMPLE_RATE = 24000;
private DashScopeConfig() { 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.OmniRealtimeClient
import ai.guiji.duix.test.realtime.QwenTtsRealtimeClient import ai.guiji.duix.test.realtime.QwenTtsRealtimeClient
import ai.guiji.duix.test.service.DashScopeConfig 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.adapter.MotionAdapter
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
import ai.guiji.duix.test.ui.widget.AudioSpectrumView import ai.guiji.duix.test.ui.widget.AudioSpectrumView
@@ -29,9 +30,14 @@ import android.view.View
import android.widget.CompoundButton import android.widget.CompoundButton
import android.widget.Toast import android.widget.Toast
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import org.json.JSONArray
import org.json.JSONObject
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class CallActivity : BaseActivity() { class CallActivity : BaseActivity() {
@@ -74,11 +80,15 @@ class CallActivity : BaseActivity() {
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var assistantTextBuffer = StringBuilder() private var assistantTextBuffer = StringBuilder()
private var hasStartedPushForResponse = false private var hasStartedPushForResponse = false
private val enableWebSearch = true private val enableWebSearch = false
private val enableToolCalling = true
private var isCloneMode = false private var isCloneMode = false
private var clonedVoice = "" private var clonedVoice = ""
private var cloneTtsModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL private var cloneTtsModel = DashScopeConfig.VOICE_CLONE_TTS_MODEL
private var ttsRealtimeClient: QwenTtsRealtimeClient? = null 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -96,6 +106,10 @@ class CallActivity : BaseActivity() {
clonedVoice = intent.getStringExtra("clonedVoice") ?: "" clonedVoice = intent.getStringExtra("clonedVoice") ?: ""
cloneTtsModel = intent.getStringExtra("cloneTtsModel") ?: DashScopeConfig.VOICE_CLONE_TTS_MODEL 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) Glide.with(mContext).load("file:///android_asset/bg/bg1.png").into(binding.ivBg)
binding.glTextureView.setEGLContextClientVersion(GL_CONTEXT_VERSION) binding.glTextureView.setEGLContextClientVersion(GL_CONTEXT_VERSION)
@@ -263,7 +277,75 @@ class CallActivity : BaseActivity() {
} else { } else {
OmniRealtimeClient.SessionOptions.OutputMode.AUDIO_AND_TEXT 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 { private fun isImageAppendError(message: String): Boolean {
@@ -284,6 +366,7 @@ class CallActivity : BaseActivity() {
super.onDestroy() super.onDestroy()
stopAIConversation() stopAIConversation()
duix?.release() duix?.release()
bridgeExecutor.shutdownNow()
} }
private fun playPCMStream(){ private fun playPCMStream(){
@@ -359,6 +442,7 @@ class CallActivity : BaseActivity() {
binding.audioSpectrum.clear() binding.audioSpectrum.clear()
assistantTextBuffer.clear() assistantTextBuffer.clear()
hasStartedPushForResponse = false hasStartedPushForResponse = false
currentSessionId = UUID.randomUUID().toString()
binding.btnAIConversation.setImageResource(R.drawable.ic_mic_off) binding.btnAIConversation.setImageResource(R.drawable.ic_mic_off)
binding.btnAIConversation.setBackgroundResource(R.drawable.shape_fab_ai_active) 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.setBackgroundResource(R.drawable.shape_fab_camera_off)
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off) binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
isCameraEnabled = false 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 { streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
override fun onAudioData(pcmData: ByteArray) { override fun onAudioData(pcmData: ByteArray) {
omniClient?.appendAudio(pcmData) omniClient?.appendAudio(pcmData)
@@ -384,6 +468,15 @@ class CallActivity : BaseActivity() {
}) })
streamingRecorder?.start() 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() { override fun onDisconnected() {
@@ -422,6 +515,7 @@ class CallActivity : BaseActivity() {
mainHandler.post { mainHandler.post {
binding.tvSubtitle.text = getString(R.string.ai_call_user_said, transcript) binding.tvSubtitle.text = getString(R.string.ai_call_user_said, transcript)
} }
uploadConversationTurnAsync(FamilyBridgeService.ROLE_USER, transcript)
} }
override fun onAssistantTextDelta(delta: String) { override fun onAssistantTextDelta(delta: String) {
@@ -437,6 +531,19 @@ class CallActivity : BaseActivity() {
assistantTextBuffer.append(transcript) assistantTextBuffer.append(transcript)
binding.tvSubtitle.text = 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) { override fun onAssistantAudioDelta(pcmData: ByteArray) {
@@ -484,6 +591,8 @@ class CallActivity : BaseActivity() {
} }
private fun stopAIConversation() { private fun stopAIConversation() {
val endedSessionId = currentSessionId
currentSessionId = null
isAIConversationActive = false isAIConversationActive = false
stopCamera() stopCamera()
stopCloneSpeech() stopCloneSpeech()
@@ -507,6 +616,16 @@ class CallActivity : BaseActivity() {
applyMessage("AI对话: 已结束") 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) { private fun speakWithClonedVoice(text: String) {
@@ -602,6 +721,11 @@ class CallActivity : BaseActivity() {
cameraFrameCapture?.start() cameraFrameCapture?.start()
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_on) binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_on)
binding.btnCameraToggle.contentDescription = getString(R.string.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 { } else {
stopCamera() stopCamera()
binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off) binding.btnCameraToggle.setBackgroundResource(R.drawable.shape_fab_camera_off)
@@ -610,12 +734,199 @@ class CallActivity : BaseActivity() {
} }
private fun stopCamera() { private fun stopCamera() {
val wasEnabled = isCameraEnabled || cameraFrameCapture != null
cameraFrameCapture?.stop() cameraFrameCapture?.stop()
cameraFrameCapture = null cameraFrameCapture = null
isCameraEnabled = false isCameraEnabled = false
binding.cameraPreview.visibility = View.GONE 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(){ private fun showRecordDialog(){
val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{ val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{
override fun onFinish(path: String) { 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.databinding.ActivityMainBinding
import ai.guiji.duix.test.model.AvatarCatalog import ai.guiji.duix.test.model.AvatarCatalog
import ai.guiji.duix.test.model.AvatarModel 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.adapter.AvatarAdapter
import ai.guiji.duix.test.ui.dialog.LoadingDialog import ai.guiji.duix.test.ui.dialog.LoadingDialog
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import org.json.JSONObject
import java.io.File import java.io.File
class MainActivity : BaseActivity() { class MainActivity : BaseActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private val familyBridgeService by lazy { FamilyBridgeService(applicationContext) }
private var mLoadingDialog: LoadingDialog? = null private var mLoadingDialog: LoadingDialog? = null
private var mLastProgress = 0 private var mLastProgress = 0
@@ -32,6 +35,9 @@ class MainActivity : BaseActivity() {
binding.btnVoiceClone.setOnClickListener { binding.btnVoiceClone.setOnClickListener {
startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java)) startActivity(Intent(mContext, VoiceCloneSetupActivity::class.java))
} }
binding.btnSettings.setOnClickListener {
startActivity(Intent(mContext, SettingsActivity::class.java))
}
binding.rvAvatars.layoutManager = GridLayoutManager(this, 2) binding.rvAvatars.layoutManager = GridLayoutManager(this, 2)
binding.rvAvatars.adapter = AvatarAdapter(AvatarCatalog.models, object : AvatarAdapter.Callback { binding.rvAvatars.adapter = AvatarAdapter(AvatarCatalog.models, object : AvatarAdapter.Callback {
@@ -40,6 +46,20 @@ class MainActivity : BaseActivity() {
play(model) 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) { 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:paddingTop="40dp"
android:paddingBottom="20dp"> android:paddingBottom="20dp">
<TextView <FrameLayout
android:id="@+id/tv_title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:paddingStart="18dp"
android:paddingStart="24dp" android:paddingEnd="18dp">
android:paddingEnd="24dp"
android:text="@string/app_title"
android:textColor="@color/warm_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<TextView <LinearLayout
android:id="@+id/tv_subtitle" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:orientation="vertical"
android:layout_marginTop="8dp" android:paddingEnd="68dp">
android:gravity="center"
android:paddingStart="28dp" <TextView
android:paddingEnd="28dp" android:id="@+id/tv_title"
android:text="@string/app_subtitle" android:layout_width="match_parent"
android:textColor="@color/warm_text_secondary" android:layout_height="wrap_content"
android:textSize="15sp" /> 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 <LinearLayout
android:id="@+id/cardVoiceCloneEntry" 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_saved">熟悉声音已经保存好了</string>
<string name="voice_clone_create_failed">熟悉声音保存失败,请稍后再试</string> <string name="voice_clone_create_failed">熟悉声音保存失败,请稍后再试</string>
<string name="voice_clone_unavailable">请先在熟悉声音陪伴里保存一段声音</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="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string>
<string name="debug_message">调试信息</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_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_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="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="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> <string name="debug_message">Debug message</string>
</resources> </resources>