[update] use aliyun andriod sdk
This commit is contained in:
@@ -6,11 +6,13 @@ import android.util.Log;
|
|||||||
import io.flutter.embedding.android.FlutterActivity;
|
import io.flutter.embedding.android.FlutterActivity;
|
||||||
import io.flutter.embedding.engine.FlutterEngine;
|
import io.flutter.embedding.engine.FlutterEngine;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
|
||||||
import com.alibaba.fastjson.JSONException;
|
import com.alibaba.fastjson.JSONException;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import com.alibaba.idst.nui.Constants;
|
import com.alibaba.idst.nui.Constants;
|
||||||
import com.alibaba.idst.nui.INativeStreamInputTtsCallback;
|
import com.alibaba.idst.nui.INativeStreamInputTtsCallback;
|
||||||
import com.alibaba.idst.nui.NativeNui;
|
import com.alibaba.idst.nui.NativeNui;
|
||||||
|
import com.example.ai_chat_assistant.token.AccessToken;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -18,7 +20,8 @@ public class MainActivity extends FlutterActivity {
|
|||||||
private static final String CHANNEL = "com.example.ai_chat_assistant/tts";
|
private static final String CHANNEL = "com.example.ai_chat_assistant/tts";
|
||||||
private static final String TAG = "StreamInputTts";
|
private static final String TAG = "StreamInputTts";
|
||||||
private static final String APP_KEY = "bXFFc1V65iYbW6EF";
|
private static final String APP_KEY = "bXFFc1V65iYbW6EF";
|
||||||
private static final String TOKEN = "9ae5be92a9fa4fb3ac9bcd56eba69437";
|
private static final String ACCESS_KEY = "LTAI5t71JHxXRvt2mGuEVz9X";
|
||||||
|
private static final String ACCESS_KEY_SECRET = "WQOUWvngxmCg4CxIG0qkSlkcH5hrVT";
|
||||||
private static final String URL = "wss://nls-gateway-cn-beijing.aliyuncs.com/ws/v1";
|
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 NativeNui streamInputTtsInstance = new NativeNui(Constants.ModeType.MODE_STREAM_INPUT_TTS);
|
||||||
@@ -44,21 +47,25 @@ public class MainActivity extends FlutterActivity {
|
|||||||
|
|
||||||
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL)
|
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL)
|
||||||
.setMethodCallHandler((call, result) -> {
|
.setMethodCallHandler((call, result) -> {
|
||||||
|
Map<String, Object> args = (Map<String, Object>) call.arguments;
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
case "startTts":
|
case "start":
|
||||||
boolean isSuccess = startTts();
|
Object isChinese = args.get("isChinese");
|
||||||
|
if (isChinese == null || isChinese.toString().isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean isSuccess = start(Boolean.parseBoolean(isChinese.toString()));
|
||||||
result.success(isSuccess);
|
result.success(isSuccess);
|
||||||
break;
|
break;
|
||||||
case "sendTts":
|
case "send":
|
||||||
Map<String, Object> args = (Map<String, Object>) call.arguments;
|
|
||||||
Object textArg = args.get("text");
|
Object textArg = args.get("text");
|
||||||
if (textArg == null || textArg.toString().isBlank()) {
|
if (textArg == null || textArg.toString().isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendTts(textArg.toString());
|
send(textArg.toString());
|
||||||
break;
|
break;
|
||||||
case "stopTts":
|
case "stop":
|
||||||
stopTts();
|
stop();
|
||||||
result.success("已停止");
|
result.success("已停止");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -81,7 +88,7 @@ public class MainActivity extends FlutterActivity {
|
|||||||
streamInputTtsInstance.stopStreamInputTts();
|
streamInputTtsInstance.stopStreamInputTts();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean startTts() {
|
private boolean start(boolean isChinese) {
|
||||||
int ret = streamInputTtsInstance.startStreamInputTts(new INativeStreamInputTtsCallback() {
|
int ret = streamInputTtsInstance.startStreamInputTts(new INativeStreamInputTtsCallback() {
|
||||||
@Override
|
@Override
|
||||||
public void onStreamInputTtsEventCallback(INativeStreamInputTtsCallback.StreamInputTtsEvent event, String task_id, String session_id, int ret_code, String error_msg, String timestamp, String all_response) {
|
public void onStreamInputTtsEventCallback(INativeStreamInputTtsCallback.StreamInputTtsEvent event, String task_id, String session_id, int ret_code, String error_msg, String timestamp, String all_response) {
|
||||||
@@ -117,13 +124,7 @@ public class MainActivity extends FlutterActivity {
|
|||||||
mAudioTrack.setAudioData(data);
|
mAudioTrack.setAudioData(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, genTicket(), genParameters(isChinese), "", Constants.LogLevel.toInt(Constants.LogLevel.LOG_LEVEL_NONE), false);
|
||||||
@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) {
|
if (Constants.NuiResultCode.SUCCESS != ret) {
|
||||||
Log.i(TAG, "start tts failed " + ret);
|
Log.i(TAG, "start tts failed " + ret);
|
||||||
return false;
|
return false;
|
||||||
@@ -132,11 +133,11 @@ public class MainActivity extends FlutterActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendTts(String text) {
|
private void send(String text) {
|
||||||
streamInputTtsInstance.sendStreamInputTts(text);
|
streamInputTtsInstance.sendStreamInputTts(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stopTts() {
|
private void stop() {
|
||||||
streamInputTtsInstance.cancelStreamInputTts();
|
streamInputTtsInstance.cancelStreamInputTts();
|
||||||
mAudioTrack.stop();
|
mAudioTrack.stop();
|
||||||
}
|
}
|
||||||
@@ -144,20 +145,16 @@ public class MainActivity extends FlutterActivity {
|
|||||||
private String genTicket() {
|
private String genTicket() {
|
||||||
String str = "";
|
String str = "";
|
||||||
try {
|
try {
|
||||||
//获取账号访问凭证:
|
Auth.GetTicketMethod method = Auth.GetTicketMethod.GET_ACCESS_IN_CLIENT_FOR_ONLINE_FEATURES;
|
||||||
Auth.GetTicketMethod method = Auth.GetTicketMethod.GET_TOKEN_IN_CLIENT_FOR_ONLINE_FEATURES;
|
|
||||||
Auth.setAppKey(APP_KEY);
|
Auth.setAppKey(APP_KEY);
|
||||||
Auth.setToken(TOKEN);
|
Auth.setAccessKey(ACCESS_KEY);
|
||||||
|
Auth.setAccessKeySecret(ACCESS_KEY_SECRET);
|
||||||
Log.i(TAG, "Use method:" + method);
|
Log.i(TAG, "Use method:" + method);
|
||||||
JSONObject object = Auth.getTicket(method);
|
JSONObject object = Auth.getTicket(method);
|
||||||
if (!object.containsKey("token") && !object.containsKey("sts_token")) {
|
if (!object.containsKey("token") && !object.containsKey("sts_token")) {
|
||||||
Log.e(TAG, "Cannot get token or sts_token!!!");
|
Log.e(TAG, "Cannot get token or sts_token!!!");
|
||||||
}
|
}
|
||||||
object.put("url", URL);
|
object.put("url", URL);
|
||||||
|
|
||||||
//过滤SDK内部日志通过回调送回到用户层
|
|
||||||
object.put("log_track_level", String.valueOf(Constants.LogLevel.toInt(Constants.LogLevel.LOG_LEVEL_INFO)));
|
|
||||||
|
|
||||||
str = object.toString();
|
str = object.toString();
|
||||||
} catch (JSONException e) {
|
} catch (JSONException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
@@ -166,15 +163,14 @@ public class MainActivity extends FlutterActivity {
|
|||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String genParameters() {
|
private String genParameters(boolean isChinese) {
|
||||||
String str = "";
|
String str = "";
|
||||||
try {
|
try {
|
||||||
JSONObject object = new JSONObject();
|
JSONObject object = new JSONObject();
|
||||||
object.put("enable_subtitle", true);
|
object.put("voice", isChinese ? "zhimiao_emo" : "beth");
|
||||||
object.put("voice", "zhixiaoxia");
|
|
||||||
object.put("format", "pcm");
|
object.put("format", "pcm");
|
||||||
object.put("sample_rate", 16000);
|
object.put("sample_rate", 16000);
|
||||||
object.put("volume", 50);
|
object.put("volume", 100);
|
||||||
object.put("speech_rate", 0);
|
object.put("speech_rate", 0);
|
||||||
object.put("pitch_rate", 0);
|
object.put("pitch_rate", 0);
|
||||||
str = object.toString();
|
str = object.toString();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:ai_chat_assistant/utils/tts_util.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../utils/common_util.dart';
|
import '../utils/common_util.dart';
|
||||||
@@ -16,15 +17,20 @@ class ChatSseService {
|
|||||||
HttpClient? _currentClient;
|
HttpClient? _currentClient;
|
||||||
HttpClientResponse? _currentResponse;
|
HttpClientResponse? _currentResponse;
|
||||||
bool _isAborted = true;
|
bool _isAborted = true;
|
||||||
bool isTtsStarted = false;
|
bool _isFirstData = true;
|
||||||
|
bool _isTtsStarted = false;
|
||||||
|
|
||||||
String? get conversationId => _cachedConversationId;
|
String? get conversationId => _cachedConversationId;
|
||||||
|
|
||||||
|
Future<void>? _startTtsFuture;
|
||||||
|
|
||||||
void request({
|
void request({
|
||||||
required String messageId,
|
required String messageId,
|
||||||
required String text,
|
required String text,
|
||||||
required Function(String, String, String, bool) onStreamResponse,
|
required bool isChinese,
|
||||||
|
required Function(String, String, bool) onStreamResponse,
|
||||||
}) async {
|
}) async {
|
||||||
|
print("----------------------SSE Start");
|
||||||
_isAborted = false;
|
_isAborted = false;
|
||||||
if (_cachedUserId == null) {
|
if (_cachedUserId == null) {
|
||||||
_cachedUserId = _generateRandomUserId(6);
|
_cachedUserId = _generateRandomUserId(6);
|
||||||
@@ -48,25 +54,17 @@ class ChatSseService {
|
|||||||
request.add(utf8.encode(json.encode(body)));
|
request.add(utf8.encode(json.encode(body)));
|
||||||
_currentResponse = await request.close();
|
_currentResponse = await request.close();
|
||||||
if (_currentResponse!.statusCode == 200) {
|
if (_currentResponse!.statusCode == 200) {
|
||||||
bool isFirst = true;
|
_startTtsFuture = startTts(isChinese);
|
||||||
await for (final line in _currentResponse!
|
await for (final line in _currentResponse!
|
||||||
.transform(utf8.decoder)
|
.transform(utf8.decoder)
|
||||||
.transform(const LineSplitter())) {
|
.transform(const LineSplitter())) {
|
||||||
if (_isAborted) {
|
if (_isAborted) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (isFirst) {
|
|
||||||
if (!isTtsStarted) {
|
|
||||||
if (await _channel.invokeMethod<bool>("startTts") == true) {
|
|
||||||
isTtsStarted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isFirst = false;
|
|
||||||
}
|
|
||||||
if (line.startsWith('data:')) {
|
if (line.startsWith('data:')) {
|
||||||
final jsonStr = line.substring(5).trim();
|
final jsonStr = line.substring(5).trim();
|
||||||
if (jsonStr == '[DONE]' || jsonStr.contains('message_end')) {
|
if (jsonStr == '[DONE]' || jsonStr.contains('message_end')) {
|
||||||
onStreamResponse(messageId, responseText, tempText, true);
|
onStreamResponse(messageId, responseText, true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -81,13 +79,21 @@ class ChatSseService {
|
|||||||
if (_isAborted) {
|
if (_isAborted) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_channel.invokeMethod("sendTts", {"text": CommonUtil.cleanText(textChunk, true)});
|
tempText += textChunk;
|
||||||
|
int endIndex = _getCompleteTextEndIndex(tempText);
|
||||||
|
String completeText = CommonUtil.cleanText(tempText.substring(0, endIndex).trim(), true);
|
||||||
|
if (completeText.isNotEmpty) {
|
||||||
|
if (_isFirstData && _startTtsFuture != null) {
|
||||||
|
await _startTtsFuture;
|
||||||
|
_isFirstData = false;
|
||||||
|
}
|
||||||
|
print("----------------------Send text: " + completeText);
|
||||||
|
completeText += isChinese ? ',' : ',';
|
||||||
|
TtsUtil.send(completeText);
|
||||||
|
}
|
||||||
|
tempText = tempText.substring(endIndex).trim();
|
||||||
responseText += textChunk;
|
responseText += textChunk;
|
||||||
// tempText += textChunk;
|
onStreamResponse(messageId, responseText, false);
|
||||||
// int endIndex = _getCompleteTextEndIndex(tempText);
|
|
||||||
// final completeText = tempText.substring(0, endIndex).trim();
|
|
||||||
// tempText = tempText.substring(endIndex).trim();
|
|
||||||
onStreamResponse(messageId, responseText, "completeText", false);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('解析 SSE 数据出错: $e, 原始数据: $jsonStr');
|
print('解析 SSE 数据出错: $e, 原始数据: $jsonStr');
|
||||||
@@ -100,15 +106,22 @@ class ChatSseService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// todo
|
// todo
|
||||||
} finally {
|
} finally {
|
||||||
isTtsStarted = false;
|
|
||||||
resetRequest();
|
resetRequest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> startTts(bool isChinese) async {
|
||||||
|
print("----------------------TTS Start");
|
||||||
|
if (!_isTtsStarted) {
|
||||||
|
if (await TtsUtil.start(isChinese) == true) {
|
||||||
|
_isTtsStarted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> abort() async {
|
Future<void> abort() async {
|
||||||
_isAborted = true;
|
_isAborted = true;
|
||||||
_channel.invokeMethod("stopTts");
|
TtsUtil.stopTts();
|
||||||
isTtsStarted = false;
|
|
||||||
resetRequest();
|
resetRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +132,13 @@ class ChatSseService {
|
|||||||
_currentClient?.close(force: true);
|
_currentClient?.close(force: true);
|
||||||
_currentClient = null;
|
_currentClient = null;
|
||||||
_currentResponse = null;
|
_currentResponse = null;
|
||||||
|
_isFirstData = true;
|
||||||
|
_isTtsStarted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int _getCompleteTextEndIndex(String buffer) {
|
int _getCompleteTextEndIndex(String buffer) {
|
||||||
// 支持句号、问号、感叹号和换行符作为分割依据
|
// 支持句号、问号、感叹号和换行符作为分割依据
|
||||||
final sentenceEnders = RegExp(r'[。!?\n]');
|
final sentenceEnders = RegExp(r'[,!?:,。!?:\n]');
|
||||||
final matches = sentenceEnders.allMatches(buffer);
|
final matches = sentenceEnders.allMatches(buffer);
|
||||||
return matches.isEmpty ? 0 : matches.last.end;
|
return matches.isEmpty ? 0 : matches.last.end;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:ai_chat_assistant/utils/common_util.dart';
|
import 'package:ai_chat_assistant/utils/common_util.dart';
|
||||||
|
import 'package:ai_chat_assistant/utils/tts_util.dart';
|
||||||
import 'package:basic_intl/intl.dart';
|
import 'package:basic_intl/intl.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
@@ -15,7 +16,6 @@ import '../services/classification_service.dart';
|
|||||||
import '../services/control_recognition_service.dart';
|
import '../services/control_recognition_service.dart';
|
||||||
import '../services/audio_recorder_service.dart';
|
import '../services/audio_recorder_service.dart';
|
||||||
import '../services/voice_recognition_service.dart';
|
import '../services/voice_recognition_service.dart';
|
||||||
import '../services/tts_service.dart';
|
|
||||||
import 'command_service.dart';
|
import 'command_service.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
|
||||||
@@ -213,10 +213,11 @@ class MessageService extends ChangeNotifier {
|
|||||||
id: _latestAssistantMessageId!,
|
id: _latestAssistantMessageId!,
|
||||||
text: vehicleCommandResponse.tips!,
|
text: vehicleCommandResponse.tips!,
|
||||||
status: MessageStatus.executing);
|
status: MessageStatus.executing);
|
||||||
// if (!_isReplyAborted) {
|
if (!_isReplyAborted) {
|
||||||
// _ttsService.pushTextForStreamTTS(vehicleCommandResponse.tips!);
|
if (await TtsUtil.start(isChinese) == true) {
|
||||||
// _ttsService.markSSEStreamCompleted();
|
TtsUtil.send(vehicleCommandResponse.tips!);
|
||||||
// }
|
}
|
||||||
|
}
|
||||||
bool containOpenAC = false;
|
bool containOpenAC = false;
|
||||||
for (var command in vehicleCommandResponse.commands) {
|
for (var command in vehicleCommandResponse.commands) {
|
||||||
if (_isReplyAborted) {
|
if (_isReplyAborted) {
|
||||||
@@ -309,6 +310,7 @@ class MessageService extends ChangeNotifier {
|
|||||||
_chatSseService.request(
|
_chatSseService.request(
|
||||||
messageId: _latestAssistantMessageId!,
|
messageId: _latestAssistantMessageId!,
|
||||||
text: text,
|
text: text,
|
||||||
|
isChinese: isChinese,
|
||||||
onStreamResponse: handleStreamResponse,
|
onStreamResponse: handleStreamResponse,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -325,26 +327,18 @@ class MessageService extends ChangeNotifier {
|
|||||||
status: MessageStatus.normal);
|
status: MessageStatus.normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleStreamResponse(String messageId, String responseText,
|
void handleStreamResponse(String messageId, String responseText, bool isComplete) {
|
||||||
String completeText, bool isComplete) {
|
|
||||||
if (_isReplyAborted) {
|
if (_isReplyAborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
// if (completeText.isNotEmpty) {
|
|
||||||
// _ttsService.pushTextForStreamTTS(completeText);
|
|
||||||
// }
|
|
||||||
// _ttsService.markSSEStreamCompleted();
|
|
||||||
replaceMessage(
|
replaceMessage(
|
||||||
id: messageId,
|
id: messageId,
|
||||||
text: responseText,
|
text: responseText,
|
||||||
status: MessageStatus.completed,
|
status: MessageStatus.completed,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// if (completeText.isNotEmpty) {
|
|
||||||
// _ttsService.pushTextForStreamTTS(completeText);
|
|
||||||
// }
|
|
||||||
replaceMessage(
|
replaceMessage(
|
||||||
id: messageId, text: responseText, status: MessageStatus.thinking);
|
id: messageId, text: responseText, status: MessageStatus.thinking);
|
||||||
}
|
}
|
||||||
@@ -355,7 +349,6 @@ class MessageService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> abortReply() async {
|
Future<void> abortReply() async {
|
||||||
_isReplyAborted = true;
|
_isReplyAborted = true;
|
||||||
// _ttsService.stop();
|
|
||||||
_chatSseService.abort();
|
_chatSseService.abort();
|
||||||
int index = findMessageIndexById(_latestAssistantMessageId);
|
int index = findMessageIndexById(_latestAssistantMessageId);
|
||||||
if (index == -1 || messages[index].status != MessageStatus.thinking) {
|
if (index == -1 || messages[index].status != MessageStatus.thinking) {
|
||||||
|
|||||||
23
lib/utils/tts_util.dart
Normal file
23
lib/utils/tts_util.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class TtsUtil {
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('com.example.ai_chat_assistant/tts');
|
||||||
|
|
||||||
|
static Future<T?> execute<T>(String method,
|
||||||
|
[Map<Object, Object>? arguments]) {
|
||||||
|
return _channel.invokeMethod(method, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> start(bool isChinese) async {
|
||||||
|
return await execute('start', {'isChinese': isChinese});
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> send(String text) async {
|
||||||
|
await execute('send', {'text': text});
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> stopTts() async {
|
||||||
|
await execute('stop');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user