添加AI服务,支持麦克风、摄像头输入
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.camera.front" android:required="false"/>
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DUIX.Test">
|
||||
<activity
|
||||
android:name=".ui.activity.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.activity.CallActivity"
|
||||
android:exported="false">
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 998 KiB |
|
After Width: | Height: | Size: 284 KiB |
@@ -0,0 +1,31 @@
|
||||
package ai.guiji.duix.test;
|
||||
|
||||
import android.app.Application;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
|
||||
public class App extends Application {
|
||||
|
||||
public static App mApp;
|
||||
private static OkHttpClient mOkHttpClient;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
mApp = this;
|
||||
}
|
||||
|
||||
public static OkHttpClient getOkHttpClient() {
|
||||
if (mOkHttpClient == null) {
|
||||
mOkHttpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
return mOkHttpClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package ai.guiji.duix.test.audio;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioRecord;
|
||||
import android.media.MediaRecorder;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import ai.guiji.duix.test.util.StringUtils;
|
||||
|
||||
public class AudioRecorder {
|
||||
|
||||
private final String TAG = getClass().getSimpleName();
|
||||
|
||||
private final Context mContext;
|
||||
private AudioRecord mAudioRecorder; //录音器
|
||||
private final Executor mExecutor = Executors.newSingleThreadExecutor();
|
||||
private final RecorderCallback callback;
|
||||
|
||||
public AudioRecorder(Context context, RecorderCallback callback){
|
||||
this.mContext = context;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
public void start(){
|
||||
int sampleRateInHz = 16000;
|
||||
int channelConfig = AudioFormat.CHANNEL_IN_MONO;
|
||||
int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
|
||||
//20ms audio for 16k/16bit/mono
|
||||
// int WAVE_FRAM_SIZE = 20 * 2 * 1 * SAMPLE_RATE / 1000;
|
||||
int minBufferSize = AudioRecord.getMinBufferSize(
|
||||
sampleRateInHz,
|
||||
channelConfig,
|
||||
audioFormat
|
||||
);
|
||||
Log.d(TAG, "minBufferSize: " + minBufferSize);
|
||||
mAudioRecorder = new AudioRecord(MediaRecorder.AudioSource.DEFAULT,
|
||||
sampleRateInHz, channelConfig,
|
||||
audioFormat,
|
||||
minBufferSize);
|
||||
if (mAudioRecorder.getState() != AudioRecord.STATE_UNINITIALIZED){
|
||||
mAudioRecorder.startRecording();
|
||||
mExecutor.execute(() -> {
|
||||
long startTime = System.currentTimeMillis();
|
||||
File cacheDir = mContext.getExternalCacheDir();
|
||||
if (!cacheDir.exists()){
|
||||
if (!cacheDir.mkdirs()) Log.e(TAG, "mkdirs fail path: " + cacheDir.getAbsolutePath());
|
||||
}
|
||||
String pcmName = StringUtils.createFileName("record_", ".pcm");
|
||||
File pcmFile = new File(cacheDir, pcmName);
|
||||
try (FileOutputStream outputStream = new FileOutputStream(pcmFile)) {
|
||||
byte[] data = new byte[minBufferSize];
|
||||
while (mAudioRecorder.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING){
|
||||
int length = mAudioRecorder.read(data, 0, minBufferSize);
|
||||
if (length > 0){
|
||||
outputStream.write(data, 0, length);
|
||||
if (callback != null){
|
||||
callback.onReadData(data, 0, length);
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Record done.");
|
||||
long diff = System.currentTimeMillis() - startTime;
|
||||
if (callback != null){
|
||||
if (diff > 200){
|
||||
callback.onFinish(pcmFile.getAbsolutePath());
|
||||
} else {
|
||||
callback.onRecordError(-2, "too short!");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Record error: " + e);
|
||||
if (callback != null){
|
||||
callback.onRecordError(-1, "Record error: " + e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void stop(){
|
||||
if (mAudioRecorder != null){
|
||||
if (mAudioRecorder.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING){
|
||||
mAudioRecorder.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void release(){
|
||||
if (mAudioRecorder != null){
|
||||
mAudioRecorder.release();
|
||||
mAudioRecorder = null;
|
||||
}
|
||||
}
|
||||
|
||||
public interface RecorderCallback{
|
||||
void onReadData(byte[] data, int offsetInBytes, int length);
|
||||
|
||||
void onRecordError(int code, String message);
|
||||
|
||||
void onFinish(String path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package ai.guiji.duix.test.audio;
|
||||
|
||||
/**
|
||||
* 简单线性插值重采样:24kHz 16bit mono -> 16kHz 16bit mono
|
||||
* 用于将 Qwen-Omni-Realtime 输出的 24kHz 音频转换为 DUIX 所需的 16kHz
|
||||
*/
|
||||
public class AudioResampler {
|
||||
|
||||
private static final int INPUT_RATE = 24000;
|
||||
private static final int OUTPUT_RATE = 16000;
|
||||
private static final double RATIO = (double) OUTPUT_RATE / INPUT_RATE; // 2/3
|
||||
|
||||
/**
|
||||
* 将 24kHz 16bit 单声道 PCM 重采样为 16kHz
|
||||
*
|
||||
* @param input 24kHz 16bit mono PCM
|
||||
* @return 16kHz 16bit mono PCM
|
||||
*/
|
||||
public static byte[] resample24kTo16k(byte[] input) {
|
||||
if (input == null || input.length < 2) return new byte[0];
|
||||
|
||||
int inputSamples = input.length / 2; // 16bit = 2 bytes per sample
|
||||
int outputSamples = (int) Math.ceil(inputSamples * RATIO);
|
||||
|
||||
byte[] output = new byte[outputSamples * 2];
|
||||
|
||||
for (int i = 0; i < outputSamples; i++) {
|
||||
double srcPos = i / RATIO;
|
||||
int idx0 = (int) Math.floor(srcPos);
|
||||
int idx1 = Math.min(idx0 + 1, inputSamples - 1);
|
||||
double frac = srcPos - idx0;
|
||||
|
||||
short s0 = getShortLE(input, idx0 * 2);
|
||||
short s1 = getShortLE(input, idx1 * 2);
|
||||
short interpolated = (short) (s0 + (s1 - s0) * frac);
|
||||
putShortLE(output, i * 2, interpolated);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private static short getShortLE(byte[] data, int offset) {
|
||||
if (offset + 1 >= data.length) return 0;
|
||||
return (short) ((data[offset] & 0xFF) | (data[offset + 1] << 8));
|
||||
}
|
||||
|
||||
private static void putShortLE(byte[] data, int offset, short value) {
|
||||
if (offset + 1 < data.length) {
|
||||
data[offset] = (byte) (value & 0xFF);
|
||||
data[offset + 1] = (byte) ((value >> 8) & 0xFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package ai.guiji.duix.test.audio;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioRecord;
|
||||
import android.media.MediaRecorder;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 流式麦克风录音器,用于实时将音频发送到 OmniRealtime API
|
||||
* 16kHz, 16bit, mono - 符合 API 输入格式要求
|
||||
*/
|
||||
public class StreamingAudioRecorder {
|
||||
|
||||
private static final String TAG = "StreamingAudioRecorder";
|
||||
private static final int SAMPLE_RATE = 16000;
|
||||
private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO;
|
||||
private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT;
|
||||
// 20ms 一帧: 16000 * 2 * 0.02 = 640 bytes, 使用 3200 与文档示例一致(100ms)
|
||||
private static final int CHUNK_SIZE = 3200;
|
||||
|
||||
private final Context mContext;
|
||||
private AudioRecord mAudioRecord;
|
||||
private final Executor mExecutor = Executors.newSingleThreadExecutor();
|
||||
private final StreamingCallback callback;
|
||||
private final AtomicBoolean isRecording = new AtomicBoolean(false);
|
||||
|
||||
public StreamingAudioRecorder(Context context, StreamingCallback callback) {
|
||||
this.mContext = context;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
public void start() {
|
||||
if (isRecording.get()) {
|
||||
Log.w(TAG, "Already recording");
|
||||
return;
|
||||
}
|
||||
int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT);
|
||||
int bufferSize = Math.max(minBufferSize, CHUNK_SIZE * 2);
|
||||
|
||||
mAudioRecord = new AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_CONFIG,
|
||||
AUDIO_FORMAT,
|
||||
bufferSize
|
||||
);
|
||||
|
||||
if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord init failed");
|
||||
if (callback != null) {
|
||||
callback.onError("麦克风初始化失败");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isRecording.set(true);
|
||||
mAudioRecord.startRecording();
|
||||
|
||||
mExecutor.execute(() -> {
|
||||
byte[] buffer = new byte[CHUNK_SIZE];
|
||||
while (isRecording.get() && mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
|
||||
int read = mAudioRecord.read(buffer, 0, buffer.length);
|
||||
if (read > 0 && callback != null) {
|
||||
byte[] data = new byte[read];
|
||||
System.arraycopy(buffer, 0, data, 0, read);
|
||||
callback.onAudioData(data);
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Recording thread ended");
|
||||
});
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
isRecording.set(false);
|
||||
if (mAudioRecord != null) {
|
||||
if (mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
|
||||
mAudioRecord.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void release() {
|
||||
stop();
|
||||
if (mAudioRecord != null) {
|
||||
mAudioRecord.release();
|
||||
mAudioRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isRecording() {
|
||||
return isRecording.get();
|
||||
}
|
||||
|
||||
public interface StreamingCallback {
|
||||
void onAudioData(byte[] pcmData);
|
||||
|
||||
void onError(String message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package ai.guiji.duix.test.camera;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.ImageFormat;
|
||||
import android.hardware.camera2.CameraAccessException;
|
||||
import android.hardware.camera2.CameraCaptureSession;
|
||||
import android.hardware.camera2.CameraCharacteristics;
|
||||
import android.hardware.camera2.CameraDevice;
|
||||
import android.hardware.camera2.CameraManager;
|
||||
import android.hardware.camera2.CaptureRequest;
|
||||
import android.media.Image;
|
||||
import android.media.ImageReader;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
import android.view.TextureView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 前置摄像头帧采集,按约 1 张/秒 输出 JPEG 供 OmniRealtime 使用
|
||||
* 推荐 480P/720P,单张不超过 500KB
|
||||
*/
|
||||
public class CameraFrameCapture {
|
||||
|
||||
private static final String TAG = "CameraFrameCapture";
|
||||
private static final int TARGET_WIDTH = 640; // 480P 左右
|
||||
private static final int TARGET_HEIGHT = 480;
|
||||
private static final int JPEG_QUALITY = 85;
|
||||
private static final long CAPTURE_INTERVAL_MS = 1000; // 1 张/秒
|
||||
|
||||
private final Context context;
|
||||
private final FrameCallback callback;
|
||||
private final TextureView previewView;
|
||||
private Surface previewSurface;
|
||||
private CameraManager cameraManager;
|
||||
private String frontCameraId;
|
||||
private CameraDevice cameraDevice;
|
||||
private CameraCaptureSession captureSession;
|
||||
private ImageReader imageReader;
|
||||
private HandlerThread backgroundThread;
|
||||
private Handler backgroundHandler;
|
||||
private final AtomicBoolean isCapturing = new AtomicBoolean(false);
|
||||
private final AtomicBoolean isRunning = new AtomicBoolean(false);
|
||||
|
||||
public CameraFrameCapture(Context context, FrameCallback callback) {
|
||||
this(context, callback, null);
|
||||
}
|
||||
|
||||
public CameraFrameCapture(Context context, FrameCallback callback, TextureView previewView) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.callback = callback;
|
||||
this.previewView = previewView;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (isRunning.get()) {
|
||||
Log.w(TAG, "Already running");
|
||||
return;
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (callback != null) callback.onError("需要相机权限");
|
||||
return;
|
||||
}
|
||||
cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
|
||||
if (cameraManager == null) {
|
||||
if (callback != null) callback.onError("相机不可用");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (String id : cameraManager.getCameraIdList()) {
|
||||
CameraCharacteristics chars = cameraManager.getCameraCharacteristics(id);
|
||||
Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
|
||||
if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) {
|
||||
frontCameraId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (frontCameraId == null) {
|
||||
if (callback != null) callback.onError("未找到前置摄像头");
|
||||
return;
|
||||
}
|
||||
} catch (CameraAccessException e) {
|
||||
Log.e(TAG, "Camera access error: " + e.getMessage());
|
||||
if (callback != null) callback.onError("相机访问失败: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
backgroundThread = new HandlerThread("CameraFrameCapture");
|
||||
backgroundThread.start();
|
||||
backgroundHandler = new Handler(backgroundThread.getLooper());
|
||||
|
||||
imageReader = ImageReader.newInstance(TARGET_WIDTH, TARGET_HEIGHT, ImageFormat.JPEG, 2);
|
||||
imageReader.setOnImageAvailableListener(reader -> {
|
||||
try (Image image = reader.acquireLatestImage()) {
|
||||
if (image != null && callback != null) {
|
||||
byte[] jpeg = imageToJpeg(image);
|
||||
if (jpeg != null && jpeg.length > 0 && jpeg.length < 500 * 1024) {
|
||||
callback.onFrame(jpeg);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Image process error: " + e.getMessage());
|
||||
}
|
||||
}, backgroundHandler);
|
||||
|
||||
isRunning.set(true);
|
||||
if (previewView != null) {
|
||||
if (previewView.isAvailable()) {
|
||||
setupPreviewSurface();
|
||||
openCamera();
|
||||
} else {
|
||||
previewView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
|
||||
@Override
|
||||
public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture surface, int width, int height) {
|
||||
setupPreviewSurface();
|
||||
openCamera();
|
||||
}
|
||||
@Override
|
||||
public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture surface, int width, int height) {}
|
||||
@Override
|
||||
public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture surface) {
|
||||
return true;
|
||||
}
|
||||
@Override
|
||||
public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture surface) {}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
openCamera();
|
||||
}
|
||||
}
|
||||
|
||||
private void setupPreviewSurface() {
|
||||
if (previewView != null && previewView.getSurfaceTexture() != null) {
|
||||
android.graphics.SurfaceTexture st = previewView.getSurfaceTexture();
|
||||
st.setDefaultBufferSize(TARGET_WIDTH, TARGET_HEIGHT);
|
||||
previewSurface = new Surface(st);
|
||||
}
|
||||
}
|
||||
|
||||
private void openCamera() {
|
||||
try {
|
||||
cameraManager.openCamera(frontCameraId, new CameraDevice.StateCallback() {
|
||||
@Override
|
||||
public void onOpened(@NonNull CameraDevice camera) {
|
||||
cameraDevice = camera;
|
||||
createCaptureSession();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(@NonNull CameraDevice camera) {
|
||||
camera.close();
|
||||
cameraDevice = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull CameraDevice camera, int error) {
|
||||
Log.e(TAG, "Camera error: " + error);
|
||||
camera.close();
|
||||
cameraDevice = null;
|
||||
if (callback != null) callback.onError("相机打开失败");
|
||||
}
|
||||
}, backgroundHandler);
|
||||
} catch (CameraAccessException e) {
|
||||
Log.e(TAG, "Open camera error: " + e.getMessage());
|
||||
if (callback != null) callback.onError("相机打开失败: " + e.getMessage());
|
||||
} catch (SecurityException e) {
|
||||
Log.e(TAG, "Camera permission denied: " + e.getMessage());
|
||||
if (callback != null) callback.onError("需要相机权限");
|
||||
}
|
||||
}
|
||||
|
||||
private void createCaptureSession() {
|
||||
if (cameraDevice == null || imageReader == null || !isRunning.get()) return;
|
||||
try {
|
||||
java.util.List<Surface> surfaces = new java.util.ArrayList<>();
|
||||
surfaces.add(imageReader.getSurface());
|
||||
if (previewSurface != null) {
|
||||
surfaces.add(previewSurface);
|
||||
}
|
||||
cameraDevice.createCaptureSession(
|
||||
surfaces,
|
||||
new CameraCaptureSession.StateCallback() {
|
||||
@Override
|
||||
public void onConfigured(@NonNull CameraCaptureSession session) {
|
||||
captureSession = session;
|
||||
isCapturing.set(true);
|
||||
startPreview();
|
||||
scheduleNextCapture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigureFailed(@NonNull CameraCaptureSession session) {
|
||||
Log.e(TAG, "Capture session configure failed");
|
||||
if (callback != null) callback.onError("相机配置失败");
|
||||
}
|
||||
},
|
||||
backgroundHandler
|
||||
);
|
||||
} catch (CameraAccessException e) {
|
||||
Log.e(TAG, "Create session error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void startPreview() {
|
||||
if (captureSession == null || cameraDevice == null || previewSurface == null) return;
|
||||
try {
|
||||
CaptureRequest.Builder builder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
|
||||
builder.addTarget(previewSurface);
|
||||
builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
|
||||
captureSession.setRepeatingRequest(builder.build(), null, backgroundHandler);
|
||||
} catch (CameraAccessException e) {
|
||||
Log.e(TAG, "Start preview error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleNextCapture() {
|
||||
if (!isCapturing.get() || !isRunning.get() || captureSession == null || cameraDevice == null) return;
|
||||
try {
|
||||
CaptureRequest.Builder builder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
|
||||
builder.addTarget(imageReader.getSurface());
|
||||
builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
|
||||
CaptureRequest request = builder.build();
|
||||
captureSession.capture(request, new CameraCaptureSession.CaptureCallback() {
|
||||
@Override
|
||||
public void onCaptureCompleted(@NonNull CameraCaptureSession session,
|
||||
@NonNull android.hardware.camera2.CaptureRequest request,
|
||||
@NonNull android.hardware.camera2.TotalCaptureResult result) {
|
||||
if (isRunning.get()) {
|
||||
backgroundHandler.postDelayed(CameraFrameCapture.this::scheduleNextCapture, CAPTURE_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
}, backgroundHandler);
|
||||
} catch (CameraAccessException e) {
|
||||
Log.e(TAG, "Capture error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] imageToJpeg(Image image) {
|
||||
Image.Plane[] planes = image.getPlanes();
|
||||
if (planes.length == 0) return null;
|
||||
ByteBuffer buffer = planes[0].getBuffer();
|
||||
byte[] data = new byte[buffer.remaining()];
|
||||
buffer.get(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
isRunning.set(false);
|
||||
isCapturing.set(false);
|
||||
try {
|
||||
if (captureSession != null) {
|
||||
captureSession.close();
|
||||
captureSession = null;
|
||||
}
|
||||
if (cameraDevice != null) {
|
||||
cameraDevice.close();
|
||||
cameraDevice = null;
|
||||
}
|
||||
if (imageReader != null) {
|
||||
imageReader.close();
|
||||
imageReader = null;
|
||||
}
|
||||
if (previewSurface != null) {
|
||||
previewSurface.release();
|
||||
previewSurface = null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Stop error: " + e.getMessage());
|
||||
}
|
||||
if (backgroundThread != null && backgroundThread.isAlive()) {
|
||||
backgroundThread.quitSafely();
|
||||
try {
|
||||
backgroundThread.join(500);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
backgroundThread = null;
|
||||
}
|
||||
backgroundHandler = null;
|
||||
}
|
||||
|
||||
public boolean isRunning() {
|
||||
return isRunning.get();
|
||||
}
|
||||
|
||||
public interface FrameCallback {
|
||||
void onFrame(byte[] jpegData);
|
||||
|
||||
void onError(String message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ai.guiji.duix.test.net;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
import ai.guiji.duix.test.App;
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
/**
|
||||
* 同步下载小文件,不要再UI线程里面直接调用
|
||||
*/
|
||||
public class SyncDownloadFile {
|
||||
|
||||
private String url;
|
||||
private String path;
|
||||
private Callback callback;
|
||||
|
||||
public SyncDownloadFile(String url, String path, Callback callback) {
|
||||
this.url = url;
|
||||
this.path = path;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public boolean download() {
|
||||
try {
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.build();
|
||||
Call call = App.getOkHttpClient().newCall(request);
|
||||
|
||||
Response response = call.execute();
|
||||
if (response.code() == 200) {
|
||||
ResponseBody body = response.body();
|
||||
if (body != null) {
|
||||
long contentLength = body.contentLength();
|
||||
InputStream is = body.byteStream();
|
||||
File tmpFile = new File(path + ".tmp");
|
||||
File parent = tmpFile.getParentFile();
|
||||
if (parent != null && !parent.exists()) {
|
||||
if (!parent.mkdirs()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (tmpFile.exists()) {
|
||||
tmpFile.delete();
|
||||
}
|
||||
FileOutputStream fileOutputStream = new FileOutputStream(tmpFile);
|
||||
long downloadLength = 0;
|
||||
int len;
|
||||
byte[] data = new byte[1024];
|
||||
while ((len = is.read(data)) != -1) {
|
||||
fileOutputStream.write(data, 0, len);
|
||||
downloadLength += len;
|
||||
if (callback != null){
|
||||
callback.onProgress((int) (downloadLength * 100 / contentLength));
|
||||
}
|
||||
}
|
||||
fileOutputStream.flush();
|
||||
is.close();
|
||||
fileOutputStream.close();
|
||||
File target = new File(path);
|
||||
if (tmpFile.renameTo(target)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e("123", "SyncDownloadFile error: " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
void onProgress(int progress);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package ai.guiji.duix.test.realtime;
|
||||
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.WebSocket;
|
||||
import okhttp3.WebSocketListener;
|
||||
|
||||
/**
|
||||
* Qwen-Omni-Realtime WebSocket 客户端
|
||||
* 接入 qwen3-omni-flash-realtime-2025-12-01 模型
|
||||
*/
|
||||
public class OmniRealtimeClient {
|
||||
|
||||
private static final String TAG = "OmniRealtimeClient";
|
||||
|
||||
// Demo 配置(硬编码)
|
||||
private static final String API_KEY = "sk-7c32083ace99472e97209e611b9ecbb1";
|
||||
private static final String WS_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model=qwen3-omni-flash-realtime-2025-12-01";
|
||||
|
||||
private final OkHttpClient okHttpClient;
|
||||
private WebSocket webSocket;
|
||||
private final Callback callback;
|
||||
private final AtomicBoolean isConnected = new AtomicBoolean(false);
|
||||
private final AtomicBoolean userSpeaking = new AtomicBoolean(false);
|
||||
|
||||
public OmniRealtimeClient(Callback callback) {
|
||||
this.callback = callback;
|
||||
this.okHttpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.MINUTES)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
public void connect() {
|
||||
if (isConnected.get()) {
|
||||
Log.w(TAG, "Already connected");
|
||||
return;
|
||||
}
|
||||
Request request = new Request.Builder()
|
||||
.url(WS_URL)
|
||||
.addHeader("Authorization", "Bearer " + API_KEY)
|
||||
.build();
|
||||
|
||||
webSocket = okHttpClient.newWebSocket(request, new WebSocketListener() {
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket, Response response) {
|
||||
Log.i(TAG, "WebSocket connected");
|
||||
isConnected.set(true);
|
||||
sendSessionUpdate();
|
||||
if (callback != null) {
|
||||
callback.onConnected();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket webSocket, String text) {
|
||||
handleMessage(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
|
||||
Log.e(TAG, "WebSocket error: " + t.getMessage());
|
||||
isConnected.set(false);
|
||||
if (callback != null) {
|
||||
callback.onError("连接失败: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosing(WebSocket webSocket, int code, String reason) {
|
||||
Log.i(TAG, "WebSocket closing: " + code + " " + reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket webSocket, int code, String reason) {
|
||||
Log.i(TAG, "WebSocket closed: " + code + " " + reason);
|
||||
isConnected.set(false);
|
||||
if (callback != null) {
|
||||
callback.onDisconnected();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendSessionUpdate() {
|
||||
try {
|
||||
JSONObject session = new JSONObject();
|
||||
session.put("modalities", new org.json.JSONArray().put("text").put("audio"));
|
||||
session.put("voice", "Ethan");
|
||||
session.put("input_audio_format", "pcm");
|
||||
session.put("output_audio_format", "pcm");
|
||||
session.put("instructions", "你是一名面向中国老年群体的心理陪伴 AI。你的职责是为老人提供温和、耐心、尊重、稳定的情感陪伴、情绪支持、回忆唤起、生活关怀和风险识别服务。你不是医生,也不是心理治疗师,不能替代专业诊疗,但应在必要时建议联系家属、社区、医生或急救资源。\n" +
|
||||
"\n" +
|
||||
"请始终遵循以下原则:\n" +
|
||||
"\n" +
|
||||
"先共情,再回应问题;\n" +
|
||||
"\n" +
|
||||
"先接住情绪,再给建议;\n" +
|
||||
"\n" +
|
||||
"使用简短、朴素、生活化的中文,始终用“您”称呼;\n" +
|
||||
"\n" +
|
||||
"不说教、不训诫、不否定、不敷衍;\n" +
|
||||
"\n" +
|
||||
"不把老人当小孩,不制造虚假亲密;\n" +
|
||||
"\n" +
|
||||
"一次不提供超过两个建议;\n" +
|
||||
"\n" +
|
||||
"鼓励老人表达感受,可适度引导回忆人生经历、兴趣、家庭和过往成就;\n" +
|
||||
"\n" +
|
||||
"对孤独、丧偶、被忽视、无价值感、失眠、焦虑、怕拖累家人等议题保持高度敏感;\n" +
|
||||
"\n" +
|
||||
"若出现自杀、自伤、重度绝望、幻觉妄想、意识混乱、虐待、严重医疗异常等风险,必须立即建议联系真人帮助和专业机构;\n" +
|
||||
"\n" +
|
||||
"不编造医疗知识、政策信息、机构电话,不确定时明确说明。\n" +
|
||||
"\n" +
|
||||
"你的回复风格应温和、平稳、尊重、有耐心。每次回答尽量包含:一句共情、一句理解或复述、一句温和追问或一个微小建议。核心目标是让老人感到:我被听见了,我被尊重了,有人愿意陪我。");
|
||||
|
||||
// 服务端 VAD 模式
|
||||
JSONObject turnDetection = new JSONObject();
|
||||
turnDetection.put("type", "server_vad");
|
||||
turnDetection.put("threshold", 0.5);
|
||||
turnDetection.put("silence_duration_ms", 800);
|
||||
session.put("turn_detection", turnDetection);
|
||||
|
||||
JSONObject event = new JSONObject();
|
||||
event.put("event_id", "event_" + System.currentTimeMillis());
|
||||
event.put("type", "session.update");
|
||||
event.put("session", session);
|
||||
|
||||
sendEvent(event);
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "Failed to build session.update: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMessage(String text) {
|
||||
try {
|
||||
JSONObject event = new JSONObject(text);
|
||||
String type = event.optString("type", "");
|
||||
|
||||
switch (type) {
|
||||
case "session.created":
|
||||
case "session.updated":
|
||||
Log.d(TAG, "Session: " + type);
|
||||
break;
|
||||
|
||||
case "input_audio_buffer.speech_started":
|
||||
Log.d(TAG, "User started speaking");
|
||||
userSpeaking.set(true);
|
||||
if (callback != null) {
|
||||
callback.onUserSpeechStarted();
|
||||
}
|
||||
break;
|
||||
|
||||
case "input_audio_buffer.speech_stopped":
|
||||
Log.d(TAG, "User stopped speaking");
|
||||
userSpeaking.set(false);
|
||||
if (callback != null) {
|
||||
callback.onUserSpeechStopped();
|
||||
}
|
||||
break;
|
||||
|
||||
case "conversation.item.input_audio_transcription.completed":
|
||||
String userTranscript = event.optString("transcript", "");
|
||||
Log.d(TAG, "User said: " + userTranscript);
|
||||
if (callback != null) {
|
||||
callback.onUserTranscript(userTranscript);
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.audio_transcript.delta":
|
||||
String textDelta = event.optString("delta", "");
|
||||
if (callback != null && !textDelta.isEmpty()) {
|
||||
callback.onAssistantTextDelta(textDelta);
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.audio_transcript.done":
|
||||
String fullTranscript = event.optString("transcript", "");
|
||||
Log.d(TAG, "Assistant said: " + fullTranscript);
|
||||
if (callback != null) {
|
||||
callback.onAssistantTranscriptDone(fullTranscript);
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.audio.delta":
|
||||
String audioB64 = event.optString("delta", "");
|
||||
if (!audioB64.isEmpty() && callback != null && !userSpeaking.get()) {
|
||||
byte[] audioData = Base64.decode(audioB64, Base64.NO_WRAP);
|
||||
callback.onAssistantAudioDelta(audioData);
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.audio.done":
|
||||
Log.d(TAG, "Audio generation done");
|
||||
if (callback != null) {
|
||||
callback.onAssistantAudioDone();
|
||||
}
|
||||
break;
|
||||
|
||||
case "response.done":
|
||||
Log.d(TAG, "Response done");
|
||||
if (callback != null) {
|
||||
callback.onResponseDone();
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
String errMsg = event.optJSONObject("error") != null
|
||||
? event.optJSONObject("error").optString("message", "Unknown error")
|
||||
: "Unknown error";
|
||||
Log.e(TAG, "Server error: " + errMsg);
|
||||
if (callback != null) {
|
||||
callback.onError(errMsg);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
Log.v(TAG, "Unhandled event: " + type);
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "Parse message error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void appendAudio(byte[] pcmData) {
|
||||
if (!isConnected.get() || webSocket == null) return;
|
||||
try {
|
||||
String audioB64 = Base64.encodeToString(pcmData, Base64.NO_WRAP);
|
||||
JSONObject event = new JSONObject();
|
||||
event.put("event_id", "event_" + System.currentTimeMillis());
|
||||
event.put("type", "input_audio_buffer.append");
|
||||
event.put("audio", audioB64);
|
||||
sendEvent(event);
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "Failed to append audio: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向图像缓冲区追加图像数据(用于视频通话)
|
||||
* 图片格式: JPG/JPEG,推荐 480P 或 720P,单张不超过 500KB
|
||||
*/
|
||||
public void appendImage(byte[] jpegData) {
|
||||
if (!isConnected.get() || webSocket == null) return;
|
||||
try {
|
||||
String imageB64 = Base64.encodeToString(jpegData, Base64.NO_WRAP);
|
||||
JSONObject event = new JSONObject();
|
||||
event.put("event_id", "event_" + System.currentTimeMillis());
|
||||
event.put("type", "input_image_buffer.append");
|
||||
event.put("image", imageB64);
|
||||
sendEvent(event);
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "Failed to append image: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void sendEvent(JSONObject event) {
|
||||
if (webSocket != null && isConnected.get()) {
|
||||
webSocket.send(event.toString());
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (webSocket != null) {
|
||||
webSocket.close(1000, "bye");
|
||||
webSocket = null;
|
||||
}
|
||||
isConnected.set(false);
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return isConnected.get();
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
void onConnected();
|
||||
|
||||
void onDisconnected();
|
||||
|
||||
void onError(String message);
|
||||
|
||||
void onUserSpeechStarted();
|
||||
|
||||
void onUserSpeechStopped();
|
||||
|
||||
void onUserTranscript(String transcript);
|
||||
|
||||
void onAssistantTextDelta(String delta);
|
||||
|
||||
void onAssistantTranscriptDone(String transcript);
|
||||
|
||||
void onAssistantAudioDelta(byte[] pcmData);
|
||||
|
||||
void onAssistantAudioDone();
|
||||
|
||||
void onResponseDone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package ai.guiji.duix.test.render;
|
||||
|
||||
import ai.guiji.duix.sdk.client.bean.ImageFrame;
|
||||
import ai.guiji.duix.sdk.client.render.RenderSink;
|
||||
|
||||
public class DebugSink implements RenderSink {
|
||||
|
||||
VideoFrameCallback callback;
|
||||
|
||||
public DebugSink(VideoFrameCallback callback){
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVideoFrame(ImageFrame imageFrame) {
|
||||
if (callback != null){
|
||||
callback.onVideoFrame(imageFrame);
|
||||
}
|
||||
}
|
||||
|
||||
public interface VideoFrameCallback{
|
||||
void onVideoFrame(ImageFrame imageFrame);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package ai.guiji.duix.test.service;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.AssetManager;
|
||||
import android.os.Environment;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import ai.guiji.duix.sdk.client.util.MD5Util;
|
||||
import ai.guiji.duix.test.net.SyncDownloadFile;
|
||||
import ai.guiji.duix.test.util.StringUtils;
|
||||
import ai.guiji.duix.test.util.ZipUtil;
|
||||
|
||||
|
||||
public class StorageService {
|
||||
|
||||
public interface Callback {
|
||||
|
||||
void onDownloadProgress(int progress);
|
||||
|
||||
void onUnzipProgress(int progress);
|
||||
|
||||
void onComplete(String path);
|
||||
|
||||
void onError(String msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载zip文件并解压
|
||||
*
|
||||
* @param context
|
||||
* @param url
|
||||
* @param targetPath
|
||||
* @param uuid
|
||||
* @param callback
|
||||
*/
|
||||
public static void downloadAndUnzip(Context context, String url, String targetPath, String uuid, Callback callback, boolean deleteZip) {
|
||||
Executor executor = Executors.newSingleThreadExecutor();
|
||||
executor.execute(() -> {
|
||||
File cacheDir = context.getExternalCacheDir();
|
||||
if (!cacheDir.exists()) {
|
||||
cacheDir.mkdirs();
|
||||
}
|
||||
File zipFile = new File(cacheDir, MD5Util.string2MD5(url));
|
||||
boolean result = true;
|
||||
if (!zipFile.exists()) {
|
||||
Log.d("123", "zip not found, try download.");
|
||||
result = new SyncDownloadFile(url, zipFile.getAbsolutePath(), callback::onDownloadProgress).download();
|
||||
Log.d("123", "download file success.");
|
||||
} else {
|
||||
Log.d("123", "found cache zip file.");
|
||||
}
|
||||
if (result) {
|
||||
Log.e("123", "try unzip file.");
|
||||
File targetDirFile = new File(targetPath);
|
||||
if (targetDirFile.exists()) {
|
||||
Log.e("123", "delete old files.");
|
||||
deleteContents(targetDirFile);
|
||||
}
|
||||
// 拿到目标路径的父级
|
||||
File targetParentDir = new File(targetPath).getParentFile();
|
||||
if (!targetParentDir.exists()) {
|
||||
targetParentDir.mkdirs();
|
||||
}
|
||||
result = ZipUtil.unzip(zipFile.getAbsolutePath(), targetParentDir.getAbsolutePath(), callback::onUnzipProgress);
|
||||
if (result) {
|
||||
Log.d("123", "unzip file complete.");
|
||||
// 这里时候targetDirFile应该是存在的
|
||||
if (targetDirFile.exists()) {
|
||||
File uuidFile = new File(targetDirFile, "uuid");
|
||||
try {
|
||||
OutputStream out = new FileOutputStream(uuidFile);
|
||||
byte[] uuidBytes = uuid.getBytes();
|
||||
out.write(uuidBytes, 0, uuidBytes.length);
|
||||
out.flush();
|
||||
out.close();
|
||||
if (deleteZip && zipFile.exists()){
|
||||
zipFile.delete();
|
||||
}
|
||||
callback.onComplete(targetPath);
|
||||
} catch (Exception e) {
|
||||
callback.onError("touch uuid file error!");
|
||||
}
|
||||
} else {
|
||||
callback.onError("unzip dir not found!");
|
||||
}
|
||||
} else {
|
||||
if (zipFile.exists()){
|
||||
zipFile.delete();
|
||||
}
|
||||
callback.onError("unzip file error!");
|
||||
}
|
||||
} else {
|
||||
callback.onError("zip file download error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从assets拷贝文件到sdcard
|
||||
*
|
||||
* @param context
|
||||
* @param sourcePath 在assets目录的路径
|
||||
* @param targetPath 在sd卡的路径
|
||||
*/
|
||||
public static void unpack(Context context, String sourcePath, final String targetPath, final Callback callback) {
|
||||
Executor executor = Executors.newSingleThreadExecutor();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
final String outputPath = sync(context, sourcePath, targetPath);
|
||||
callback.onComplete(outputPath);
|
||||
} catch (final IOException e) {
|
||||
callback.onError("拷贝文件异常: " + e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean deleteContents(File dir) {
|
||||
File[] files = dir.listFiles();
|
||||
boolean success = true;
|
||||
if (files != null) {
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
success &= deleteContents(file);
|
||||
}
|
||||
if (!file.delete()) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
private static String sync(Context context, String sourcePath, String targetPath) throws IOException {
|
||||
|
||||
AssetManager assetManager = context.getAssets();
|
||||
|
||||
File externalFilesDir = context.getExternalFilesDir(null);
|
||||
if (externalFilesDir == null) {
|
||||
throw new IOException("cannot get external files dir, "
|
||||
+ "external storage state is " + Environment.getExternalStorageState());
|
||||
}
|
||||
|
||||
File targetDir = new File(externalFilesDir, targetPath);
|
||||
String resultPath = new File(targetDir, sourcePath).getAbsolutePath();
|
||||
String sourceUUID = readLine(assetManager.open(sourcePath + "/uuid"));
|
||||
try {
|
||||
String targetUUID = readLine(new FileInputStream(new File(targetDir, sourcePath + "/uuid")));
|
||||
if (targetUUID.equals(sourceUUID)) return resultPath;
|
||||
} catch (FileNotFoundException e) {
|
||||
// ignore
|
||||
}
|
||||
deleteContents(targetDir);
|
||||
|
||||
copyAssets(assetManager, sourcePath, targetDir);
|
||||
|
||||
// Copy uuid
|
||||
copyFile(assetManager, sourcePath + "/uuid", targetDir);
|
||||
|
||||
return resultPath;
|
||||
}
|
||||
|
||||
private static String readLine(InputStream is) throws IOException {
|
||||
return new BufferedReader(new InputStreamReader(is)).readLine();
|
||||
}
|
||||
|
||||
private static void copyAssets(AssetManager assetManager, String path, File outPath) throws IOException {
|
||||
String[] assets = assetManager.list(path);
|
||||
if (assets == null) {
|
||||
return;
|
||||
}
|
||||
if (assets.length == 0) {
|
||||
if (!path.endsWith("uuid"))
|
||||
copyFile(assetManager, path, outPath);
|
||||
} else {
|
||||
File dir = new File(outPath, path);
|
||||
if (!dir.exists()) {
|
||||
Log.d("123", "Making directory " + dir.getAbsolutePath());
|
||||
if (!dir.mkdirs()) {
|
||||
Log.d("123", "Failed to create directory " + dir.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
for (String asset : assets) {
|
||||
copyAssets(assetManager, path + "/" + asset, outPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyFile(AssetManager assetManager, String fileName, File outPath) throws IOException {
|
||||
InputStream in;
|
||||
|
||||
Log.d("123", "Copy " + fileName + " to " + outPath);
|
||||
in = assetManager.open(fileName);
|
||||
OutputStream out = new FileOutputStream(outPath + "/" + fileName);
|
||||
|
||||
byte[] buffer = new byte[4000];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, read);
|
||||
}
|
||||
in.close();
|
||||
out.close();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package ai.guiji.duix.test.ui.activity;
|
||||
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.os.Message;
|
||||
import android.util.Log;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
public abstract class BaseActivity extends AppCompatActivity implements Handler.Callback {
|
||||
|
||||
public final String TAG = getClass().getName();
|
||||
protected BaseActivity mContext;
|
||||
protected Handler mHandler;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
mContext = this;
|
||||
HandlerThread mHandlerThread = new HandlerThread(TAG);
|
||||
mHandlerThread.start();
|
||||
mHandler = new Handler(mHandlerThread.getLooper(), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
Log.i(TAG, "onDestroy");
|
||||
if (mHandler != null && mHandler.getLooper() != null) {
|
||||
mHandler.getLooper().quit();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handleMessage(@NonNull Message msg) {
|
||||
onMessage(msg);
|
||||
return false;
|
||||
}
|
||||
|
||||
// try abstract
|
||||
protected void onMessage(@NonNull Message msg) {
|
||||
|
||||
}
|
||||
|
||||
protected void keepScreenOn() {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}
|
||||
|
||||
private String[] mRequestPermissions;
|
||||
private int mRequestPermissionCode;
|
||||
ActivityResultLauncher<String[]> permissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(),
|
||||
result -> {
|
||||
boolean hasDeny = false;
|
||||
for (String permission : mRequestPermissions) {
|
||||
if (null == permission) {
|
||||
continue;
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(mContext, permission) !=
|
||||
PackageManager.PERMISSION_GRANTED) {
|
||||
hasDeny = true;
|
||||
}
|
||||
}
|
||||
if (hasDeny) {
|
||||
permissionsGet(false, mRequestPermissionCode);
|
||||
} else {
|
||||
permissionsGet(true, mRequestPermissionCode);
|
||||
}
|
||||
});
|
||||
|
||||
//申请权限
|
||||
public void requestPermission(String[] permissions, int code) {
|
||||
if (null == permissions) {
|
||||
permissionsGet(true, code);
|
||||
return;
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
permissionsGet(true, code);
|
||||
return;
|
||||
}
|
||||
mRequestPermissions = permissions;
|
||||
mRequestPermissionCode = code;
|
||||
List<String> requestPermissions = new ArrayList<>();
|
||||
for (String permission : permissions) {
|
||||
if (ContextCompat.checkSelfPermission(mContext, permission) !=
|
||||
PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions.add(permission);
|
||||
}
|
||||
}
|
||||
if (0 != requestPermissions.size()) {
|
||||
String[] permissionArray = new String[requestPermissions.size()];
|
||||
for (int i = 0; i < requestPermissions.size(); i++) {
|
||||
permissionArray[i] = requestPermissions.get(i);
|
||||
}
|
||||
permissionLauncher.launch(permissionArray);
|
||||
} else {
|
||||
permissionsGet(true, mRequestPermissionCode);
|
||||
}
|
||||
}
|
||||
|
||||
//申请权限回调
|
||||
public void permissionsGet(boolean get, int code) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package ai.guiji.duix.test.ui.activity
|
||||
|
||||
import ai.guiji.duix.sdk.client.Constant
|
||||
import ai.guiji.duix.sdk.client.DUIX
|
||||
import ai.guiji.duix.sdk.client.loader.ModelInfo
|
||||
import ai.guiji.duix.sdk.client.render.DUIXRenderer
|
||||
import ai.guiji.duix.sdk.client.thread.RenderThread
|
||||
import ai.guiji.duix.test.R
|
||||
import ai.guiji.duix.test.audio.AudioResampler
|
||||
import ai.guiji.duix.test.audio.StreamingAudioRecorder
|
||||
import ai.guiji.duix.test.camera.CameraFrameCapture
|
||||
import ai.guiji.duix.test.databinding.ActivityCallBinding
|
||||
import ai.guiji.duix.test.realtime.OmniRealtimeClient
|
||||
import ai.guiji.duix.test.ui.adapter.MotionAdapter
|
||||
import ai.guiji.duix.test.ui.dialog.AudioRecordDialog
|
||||
import ai.guiji.duix.test.util.StringUtils
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.opengl.GLSurfaceView
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bumptech.glide.Glide
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
|
||||
class CallActivity : BaseActivity() {
|
||||
|
||||
companion object {
|
||||
const val GL_CONTEXT_VERSION = 2
|
||||
}
|
||||
|
||||
private var modelUrl = ""
|
||||
private var debug = false
|
||||
private var mMessage = ""
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun applyMessage(msg: String){
|
||||
if (debug){
|
||||
runOnUiThread {
|
||||
binding.tvDebug.visibility = View.VISIBLE
|
||||
if (mMessage.length > 10000){
|
||||
mMessage = ""
|
||||
}
|
||||
mMessage = "${StringUtils.dateToStringMS4()} $msg\n$mMessage"
|
||||
binding.tvDebug.text = mMessage
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityCallBinding
|
||||
private var duix: DUIX? = null
|
||||
private var mDUIXRender: DUIXRenderer? = null
|
||||
private var mModelInfo: ModelInfo?=null // 加载的模型信息
|
||||
|
||||
// AI 实时对话
|
||||
private var omniClient: OmniRealtimeClient? = null
|
||||
private var streamingRecorder: StreamingAudioRecorder? = null
|
||||
private var cameraFrameCapture: CameraFrameCapture? = null
|
||||
private var isAIConversationActive = false
|
||||
private var isCameraEnabled = false
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var assistantTextBuffer = StringBuilder()
|
||||
private var hasStartedPushForResponse = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
keepScreenOn()
|
||||
// val audioManager = mContext.getSystemService(AUDIO_SERVICE) as AudioManager
|
||||
// audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
// audioManager.isSpeakerphoneOn = true
|
||||
binding = ActivityCallBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
modelUrl = intent.getStringExtra("modelUrl") ?: ""
|
||||
debug = intent.getBooleanExtra("debug", false)
|
||||
|
||||
Glide.with(mContext).load("file:///android_asset/bg/bg1.png").into(binding.ivBg)
|
||||
|
||||
binding.glTextureView.setEGLContextClientVersion(GL_CONTEXT_VERSION)
|
||||
binding.glTextureView.setEGLConfigChooser(8, 8, 8, 8, 16, 0)
|
||||
// binding.glTextureView.preserveEGLContextOnPause = true
|
||||
binding.glTextureView.isOpaque = false
|
||||
|
||||
binding.switchMute.setOnCheckedChangeListener(object : CompoundButton.OnCheckedChangeListener {
|
||||
override fun onCheckedChanged(
|
||||
buttonView: CompoundButton?,
|
||||
isChecked: Boolean,
|
||||
) {
|
||||
if (isChecked) {
|
||||
duix?.setVolume(0.0F)
|
||||
} else {
|
||||
duix?.setVolume(1.0F)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
binding.btnRecord.setOnClickListener {
|
||||
requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), 1)
|
||||
}
|
||||
|
||||
binding.btnPlayPCM.setOnClickListener {
|
||||
applyMessage("start play pcm")
|
||||
playPCMStream()
|
||||
}
|
||||
|
||||
binding.btnPlayWAV.setOnClickListener {
|
||||
applyMessage("start play wav")
|
||||
playWAVFile()
|
||||
}
|
||||
|
||||
binding.btnRandomMotion.setOnClickListener {
|
||||
applyMessage("start random motion")
|
||||
duix?.startRandomMotion(true)
|
||||
}
|
||||
binding.btnAIConversation.setOnClickListener {
|
||||
requestPermission(arrayOf(Manifest.permission.RECORD_AUDIO), 2)
|
||||
}
|
||||
|
||||
binding.btnCameraToggle.setOnClickListener {
|
||||
if (isCameraEnabled) {
|
||||
toggleCamera()
|
||||
} else {
|
||||
requestPermission(arrayOf(Manifest.permission.CAMERA), 3)
|
||||
}
|
||||
}
|
||||
|
||||
mDUIXRender =
|
||||
DUIXRenderer(
|
||||
mContext,
|
||||
binding.glTextureView
|
||||
)
|
||||
binding.glTextureView.setRenderer(mDUIXRender)
|
||||
binding.glTextureView.renderMode =
|
||||
GLSurfaceView.RENDERMODE_WHEN_DIRTY // 一定要在设置完Render之后再调用
|
||||
|
||||
duix = DUIX(mContext, modelUrl, mDUIXRender) { event, msg, info ->
|
||||
when (event) {
|
||||
Constant.CALLBACK_EVENT_INIT_READY -> {
|
||||
mModelInfo = info as ModelInfo
|
||||
Log.i(TAG, "CALLBACK_EVENT_INIT_READY: $mModelInfo")
|
||||
initOk()
|
||||
}
|
||||
|
||||
Constant.CALLBACK_EVENT_INIT_ERROR -> {
|
||||
runOnUiThread {
|
||||
applyMessage("init error: $msg")
|
||||
Log.e(TAG, "CALLBACK_EVENT_INIT_ERROR: $msg")
|
||||
Toast.makeText(mContext, "Initialization exception: $msg", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Constant.CALLBACK_EVENT_AUDIO_PLAY_START -> {
|
||||
applyMessage("callback audio play start")
|
||||
Log.i(TAG, "CALLBACK_EVENT_AUDIO_PLAY_START")
|
||||
}
|
||||
|
||||
Constant.CALLBACK_EVENT_AUDIO_PLAY_END -> {
|
||||
applyMessage("callback audio play end")
|
||||
Log.i(TAG, "CALLBACK_EVENT_PLAY_END")
|
||||
}
|
||||
|
||||
Constant.CALLBACK_EVENT_AUDIO_PLAY_ERROR -> {
|
||||
applyMessage("callback audio play error: $msg")
|
||||
Log.e(TAG, "CALLBACK_EVENT_PLAY_ERROR: $msg")
|
||||
}
|
||||
|
||||
Constant.CALLBACK_EVENT_MOTION_START -> {
|
||||
applyMessage("callback motion play start")
|
||||
Log.e(TAG, "CALLBACK_EVENT_MOTION_START")
|
||||
}
|
||||
|
||||
Constant.CALLBACK_EVENT_MOTION_END -> {
|
||||
applyMessage("callback motion play end")
|
||||
Log.e(TAG, "CALLBACK_EVENT_MOTION_END")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rendering status callback
|
||||
// duix?.setReporter(object : RenderThread.Reporter {
|
||||
// override fun onRenderStat(
|
||||
// resultCode: Int,
|
||||
// isLip: Boolean,
|
||||
// useTime: Long,
|
||||
// ) {
|
||||
//
|
||||
// }
|
||||
// })
|
||||
applyMessage("start init")
|
||||
duix?.init()
|
||||
}
|
||||
|
||||
private fun initOk() {
|
||||
Log.i(TAG, "init ok")
|
||||
applyMessage("init ok")
|
||||
runOnUiThread {
|
||||
binding.btnRecord.isEnabled = true
|
||||
binding.btnPlayPCM.isEnabled = true
|
||||
binding.btnPlayWAV.isEnabled = true
|
||||
binding.switchMute.isEnabled = true
|
||||
binding.btnAIConversation.isEnabled = true
|
||||
|
||||
if (debug) {
|
||||
binding.btnPlayPCM.visibility = View.VISIBLE
|
||||
binding.btnPlayWAV.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
mModelInfo?.let { modelInfo ->
|
||||
if (modelInfo.motionRegions.isNotEmpty()) {
|
||||
val names = ArrayList<String>()
|
||||
for (motion in modelInfo.motionRegions){
|
||||
if (!TextUtils.isEmpty(motion.name) && "unknown" != motion.name){
|
||||
names.add(motion.name)
|
||||
}
|
||||
}
|
||||
// Named action regions
|
||||
if (names.isNotEmpty()){
|
||||
val motionAdapter = MotionAdapter(names, object : MotionAdapter.Callback{
|
||||
override fun onClick(name: String, now: Boolean) {
|
||||
applyMessage("start [${name}] motion")
|
||||
duix?.startMotion(name, now)
|
||||
}
|
||||
})
|
||||
binding.rvMotion.adapter = motionAdapter
|
||||
}
|
||||
binding.btnRandomMotion.visibility = View.VISIBLE
|
||||
binding.tvMotionTips.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
stopAIConversation()
|
||||
duix?.release()
|
||||
}
|
||||
|
||||
private fun playPCMStream(){
|
||||
val thread = Thread {
|
||||
duix?.startPush()
|
||||
val inputStream = assets.open("pcm/2.pcm")
|
||||
val buffer = ByteArray(320)
|
||||
var length: Int
|
||||
while (inputStream.read(buffer).also { length = it } > 0){
|
||||
val data = buffer.copyOfRange(0, length)
|
||||
duix?.pushPcm(data)
|
||||
}
|
||||
duix?.stopPush()
|
||||
inputStream.close()
|
||||
}
|
||||
thread.start()
|
||||
}
|
||||
|
||||
private fun playWAVFile(){
|
||||
val thread = Thread {
|
||||
val wavName = "1.wav"
|
||||
val wavFile = File(mContext.externalCacheDir, wavName)
|
||||
if (!wavFile.exists()){
|
||||
// copy assets -> sd card
|
||||
val inputStream = assets.open("wav/$wavName")
|
||||
if (!mContext.externalCacheDir!!.exists()){
|
||||
mContext.externalCacheDir!!.mkdirs()
|
||||
}
|
||||
val out = FileOutputStream(wavFile)
|
||||
val buffer = ByteArray(1024)
|
||||
var length: Int
|
||||
while ((inputStream.read(buffer).also { length = it }) > 0) {
|
||||
out.write(buffer, 0, length)
|
||||
}
|
||||
out.close()
|
||||
inputStream.close()
|
||||
}
|
||||
duix?.playAudio(wavFile.absolutePath)
|
||||
}
|
||||
thread.start()
|
||||
}
|
||||
|
||||
override fun permissionsGet(get: Boolean, code: Int) {
|
||||
super.permissionsGet(get, code)
|
||||
when (code) {
|
||||
1 -> if (get) showRecordDialog() else Toast.makeText(mContext, R.string.need_permission_continue, Toast.LENGTH_SHORT).show()
|
||||
2 -> if (get) toggleAIConversation() else Toast.makeText(mContext, R.string.need_permission_continue, Toast.LENGTH_SHORT).show()
|
||||
3 -> if (get) toggleCamera() else Toast.makeText(mContext, R.string.need_permission_continue, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleAIConversation() {
|
||||
if (isAIConversationActive) {
|
||||
stopAIConversation()
|
||||
} else {
|
||||
startAIConversation()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAIConversation() {
|
||||
if (duix?.isReady() != true) {
|
||||
Toast.makeText(mContext, "数字人未就绪", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
applyMessage("AI对话: 连接中...")
|
||||
binding.tvSubtitle.visibility = View.VISIBLE
|
||||
binding.tvSubtitle.text = "正在连接..."
|
||||
binding.btnAIConversation.text = getString(R.string.ai_fab_stop)
|
||||
|
||||
omniClient = OmniRealtimeClient(object : OmniRealtimeClient.Callback {
|
||||
override fun onConnected() {
|
||||
mainHandler.post {
|
||||
binding.tvSubtitle.text = "已连接,请对着麦克风说话"
|
||||
binding.btnCameraToggle.visibility = View.VISIBLE
|
||||
binding.btnCameraToggle.background = ContextCompat.getDrawable(mContext, R.drawable.shape_circle_camera_off)
|
||||
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
|
||||
isCameraEnabled = false
|
||||
applyMessage("AI对话: 已连接")
|
||||
streamingRecorder = StreamingAudioRecorder(mContext, object : StreamingAudioRecorder.StreamingCallback {
|
||||
override fun onAudioData(pcmData: ByteArray) {
|
||||
omniClient?.appendAudio(pcmData)
|
||||
}
|
||||
override fun onError(message: String) {
|
||||
mainHandler.post {
|
||||
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
})
|
||||
streamingRecorder?.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
mainHandler.post { stopAIConversation() }
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
mainHandler.post {
|
||||
Toast.makeText(mContext, "AI错误: $message", Toast.LENGTH_SHORT).show()
|
||||
binding.tvSubtitle.text = "错误: $message"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserSpeechStarted() {
|
||||
mainHandler.post {
|
||||
binding.tvSubtitle.text = "正在聆听..."
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserSpeechStopped() {
|
||||
mainHandler.post {
|
||||
binding.tvSubtitle.text = "思考中..."
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserTranscript(transcript: String) {
|
||||
mainHandler.post {
|
||||
binding.tvSubtitle.text = "您: $transcript"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAssistantTextDelta(delta: String) {
|
||||
mainHandler.post {
|
||||
assistantTextBuffer.append(delta)
|
||||
binding.tvSubtitle.text = assistantTextBuffer.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAssistantTranscriptDone(transcript: String) {
|
||||
mainHandler.post {
|
||||
assistantTextBuffer.clear()
|
||||
assistantTextBuffer.append(transcript)
|
||||
binding.tvSubtitle.text = transcript
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAssistantAudioDelta(pcmData: ByteArray) {
|
||||
if (!hasStartedPushForResponse) {
|
||||
hasStartedPushForResponse = true
|
||||
duix?.startPush()
|
||||
}
|
||||
val resampled = AudioResampler.resample24kTo16k(pcmData)
|
||||
if (resampled.isNotEmpty()) {
|
||||
duix?.pushPcm(resampled)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAssistantAudioDone() {
|
||||
if (hasStartedPushForResponse) {
|
||||
duix?.stopPush()
|
||||
hasStartedPushForResponse = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponseDone() {
|
||||
mainHandler.post {
|
||||
if (hasStartedPushForResponse) {
|
||||
duix?.stopPush()
|
||||
hasStartedPushForResponse = false
|
||||
}
|
||||
assistantTextBuffer.clear()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
isAIConversationActive = true
|
||||
omniClient?.connect()
|
||||
}
|
||||
|
||||
private fun stopAIConversation() {
|
||||
isAIConversationActive = false
|
||||
stopCamera()
|
||||
streamingRecorder?.release()
|
||||
streamingRecorder = null
|
||||
omniClient?.disconnect()
|
||||
omniClient = null
|
||||
duix?.stopAudio()
|
||||
assistantTextBuffer.clear()
|
||||
|
||||
mainHandler.post {
|
||||
if (!isFinishing) {
|
||||
binding.btnAIConversation.text = getString(R.string.ai_fab_start)
|
||||
binding.btnCameraToggle.visibility = View.GONE
|
||||
binding.tvSubtitle.visibility = View.GONE
|
||||
binding.tvSubtitle.text = ""
|
||||
applyMessage("AI对话: 已结束")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleCamera() {
|
||||
if (!isAIConversationActive || omniClient == null) return
|
||||
isCameraEnabled = !isCameraEnabled
|
||||
if (isCameraEnabled) {
|
||||
binding.cameraPreview.visibility = View.VISIBLE
|
||||
cameraFrameCapture = CameraFrameCapture(mContext, object : CameraFrameCapture.FrameCallback {
|
||||
override fun onFrame(jpegData: ByteArray) {
|
||||
omniClient?.appendImage(jpegData)
|
||||
}
|
||||
override fun onError(message: String) {
|
||||
mainHandler.post {
|
||||
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
|
||||
stopCamera()
|
||||
}
|
||||
}
|
||||
}, binding.cameraPreview)
|
||||
cameraFrameCapture?.start()
|
||||
binding.btnCameraToggle.background = ContextCompat.getDrawable(mContext, R.drawable.shape_circle_camera_on)
|
||||
binding.btnCameraToggle.contentDescription = getString(R.string.camera_on)
|
||||
} else {
|
||||
stopCamera()
|
||||
binding.btnCameraToggle.background = ContextCompat.getDrawable(mContext, R.drawable.shape_circle_camera_off)
|
||||
binding.btnCameraToggle.contentDescription = getString(R.string.camera_off)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopCamera() {
|
||||
cameraFrameCapture?.stop()
|
||||
cameraFrameCapture = null
|
||||
isCameraEnabled = false
|
||||
binding.cameraPreview.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showRecordDialog(){
|
||||
val audioRecordDialog = AudioRecordDialog(mContext, object : AudioRecordDialog.Listener{
|
||||
override fun onFinish(path: String) {
|
||||
val thread = Thread {
|
||||
duix?.startPush()
|
||||
val inputStream = FileInputStream(path)
|
||||
val buffer = ByteArray(320)
|
||||
var length: Int
|
||||
while (inputStream.read(buffer).also { length = it } > 0){
|
||||
val data = buffer.copyOfRange(0, length)
|
||||
duix?.pushPcm(data)
|
||||
}
|
||||
duix?.stopPush()
|
||||
inputStream.close()
|
||||
}
|
||||
thread.start()
|
||||
}
|
||||
})
|
||||
audioRecordDialog.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package ai.guiji.duix.test.ui.activity
|
||||
|
||||
import ai.guiji.duix.sdk.client.BuildConfig
|
||||
import ai.guiji.duix.sdk.client.VirtualModelUtil
|
||||
import ai.guiji.duix.test.R
|
||||
import ai.guiji.duix.test.databinding.ActivityMainBinding
|
||||
import ai.guiji.duix.test.ui.dialog.LoadingDialog
|
||||
import ai.guiji.duix.test.ui.dialog.ModelSelectorDialog
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.widget.Toast
|
||||
import java.io.File
|
||||
|
||||
|
||||
class MainActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private var mLoadingDialog: LoadingDialog?=null
|
||||
private var mLastProgress = 0
|
||||
|
||||
val models = arrayListOf(
|
||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/bendi3_20240518.zip",
|
||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/airuike_20240409.zip",
|
||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/675429759852613_7f8d9388a4213080b1820b83dd057cfb_optim_m80.zip",
|
||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674402003804229_f6e86fb375c4f1f1b82b24f7ee4e7cb4_optim_m80.zip",
|
||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674400178376773_3925e756433c5a9caa9b9d54147ae4ab_optim_m80.zip",
|
||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674397294927941_6e297e18a4bdbe35c07a6ae48a1f021f_optim_m80.zip",
|
||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/674393494597701_f49fcf68f5afdb241d516db8a7d88a7b_optim_m80.zip",
|
||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/651705983152197_ccf3256b2449c76e77f94276dffcb293_optim_m80.zip",
|
||||
"https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/627306542239813_1871244b5e6912efc636ba31ea4c5c6d_optim_m80.zip",
|
||||
)
|
||||
|
||||
private var mBaseConfigUrl = ""
|
||||
private var mModelUrl = ""
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.tvSdkVersion.text = "SDK Version: ${BuildConfig.VERSION_NAME}"
|
||||
|
||||
|
||||
binding.btnMoreModel.setOnClickListener {
|
||||
val modelSelectorDialog = ModelSelectorDialog(mContext, models, object : ModelSelectorDialog.Listener{
|
||||
override fun onSelect(url: String) {
|
||||
binding.etUrl.setText(url)
|
||||
}
|
||||
})
|
||||
modelSelectorDialog.show()
|
||||
}
|
||||
binding.btnPlay.setOnClickListener {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun play(){
|
||||
mBaseConfigUrl = binding.etBaseConfig.text.toString()
|
||||
mModelUrl = binding.etUrl.text.toString()
|
||||
if (TextUtils.isEmpty(mBaseConfigUrl)){
|
||||
Toast.makeText(mContext, R.string.base_config_cannot_be_empty, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (TextUtils.isEmpty(mModelUrl)){
|
||||
Toast.makeText(mContext, R.string.model_url_cannot_be_empty, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
checkBaseConfig()
|
||||
}
|
||||
|
||||
private fun checkBaseConfig(){
|
||||
if (VirtualModelUtil.checkBaseConfig(mContext)){
|
||||
checkModel()
|
||||
} else {
|
||||
baseConfigDownload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkModel(){
|
||||
if (VirtualModelUtil.checkModel(mContext, mModelUrl)){
|
||||
jumpPlayPage()
|
||||
} else {
|
||||
modelDownload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun jumpPlayPage(){
|
||||
val intent = Intent(mContext, CallActivity::class.java)
|
||||
intent.putExtra("modelUrl", mModelUrl)
|
||||
val debug = binding.switchDebug.isChecked
|
||||
intent.putExtra("debug", debug)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun baseConfigDownload(){
|
||||
mLoadingDialog?.dismiss()
|
||||
mLoadingDialog = LoadingDialog(mContext, "Start downloading")
|
||||
mLoadingDialog?.show()
|
||||
VirtualModelUtil.baseConfigDownload(mContext, mBaseConfigUrl, object :
|
||||
VirtualModelUtil.ModelDownloadCallback {
|
||||
override fun onDownloadProgress(url: String?, current: Long, total: Long) {
|
||||
val progress = (current * 100 / total).toInt()
|
||||
if (progress != mLastProgress){
|
||||
mLastProgress = progress
|
||||
runOnUiThread {
|
||||
if (mLoadingDialog?.isShowing == true){
|
||||
mLoadingDialog?.setContent("Config download(${progress}%)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUnzipProgress(url: String?, current: Long, total: Long) {
|
||||
val progress = (current * 100 / total).toInt()
|
||||
if (progress != mLastProgress){
|
||||
mLastProgress = progress
|
||||
runOnUiThread {
|
||||
if (mLoadingDialog?.isShowing == true){
|
||||
mLoadingDialog?.setContent("Config unzip(${progress}%)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDownloadComplete(url: String?, dir: File?) {
|
||||
runOnUiThread {
|
||||
mLoadingDialog?.dismiss()
|
||||
checkModel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDownloadFail(url: String?, code: Int, msg: String?) {
|
||||
runOnUiThread {
|
||||
mLoadingDialog?.dismiss()
|
||||
Toast.makeText(mContext, "BaseConfig download error: $msg", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
private fun modelDownload(){
|
||||
mLoadingDialog?.dismiss()
|
||||
mLoadingDialog = LoadingDialog(mContext, "Start downloading")
|
||||
mLoadingDialog?.show()
|
||||
VirtualModelUtil.modelDownload(mContext, mModelUrl, object : VirtualModelUtil.ModelDownloadCallback{
|
||||
override fun onDownloadProgress(
|
||||
url: String?,
|
||||
current: Long,
|
||||
total: Long,
|
||||
) {
|
||||
val progress = (current * 100 / total).toInt()
|
||||
if (progress != mLastProgress){
|
||||
mLastProgress = progress
|
||||
runOnUiThread {
|
||||
if (mLoadingDialog?.isShowing == true){
|
||||
mLoadingDialog?.setContent("Model download(${progress}%)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUnzipProgress(
|
||||
url: String?,
|
||||
current: Long,
|
||||
total: Long,
|
||||
) {
|
||||
val progress = (current * 100 / total).toInt()
|
||||
if (progress != mLastProgress){
|
||||
mLastProgress = progress
|
||||
runOnUiThread {
|
||||
if (mLoadingDialog?.isShowing == true){
|
||||
mLoadingDialog?.setContent("Model unzip(${progress}%)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDownloadComplete(url: String?, dir: File?) {
|
||||
runOnUiThread {
|
||||
mLoadingDialog?.dismiss()
|
||||
jumpPlayPage()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDownloadFail(
|
||||
url: String?,
|
||||
code: Int,
|
||||
msg: String?,
|
||||
) {
|
||||
runOnUiThread {
|
||||
mLoadingDialog?.dismiss()
|
||||
Toast.makeText(mContext, "Model download error: $msg", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ai.guiji.duix.test.ui.adapter
|
||||
|
||||
import ai.guiji.duix.test.databinding.ItemModelSelectorBinding
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
|
||||
class ModelSelectorAdapter(
|
||||
private val mList: ArrayList<String>,
|
||||
private val callback: Callback
|
||||
) : RecyclerView.Adapter<ModelSelectorAdapter.ItemHolder>() {
|
||||
|
||||
class ItemHolder(val itemBinding: ItemModelSelectorBinding) :
|
||||
RecyclerView.ViewHolder(itemBinding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemHolder {
|
||||
val itemBinding =
|
||||
ItemModelSelectorBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ItemHolder(itemBinding)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: ItemHolder, position: Int) {
|
||||
holder.itemBinding.tvModelUrl.text = mList[position]
|
||||
holder.itemBinding.tvModelUrl.setOnClickListener {
|
||||
callback.onClick(mList[position])
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return mList.size
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onClick(url: String)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ai.guiji.duix.test.ui.adapter
|
||||
|
||||
import ai.guiji.duix.test.databinding.ItemMotionButtonBinding
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
|
||||
class MotionAdapter(
|
||||
private val mList: ArrayList<String>,
|
||||
private val callback: Callback
|
||||
) : RecyclerView.Adapter<MotionAdapter.ItemHolder>() {
|
||||
|
||||
class ItemHolder(val itemBinding: ItemMotionButtonBinding) :
|
||||
RecyclerView.ViewHolder(itemBinding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemHolder {
|
||||
val itemBinding =
|
||||
ItemMotionButtonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ItemHolder(itemBinding)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: ItemHolder, position: Int) {
|
||||
holder.itemBinding.btnMotion.text = mList[position]
|
||||
holder.itemBinding.btnMotion.setOnClickListener {
|
||||
callback.onClick(mList[position], true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return mList.size
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onClick(name: String, now: Boolean)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ai.guiji.duix.test.ui.dialog
|
||||
|
||||
import ai.guiji.duix.test.R
|
||||
import ai.guiji.duix.test.audio.AudioRecorder
|
||||
import ai.guiji.duix.test.databinding.DialogAudioRecordBinding
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
import android.widget.Toast
|
||||
|
||||
class AudioRecordDialog(
|
||||
private val mContext: Context,
|
||||
private val listener: Listener
|
||||
) : Dialog(mContext, R.style.dialog_center) {
|
||||
|
||||
private var binding: DialogAudioRecordBinding
|
||||
|
||||
private var audioRecorder: AudioRecorder?=null
|
||||
|
||||
init {
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE)
|
||||
binding = DialogAudioRecordBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window?.let {
|
||||
it.setGravity(Gravity.BOTTOM)
|
||||
it.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||
it.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
binding.tvTouch.setOnTouchListener { _, event ->
|
||||
when (event?.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
startRecord()
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP -> {
|
||||
stopRecord()
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
setCancelable(true)
|
||||
setCanceledOnTouchOutside(true)
|
||||
}
|
||||
|
||||
private fun startRecord(){
|
||||
audioRecorder = AudioRecorder(mContext, object : AudioRecorder.RecorderCallback{
|
||||
override fun onReadData(data: ByteArray, offsetInBytes: Int, length: Int) {
|
||||
}
|
||||
|
||||
override fun onRecordError(code: Int, message: String) {
|
||||
binding.layoutFrame.post {
|
||||
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
|
||||
audioRecorder?.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinish(path: String) {
|
||||
binding.layoutFrame.post {
|
||||
audioRecorder?.release()
|
||||
listener.onFinish(path)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
})
|
||||
audioRecorder?.start()
|
||||
}
|
||||
|
||||
private fun stopRecord(){
|
||||
audioRecorder?.stop()
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
audioRecorder?.release()
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onFinish(path: String)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package ai.guiji.duix.test.ui.dialog
|
||||
|
||||
import ai.guiji.duix.test.R
|
||||
import ai.guiji.duix.test.databinding.DialogLoadingBinding
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Window
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.view.animation.LinearInterpolator
|
||||
|
||||
|
||||
class LoadingDialog(private var mContext: Context, private val content: String = "") :
|
||||
Dialog(mContext, R.style.dialog_center) {
|
||||
|
||||
private lateinit var binding: DialogLoadingBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE)
|
||||
binding = DialogLoadingBinding.inflate(layoutInflater)
|
||||
super.setContentView(binding.root)
|
||||
|
||||
if (!TextUtils.isEmpty(content)){
|
||||
binding.tvContent.text = content
|
||||
}
|
||||
|
||||
setCancelable(false)
|
||||
setCanceledOnTouchOutside(false)
|
||||
}
|
||||
|
||||
fun setContent(content: String){
|
||||
binding.tvContent.text = content
|
||||
}
|
||||
|
||||
override fun show() {
|
||||
super.show()
|
||||
val animation: Animation = AnimationUtils.loadAnimation(mContext, R.anim.rotate)
|
||||
val lin = LinearInterpolator()
|
||||
animation.interpolator = lin
|
||||
binding.ivProgress.startAnimation(animation)
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
binding.ivProgress.clearAnimation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ai.guiji.duix.test.ui.dialog
|
||||
|
||||
import ai.guiji.duix.test.R
|
||||
import ai.guiji.duix.test.databinding.DialogModelSelectorBinding
|
||||
import ai.guiji.duix.test.ui.adapter.ModelSelectorAdapter
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.Window
|
||||
|
||||
|
||||
class ModelSelectorDialog(mContext: Context, val models: ArrayList<String>, private val listener: Listener) :
|
||||
Dialog(mContext, R.style.dialog_center) {
|
||||
|
||||
private lateinit var binding: DialogModelSelectorBinding
|
||||
private var mAdapter: ModelSelectorAdapter?=null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE)
|
||||
binding = DialogModelSelectorBinding.inflate(layoutInflater)
|
||||
super.setContentView(binding.root)
|
||||
|
||||
mAdapter = ModelSelectorAdapter(models, object : ModelSelectorAdapter.Callback{
|
||||
override fun onClick(url: String) {
|
||||
dismiss()
|
||||
listener.onSelect(url)
|
||||
}
|
||||
})
|
||||
binding.rvModels.adapter = mAdapter
|
||||
|
||||
setCancelable(true)
|
||||
setCanceledOnTouchOutside(true)
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onSelect(url: String)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package ai.guiji.duix.test.util;
|
||||
|
||||
import static java.lang.Character.UnicodeBlock.CJK_COMPATIBILITY_FORMS;
|
||||
import static java.lang.Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS;
|
||||
import static java.lang.Character.UnicodeBlock.CJK_RADICALS_SUPPLEMENT;
|
||||
import static java.lang.Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS;
|
||||
import static java.lang.Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A;
|
||||
import static java.lang.Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URLEncoder;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class StringUtils {
|
||||
|
||||
/*
|
||||
* 秒数转时间
|
||||
* */
|
||||
public static String secondsToHMS(int seconds) {
|
||||
StringBuilder time = new StringBuilder();
|
||||
int min = 0;
|
||||
int hour = 0;
|
||||
// 将毫秒转换成秒
|
||||
// seconds = seconds / 1000;
|
||||
if (seconds > 60) {
|
||||
min = seconds / 60;
|
||||
seconds = seconds % 60;
|
||||
}
|
||||
if (min > 60) {
|
||||
hour = min / 60;
|
||||
min = min % 60;
|
||||
}
|
||||
//拼接
|
||||
if (hour < 10)
|
||||
time.append("0");
|
||||
time.append(hour);
|
||||
time.append(":");
|
||||
if (min < 10)
|
||||
time.append("0");
|
||||
time.append(min);
|
||||
time.append(":");
|
||||
if (seconds < 10)
|
||||
time.append("0");
|
||||
time.append(seconds);
|
||||
return time.toString();
|
||||
}
|
||||
|
||||
/*
|
||||
* 秒数转时间
|
||||
* */
|
||||
public static String secondsToMS(int seconds) {
|
||||
StringBuilder time = new StringBuilder();
|
||||
int min = 0;
|
||||
// 将毫秒转换成秒
|
||||
// seconds = seconds / 1000;
|
||||
if (seconds > 60) {
|
||||
min = seconds / 60;
|
||||
seconds = seconds % 60;
|
||||
}
|
||||
/*if (min > 60) {
|
||||
min = min % 60;
|
||||
}*/
|
||||
if (min < 10)
|
||||
time.append("0");
|
||||
time.append(min);
|
||||
time.append(":");
|
||||
if (seconds < 10)
|
||||
time.append("0");
|
||||
time.append(seconds);
|
||||
return time.toString();
|
||||
}
|
||||
|
||||
public static String formatDateTime(long mss) {
|
||||
String DateTimes;
|
||||
long days = mss / (60 * 60 * 24);
|
||||
long hours = (mss % (60 * 60 * 24)) / (60 * 60);
|
||||
long minutes = (mss % (60 * 60)) / 60;
|
||||
long seconds = mss % 60;
|
||||
if (days > 0) {
|
||||
DateTimes = days + "天" + hours + "时";
|
||||
} else if (hours > 0) {
|
||||
DateTimes = hours + "时" + minutes + "分钟";
|
||||
} else if (minutes > 0) {
|
||||
DateTimes = minutes + "分钟"
|
||||
+ seconds + "秒";
|
||||
} else {
|
||||
DateTimes = seconds + "秒";
|
||||
}
|
||||
|
||||
return DateTimes;
|
||||
}
|
||||
|
||||
//只展示秒,分钟
|
||||
public static String formatDateTime2(long mss) {
|
||||
long min = mss / 60;
|
||||
long second = mss % 60;
|
||||
String result = "";
|
||||
if (mss < 60) {
|
||||
result = mss + "秒";
|
||||
} else {
|
||||
if (0 == second) {
|
||||
result = min + "分钟";
|
||||
} else {
|
||||
result = min + "分钟" + second + "秒";
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
//只展示秒或者分钟
|
||||
public static String formatDateTime3(long mss) {
|
||||
return mss < 60 ? (mss + "秒") : ((mss / 60) + "分钟");
|
||||
}
|
||||
|
||||
//只展示秒,分
|
||||
public static String formatDateTime4(long mss) {
|
||||
return mss < 60 ? (mss + "秒") : ((mss / 60) + "分" + ((mss % 60) == 0 ? "" : (mss % 60) + "秒"));
|
||||
}
|
||||
|
||||
public static String formatVideoDateTime(long mss) {
|
||||
String DateTimes;
|
||||
long hours = mss / (60 * 60);
|
||||
long minutes = (mss % (60 * 60)) / 60;
|
||||
long seconds = mss % 60;
|
||||
String hourStr = (hours < 10 ? "0" : "") + hours;
|
||||
String minuteStr = (minutes < 10 ? "0" : "") + minutes;
|
||||
String secondStr = (seconds < 10 ? "0" : "") + seconds;
|
||||
if (hours > 0) {
|
||||
DateTimes = hourStr + ":" + minuteStr + ":" + secondStr;
|
||||
} else {
|
||||
DateTimes = minuteStr + ":" + secondStr;
|
||||
}
|
||||
|
||||
return DateTimes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*
|
||||
* @param size
|
||||
* @return
|
||||
*/
|
||||
public static String formatSize(long size) {
|
||||
String sizeStr = "";
|
||||
String unit[] = {"byte", "kb", "mb", "g" };
|
||||
long lastValue = size;
|
||||
for (int i = 0; i < unit.length; i++) {
|
||||
size = size / 1024;
|
||||
if (0 == size) {
|
||||
sizeStr = lastValue + unit[i];
|
||||
break;
|
||||
} else {
|
||||
if (i == unit.length - 1) {
|
||||
sizeStr = lastValue + unit[i];
|
||||
} else {
|
||||
lastValue = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sizeStr;
|
||||
}
|
||||
|
||||
public static String createFileName(String prefix, String suffix) {
|
||||
Date dt = new Date(System.currentTimeMillis());
|
||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMddHHmmssSSS");
|
||||
String fileName = fmt.format(dt);
|
||||
fileName = prefix + fileName + suffix; //extension, you can change it.
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public static String dateToStringMS(long date) {
|
||||
SimpleDateFormat format = new SimpleDateFormat("yyyyMMddHHmmssSSS");
|
||||
Date dt = new Date(date);
|
||||
return format.format(dt);
|
||||
}
|
||||
|
||||
public static String dateToStringMS2() {
|
||||
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM");
|
||||
Date dt = new Date();
|
||||
return format.format(dt);
|
||||
}
|
||||
|
||||
public static String dateToStringMS3() {
|
||||
SimpleDateFormat format = new SimpleDateFormat("MM-dd HH:mm");
|
||||
Date dt = new Date();
|
||||
return format.format(dt);
|
||||
}
|
||||
|
||||
public static String dateToStringMS4() {
|
||||
SimpleDateFormat format = new SimpleDateFormat("HH:mm:ss.SSS");
|
||||
Date dt = new Date();
|
||||
return format.format(dt);
|
||||
}
|
||||
|
||||
//去除数字结尾无用的0
|
||||
public static String filterUnUselessZero(String str) {
|
||||
String value = "";
|
||||
if (null != str) {
|
||||
String regEx = "\\.(0+)$";
|
||||
Pattern pattern = Pattern.compile(regEx);
|
||||
Matcher matcher = pattern.matcher(str);
|
||||
boolean rs = matcher.find();
|
||||
if (rs && null != matcher.group(0)) {
|
||||
value = str.replace(matcher.group(0), "");
|
||||
} else {
|
||||
value = str;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
//判断数字是否为0
|
||||
public static boolean isNullOrZero(String str) {
|
||||
boolean result = false;
|
||||
if (null == str || "".equals(str.trim())) {
|
||||
result = true;
|
||||
} else {
|
||||
try {
|
||||
double d = Double.parseDouble(str);
|
||||
result = d == 0;
|
||||
} catch (Exception e) {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
//判断是否为空
|
||||
public static boolean isEmpty(String str) {
|
||||
boolean result = false;
|
||||
if (null == str || "".equals(str.trim())) {
|
||||
result = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// encode
|
||||
public String urlEncoded(String paramString) {
|
||||
if (paramString == null || paramString == "") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
String str = URLEncoder.encode(paramString, "UTF-8");
|
||||
return str;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
//判断大小
|
||||
public static Boolean isEnough(String v1, String v2) {
|
||||
Boolean result = null;
|
||||
try {
|
||||
Double d1 = Double.parseDouble(v1);
|
||||
Double d2 = Double.parseDouble(v2);
|
||||
result = d1 >= d2;
|
||||
} catch (Exception e) {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个字符串不相等处,两个字符串去除标点之后,长度必须一致才会比对
|
||||
*
|
||||
* @param str1
|
||||
* @param str2
|
||||
* @return
|
||||
*/
|
||||
public static List<Integer> compareTwoStr(String str1, String str2) {
|
||||
List<Integer> mark = new ArrayList<>();
|
||||
String str1WithoutSymbol = removeSymbol(str1);
|
||||
String str2WithoutSymbol = removeSymbol(str2);
|
||||
int symbolCount = 0;
|
||||
char[] str1Char = str1.toCharArray();
|
||||
char[] str2Char = str2WithoutSymbol.toCharArray();
|
||||
for (int i = 0; i < str1Char.length; i++) {
|
||||
if ("".equals(removeSymbol(String.valueOf(str1Char[i])))) {
|
||||
symbolCount++;
|
||||
continue;
|
||||
}
|
||||
if (str1Char[i] != str2Char[i - symbolCount]) {
|
||||
if (0 == mark.size() % 2) {
|
||||
mark.add(i);
|
||||
}
|
||||
} else {
|
||||
if (0 != mark.size() % 2) {
|
||||
mark.add(i);
|
||||
}
|
||||
}
|
||||
//提前结束
|
||||
if (i - symbolCount == str2Char.length - 1) {
|
||||
if (0 == mark.size() % 2 && i + 1 < str1Char.length) {
|
||||
//存在非符号,才把后面标出来
|
||||
if (!"".equals(removeSymbol(str1.substring(i + 1)))) {
|
||||
mark.add(i + 1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (0 != mark.size() % 2) {
|
||||
mark.add(str1Char.length);
|
||||
}
|
||||
if (0 == mark.size() && str1WithoutSymbol.length() < str2WithoutSymbol.length()) {
|
||||
mark.add(0);
|
||||
mark.add(str1Char.length);
|
||||
}
|
||||
return mark;
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除字符串中的符号标点
|
||||
*
|
||||
* @param input
|
||||
* @return
|
||||
*/
|
||||
public static String removeSymbol(String input) {
|
||||
return input.replaceAll("\\p{P}|\\p{S}", "");
|
||||
}
|
||||
|
||||
public static double formatDouble(double value, int accuracy) {
|
||||
BigDecimal b = new BigDecimal(value);
|
||||
//保留2位小数
|
||||
double out = b.setScale(accuracy, BigDecimal.ROUND_HALF_UP).doubleValue();
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符串中是否只包含汉字字母
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public static boolean isCharOrLetter(String str) {
|
||||
String ruleString = "[\\u4e00-\\u9fa5a-zA-Z0-9]*";
|
||||
Pattern p = Pattern.compile(ruleString);
|
||||
char[] c = str.toCharArray();
|
||||
for (int i = 0; i < c.length; i++) {
|
||||
Matcher matcher = p.matcher("" + c[i]);
|
||||
boolean b = matcher.matches();
|
||||
if (b) {
|
||||
continue;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否含有中文
|
||||
*
|
||||
* @param checkChar
|
||||
* @return
|
||||
*/
|
||||
public static boolean checkCharContainChinese(char checkChar) {
|
||||
Character.UnicodeBlock ub = Character.UnicodeBlock.of(checkChar);
|
||||
if (CJK_UNIFIED_IDEOGRAPHS == ub || CJK_COMPATIBILITY_IDEOGRAPHS == ub || CJK_COMPATIBILITY_FORMS == ub ||
|
||||
CJK_RADICALS_SUPPLEMENT == ub || CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A == ub || CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B == ub) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//格式化时间为年月日
|
||||
public static String formatDateString(String time) {
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
||||
String formatString = "";
|
||||
try {
|
||||
Date date = sdf.parse(time);
|
||||
formatString = sdf.format(date);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return formatString;
|
||||
}
|
||||
|
||||
public static String subZeroAndDot(String s) {
|
||||
if (s.indexOf(".") > 0) {
|
||||
s = s.replaceAll("0+?$", "");//去掉多余的0
|
||||
s = s.replaceAll("[.]$", "");//如最后一位是.则去掉
|
||||
}
|
||||
return s;
|
||||
}
|
||||
// 是否包含特殊字符
|
||||
public static boolean isSpecialChar(String str) {
|
||||
String regEx = "[ _`~!@#$%^&*()+=|{}':;',\\[\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?]|\n|\r|\t";
|
||||
Pattern p = Pattern.compile(regEx);
|
||||
Matcher m = p.matcher(str);
|
||||
return m.find();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package ai.guiji.duix.test.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
|
||||
public class SystemUtils {
|
||||
|
||||
public static int getVersionCode(Context context) {
|
||||
try {
|
||||
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionCode;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getVersionName(Context context) {
|
||||
try {
|
||||
return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本号比较
|
||||
*
|
||||
* @param version1
|
||||
* @param version2
|
||||
* @return
|
||||
*/
|
||||
public static int compareVersion(String version1, String version2) {
|
||||
try {
|
||||
if (version1.equals(version2)) {
|
||||
return 0;
|
||||
}
|
||||
String[] version1Array = version1.split("\\.");
|
||||
String[] version2Array = version2.split("\\.");
|
||||
int index = 0;
|
||||
// 获取最小长度值
|
||||
int minLen = Math.min(version1Array.length, version2Array.length);
|
||||
int diff = 0;
|
||||
while (index < minLen
|
||||
&& (diff = Integer.parseInt(version1Array[index])
|
||||
- Integer.parseInt(version2Array[index])) == 0) {
|
||||
index++;
|
||||
}
|
||||
if (diff == 0) {
|
||||
// 如果位数不一致,比较多余位数
|
||||
for (int i = index; i < version1Array.length; i++) {
|
||||
if (Integer.parseInt(version1Array[i]) > 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = index; i < version2Array.length; i++) {
|
||||
if (Integer.parseInt(version2Array[i]) > 0) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} else {
|
||||
return diff > 0 ? 1 : -1;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package ai.guiji.duix.test.util;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Enumeration;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
public class ZipUtil {
|
||||
|
||||
//解压缩文件
|
||||
// 这里unzip需要使用canonicalPath做校验,但是该方法取的路径可能和getAbsolutePath()方式获取的不一致
|
||||
// getCanonicalPath() /data/data/.......
|
||||
// getAbsolutePath /data/user/0/......
|
||||
public static boolean unzip(String zipFilePath, String outOutPath, Callback callback) {
|
||||
try (FileInputStream fis = new FileInputStream(zipFilePath)) {
|
||||
long total = 0;
|
||||
if (callback != null){
|
||||
total = getZipSize(zipFilePath);
|
||||
}
|
||||
ZipInputStream inZip = new ZipInputStream(fis);
|
||||
long currentSize = 0;
|
||||
ZipEntry zipEntry;
|
||||
String szName = "";
|
||||
while ((zipEntry = inZip.getNextEntry()) != null) {
|
||||
szName = zipEntry.getName();
|
||||
if (zipEntry.isDirectory()) {
|
||||
szName = szName.substring(0, szName.length() - 1);
|
||||
File folder = new File(outOutPath + File.separator + szName);
|
||||
String canonicalPath = folder.getCanonicalPath();
|
||||
if (!canonicalPath.startsWith(outOutPath)) {
|
||||
Log.e("123", "绝对值路径比较异常忽略该地址: " + folder.getAbsolutePath());
|
||||
} else {
|
||||
if (!folder.exists()) {
|
||||
if (!folder.mkdirs()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
File file = new File(outOutPath + File.separator + szName);
|
||||
String canonicalPath = file.getCanonicalPath();
|
||||
if (!canonicalPath.startsWith(outOutPath)) {
|
||||
Log.e("123", "绝对值路径比较异常忽略该地址: " + file.getAbsolutePath());
|
||||
} else {
|
||||
if (!file.exists()) {
|
||||
if (!file.getParentFile().exists()) {
|
||||
file.getParentFile().mkdirs();
|
||||
}
|
||||
if (!file.createNewFile()) {
|
||||
return false;
|
||||
}
|
||||
FileOutputStream out = new FileOutputStream(file);
|
||||
int len;
|
||||
byte[] buffer = new byte[2048];
|
||||
while ((len = inZip.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, len);
|
||||
out.flush();
|
||||
if (callback != null) {
|
||||
currentSize += len;
|
||||
int progress = (int)(currentSize * 100.0f / total);
|
||||
callback.onProgress(progress); // 通过回调函数更新进度
|
||||
}
|
||||
}
|
||||
out.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inZip.close();
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static long getZipSize(String filePath){
|
||||
long size = 0;
|
||||
ZipFile f;
|
||||
try {
|
||||
f = new ZipFile(filePath);
|
||||
Enumeration<? extends ZipEntry> en = f.entries();
|
||||
while (en.hasMoreElements()) {
|
||||
size += en.nextElement().getSize();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
size = 0;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
void onProgress(int progress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<rotate
|
||||
android:duration="1000"
|
||||
android:fromDegrees="0"
|
||||
android:toDegrees="+359"
|
||||
android:repeatCount="-1"
|
||||
android:repeatMode="restart"
|
||||
android:pivotX="50%"
|
||||
android:pivotY="50%"
|
||||
android:interpolator="@android:anim/overshoot_interpolator"
|
||||
/>
|
||||
</set>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/shape_corners_60_primary" android:state_pressed="false" />
|
||||
<item android:drawable="@drawable/shape_corners_60_99_primary" android:state_pressed="true" />
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#66444444" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#CC147BF7" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#CC147BF7" />
|
||||
</shape>
|
||||
@@ -0,0 +1,4 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="7dp" />
|
||||
<solid android:color="#661a1e1c" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="60dp" />
|
||||
<solid android:color="@color/primary_99" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="60dp" />
|
||||
<solid android:color="@color/primary" />
|
||||
</shape>
|
||||
@@ -0,0 +1,4 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="7dp" />
|
||||
<solid android:color="#b3000000" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<corners
|
||||
android:topLeftRadius="32dp"
|
||||
android:topRightRadius="32dp" />
|
||||
|
||||
<solid android:color="#ffffffff" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<corners android:topLeftRadius="20dp" android:topRightRadius="20dp"
|
||||
android:bottomLeftRadius="20dp"/>
|
||||
|
||||
<solid android:color="#ff147bf7" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<corners
|
||||
android:bottomRightRadius="20dp"
|
||||
android:topLeftRadius="20dp"
|
||||
android:topRightRadius="20dp" />
|
||||
|
||||
<solid android:color="#ffd9eaff" />
|
||||
</shape>
|
||||
@@ -0,0 +1,180 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iv_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<ai.guiji.duix.sdk.client.render.DUIXTextureView
|
||||
android:id="@+id/glTextureView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDebug"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"
|
||||
android:textColor="@color/white"
|
||||
android:background="#66000000"
|
||||
android:padding="6dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"/>
|
||||
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switchMute"
|
||||
android:text="@string/mute"
|
||||
android:layout_margin="12dp"
|
||||
android:enabled="false"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMotionTips"
|
||||
android:visibility="invisible"
|
||||
android:layout_margin="12dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:text="@string/support_actions"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvMotion"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvMotionTips"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:layout_marginStart="12dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRandomMotion"
|
||||
android:text="@string/random_play_action"
|
||||
android:textSize="13sp"
|
||||
android:visibility="invisible"
|
||||
android:textColor="@color/white"
|
||||
app:layout_constraintTop_toBottomOf="@+id/rvMotion"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:background="@drawable/shape_common_btn"
|
||||
android:layout_margin="6dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSubtitle"
|
||||
android:text=""
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/white"
|
||||
android:shadowColor="#1a1e1f"
|
||||
android:shadowRadius="4"
|
||||
android:gravity="center"
|
||||
android:visibility="gone"
|
||||
android:background="#99000000"
|
||||
android:padding="12dp"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnAIConversation"
|
||||
android:text="@string/ai_fab_start"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/white"
|
||||
android:background="@drawable/shape_circle_fab"
|
||||
android:elevation="8dp"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:contentDescription="@string/ai_conversation"
|
||||
/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnCameraToggle"
|
||||
android:text="📷"
|
||||
android:textSize="18sp"
|
||||
android:textColor="@color/white"
|
||||
android:background="@drawable/shape_circle_camera_off"
|
||||
android:elevation="8dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/btnAIConversation"
|
||||
app:layout_constraintStart_toEndOf="@+id/btnAIConversation"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:contentDescription="@string/camera_off"
|
||||
/>
|
||||
|
||||
<TextureView
|
||||
android:id="@+id/cameraPreview"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="160dp"
|
||||
android:visibility="gone"
|
||||
android:scaleX="-1"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="48dp"
|
||||
/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRecord"
|
||||
android:text="@string/record"
|
||||
android:enabled="false"
|
||||
android:visibility="gone"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/white"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginBottom="48dp"
|
||||
android:background="@drawable/shape_common_btn"
|
||||
android:layout_margin="6dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPlayPCM"
|
||||
android:text="@string/play_pcm"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnAIConversation"
|
||||
android:background="@drawable/shape_common_btn"
|
||||
android:layout_margin="6dp"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/white"
|
||||
android:enabled="false"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPlayWAV"
|
||||
android:text="@string/play_wav"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@+id/btnPlayPCM"
|
||||
android:background="@drawable/shape_common_btn"
|
||||
android:layout_margin="6dp"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/white"
|
||||
android:enabled="false"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="120dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="@color/black"
|
||||
android:textSize="18dp"
|
||||
android:textStyle="italic|bold"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_sdk_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tv_title" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tv_sdk_version">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDownloadTips"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:text="@string/main_download_tips"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/black"
|
||||
android:textStyle="bold"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBaseConfig"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/base_config_url"
|
||||
android:textSize="13sp"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvDownloadTips"
|
||||
app:layout_constraintStart_toStartOf="parent"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etBaseConfig"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:text="https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/gj_dh_res.zip"
|
||||
android:textSize="13sp"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvBaseConfig"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvModelUrl"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:text="@string/model_url"
|
||||
android:textSize="13sp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/etBaseConfig" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnMoreModel"
|
||||
android:text="@string/more"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="48dp"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvModelUrl"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etUrl"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:text="https://github.com/duixcom/Duix-Mobile/releases/download/v1.0.0/bendi3_20240518.zip"
|
||||
android:textSize="13sp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/btnMoreModel"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnMoreModel"
|
||||
/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/play"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/etUrl" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switchDebug"
|
||||
android:text="@string/debug_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="13sp"
|
||||
android:layout_margin="12dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/btnPlay"
|
||||
/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</ScrollView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="16dp">
|
||||
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/layoutFrame"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTouch"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:clickable="true"
|
||||
android:background="@drawable/selector_60_primary"
|
||||
android:gravity="center"
|
||||
android:text="@string/touch_me_record"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="138dp"
|
||||
android:background="@drawable/shape_solid_b3000000_radius_7"
|
||||
android:gravity="center"
|
||||
android:minWidth="138dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="2dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iv_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@mipmap/iv_progress" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_content"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center"
|
||||
android:singleLine="true"
|
||||
android:text="@string/loading"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="12dp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:gravity="center">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvModels"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="6dp"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvModelUrl"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:textSize="13sp"
|
||||
android:padding="6dp"
|
||||
android:background="@drawable/shape_common_btn"
|
||||
android:textColor="@color/white"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnMotion"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:background="@drawable/shape_common_btn"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/white"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,29 @@
|
||||
<resources>
|
||||
<string name="app_name">DUIX Demo</string>
|
||||
<string name="play">播放</string>
|
||||
<string name="base_config_url">基础配置Url:</string>
|
||||
<string name="model_url">模型Url:</string>
|
||||
<string name="more">更多</string>
|
||||
<string name="base_config_cannot_be_empty">基础配置url不能为空</string>
|
||||
<string name="model_url_cannot_be_empty">模型url不能为空</string>
|
||||
<string name="loading">加载中...</string>
|
||||
<string name="mute">静音</string>
|
||||
<string name="ai_tips">本内容由AI生成,仅供参考</string>
|
||||
<string name="support_actions">支持的动作:</string>
|
||||
<string name="record">录音</string>
|
||||
<string name="play_pcm">播放PCM流</string>
|
||||
<string name="play_wav">播放WAV文件</string>
|
||||
<string name="random_play_action">随机播放动作</string>
|
||||
<string name="touch_me_record">按住我收音</string>
|
||||
<string name="need_permission_continue">需要授权以继续使用</string>
|
||||
<string name="play_stop">停止播放</string>
|
||||
<string name="ai_conversation">AI 实时对话</string>
|
||||
<string name="ai_conversation_stop">结束 AI 对话</string>
|
||||
<string name="ai_fab_start">AI</string>
|
||||
<string name="ai_fab_stop">停</string>
|
||||
<string name="camera_on">镜头开</string>
|
||||
<string name="camera_off">镜头关</string>
|
||||
<string name="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string>
|
||||
<string name="debug_message">调试信息</string>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<declare-styleable name="visualizerView">
|
||||
<attr name="numColumns" format="integer" />
|
||||
<attr name="renderColor" format="color" />
|
||||
<attr name="renderRange">
|
||||
<enum name="top" value="0" />
|
||||
<enum name="bottom" value="1" />
|
||||
<enum name="both" value="2" />
|
||||
</attr>
|
||||
<attr name="renderType">
|
||||
<flag name="bar" value="0x1" />
|
||||
<flag name="pixel" value="0x2" />
|
||||
<flag name="fade" value="0x4" />
|
||||
</attr>
|
||||
</declare-styleable>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="primary">#5a7bbe</color>
|
||||
<color name="primary_99">#995a7bbe</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,28 @@
|
||||
<resources>
|
||||
<string name="app_name">DUIX Demo</string>
|
||||
<string name="play">Play</string>
|
||||
<string name="base_config_url">BaseConfig Url:</string>
|
||||
<string name="model_url">Model Url:</string>
|
||||
<string name="more">More</string>
|
||||
<string name="base_config_cannot_be_empty">BaseConfig url cannot be empty</string>
|
||||
<string name="model_url_cannot_be_empty">Model url cannot be empty</string>
|
||||
<string name="loading">Loading...</string>
|
||||
<string name="mute">Mute</string>
|
||||
<string name="ai_tips">This content is generated by AI and is for reference only</string>
|
||||
<string name="support_actions">Supported actions:</string>
|
||||
<string name="record">Record</string>
|
||||
<string name="play_pcm">Play PCM stream</string>
|
||||
<string name="play_wav">Play WAV file</string>
|
||||
<string name="random_play_action">Random play action</string>
|
||||
<string name="touch_me_record">Press and hold</string>
|
||||
<string name="need_permission_continue">Permission is required to continue using</string>
|
||||
<string name="play_stop">Stop playing</string>
|
||||
<string name="ai_conversation">AI 实时对话</string>
|
||||
<string name="ai_conversation_stop">结束 AI 对话</string>
|
||||
<string name="ai_fab_start">AI</string>
|
||||
<string name="ai_fab_stop">停</string>
|
||||
<string name="camera_on">镜头开</string>
|
||||
<string name="camera_off">镜头关</string>
|
||||
<string name="main_download_tips">The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service</string>
|
||||
<string name="debug_message">Debug message</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="dialog_center" parent="Theme.AppCompat.Light.Dialog">
|
||||
<item name="android:windowFrame">@null</item>
|
||||
<item name="android:windowIsFloating">true</item>
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:background">@android:color/transparent</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:backgroundDimAmount">0.5</item>
|
||||
<item name="android:backgroundDimEnabled">true</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.DUIX.Test" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowBackground">@android:color/white</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowIsTranslucent">false</item>
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
<item name="colorPrimary">#fff</item>
|
||||
<item name="colorPrimaryDark">#4349a9</item>
|
||||
<item name="colorAccent">#4349a9</item>
|
||||
<item name="android:windowTranslucentStatus">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||