diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c444ffa..b592c1a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -37,8 +37,18 @@ android { signingConfig = signingConfigs.getByName("debug") } } + repositories { + flatDir { + dirs("libs") + } + } } flutter { source = "../.." } + +dependencies { + implementation(files("libs/fastjson-1.1.46.android.jar")) + implementation(files("libs/nuisdk-release.aar")) +} \ No newline at end of file diff --git a/android/app/libs/fastjson-1.1.46.android.jar b/android/app/libs/fastjson-1.1.46.android.jar new file mode 100644 index 0000000..1d607f2 Binary files /dev/null and b/android/app/libs/fastjson-1.1.46.android.jar differ diff --git a/android/app/libs/nuisdk-release.aar b/android/app/libs/nuisdk-release.aar new file mode 100644 index 0000000..37d6835 Binary files /dev/null and b/android/app/libs/nuisdk-release.aar differ diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/AudioPlayer.java b/android/app/src/main/java/com/example/ai_chat_assistant/AudioPlayer.java new file mode 100644 index 0000000..f901250 --- /dev/null +++ b/android/app/src/main/java/com/example/ai_chat_assistant/AudioPlayer.java @@ -0,0 +1,238 @@ +package com.example.ai_chat_assistant; + +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.util.Log; + +import java.util.concurrent.LinkedBlockingQueue; + +public class AudioPlayer { + + public enum PlayState { + idle, + playing, + pause, + release + } + + private static final String TAG = "AudioPlayer"; + + private int SAMPLE_RATE = 16000; + private String ENCODE_TYPE = "pcm"; + private boolean isFinishSend = false; + private AudioPlayerCallback audioPlayerCallback; + private LinkedBlockingQueue audioQueue = new LinkedBlockingQueue(); + private PlayState playState; + private byte[] tempData; + private Thread ttsPlayerThread; + + // 初始化播放器 + // 此处仅使用Android系统自带的AudioTrack进行音频播放Demo演示, 客户可根据自己需要替换播放器 + // 默认采样率为16000、单通道、16bit pcm格式 + private int iMinBufSize = AudioTrack.getMinBufferSize(SAMPLE_RATE, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT) * 2; + private AudioTrack audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, SAMPLE_RATE, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + iMinBufSize, AudioTrack.MODE_STREAM); + + + AudioPlayer(AudioPlayerCallback callback) { + Log.i(TAG,"Audio Player init!"); + playState = PlayState.idle; + if (audioTrack == null) { + Log.e(TAG, "AudioTrack is uninited!! new again..."); + iMinBufSize = AudioTrack.getMinBufferSize(SAMPLE_RATE, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT) * 2; + audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, SAMPLE_RATE, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + iMinBufSize, AudioTrack.MODE_STREAM); + } + if (audioTrack == null) { + Log.e(TAG, "AudioTrack new failed ..."); + } + audioTrack.play(); + audioPlayerCallback = callback; + + ttsPlayerThread = new Thread(new Runnable() { + @Override + public void run() { + while (playState != PlayState.release) { + if (playState == PlayState.playing) { + if (audioQueue.size() == 0) { + if (isFinishSend) { + audioPlayerCallback.playOver(); + isFinishSend = false; + } else { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + continue; + } + try { + tempData = audioQueue.take(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + +// int sound_level = calculateRMSLevel(tempData); +// Log.i(TAG,"sound_level: " + sound_level); +// audioPlayerCallback.playSoundLevel(sound_level); + audioTrack.write(tempData, 0, tempData.length); + } else { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } // while + Log.i(TAG,"exit ttsPlayerThread"); + } + }); + } + + public void setAudioData(byte[] data) { + audioQueue.offer(data); + //非阻塞 + } + + public void isFinishSend(boolean isFinish) { + isFinishSend = isFinish; + Log.i(TAG,"Player isFinishSend:" + isFinishSend); + } + + public void play() { + if (!ttsPlayerThread.isAlive()) { + ttsPlayerThread.start(); + } + playState = PlayState.playing; + Log.i(TAG,"Player playState:" + playState); + isFinishSend = false; + if (audioTrack != null) { + audioTrack.play(); + } + audioPlayerCallback.playStart(); + } + + public void stop() { + playState = PlayState.idle; + Log.i(TAG,"stop-playState :" + playState); + audioQueue.clear(); + if (audioTrack != null) { + audioTrack.flush(); + audioTrack.pause(); + audioTrack.stop(); + } + } + + public void pause() { + playState = PlayState.pause; + if (audioTrack != null) { + audioTrack.pause(); + } + } + + public void resume() { + if (audioTrack != null) { + audioTrack.play(); + } + playState = PlayState.playing; + } + + public void initAudioTrack(int samplerate) { + // 初始化播放器 + // 此处仅使用Android系统自带的AudioTrack进行音频播放Demo演示, 客户可根据自己需要替换播放器 + // 默认采样率为16000、单通道、16bit pcm格式 + Log.i(TAG,"initAudioTrack audioTrack"); + int iMinBufSize = AudioTrack.getMinBufferSize(samplerate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT) * 2; + audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, samplerate, + AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, + iMinBufSize, AudioTrack.MODE_STREAM); + if (audioTrack == null) { + Log.e(TAG, "new AudioTrack failed with sr:" + samplerate + " and encode_type:" + ENCODE_TYPE); + } + } + + public void releaseAudioTrack(boolean finish) { + if (audioTrack != null) { + audioTrack.stop(); + if (finish) { + playState = PlayState.release; + } + audioTrack.release(); + Log.i(TAG,"releaseAudioTrack audioTrack released"); + } + audioTrack = null; + } + + public void releaseAudioTrack() { + if (audioTrack != null) { + audioTrack.stop(); + playState = PlayState.release; + audioTrack.release(); + Log.i(TAG,"releaseAudioTrack audioTrack released"); + } + audioTrack = null; + } + + public void setSampleRate(int sampleRate) { + if (SAMPLE_RATE != sampleRate) { + releaseAudioTrack(false); + initAudioTrack(sampleRate); + SAMPLE_RATE = sampleRate; + } + } + + public void setEncodeType(String type) { +// int encode_type = ENCODE_TYPE; +// if (type.equals("mp3")) { +// encode_type = AudioFormat.ENCODING_MP3; +// } else { +// encode_type = AudioFormat.ENCODING_PCM_16BIT; +// } +// if (encode_type != ENCODE_TYPE) { +// ENCODE_TYPE = encode_type; +// releaseAudioTrack(); +// initAudioTrack(SAMPLE_RATE); +// } + } + + // 计算给定PCM音频数据的RMS值 + private int calculateRMSLevel(byte[] audioData) { + // 将byte数组转换为short数组(假设是16位PCM,小端序) + short[] shorts = new short[audioData.length / 2]; + for (int i = 0; i < shorts.length; i++) { + shorts[i] = (short) ((audioData[i * 2] & 0xFF) | (audioData[i * 2 + 1] << 8)); + } + + // 计算平均平方值 + double rms = 1.0; + for (short s : shorts) { + rms += (double)Math.abs(s); + } + rms = rms / shorts.length; + + // 计算分贝值 + double db = 20 * Math.log10(rms); + db = db * 160 / 90 - 160; + if (db > 0.0) { + db = 0.0; + } else if (db < -160.0) { + db = -160; + } + + // level值 + int level = (int)((db + 160) * 100 / 160); + return level; + } +} diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/AudioPlayerCallback.java b/android/app/src/main/java/com/example/ai_chat_assistant/AudioPlayerCallback.java new file mode 100644 index 0000000..aaaefe8 --- /dev/null +++ b/android/app/src/main/java/com/example/ai_chat_assistant/AudioPlayerCallback.java @@ -0,0 +1,7 @@ +package com.example.ai_chat_assistant; + +public interface AudioPlayerCallback { + public void playStart(); + public void playOver(); + public void playSoundLevel(int level); +} diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/Auth.java b/android/app/src/main/java/com/example/ai_chat_assistant/Auth.java new file mode 100644 index 0000000..ccd577b --- /dev/null +++ b/android/app/src/main/java/com/example/ai_chat_assistant/Auth.java @@ -0,0 +1,674 @@ +package com.example.ai_chat_assistant; + +import android.util.Log; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; + +import com.example.ai_chat_assistant.token.AccessToken; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; + +/* + * STS服务不可直接用于在线功能,包括一句话识别、实时识别、语音合成等。 + * 在线功能需要TOKEN,可由STS临时账号通过TOKEN工具生成,也可在服务端下发TOKEN。 + * STS服务可用于离线功能的鉴权,比如本地语音合成和唤醒。 + */ +public class Auth { + public enum GetTicketMethod { + /* + * 客户远端服务端使用STS服务获得STS临时凭证,然后下发给移动端侧, + * 然后生成语音交互临时凭证Token。用于在线功能场景。 + */ + GET_STS_ACCESS_FROM_SERVER_FOR_ONLINE_FEATURES, + /* + * 客户远端服务端使用STS服务获得STS临时凭证,然后下发给移动端侧, + * 同时设置sdk_code, 用于离线功能场景。 + */ + GET_STS_ACCESS_FROM_SERVER_FOR_OFFLINE_FEATURES, + /* + * 客户远端服务端使用STS服务获得STS临时凭证,然后下发给移动端侧, + * 然后生成语音交互临时凭证Token。 + * 同时设置sdk_code, 用于离线在线功能混合场景。 + */ + GET_STS_ACCESS_FROM_SERVER_FOR_MIXED_FEATURES, + /* + * 客户远端服务端使用Token服务获得Token临时令牌,然后下发给移动端侧, + * 用于在线功能场景。 + */ + GET_TOKEN_FROM_SERVER_FOR_ONLINE_FEATURES, + /* + * 客户远端服务端将账号信息ak_id和ak_secret(请加密)下发给移动端侧, + * 同时设置sdk_code, 用于离线功能场景。 + */ + GET_ACCESS_FROM_SERVER_FOR_OFFLINE_FEATURES, + /* + * 客户远端服务端将账号信息ak_id和ak_secret(请加密)下发给移动端侧, + * 然后生成语音交互临时凭证Token。 + * 同时设置sdk_code, 用于离线在线功能混合场景。 + */ + GET_ACCESS_FROM_SERVER_FOR_MIXED_FEATURES, + /* + * 客户直接使用存储在移动端侧的Token, + * 用于在线功能场景。 + */ + GET_TOKEN_IN_CLIENT_FOR_ONLINE_FEATURES, + /* + * 客户直接使用存储在移动端侧的ak_id和ak_secret(请加密), + * 同时设置sdk_code, 用于离线功能场景。 + */ + GET_ACCESS_IN_CLIENT_FOR_OFFLINE_FEATURES, + /* + * 客户直接使用存储在移动端侧的ak_id和ak_secret(请加密), + * 然后生成语音交互临时凭证Token。 + * 同时设置sdk_code, 用于离线在线功能混合场景。 + */ + GET_ACCESS_IN_CLIENT_FOR_MIXED_FEATURES, + /* + * 客户直接使用存储在移动端侧的ak_id和ak_secret(请加密), + * 用于在线功能场景。 + */ + GET_ACCESS_IN_CLIENT_FOR_ONLINE_FEATURES, + /* + * 客户直接使用存储在移动端侧的STS凭证, + * 然后生成语音交互临时凭证Token。用于在线功能场景。 + */ + GET_STS_ACCESS_IN_CLIENT_FOR_ONLINE_FEATURES, + /* + * 客户直接使用存储在移动端侧的STS凭证, + * 同时设置sdk_code, 用于离线功能场景。 + */ + GET_STS_ACCESS_IN_CLIENT_FOR_OFFLINE_FEATURES, + /* + * 客户直接使用存储在移动端侧的STS凭证, + * 然后生成语音交互临时凭证Token。 + * 同时设置sdk_code, 用于离线在线功能混合场景。 + */ + GET_STS_ACCESS_IN_CLIENT_FOR_MIXED_FEATURES; + } + + private static GetTicketMethod cur_method = GetTicketMethod.GET_STS_ACCESS_FROM_SERVER_FOR_ONLINE_FEATURES; + private static String cur_appkey = ""; + private static String cur_token = ""; + private static String cur_sts_token = ""; + private static long cur_token_expired_time = 0; + private static String cur_ak = ""; + private static String cur_sk = ""; + private static String cur_sdk_code = "software_nls_tts_offline_standard"; + + public static void setAppKey(String appkey) { + if (!appkey.isEmpty()) { + cur_appkey = appkey; + } + } + public static void setToken(String token) { + if (!token.isEmpty()) { + cur_token = token; + } + } + public static void setStsToken(String stsToken) { + cur_sts_token = stsToken; + } + public static void setAccessKey(String ak) { + if (!ak.isEmpty()) { + cur_ak = ak; + } + } + public static void setAccessKeySecret(String sk) { + if (!sk.isEmpty()) { + cur_sk = sk; + } + } + public static void setSdkCode(String sdkCode) { + if (!sdkCode.isEmpty()) { + cur_sdk_code = sdkCode; + } + } + + // 将鉴权信息打包成json格式 + public static JSONObject getTicket(GetTicketMethod method) { + //郑重提示: + // 语音交互服务需要先准备好账号,并开通相关服务。具体步骤请查看: + // https://help.aliyun.com/zh/isi/getting-started/start-here + // + //原始账号: + // 账号(子账号)信息主要包括AccessKey ID(后续简称为ak_id)和AccessKey Secret(后续简称为ak_secret)。 + // 此账号信息一定不可存储在app代码中或移动端侧,以防账号信息泄露造成资费损失。 + // + //STS临时凭证: + // 由于账号信息下发给客户端存在泄露的可能,阿里云提供的一种临时访问权限管理服务STS(Security Token Service)。 + // STS是由账号信息ak_id和ak_secret,通过请求生成临时的sts_ak_id/sts_ak_secret/sts_token + // (为了区别原始账号信息和STS临时凭证, 命名前缀sts_表示STS生成的临时凭证信息) + //什么是STS:https://help.aliyun.com/zh/ram/product-overview/what-is-sts + //STS SDK概览:https://help.aliyun.com/zh/ram/developer-reference/sts-sdk-overview + //STS Python SDK调用示例:https://help.aliyun.com/zh/ram/developer-reference/use-the-sts-openapi-example + // + //账号需求说明: + // 若使用离线功能(离线语音合成、唤醒), 则必须app_key、ak_id和ak_secret,或app_key、sts_ak_id、sts_ak_secret和sts_token + // 若使用在线功能(语音合成、实时转写、一句话识别、录音文件转写等), 则只需app_key和token + + JSONObject object = new JSONObject(); + + cur_method = method; + + //项目创建 + // 创建appkey请查看:https://help.aliyun.com/zh/isi/getting-started/start-here + String APPKEY = "<您申请创建的app_key>"; + if (!cur_appkey.isEmpty()) { + APPKEY = cur_appkey; + } + object.put("app_key", APPKEY); // 必填 + cur_appkey = APPKEY; + + if (method == GetTicketMethod.GET_STS_ACCESS_FROM_SERVER_FOR_ONLINE_FEATURES) { + //方法一,仅适合在线语音交互服务(强烈推荐): + // 客户远端服务端使用STS服务获得STS临时凭证,然后下发给移动端侧,详情请查看: + // https://help.aliyun.com/document_detail/466615.html 使用其中方案二使用STS获取临时账号。 + // 然后在移动端侧通过AccessToken()获得Token和有效期,用于在线语音交互服务。 + String STS_AK_ID = "STS.<服务器生成的具有时效性的临时凭证>"; + String STS_AK_SECRET = "<服务器生成的具有时效性的临时凭证>"; + String STS_TOKEN = "<服务器生成的具有时效性的临时凭证>"; + String TOKEN = "<由STS生成的临时访问令牌>"; + final AccessToken token = new AccessToken(STS_AK_ID, STS_AK_SECRET, STS_TOKEN); + Thread th = new Thread() { + @Override + public void run() { + try { + token.apply(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + th.start(); + try { + th.join(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + TOKEN = token.getToken(); + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_token = TOKEN; + cur_ak = STS_AK_ID; + cur_sk = STS_AK_SECRET; + cur_sts_token = STS_TOKEN; + // token生命周期超过expired_time, 则需要重新token = new AccessToken() + cur_token_expired_time = token.getExpireTime(); + } else { + cur_token = ""; + cur_sts_token = ""; + } + } else if (method == GetTicketMethod.GET_STS_ACCESS_FROM_SERVER_FOR_OFFLINE_FEATURES) { + //方法二,仅适合离线语音交互服务(强烈推荐): + // 客户远端服务端使用STS服务获得STS临时凭证,然后下发给移动端侧,详情请查看: + // https://help.aliyun.com/document_detail/466615.html 使用其中方案二使用STS获取临时账号。 + String STS_AK_ID = "STS.<服务器生成的具有时效性的临时凭证>"; + String STS_AK_SECRET = "<服务器生成的具有时效性的临时凭证>"; + String STS_TOKEN = "<服务器生成的具有时效性的临时凭证>"; + object.put("ak_id", STS_AK_ID); // 必填 + object.put("ak_secret", STS_AK_SECRET); // 必填 + object.put("sts_token", STS_TOKEN); // 必填 + cur_ak = STS_AK_ID; + cur_sk = STS_AK_SECRET; + cur_sts_token = STS_TOKEN; + // 离线语音合成sdk_code取值:精品版为software_nls_tts_offline, 标准版为software_nls_tts_offline_standard + // 离线语音合成账户和sdk_code可用于唤醒 + // 由创建Appkey时设置 + object.put("sdk_code", cur_sdk_code); // 必填 + } else if (method == GetTicketMethod.GET_STS_ACCESS_FROM_SERVER_FOR_MIXED_FEATURES) { + //方法三,适合离在线语音交互服务(强烈推荐): + // 客户远端服务端使用STS服务获得STS临时凭证,然后下发给移动端侧,详情请查看: + // https://help.aliyun.com/document_detail/466615.html 使用其中方案二使用STS获取临时账号。 + // 然后在移动端侧通过AccessToken()获得Token和有效期,用于在线语音交互服务。 + //注意!此处介绍同一个Appkey用于在线和离线功能,用户可创建两个Appkey分别用于在线和离线功能。 + String STS_AK_ID = "STS.<服务器生成的具有时效性的临时凭证>"; + String STS_AK_SECRET = "<服务器生成的具有时效性的临时凭证>"; + String STS_TOKEN = "<服务器生成的具有时效性的临时凭证>"; + String TOKEN = "<由STS生成的临时访问令牌>"; + final AccessToken token = new AccessToken(STS_AK_ID, STS_AK_SECRET, STS_TOKEN); + Thread th = new Thread() { + @Override + public void run() { + try { + token.apply(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + th.start(); + try { + th.join(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + TOKEN = token.getToken(); + + object.put("ak_id", STS_AK_ID); // 必填 + object.put("ak_secret", STS_AK_SECRET); // 必填 + object.put("sts_token", STS_TOKEN); // 必填 + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_token = TOKEN; + cur_ak = STS_AK_ID; + cur_sk = STS_AK_SECRET; + cur_sts_token = STS_TOKEN; + // token生命周期超过expired_time, 则需要重新token = new AccessToken() + cur_token_expired_time = token.getExpireTime(); + } else { + cur_token = ""; + cur_sts_token = ""; + } + // 离线语音合成sdk_code取值:精品版为software_nls_tts_offline, 标准版为software_nls_tts_offline_standard + // 离线语音合成账户和sdk_code可用于唤醒 + // 由创建Appkey时设置 + object.put("sdk_code", cur_sdk_code); // 必填 + } else if (method == GetTicketMethod.GET_TOKEN_FROM_SERVER_FOR_ONLINE_FEATURES) { + //方法四,仅适合在线语音交互服务(推荐): + // 客户远端服务端使用Token服务获得Token临时令牌,然后下发给移动端侧,详情请查看: + // https://help.aliyun.com/document_detail/466615.html 使用其中方案一获取临时令牌Token + // 获得Token方法: + // https://help.aliyun.com/zh/isi/getting-started/overview-of-obtaining-an-access-token + String TOKEN = "<服务器生成的具有时效性的临时凭证>"; + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_appkey = APPKEY; + cur_token = TOKEN; + } else { + cur_token = ""; + } + } else if (method == GetTicketMethod.GET_ACCESS_FROM_SERVER_FOR_OFFLINE_FEATURES) { + //方法五,仅适合离线语音交互服务(不推荐): + // 客户远端服务端将账号信息ak_id和ak_secret(请加密)下发给移动端侧。 + //注意!账号信息出现在移动端侧,存在泄露风险。 + String AK_ID = "<一定不可代码中存储和本地明文存储>"; + String AK_SECRET = "<一定不可代码中存储和本地明文存储>"; + object.put("ak_id", AK_ID); // 必填 + object.put("ak_secret", AK_SECRET); // 必填 + cur_ak = AK_ID; + cur_sk = AK_SECRET; + // 离线语音合成sdk_code取值:精品版为software_nls_tts_offline, 标准版为software_nls_tts_offline_standard + // 离线语音合成账户和sdk_code可用于唤醒 + // 由创建Appkey时设置 + object.put("sdk_code", cur_sdk_code); // 必填 + } else if (method == GetTicketMethod.GET_ACCESS_FROM_SERVER_FOR_MIXED_FEATURES) { + //方法六,适合离在线语音交互服务(不推荐): + // 客户远端服务端将账号信息ak_id和ak_secret(请加密)下发给移动端侧。 + // 然后在移动端侧通过AccessToken()获得Token和有效期,用于在线语音交互服务。 + //注意!账号信息出现在移动端侧,存在泄露风险。 + String AK_ID = "<一定不可代码中存储和本地明文存储>"; + String AK_SECRET = "<一定不可代码中存储和本地明文存储>"; + String TOKEN = "<生成的临时访问令牌>"; + final AccessToken token = new AccessToken(AK_ID, AK_SECRET, ""); + Thread th = new Thread() { + @Override + public void run() { + try { + token.apply(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + th.start(); + try { + th.join(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + TOKEN = token.getToken(); + + object.put("ak_id", AK_ID); // 必填 + object.put("ak_secret", AK_SECRET); // 必填 + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_appkey = APPKEY; + cur_token = TOKEN; + // token生命周期超过expired_time, 则需要重新token = new AccessToken() + cur_token_expired_time = token.getExpireTime(); + } else { + cur_token = ""; + } + // 离线语音合成sdk_code取值:精品版为software_nls_tts_offline, 标准版为software_nls_tts_offline_standard + // 离线语音合成账户和sdk_code可用于唤醒 + // 由创建Appkey时设置 + object.put("sdk_code", cur_sdk_code); // 必填 + } else if (method == GetTicketMethod.GET_TOKEN_IN_CLIENT_FOR_ONLINE_FEATURES) { + //方法七,仅适合在线语音交互服务(强烈不推荐): + // 仅仅用于开发和调试 + //注意!账号信息出现在移动端侧,存在泄露风险。 + String TOKEN = "<移动端写死的访问令牌,仅用于调试>"; + if (!cur_token.isEmpty()) { + TOKEN = cur_token; + } + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_appkey = APPKEY; + cur_token = TOKEN; + } else { + cur_token = ""; + } + } else if (method == GetTicketMethod.GET_ACCESS_IN_CLIENT_FOR_OFFLINE_FEATURES) { + //方法八,仅适合离线语音交互服务(强烈不推荐): + // 仅仅用于开发和调试 + //注意!账号信息出现在移动端侧,存在泄露风险。 + String AK_ID = "<移动端写死的账号信息,仅用于调试>"; + String AK_SECRET = "<移动端写死的账号信息,仅用于调试>"; + if (!cur_ak.isEmpty()) { + AK_ID = cur_ak; + } + if (!cur_sk.isEmpty()) { + AK_SECRET = cur_sk; + } + + object.put("ak_id", AK_ID); // 必填 + object.put("ak_secret", AK_SECRET); // 必填 + // 离线语音合成sdk_code取值:精品版为software_nls_tts_offline, 标准版为software_nls_tts_offline_standard + // 离线语音合成账户和sdk_code可用于唤醒 + // 由创建Appkey时设置 + object.put("sdk_code", cur_sdk_code); // 必填 + } else if (method == GetTicketMethod.GET_ACCESS_IN_CLIENT_FOR_MIXED_FEATURES) { + //方法九,适合离在线语音交互服务(强烈不推荐): + // 仅仅用于开发和调试 + //注意!账号信息出现在移动端侧,存在泄露风险。 + String AK_ID = "<移动端写死的账号信息,仅用于调试>"; + String AK_SECRET = "<移动端写死的账号信息,仅用于调试>"; + String TOKEN = "<生成的临时访问令牌>"; + if (!cur_ak.isEmpty()) { + AK_ID = cur_ak; + } + if (!cur_sk.isEmpty()) { + AK_SECRET = cur_sk; + } + final AccessToken token = new AccessToken(AK_ID, AK_SECRET, ""); + Thread th = new Thread() { + @Override + public void run() { + try { + token.apply(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + th.start(); + try { + th.join(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + TOKEN = token.getToken(); + + object.put("ak_id", AK_ID); // 必填 + object.put("ak_secret", AK_SECRET); // 必填 + cur_ak = AK_ID; + cur_sk = AK_SECRET; + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_appkey = APPKEY; + cur_token = TOKEN; + // token生命周期超过expired_time, 则需要重新token = new AccessToken() + cur_token_expired_time = token.getExpireTime(); + } else { + cur_token = ""; + } + // 离线语音合成sdk_code取值:精品版为software_nls_tts_offline, 标准版为software_nls_tts_offline_standard + // 离线语音合成账户和sdk_code可用于唤醒 + // 由创建Appkey时设置 + object.put("sdk_code", cur_sdk_code); // 必填 + } else if (method == GetTicketMethod.GET_ACCESS_IN_CLIENT_FOR_ONLINE_FEATURES) { + //方法十,适合在线语音交互服务(强烈不推荐): + // 仅仅用于开发和调试 + //注意!账号信息出现在移动端侧,存在泄露风险。 + String AK_ID = "<移动端写死的账号信息,仅用于调试>"; + String AK_SECRET = "<移动端写死的账号信息,仅用于调试>"; + String TOKEN = "<生成的临时访问令牌>"; + if (!cur_ak.isEmpty()) { + AK_ID = cur_ak; + } + if (!cur_sk.isEmpty()) { + AK_SECRET = cur_sk; + } + final AccessToken token = new AccessToken(AK_ID, AK_SECRET, ""); + Thread th = new Thread() { + @Override + public void run() { + try { + token.apply(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + th.start(); + try { + th.join(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + TOKEN = token.getToken(); + + object.put("ak_id", AK_ID); // 必填 + object.put("ak_secret", AK_SECRET); // 必填 + cur_ak = AK_ID; + cur_sk = AK_SECRET; + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_appkey = APPKEY; + cur_token = TOKEN; + // token生命周期超过expired_time, 则需要重新token = new AccessToken() + cur_token_expired_time = token.getExpireTime(); + } else { + cur_token = ""; + } + } else if (method == GetTicketMethod.GET_STS_ACCESS_IN_CLIENT_FOR_ONLINE_FEATURES) { + //方法十一,适合在线语音交互服务(强烈不推荐): + // 仅仅用于开发和调试 + String STS_AK_ID = "STS.<移动端写死的账号信息,仅用于调试>"; + String STS_AK_SECRET = "<移动端写死的账号信息,仅用于调试>"; + String STS_TOKEN = "<移动端写死的账号信息,仅用于调试>"; + if (!cur_ak.isEmpty()) { + STS_AK_ID = cur_ak; + } + if (!cur_sk.isEmpty()) { + STS_AK_SECRET = cur_sk; + } + if (!cur_sts_token.isEmpty()) { + STS_TOKEN = cur_sts_token; + } + String TOKEN = "<由STS生成的临时访问令牌>"; + final AccessToken token = new AccessToken(STS_AK_ID, STS_AK_SECRET, STS_TOKEN); + Thread th = new Thread() { + @Override + public void run() { + try { + token.apply(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + th.start(); + try { + th.join(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + TOKEN = token.getToken(); + + object.put("ak_id", STS_AK_ID); // 必填 + object.put("ak_secret", STS_AK_SECRET); // 必填 + object.put("sts_token", STS_TOKEN); // 必填 + cur_ak = STS_AK_ID; + cur_sk = STS_AK_SECRET; + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_appkey = APPKEY; + cur_token = TOKEN; + cur_sts_token = STS_TOKEN; + // token生命周期超过expired_time, 则需要重新token = new AccessToken() + cur_token_expired_time = token.getExpireTime(); + } else { + cur_token = ""; + cur_sts_token = ""; + } + } else if (method == GetTicketMethod.GET_STS_ACCESS_IN_CLIENT_FOR_OFFLINE_FEATURES) { + //方法十二,适合离线语音交互服务(强烈不推荐): + // 仅仅用于开发和调试 + String STS_AK_ID = "STS.<移动端写死的账号信息,仅用于调试>"; + String STS_AK_SECRET = "<移动端写死的账号信息,仅用于调试>"; + String STS_TOKEN = "<移动端写死的账号信息,仅用于调试>"; + if (!cur_ak.isEmpty()) { + STS_AK_ID = cur_ak; + } + if (!cur_sk.isEmpty()) { + STS_AK_SECRET = cur_sk; + } + if (!cur_sts_token.isEmpty()) { + STS_TOKEN = cur_sts_token; + } + String TOKEN = "<由STS生成的临时访问令牌>"; + final AccessToken token = new AccessToken(STS_AK_ID, STS_AK_SECRET, STS_TOKEN); + Thread th = new Thread() { + @Override + public void run() { + try { + token.apply(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + th.start(); + try { + th.join(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + TOKEN = token.getToken(); + + object.put("ak_id", STS_AK_ID); // 必填 + object.put("ak_secret", STS_AK_SECRET); // 必填 + object.put("sts_token", STS_TOKEN); // 必填 + cur_ak = STS_AK_ID; + cur_sk = STS_AK_SECRET; + cur_sts_token = STS_TOKEN; + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_appkey = APPKEY; + cur_token = TOKEN; + // token生命周期超过expired_time, 则需要重新token = new AccessToken() + cur_token_expired_time = token.getExpireTime(); + } else { + cur_token = ""; + cur_sts_token = ""; + } + // 离线语音合成sdk_code取值:精品版为software_nls_tts_offline, 标准版为software_nls_tts_offline_standard + // 离线语音合成账户和sdk_code可用于唤醒 + // 由创建Appkey时设置 + object.put("sdk_code", cur_sdk_code); // 必填 + } else if (method == GetTicketMethod.GET_STS_ACCESS_IN_CLIENT_FOR_MIXED_FEATURES) { + //方法十三,适合离在线语音交互服务(强烈不推荐): + // 仅仅用于开发和调试 + String STS_AK_ID = "STS.<移动端写死的账号信息,仅用于调试>"; + String STS_AK_SECRET = "<移动端写死的账号信息,仅用于调试>"; + String STS_TOKEN = "<移动端写死的账号信息,仅用于调试>"; + if (!cur_ak.isEmpty()) { + STS_AK_ID = cur_ak; + } + if (!cur_sk.isEmpty()) { + STS_AK_SECRET = cur_sk; + } + if (!cur_sts_token.isEmpty()) { + STS_TOKEN = cur_sts_token; + } + String TOKEN = "<由STS生成的临时访问令牌>"; + final AccessToken token = new AccessToken(STS_AK_ID, STS_AK_SECRET, STS_TOKEN); + Thread th = new Thread() { + @Override + public void run() { + try { + token.apply(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + th.start(); + try { + th.join(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + TOKEN = token.getToken(); + + object.put("ak_id", STS_AK_ID); // 必填 + object.put("ak_secret", STS_AK_SECRET); // 必填 + object.put("sts_token", STS_TOKEN); // 必填 + cur_ak = STS_AK_ID; + cur_sk = STS_AK_SECRET; + cur_sts_token = STS_TOKEN; + if (TOKEN != null && !TOKEN.isEmpty()) { + object.put("token", TOKEN); // 必填 + cur_appkey = APPKEY; + cur_token = TOKEN; + // token生命周期超过expired_time, 则需要重新token = new AccessToken() + cur_token_expired_time = token.getExpireTime(); + } else { + cur_token = ""; + cur_sts_token = ""; + } + // 离线语音合成sdk_code取值:精品版为software_nls_tts_offline, 标准版为software_nls_tts_offline_standard + // 离线语音合成账户和sdk_code可用于唤醒 + // 由创建Appkey时设置 + object.put("sdk_code", cur_sdk_code); // 必填 + } + + return object; + } + + // 也可以将鉴权信息以json格式保存至文件,然后从文件里加载(必须包含成员:ak_id/ak_secret/app_key/device_id/sdk_code) + // 该方式切换账号切换账号比较方便 + public static JSONObject getTicketFromJsonFile(String fileName) { + try { + File jsonFile = new File(fileName); + FileReader fileReader = new FileReader(jsonFile); + Reader reader = new InputStreamReader(new FileInputStream(jsonFile), "utf-8"); + int ch = 0; + StringBuffer sb = new StringBuffer(); + while ((ch = reader.read()) != -1) { + sb.append((char) ch); + } + fileReader.close(); + reader.close(); + String jsonStr = sb.toString(); + return JSON.parseObject(jsonStr); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + public static JSONObject refreshTokenIfNeed(JSONObject json, long distance_expire_time) { + if (!cur_appkey.isEmpty() && !cur_token.isEmpty() && cur_token_expired_time > 0) { + long millis = System.currentTimeMillis(); + long unixTimestampInSeconds = millis / 1000; + if (cur_token_expired_time - distance_expire_time < unixTimestampInSeconds) { + String old_token = cur_token; + long old_expire_time = cur_token_expired_time; + json = getTicket(cur_method); + String new_token = cur_token; + long new_expire_time = cur_token_expired_time; + Log.i("Auth", "Refresh old token(" + old_token + " : " + old_expire_time + + ") to (" + new_token + " : " + new_expire_time + ")."); + } + } + return json; + } +} diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/MainActivity.java b/android/app/src/main/java/com/example/ai_chat_assistant/MainActivity.java index 250bda3..588d004 100644 --- a/android/app/src/main/java/com/example/ai_chat_assistant/MainActivity.java +++ b/android/app/src/main/java/com/example/ai_chat_assistant/MainActivity.java @@ -1,6 +1,187 @@ package com.example.ai_chat_assistant; +import android.os.Bundle; +import android.util.Log; + import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.plugin.common.MethodChannel; +import com.alibaba.fastjson.JSONException; +import com.alibaba.fastjson.JSONObject; +import com.alibaba.idst.nui.Constants; +import com.alibaba.idst.nui.INativeStreamInputTtsCallback; +import com.alibaba.idst.nui.NativeNui; + +import java.util.Map; public class MainActivity extends FlutterActivity { -} + private static final String CHANNEL = "com.example.ai_chat_assistant/tts"; + private static final String TAG = "StreamInputTts"; + private static final String APP_KEY = "bXFFc1V65iYbW6EF"; + private static final String TOKEN = "9ae5be92a9fa4fb3ac9bcd56eba69437"; + private static final String URL = "wss://nls-gateway-cn-beijing.aliyuncs.com/ws/v1"; + + private final NativeNui streamInputTtsInstance = new NativeNui(Constants.ModeType.MODE_STREAM_INPUT_TTS); + private final AudioPlayer mAudioTrack = new AudioPlayer(new AudioPlayerCallback() { + @Override + public void playStart() { + Log.i(TAG, "start play"); + } + + @Override + public void playOver() { + Log.i(TAG, "play over"); + } + + @Override + public void playSoundLevel(int level) { + } + }); + + @Override + public void configureFlutterEngine(FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + + new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL) + .setMethodCallHandler((call, result) -> { + switch (call.method) { + case "startTts": + boolean isSuccess = startTts(); + result.success(isSuccess); + break; + case "sendTts": + Map args = (Map) call.arguments; + Object textArg = args.get("text"); + if (textArg == null || textArg.toString().isBlank()) { + return; + } + sendTts(textArg.toString()); + break; + case "stopTts": + stopTts(); + result.success("已停止"); + break; + default: + result.notImplemented(); + break; + } + }); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mAudioTrack.stop(); + mAudioTrack.releaseAudioTrack(); + streamInputTtsInstance.stopStreamInputTts(); + } + + private boolean startTts() { + int ret = streamInputTtsInstance.startStreamInputTts(new INativeStreamInputTtsCallback() { + @Override + public void onStreamInputTtsEventCallback(INativeStreamInputTtsCallback.StreamInputTtsEvent event, String task_id, String session_id, int ret_code, String error_msg, String timestamp, String all_response) { + Log.i(TAG, "stream input tts event(" + event + ") session id(" + session_id + ") task id(" + task_id + ") retCode(" + ret_code + ") errMsg(" + error_msg + ")"); + if (event == StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_SYNTHESIS_STARTED) { + Log.i(TAG, "STREAM_INPUT_TTS_EVENT_SYNTHESIS_STARTED"); + mAudioTrack.play(); + Log.i(TAG, "start play"); + } else if (event == StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_SENTENCE_SYNTHESIS) { + Log.i(TAG, "STREAM_INPUT_TTS_EVENT_SENTENCE_SYNTHESIS:" + timestamp); + } else if (event == StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_SYNTHESIS_COMPLETE || event == StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_TASK_FAILED) { + /* + * 提示: STREAM_INPUT_TTS_EVENT_SYNTHESIS_COMPLETE事件表示TTS已经合成完并通过回调传回了所有音频数据, 而不是表示播放器已经播放完了所有音频数据。 + */ + Log.i(TAG, "play end"); + + // 表示推送完数据, 当播放器播放结束则会有playOver回调 + mAudioTrack.isFinishSend(true); + + if (event == StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_TASK_FAILED) { + Log.e(TAG, "STREAM_INPUT_TTS_EVENT_TASK_FAILED: " + "error_code(" + ret_code + ") error_message(" + error_msg + ")"); + } + } else if (event == StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_SENTENCE_BEGIN) { + Log.i(TAG, "STREAM_INPUT_TTS_EVENT_SENTENCE_BEGIN:" + all_response); + } else if (event == StreamInputTtsEvent.STREAM_INPUT_TTS_EVENT_SENTENCE_END) { + Log.i(TAG, "STREAM_INPUT_TTS_EVENT_SENTENCE_END:" + all_response); + } + } + + @Override + public void onStreamInputTtsDataCallback(byte[] data) { + if (data.length > 0) { + mAudioTrack.setAudioData(data); + } + } + + @Override + public void onStreamInputTtsLogTrackCallback(Constants.LogLevel level, String log) { + Log.i(TAG, "onStreamInputTtsLogTrackCallback log level:" + level + ", message -> " + log); + } + }, genTicket(), genParameters(), "", Constants.LogLevel.toInt(Constants.LogLevel.LOG_LEVEL_VERBOSE), true); + + if (Constants.NuiResultCode.SUCCESS != ret) { + Log.i(TAG, "start tts failed " + ret); + return false; + } else { + return true; + } + } + + private void sendTts(String text) { + streamInputTtsInstance.sendStreamInputTts(text); + } + + private void stopTts() { + streamInputTtsInstance.cancelStreamInputTts(); + mAudioTrack.stop(); + } + + private String genTicket() { + String str = ""; + try { + //获取账号访问凭证: + Auth.GetTicketMethod method = Auth.GetTicketMethod.GET_TOKEN_IN_CLIENT_FOR_ONLINE_FEATURES; + Auth.setAppKey(APP_KEY); + Auth.setToken(TOKEN); + Log.i(TAG, "Use method:" + method); + JSONObject object = Auth.getTicket(method); + if (!object.containsKey("token") && !object.containsKey("sts_token")) { + Log.e(TAG, "Cannot get token or sts_token!!!"); + } + object.put("url", URL); + + //过滤SDK内部日志通过回调送回到用户层 + object.put("log_track_level", String.valueOf(Constants.LogLevel.toInt(Constants.LogLevel.LOG_LEVEL_INFO))); + + str = object.toString(); + } catch (JSONException e) { + e.printStackTrace(); + } + Log.i(TAG, "user ticket:" + str); + return str; + } + + private String genParameters() { + String str = ""; + try { + JSONObject object = new JSONObject(); + object.put("enable_subtitle", true); + object.put("voice", "zhixiaoxia"); + object.put("format", "pcm"); + object.put("sample_rate", 16000); + object.put("volume", 50); + object.put("speech_rate", 0); + object.put("pitch_rate", 0); + str = object.toString(); + } catch (JSONException e) { + e.printStackTrace(); + } + Log.i(TAG, "user parameters:" + str); + return str; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/token/AccessToken.java b/android/app/src/main/java/com/example/ai_chat_assistant/token/AccessToken.java new file mode 100644 index 0000000..08ae376 --- /dev/null +++ b/android/app/src/main/java/com/example/ai_chat_assistant/token/AccessToken.java @@ -0,0 +1,97 @@ +package com.example.ai_chat_assistant.token; + +import android.util.Log; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONException; +import com.alibaba.fastjson.JSONObject; + +import java.io.IOException; + +/** + * 用来获取访问令牌的工具类 + * + * @author xuebin + */ +public class AccessToken{ + private static final String TAG="AliSpeechSDK"; + private static final String NODE_TOKEN = "Token"; + private String accessKeyId; + private String accessKeySecret; + private String securityToken; + private String token; + private long expireTime; + private int statusCode; + private String errorMessage; + private String domain = "nls-meta.cn-shanghai.aliyuncs.com"; + private String regionId= "cn-shanghai"; + private String version ="2019-02-28"; + private String action = "CreateToken"; + + /** + * 构造实例 + * @param accessKeyId + * @param accessKeySecret + */ + public AccessToken(String accessKeyId, String accessKeySecret, String securityToken) { + this.accessKeyId = accessKeyId; + this.accessKeySecret = accessKeySecret; + this.securityToken = securityToken; + } + + /** + * 构造实例 + * @param accessKeyId + * @param accessKeySecret + */ + public AccessToken(String accessKeyId, String accessKeySecret, String securityToken, + String domain, String regionId, String version) { + this.accessKeyId = accessKeyId; + this.accessKeySecret = accessKeySecret; + this.securityToken = securityToken; + this.domain = domain; + this.regionId = regionId; + this.version = version; + } + + /** + * 向服务端申请访问令牌,申请成功后会更新token和expireTime,然后返回 + * @throws IOException https调用出错,或返回数据解析出错 + */ + public void apply() throws IOException { + HttpRequest request = new HttpRequest(accessKeyId, accessKeySecret, securityToken, + this.domain, this.regionId, this.version); + + request.authorize(); + HttpResponse response = HttpUtil.send(request); + Log.i(TAG,"Get response token info :" + JSON.toJSONString(response)); + if (response.getErrorMessage() == null) { + String result = response.getResult(); + try { + JSONObject jsonObject = JSON.parseObject(result); + if (jsonObject.containsKey(NODE_TOKEN)) { + this.token = jsonObject.getJSONObject(NODE_TOKEN).getString("Id"); + this.expireTime = jsonObject.getJSONObject(NODE_TOKEN).getIntValue("ExpireTime"); + } else { + statusCode = 500; + errorMessage = "Received unexpected result: " + result; + } + } catch (JSONException e) { + throw new IOException("Failed to parse result: " + result, e); + } + } else { + Log.e(TAG, response.getErrorMessage()); + statusCode = response.getStatusCode(); + errorMessage = response.getErrorMessage(); + } + } + + public String getToken() { + return token; + } + + public long getExpireTime() { + return expireTime; + } + +} diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/token/HttpRequest.java b/android/app/src/main/java/com/example/ai_chat_assistant/token/HttpRequest.java new file mode 100644 index 0000000..03ed9f5 --- /dev/null +++ b/android/app/src/main/java/com/example/ai_chat_assistant/token/HttpRequest.java @@ -0,0 +1,272 @@ +package com.example.ai_chat_assistant.token; + +import android.util.Log; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.SimpleTimeZone; +import java.util.UUID; + +/** + * Http request + * + * @author xuebin + */ +public class HttpRequest { + public static final String TAG= "SIGN"; + + public static final String CHARSET_UTF8 = "UTF-8"; + + public static final String METHOD_POST = "POST"; + public static final String METHOD_GET = "GET"; + + public static final String HEADER_CONTENT_TYPE = "Content-Type"; + public static final String HEADER_ACCEPT = "Accept"; +// public static final String HEADER_ACCEPT_ENCODING = "Accept-Encoding"; + + public static final String CONTENT_TYPE = "application/x-www-form-urlencoded"; + public static final String ACCEPT = "application/json"; + public static final String Format = "JSON"; + public static final String SignatureMethod = "HMAC-SHA1"; + public static final String SignatureVersion = "1.0"; + private String AccessKeyId; + private String AccessKeySecret; + private String SecurityToken; + + private static final String ENCODING = "UTF-8"; + private final static String FORMAT_ISO8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + private final static String TIME_ZONE = "GMT"; + + protected Map header; + + private String domain; + private String regionId; + private String queryString; + + public String getDomain() { + return domain; + } + + public void setDomain(String domain) { + this.domain = domain; + } + + public String getRegionId() { + return regionId; + } + + public void setRegionId(String regionId) { + this.regionId = regionId; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + private String action = "CreateToken"; + private String version; + private String url; + + public HttpRequest(String accessKeyId, String accessKeySecret, String securityToken, + String domain, String regionId, String version) { + + AccessKeyId = accessKeyId; + AccessKeySecret = accessKeySecret; + SecurityToken = securityToken; + setDomain(domain); + setVersion(version); + setRegionId(regionId); + + header = new HashMap(); + header.put(HEADER_ACCEPT, ACCEPT); + header.put(HEADER_CONTENT_TYPE, CONTENT_TYPE); + // POP doesn't support gzip +// header.put(HEADER_ACCEPT_ENCODING, "identity"); + header.put("HOST", domain); + } + + /** + * 返回此 request 对应的 url 地址 + * @return url 地址 + */ + public String getUrl() { + return url; + } + + /** + * 返回 http body + * @return 二进制形式的http body + */ + public byte[] getBodyBytes() { + return null; + } + + + + public String getMethod() { + return METHOD_GET; + } + + /** + * 解析http返回结果,构建response对象 + * @param statusCode http 状态码 + * @param bytes 返回的二进制数据 + * @return response对象 + */ + public HttpResponse parse(int statusCode, byte[] bytes) throws IOException { + HttpResponse response = new HttpResponse(); + response.setStatusCode(statusCode); + String result = new String(bytes, HttpRequest.CHARSET_UTF8); + if (response.getStatusCode() == HttpUtil.STATUS_OK){ + response.setResult(result); + }else { + response.setErrorMessage(result); + } + return response; + } + + public Map getHeader() { + return header; + } + + public void authorize(){ + Map queryParamsMap = new HashMap(); + queryParamsMap.put("AccessKeyId", this.AccessKeyId); + if (this.SecurityToken != null && !this.SecurityToken.trim().isEmpty()) { + queryParamsMap.put("SecurityToken", this.SecurityToken); + } + queryParamsMap.put("Action", this.action); + queryParamsMap.put("Version", this.version); + queryParamsMap.put("RegionId", this.regionId); + queryParamsMap.put("Timestamp", getISO8601Time(null)); + queryParamsMap.put("Format", Format); + queryParamsMap.put("SignatureMethod", SignatureMethod); + queryParamsMap.put("SignatureVersion", SignatureVersion); + queryParamsMap.put("SignatureNonce", getUniqueNonce()); + + String queryString = canonicalizedQuery(queryParamsMap); + if (null == queryString) { + Log.e(TAG,"create the canonicalized query failed"); + return; + } + + String method = "GET"; + String urlPath = "/"; + String stringToSign = createStringToSign(method, urlPath, queryString); + Log.i(TAG,"String to sign is :"+ stringToSign); + // 2.计算 HMAC-SHA1 + String signature = Signer.signString(stringToSign, AccessKeySecret+"&"); +// System.out.println(signature); + // 3.得到 authorization header + String queryStringWithSign = null; + try { + queryStringWithSign = "Signature=" + percentEncode(signature) + "&" + queryString; + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } + + this.queryString = queryStringWithSign; + } + + public String getQueryString(){ + return this.queryString; + } + + /** + * 获取时间戳 + * 必须符合ISO8601规范,并需要使用UTC时间,时区为+0 + */ + + private String getISO8601Time(Date date) { + Date nowDate = date; + if (null == date) { + nowDate = new Date(); + } + SimpleDateFormat df = new SimpleDateFormat(FORMAT_ISO8601); + df.setTimeZone(new SimpleTimeZone(0, TIME_ZONE)); + return df.format(nowDate); + } + + /** + * 获取UUID + */ + private String getUniqueNonce() { + UUID uuid = UUID.randomUUID(); + return uuid.toString(); + } + + /*** + * 将参数排序后,进行规范化设置,组合成请求字符串 + * @param queryParamsMap 所有请求参数 + * @return 规范化的请求字符串 + */ + private String canonicalizedQuery(Map queryParamsMap) { + String[] sortedKeys = queryParamsMap.keySet().toArray(new String[] {}); + Arrays.sort(sortedKeys); + String queryString = null; + try { + StringBuilder canonicalizedQueryString = new StringBuilder(); + for (String key : sortedKeys) { + canonicalizedQueryString.append("&") + .append(percentEncode(key)).append("=") + .append(percentEncode(queryParamsMap.get(key))); + } + queryString = canonicalizedQueryString.toString().substring(1); + } catch (UnsupportedEncodingException e) { + Log.e(TAG,"UTF-8 encoding is not supported."); + e.printStackTrace(); + } + return queryString; + } + + /** + * URL编码 + * 使用UTF-8字符集按照 RFC3986 规则编码请求参数和参数取值 + */ + private String percentEncode(String value) throws UnsupportedEncodingException { + return value != null ? URLEncoder.encode(value, ENCODING).replace("+", "%20") + .replace("*", "%2A").replace("%7E", "~") : null; + } + + /*** + * 构造签名字符串 + * @param method HTTP请求的方法 + * @param urlPath HTTP请求的资源路径 + * @param queryString 规范化的请求字符串 + * @return 签名字符串 + */ + private String createStringToSign(String method, String urlPath, String queryString) { + String stringToSign = null; + try { + StringBuilder strBuilderSign = new StringBuilder(); + strBuilderSign.append(method); + strBuilderSign.append("&"); + strBuilderSign.append(percentEncode(urlPath)); + strBuilderSign.append("&"); + strBuilderSign.append(percentEncode(queryString)); + stringToSign = strBuilderSign.toString(); + } catch (UnsupportedEncodingException e) { + Log.e(TAG,"UTF-8 encoding is not supported."); + e.printStackTrace(); + } + return stringToSign; + } +} diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/token/HttpResponse.java b/android/app/src/main/java/com/example/ai_chat_assistant/token/HttpResponse.java new file mode 100644 index 0000000..c9b0bc9 --- /dev/null +++ b/android/app/src/main/java/com/example/ai_chat_assistant/token/HttpResponse.java @@ -0,0 +1,47 @@ +package com.example.ai_chat_assistant.token; + +/** + * Say something + * + * @author xuebin + */ +public class HttpResponse { + + private int statusCode; + private String errorMessage; + String result; + String text; + + public String getResult() { + return result; + } + + public void setResult(String result) { + this.result = result; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public int getStatusCode() { + return statusCode; + } + + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + +} diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/token/HttpUtil.java b/android/app/src/main/java/com/example/ai_chat_assistant/token/HttpUtil.java new file mode 100644 index 0000000..2432570 --- /dev/null +++ b/android/app/src/main/java/com/example/ai_chat_assistant/token/HttpUtil.java @@ -0,0 +1,91 @@ +package com.example.ai_chat_assistant.token; + +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.util.List; +import java.util.Map; + +import javax.net.ssl.HttpsURLConnection; + +/** + * Say something + * + * @author xuebin + */ +public class HttpUtil { + + public static final int STATUS_OK = 200; + + /** + * 发送请求 + * @param request 请求 + * @return response 结果 + */ + public static HttpResponse send(HttpRequest request) throws IOException { + OutputStream out = null; + InputStream inputStream = null; + try { + String url = "https://"+ request.getDomain() + "/?" +request.getQueryString(); + Log.i("SIGN","request url is :"+url); + URL realUrl = new URL(url); + System.setProperty("sun.net.http.allowRestrictedHeaders", "true"); + // 打开和URL之间的连接 + HttpsURLConnection conn = (HttpsURLConnection) realUrl.openConnection(); + // 设置通用的请求属性 + conn.setRequestMethod(request.getMethod()); + if (HttpRequest.METHOD_POST.equals(request.getMethod())){ + // 发送POST请求必须设置如下两行 + conn.setDoOutput(true); + conn.setDoInput(true); + } + conn.setUseCaches(false); + final byte[] bodyBytes = request.getBodyBytes(); + if (bodyBytes != null) { + // 获取URLConnection对象对应的输出流 + out = conn.getOutputStream(); + // 发送请求参数 + out.write(bodyBytes); + // flush输出流的缓冲 + out.flush(); + } + final int code = conn.getResponseCode(); + Map> headerFields = conn.getHeaderFields(); + String responseMessage = conn.getResponseMessage(); + Log.d("HttpUtil",responseMessage); + if (code ==STATUS_OK){ + inputStream = conn.getInputStream(); + }else { + inputStream = conn.getErrorStream(); + } + HttpResponse requestResponse = request.parse(code, readAll(inputStream)); + return requestResponse; + } finally { // 使用finally块来关闭输出流、输入流 + try { + if (out != null) { + out.close(); + } + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + } + } + + private static byte[] readAll(InputStream inputStream) throws IOException { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + byte[] bytes = new byte[1024]; + int len = inputStream.read(bytes); + while (len > 0){ + byteArrayOutputStream.write(bytes, 0, len); + len = inputStream.read(bytes); + } + return byteArrayOutputStream.toByteArray(); + } +} diff --git a/android/app/src/main/java/com/example/ai_chat_assistant/token/Signer.java b/android/app/src/main/java/com/example/ai_chat_assistant/token/Signer.java new file mode 100644 index 0000000..0eefaca --- /dev/null +++ b/android/app/src/main/java/com/example/ai_chat_assistant/token/Signer.java @@ -0,0 +1,76 @@ +package com.example.ai_chat_assistant.token; + +import android.util.Base64; + +import java.io.UnsupportedEncodingException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.SimpleTimeZone; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Code from Aliyun POP core SDK. + * Just replace javax.xml.bind.DatatypeConverter with android.util.Base64 + * + * @author xuebin + */ +public class Signer { + + private static final String ALGORITHM_NAME = "HmacSHA1"; + public static final String ENCODING = "UTF-8"; + + public static String signString(String stringToSign, String accessKeySecret) { + try { + Mac mac = Mac.getInstance(ALGORITHM_NAME); + mac.init(new SecretKeySpec( + accessKeySecret.getBytes(ENCODING), + ALGORITHM_NAME + )); + byte[] signData = mac.doFinal(stringToSign.getBytes(ENCODING)); + String encodeToString = Base64.encodeToString(signData, Base64.DEFAULT); + return encodeToString.substring(0, encodeToString.length()-1); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException(e.toString()); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException(e.toString()); + } catch (InvalidKeyException e) { + throw new IllegalArgumentException(e.toString()); + } + } + + public String getSignerName() { + return "HMAC-SHA1"; + } + + public String getSignerVersion() { + return "1.0"; + } + + public String getSignerType() { + return null; + } + + + /** + * 等同于javaScript中的 new Date().toUTCString(); + */ + public static String toGMTString() { + return toGMTString(new Date()); + } + + private static String toGMTString(Date date) { + SimpleDateFormat df = new SimpleDateFormat("E, dd MMM yyyy HH:mm:ss z", Locale.UK); + df.setTimeZone(new SimpleTimeZone(0, "GMT")); + String text = df.format(date); + if (!text.endsWith("GMT")){ + // delete +00:00 from the end of string: Sun, 30 Sep 2018 08:42:06 GMT+00:00 + text = text.substring(0, text.length()-6); + } + return text; + } +} diff --git a/android/app/src/main/java/com/example/app003/MainActivity.java b/android/app/src/main/java/com/example/app003/MainActivity.java deleted file mode 100644 index d963074..0000000 --- a/android/app/src/main/java/com/example/app003/MainActivity.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.app003; - -import io.flutter.embedding.android.FlutterActivity; - -public class MainActivity extends FlutterActivity { -} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index cc1a359..d3f963a 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/services/chat_sse_service.dart b/lib/services/chat_sse_service.dart index 575a9ce..cb24242 100644 --- a/lib/services/chat_sse_service.dart +++ b/lib/services/chat_sse_service.dart @@ -3,7 +3,12 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'package:flutter/services.dart'; + +import '../utils/common_util.dart'; + class ChatSseService { + static const MethodChannel _channel = MethodChannel('com.example.ai_chat_assistant/tts'); // 缓存用户ID和会话ID String? _cachedUserId; String? _cachedConversationId; @@ -11,6 +16,7 @@ class ChatSseService { HttpClient? _currentClient; HttpClientResponse? _currentResponse; bool _isAborted = true; + bool isTtsStarted = false; String? get conversationId => _cachedConversationId; @@ -42,12 +48,21 @@ class ChatSseService { request.add(utf8.encode(json.encode(body))); _currentResponse = await request.close(); if (_currentResponse!.statusCode == 200) { + bool isFirst = true; await for (final line in _currentResponse! .transform(utf8.decoder) .transform(const LineSplitter())) { if (_isAborted) { break; } + if (isFirst) { + if (!isTtsStarted) { + if (await _channel.invokeMethod("startTts") == true) { + isTtsStarted = true; + } + } + isFirst = false; + } if (line.startsWith('data:')) { final jsonStr = line.substring(5).trim(); if (jsonStr == '[DONE]' || jsonStr.contains('message_end')) { @@ -63,12 +78,16 @@ class ChatSseService { if (jsonData['event'].toString().contains('message')) { final textChunk = jsonData.containsKey('answer') ? jsonData['answer'] : ''; + if (_isAborted) { + break; + } + _channel.invokeMethod("sendTts", {"text": CommonUtil.cleanText(textChunk, true)}); responseText += textChunk; - tempText += textChunk; - int endIndex = _getCompleteTextEndIndex(tempText); - final completeText = tempText.substring(0, endIndex).trim(); - tempText = tempText.substring(endIndex).trim(); - onStreamResponse(messageId, responseText, completeText, false); + // tempText += textChunk; + // int endIndex = _getCompleteTextEndIndex(tempText); + // final completeText = tempText.substring(0, endIndex).trim(); + // tempText = tempText.substring(endIndex).trim(); + onStreamResponse(messageId, responseText, "completeText", false); } } catch (e) { print('解析 SSE 数据出错: $e, 原始数据: $jsonStr'); @@ -81,12 +100,15 @@ class ChatSseService { } catch (e) { // todo } finally { + isTtsStarted = false; resetRequest(); } } Future abort() async { _isAborted = true; + _channel.invokeMethod("stopTts"); + isTtsStarted = false; resetRequest(); } diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart index 96197cb..5d1627f 100644 --- a/lib/services/message_service.dart +++ b/lib/services/message_service.dart @@ -27,7 +27,7 @@ class MessageService extends ChangeNotifier { MessageService._internal(); final ChatSseService _chatSseService = ChatSseService(); - final LocalTtsService _ttsService = LocalTtsService(); + // final LocalTtsService _ttsService = LocalTtsService(); final AudioRecorderService _audioService = AudioRecorderService(); final VoiceRecognitionService _recognitionService = VoiceRecognitionService(); final TextClassificationService _classificationService = @@ -213,10 +213,10 @@ class MessageService extends ChangeNotifier { id: _latestAssistantMessageId!, text: vehicleCommandResponse.tips!, status: MessageStatus.executing); - if (!_isReplyAborted) { - _ttsService.pushTextForStreamTTS(vehicleCommandResponse.tips!); - _ttsService.markSSEStreamCompleted(); - } + // if (!_isReplyAborted) { + // _ttsService.pushTextForStreamTTS(vehicleCommandResponse.tips!); + // _ttsService.markSSEStreamCompleted(); + // } bool containOpenAC = false; for (var command in vehicleCommandResponse.commands) { if (_isReplyAborted) { @@ -332,19 +332,19 @@ class MessageService extends ChangeNotifier { } try { if (isComplete) { - if (completeText.isNotEmpty) { - _ttsService.pushTextForStreamTTS(completeText); - } - _ttsService.markSSEStreamCompleted(); + // if (completeText.isNotEmpty) { + // _ttsService.pushTextForStreamTTS(completeText); + // } + // _ttsService.markSSEStreamCompleted(); replaceMessage( id: messageId, text: responseText, status: MessageStatus.completed, ); } else { - if (completeText.isNotEmpty) { - _ttsService.pushTextForStreamTTS(completeText); - } + // if (completeText.isNotEmpty) { + // _ttsService.pushTextForStreamTTS(completeText); + // } replaceMessage( id: messageId, text: responseText, status: MessageStatus.thinking); } @@ -355,7 +355,7 @@ class MessageService extends ChangeNotifier { Future abortReply() async { _isReplyAborted = true; - _ttsService.stop(); + // _ttsService.stop(); _chatSseService.abort(); int index = findMessageIndexById(_latestAssistantMessageId); if (index == -1 || messages[index].status != MessageStatus.thinking) { diff --git a/pubspec.lock b/pubspec.lock index 0dc1dbd..319d4ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,86 +1,134 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "http://175.24.250.68:4000" + source: hosted + version: "2.7.0" async: dependency: transitive description: name: async sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "2.13.0" - boolean_selector: + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: c05c6147124cd63e725e861335a8b4d57300b80e6e92cea7c145c739223bbaef + url: "http://175.24.250.68:4000" + source: hosted + version: "5.2.1" + audioplayers_android: dependency: transitive description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" + name: audioplayers_android + sha256: b00e1a0e11365d88576320ec2d8c192bc21f1afb6c0e5995d1c57ae63156acb5 + url: "http://175.24.250.68:4000" source: hosted - version: "2.1.2" + version: "4.0.3" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "3034e99a6df8d101da0f5082dcca0a2a99db62ab1d4ddb3277bed3f6f81afe08" + url: "http://175.24.250.68:4000" + source: hosted + version: "5.0.2" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "60787e73fefc4d2e0b9c02c69885402177e818e4e27ef087074cf27c02246c9e" + url: "http://175.24.250.68:4000" + source: hosted + version: "3.1.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "365c547f1bb9e77d94dd1687903a668d8f7ac3409e48e6e6a3668a1ac2982adb" + url: "http://175.24.250.68:4000" + source: hosted + version: "6.1.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "22cd0173e54d92bd9b2c80b1204eb1eb159ece87475ab58c9788a70ec43c2a62" + url: "http://175.24.250.68:4000" + source: hosted + version: "4.1.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "9536812c9103563644ada2ef45ae523806b0745f7a78e89d1b5fb1951de90e1a" + url: "http://175.24.250.68:4000" + source: hosted + version: "3.1.0" + basic_intl: + dependency: "direct main" + description: + name: basic_intl + sha256: "15ba8447fb069bd80f0b0c74c71faf5dea45874e9566a68e4e30c897ab2b07c4" + url: "http://175.24.250.68:4000" + source: hosted + version: "0.2.0" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "http://175.24.250.68:4000" source: hosted - version: "1.4.0" + version: "1.3.0" clock: dependency: transitive description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "http://175.24.250.68:4000" source: hosted - version: "1.19.1" + version: "1.19.0" crypto: dependency: transitive description: name: crypto sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "3.0.6" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" ffi: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" - url: "https://pub.dev" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "http://175.24.250.68:4000" source: hosted - version: "2.1.4" - fixnum: + version: "2.1.3" + file: dependency: transitive description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "http://175.24.250.68:4000" source: hosted - version: "1.1.1" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -91,14 +139,25 @@ packages: description: name: flutter_lints sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "5.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "http://175.24.250.68:4000" + source: hosted + version: "0.7.7+1" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: bdf2fc4483e74450dc9fc6fe6a9b6a5663e108d4d0dad3324a22c8e26bf48af4 + url: "http://175.24.250.68:4000" + source: hosted + version: "4.2.3" flutter_web_plugins: dependency: transitive description: flutter @@ -109,87 +168,79 @@ packages: description: name: fluttertoast sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "8.2.12" http: dependency: "direct main" description: name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" - url: "https://pub.dev" + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "http://175.24.250.68:4000" source: hosted - version: "1.4.0" + version: "1.5.0" http_parser: dependency: transitive description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "4.1.2" - leak_tracker: + js: dependency: transitive description: - name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" - url: "https://pub.dev" + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "http://175.24.250.68:4000" source: hosted - version: "10.0.9" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 - url: "https://pub.dev" - source: hosted - version: "3.0.9" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" + version: "0.6.7" lints: dependency: transitive description: name: lints sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "5.1.1" - matcher: + markdown: dependency: transitive description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "http://175.24.250.68:4000" source: hosted - version: "0.12.17" + version: "7.3.0" material_color_utilities: dependency: transitive description: name: material_color_utilities sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "http://175.24.250.68:4000" source: hosted - version: "1.16.0" + version: "1.15.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "http://175.24.250.68:4000" + source: hosted + version: "1.0.0" path: dependency: transitive description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.9.1" path_provider: @@ -197,7 +248,7 @@ packages: description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "2.1.5" path_provider_android: @@ -205,7 +256,7 @@ packages: description: name: path_provider_android sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "2.2.17" path_provider_foundation: @@ -213,7 +264,7 @@ packages: description: name: path_provider_foundation sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "2.4.1" path_provider_linux: @@ -221,7 +272,7 @@ packages: description: name: path_provider_linux sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "2.2.1" path_provider_platform_interface: @@ -229,7 +280,7 @@ packages: description: name: path_provider_platform_interface sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "2.1.2" path_provider_windows: @@ -237,15 +288,55 @@ packages: description: name: path_provider_windows sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 + url: "http://175.24.250.68:4000" + source: hosted + version: "10.4.5" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47" + url: "http://175.24.250.68:4000" + source: hosted + version: "10.3.6" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" + url: "http://175.24.250.68:4000" + source: hosted + version: "9.1.4" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "6760eb5ef34589224771010805bea6054ad28453906936f843a8cc4d3a55c4a4" + url: "http://175.24.250.68:4000" + source: hosted + version: "3.12.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 + url: "http://175.24.250.68:4000" + source: hosted + version: "0.1.3" platform: dependency: transitive description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "3.1.6" plugin_platform_interface: @@ -253,15 +344,23 @@ packages: description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "http://175.24.250.68:4000" + source: hosted + version: "6.1.5" record: dependency: "direct main" description: name: record sha256: daeb3f9b3fea9797094433fe6e49a879d8e4ca4207740bc6dc7e4a58764f0817 - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "6.0.0" record_android: @@ -269,7 +368,7 @@ packages: description: name: record_android sha256: "97d7122455f30de89a01c6c244c839085be6b12abca251fc0e78f67fed73628b" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.3.3" record_ios: @@ -277,47 +376,47 @@ packages: description: name: record_ios sha256: "73706ebbece6150654c9d6f57897cf9b622c581148304132ba85dba15df0fdfb" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.0.0" record_linux: dependency: transitive description: name: record_linux - sha256: "29e7735b05c1944bb6c9b72a36c08d4a1b24117e712d6a9523c003bde12bf484" - url: "https://pub.dev" + sha256: "0626678a092c75ce6af1e32fe7fd1dea709b92d308bc8e3b6d6348e2430beb95" + url: "http://175.24.250.68:4000" source: hosted - version: "1.1.0" + version: "1.1.1" record_macos: dependency: transitive description: name: record_macos sha256: "02240833fde16c33fcf2c589f3e08d4394b704761b4a3bb609d872ff3043fbbd" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.0.0" record_platform_interface: dependency: transitive description: name: record_platform_interface - sha256: "8a575828733d4c3cb5983c914696f40db8667eab3538d4c41c50cbb79e722ef4" - url: "https://pub.dev" + sha256: c1ad38f51e4af88a085b3e792a22c685cb3e7c23fc37aa7ce44c4cf18f25fe89 + url: "http://175.24.250.68:4000" source: hosted - version: "1.2.0" + version: "1.3.0" record_web: dependency: transitive description: name: record_web - sha256: "024c81eb7f51468b1833a3eca8b461c7ca25c04899dba37abe580bb57afd32e4" - url: "https://pub.dev" + sha256: a12856d0b3dd03d336b4b10d7520a8b3e21649a06a8f95815318feaa8f07adbb + url: "http://175.24.250.68:4000" source: hosted - version: "1.1.8" + version: "1.1.9" record_windows: dependency: transitive description: name: record_windows sha256: "85a22fc97f6d73ecd67c8ba5f2f472b74ef1d906f795b7970f771a0914167e99" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.0.6" sky_engine: @@ -330,95 +429,63 @@ packages: description: name: source_span sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "http://175.24.250.68:4000" + source: hosted + version: "3.3.0+3" term_glyph: dependency: transitive description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd - url: "https://pub.dev" - source: hosted - version: "0.7.4" typed_data: dependency: transitive description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.4.0" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff - url: "https://pub.dev" + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + url: "http://175.24.250.68:4000" source: hosted - version: "4.5.1" + version: "3.0.7" vector_math: dependency: transitive description: name: vector_math sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 - url: "https://pub.dev" - source: hosted - version: "15.0.0" web: dependency: transitive description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.1.1" xdg_directories: @@ -426,9 +493,9 @@ packages: description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" + url: "http://175.24.250.68:4000" source: hosted version: "1.1.0" sdks: - dart: ">=3.8.1 <4.0.0" + dart: ">=3.6.2 <4.0.0" flutter: ">=3.27.0"