临时提交

This commit is contained in:
林若思 2025-12-23 16:50:38 +08:00
parent 7829e8223b
commit 738b39e9a0
28 changed files with 2342 additions and 171 deletions

View File

@ -6,10 +6,12 @@ import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.os.Build import android.os.Build
import android.text.TextUtils import android.text.TextUtils
import android.view.Gravity
import androidx.multidex.MultiDex import androidx.multidex.MultiDex
import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.LogUtils
import com.zs.smarthuman.common.UserInfoManager import com.zs.smarthuman.common.UserInfoManager
import com.zs.smarthuman.kt.errorMsg import com.zs.smarthuman.kt.errorMsg
import com.zs.smarthuman.toast.Toaster
import com.zs.smarthuman.utils.AESUtils import com.zs.smarthuman.utils.AESUtils
import com.zs.smarthuman.utils.CrashHandler import com.zs.smarthuman.utils.CrashHandler
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -57,6 +59,8 @@ class App : Application() {
LogUtils.getConfig().isLogSwitch = BuildConfig.DEBUG LogUtils.getConfig().isLogSwitch = BuildConfig.DEBUG
CrashHandler.getInstance().init() CrashHandler.getInstance().init()
initRxHttp() initRxHttp()
Toaster.init(this)
Toaster.setGravity(Gravity.CENTER)
} }
private fun initRxHttp() { private fun initRxHttp() {

View File

@ -9,7 +9,8 @@ class CommonManager {
companion object { companion object {
const val BASE_URL = "http://10.10.4.132:8088/" const val BASE_URL = "http://10.10.4.132:8088/"
// const val BASE_URL = "https://zs.seerteach.net/" // const val BASE_URL = "https://zs.seerteach.net/"
const val SERVER_URL = "im.seerteach.net" // const val SERVER_URL = "im.seerteach.net"
const val PORT = "8443" const val SERVER_URL = "10.10.4.132"
const val PORT = "9000"
} }
} }

View File

@ -2,18 +2,22 @@ package com.zs.smarthuman.sherpa
import android.content.res.AssetManager import android.content.res.AssetManager
import android.util.Log import android.util.Log
import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.*
class VoiceController( class VoiceController(
assetManager: AssetManager, assetManager: AssetManager,
private val onWakeup: () -> Unit, private val onWakeup: () -> Unit,
private val onFinalAudio: (FloatArray) -> Unit, private val onFinalAudio: (FloatArray) -> Unit,
private val idleTimeoutSeconds: Int = 15, private val idleTimeoutSeconds: Int = 15,
private val maxRecordingSeconds: Int = 10, // ✅ 最大录音时长
private val onStateChanged: ((VoiceState) -> Unit)? = null, private val onStateChanged: ((VoiceState) -> Unit)? = null,
private val stopBackendAudio: (() -> Unit)? = null // 可选:唤醒时打断后台播放 private val stopBackendAudio: (() -> Unit)? = null
) { ) {
private val TAG = "VoiceController" private val TAG = "VoiceController"
/* ================= 状态 ================= */
private var state: VoiceState = VoiceState.WAIT_WAKEUP private var state: VoiceState = VoiceState.WAIT_WAKEUP
set(value) { set(value) {
field = value field = value
@ -21,132 +25,142 @@ class VoiceController(
Log.d(TAG, "当前状态: $value") Log.d(TAG, "当前状态: $value")
} }
/** ================= 唤醒 ================= */ var isPlaying = false
private var wakeupDiscardEndTime = 0L private set
private val WAKEUP_DISCARD_BUFFER_MS = 200L
private var isRecordingForWakeup = false // 唤醒词残留标记 /* ================= 唤醒 ================= */
private val WAKEUP_DISCARD_MS = 600L
private val WAKEUP_COOLDOWN_MS = 1500L
private var wakeupDiscardUntil = 0L
private var lastWakeupTime = 0L
private val wakeupManager = WakeupManager(assetManager) { private val wakeupManager = WakeupManager(assetManager) {
Log.d(TAG, "唤醒触发! 当前状态=$state") val now = System.currentTimeMillis()
if (now - lastWakeupTime < WAKEUP_COOLDOWN_MS) {
// 打断后台音频 Log.d(TAG, "⚠️ 唤醒过于频繁,忽略")
if (state == VoiceState.PLAYING_BACKEND) { return@WakeupManager
stopBackendAudio?.invoke()
Log.d(TAG, "唤醒词触发, 停止后台播放")
} }
lastWakeupTime = now
Log.d(TAG, "🔥 唤醒触发")
stopBackendAudio?.invoke()
isPlaying = false
// 重置当前录音/缓存
if (state == VoiceState.RECORDING || state == VoiceState.WAIT_SPEECH) {
audioBuffer.clear() audioBuffer.clear()
preBuffer.clear() preBuffer.clear()
vadManager.reset() vadManager.reset()
isRecordingForWakeup = true vadStarted = false
Log.d(TAG, "唤醒词触发,重置录音缓存") vadEndPending = false
}
wakeupDiscardUntil = now + WAKEUP_DISCARD_MS
onWakeup() onWakeup()
playLocalPrompt() playLocalPrompt()
} }
/** ================= VAD ================= */ /* ================= VAD ================= */
private val vadManager = VadManager( private val vadManager = VadManager(
assetManager, assetManager,
onSpeechStart = { onVadSpeechStart() }, onSpeechStart = { onVadSpeechStart() },
onSpeechEnd = { onVadSpeechEnd() } onSpeechEnd = { onVadSpeechEnd() }
) )
/** ================= 音频缓存 ================= */ /* ================= 音频缓存 ================= */
private val audioBuffer = mutableListOf<Float>() private val audioBuffer = mutableListOf<Float>()
private val preBuffer = ArrayDeque<Float>() private val preBuffer = ArrayDeque<Float>()
private val PRE_BUFFER_SIZE = 16000 private val PRE_BUFFER_SIZE = 16000
private var idleTimer = 0L
/** ================= 尾部静音控制 ================= */ private var idleTimer = 0L
private var vadStarted = false
private var vadEndPending = false private var vadEndPending = false
private var vadEndTime = 0L private var vadEndTime = 0L
private val END_SILENCE_MS = 1000L private val END_SILENCE_MS = 1000L
var isPlaying = false /* ================= 外部音频输入 ================= */
private set private var recordingStartTime = 0L // ✅ 记录录音开始时间
/** ================= 外部调用 ================= */
fun acceptAudio(samples: FloatArray) { fun acceptAudio(samples: FloatArray) {
cachePreBuffer(samples) cachePreBuffer(samples)
wakeupManager.acceptAudio(samples) // 唤醒检测始终喂 wakeupManager.acceptAudio(samples)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val discardAudio = now < wakeupDiscardEndTime || isRecordingForWakeup if (now < wakeupDiscardUntil) return
// 播放提示音或后台音频时不喂 VAD
if (state == VoiceState.PLAYING_PROMPT || state == VoiceState.PLAYING_BACKEND) return
when (state) { when (state) {
VoiceState.WAIT_WAKEUP -> Unit VoiceState.WAIT_SPEECH -> {
VoiceState.WAIT_SPEECH -> if (!discardAudio) vadManager.accept(samples) vadManager.accept(samples)
VoiceState.RECORDING -> if (!discardAudio) { }
VoiceState.RECORDING -> {
audioBuffer.addAll(samples.asList()) audioBuffer.addAll(samples.asList())
vadManager.accept(samples) vadManager.accept(samples)
idleTimer = now idleTimer = now
// ✅ 最大录音时长判断
if (now - recordingStartTime >= maxRecordingSeconds * 1000) {
Log.d(TAG, "⚠️ 达到最大录音时长,自动结束录音")
finishSentence()
return
}
if (vadEndPending && now - vadEndTime >= END_SILENCE_MS) { if (vadEndPending && now - vadEndTime >= END_SILENCE_MS) {
finishSentence() finishSentence()
} }
} }
else -> {}
else -> Unit
} }
} }
/* ================= 提示音 ================= */
private val PROMPT_DURATION_MS = 3000L
private var promptFallbackJob: Job? = null
fun onPlayStartPrompt() { fun onPlayStartPrompt() {
if (state == VoiceState.PLAYING_PROMPT) return
isPlaying = true isPlaying = true
state = VoiceState.PLAYING_PROMPT state = VoiceState.PLAYING_PROMPT
Log.d(TAG, "播放提示音, 状态变为 PLAYING_PROMPT")
promptFallbackJob?.cancel()
promptFallbackJob = CoroutineScope(Dispatchers.Main).launch {
delay(PROMPT_DURATION_MS)
if (state == VoiceState.PLAYING_PROMPT) {
Log.w(TAG, "⚠️ 提示音 complete 丢失fallback")
onPlayEndPrompt()
}
}
} }
fun onPlayEndPrompt() { fun onPlayEndPrompt() {
// 只有当前状态仍是 PLAYING_PROMPT 才处理 promptFallbackJob?.cancel()
if (state != VoiceState.PLAYING_PROMPT) { if (state != VoiceState.PLAYING_PROMPT) return
Log.d(TAG, "提示音结束回调忽略,当前状态=$state")
return
}
isPlaying = false isPlaying = false
state = VoiceState.WAIT_SPEECH state = VoiceState.WAIT_SPEECH
idleTimer = System.currentTimeMillis() idleTimer = System.currentTimeMillis()
Log.d(TAG, "提示音结束 → WAIT_SPEECH")
// 设置提示音残留丢弃时间
wakeupDiscardEndTime = System.currentTimeMillis() + WAKEUP_DISCARD_BUFFER_MS
// 清掉唤醒标记,让用户语音立即生效
isRecordingForWakeup = false
// 清理 pre-buffer 中残留音频,避免干扰 VAD
audioBuffer.clear()
preBuffer.clear()
Log.d(TAG, "提示音播放结束, 状态变为 WAIT_SPEECH")
} }
/* ================= Backend ================= */
fun onPlayStartBackend() { fun onPlayStartBackend() {
// 如果正在播放提示音,后台音频暂不覆盖状态 isPlaying = true
if (state != VoiceState.PLAYING_PROMPT) {
state = VoiceState.PLAYING_BACKEND state = VoiceState.PLAYING_BACKEND
} }
Log.d(TAG, "播放后台音频, 当前状态=$state")
}
fun onPlayEndBackend() { fun onPlayEndBackend() {
if (state != VoiceState.PLAYING_BACKEND){ if (state != VoiceState.PLAYING_BACKEND) return
return
}
isPlaying = false isPlaying = false
state = VoiceState.WAIT_WAKEUP state = VoiceState.WAIT_WAKEUP
idleTimer = System.currentTimeMillis()
Log.d(TAG, "后台音频播放结束, 状态变为 WAIT_WAKEUP")
} }
/* ================= Idle ================= */
fun checkIdleTimeout() { fun checkIdleTimeout() {
if (state != VoiceState.WAIT_SPEECH) return if (state != VoiceState.WAIT_SPEECH) return
val now = System.currentTimeMillis() if (System.currentTimeMillis() - idleTimer > idleTimeoutSeconds * 1000) {
if (now - idleTimer > idleTimeoutSeconds * 1000) {
reset() reset()
} }
} }
@ -156,78 +170,106 @@ class VoiceController(
audioBuffer.clear() audioBuffer.clear()
preBuffer.clear() preBuffer.clear()
vadManager.reset() vadManager.reset()
wakeupManager.reset() vadStarted = false
isRecordingForWakeup = false
wakeupDiscardEndTime = 0
idleTimer = 0
vadEndPending = false vadEndPending = false
Log.d(TAG, "已重置, 状态变为 WAIT_WAKEUP") wakeupDiscardUntil = 0L
recordingStartTime = 0L // ✅ 重置录音开始时间
Log.d(TAG, "reset → WAIT_WAKEUP")
} }
fun release() { fun release() {
vadManager.reset() vadManager.reset()
wakeupManager.release() wakeupManager.release()
audioBuffer.clear()
preBuffer.clear()
idleTimer = 0
isPlaying = false
state = VoiceState.WAIT_WAKEUP
isRecordingForWakeup = false
wakeupDiscardEndTime = 0
vadEndPending = false
} }
/** ================= 内部逻辑 ================= */ /* ================= VAD 回调 ================= */
private fun playLocalPrompt() {
onPlayStartPrompt()
// 在这里播放提示音,播放结束后调用 onPlayEndPrompt()
}
private fun onVadSpeechStart() { private fun onVadSpeechStart() {
vadEndPending = false
val now = System.currentTimeMillis()
// 只有 WAIT_SPEECH 状态才开始录音
if (state != VoiceState.WAIT_SPEECH) return if (state != VoiceState.WAIT_SPEECH) return
vadStarted = true
// 丢弃提示音残留音频,但保留 VAD 触发
if (now < wakeupDiscardEndTime) {
Log.d(TAG, "丢弃提示音残留音频")
audioBuffer.clear() // 清掉残留
}
state = VoiceState.RECORDING state = VoiceState.RECORDING
// 添加 pre-buffer 音频到当前录音
audioBuffer.addAll(preBuffer) audioBuffer.addAll(preBuffer)
idleTimer = now idleTimer = System.currentTimeMillis()
recordingStartTime = System.currentTimeMillis() // ✅ 记录录音开始时间
Log.d(TAG, "VAD开始, 当前状态=$state") Log.d(TAG, "VAD开始 → RECORDING")
} }
private fun onVadSpeechEnd() { private fun onVadSpeechEnd() {
if (state != VoiceState.RECORDING) return if (state != VoiceState.RECORDING) return
vadEndPending = true vadEndPending = true
vadEndTime = System.currentTimeMillis() vadEndTime = System.currentTimeMillis()
Log.d(TAG, "VAD结束, 等待尾部静音")
} }
/* ================= 录音结束 & 判定 ================= */
private fun finishSentence() { private fun finishSentence() {
vadEndPending = false vadEndPending = false
state = VoiceState.WAIT_WAKEUP state = VoiceState.WAIT_WAKEUP
val finalAudio = audioBuffer.toFloatArray() val finalAudio = audioBuffer.toFloatArray()
audioBuffer.clear() audioBuffer.clear()
if (finalAudio.isNotEmpty()) onFinalAudio(finalAudio)
idleTimer = 0 if (isValidUserSpeech(finalAudio)) {
isRecordingForWakeup = false onFinalAudio(finalAudio)
Log.d(TAG, "录音结束, 返回 WAIT_WAKEUP") Log.d(TAG, "✅ 录音有效,上传")
} else {
Log.d(TAG, "❌ 噪音/旁人语音,丢弃")
} }
}
/* ================= 关键判定函数 ================= */
private fun isValidUserSpeech(audio: FloatArray): Boolean {
if (!vadStarted) {
Log.d(TAG, "❌ VAD 未触发")
return false
}
// 1⃣ 时长:>= 600ms非常宽松
val durationMs = audio.size * 1000f / 16000f
if (durationMs < 600f) {
Log.d(TAG, "❌ 太短: ${durationMs}ms")
return false
}
// 2⃣ 计算 RMS真实设备极低
var sum = 0f
var peak = 0f
for (v in audio) {
val a = kotlin.math.abs(v)
sum += a * a
if (a > peak) peak = a
}
val rms = kotlin.math.sqrt(sum / audio.size)
Log.d(TAG, "🎤 RMS=$rms peak=$peak duration=${durationMs}ms")
// 3⃣ 只排除“纯底噪”
// 实测:环境底噪 RMS 通常 < 0.001
if (rms < 0.002f && peak < 0.01f) {
Log.d(TAG, "❌ 纯环境噪声,丢弃")
return false
}
// ✅ 只要不是纯噪声,一律认为是人说话
Log.d(TAG, "✅ 判定为有效人声")
return true
}
/* ================= 工具 ================= */
private fun cachePreBuffer(samples: FloatArray) { private fun cachePreBuffer(samples: FloatArray) {
for (s in samples) { for (s in samples) {
preBuffer.addLast(s) preBuffer.addLast(s)
if (preBuffer.size > PRE_BUFFER_SIZE) preBuffer.removeFirst() if (preBuffer.size > PRE_BUFFER_SIZE) {
preBuffer.removeFirst()
} }
} }
} }
private fun playLocalPrompt() {
onPlayStartPrompt()
}
}

View File

@ -38,9 +38,9 @@ class WakeupManager(
fun acceptAudio(samples: FloatArray) { fun acceptAudio(samples: FloatArray) {
val s = stream ?: return val s = stream ?: return
// ⭐ 远讲 / 播放补偿(非常关键) // ⭐ 远讲 / 播放补偿(非常关键)
for (i in samples.indices) { // for (i in samples.indices) {
samples[i] *= 2.5f // samples[i] *= 2.5f
} // }
s.acceptWaveform(samples, sampleRate) s.acceptWaveform(samples, sampleRate)
while (kws.isReady(s)) { while (kws.isReady(s)) {

View File

@ -0,0 +1,72 @@
package com.zs.smarthuman.toast;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Application;
import android.os.Bundle;
final class ActivityStack implements Application.ActivityLifecycleCallbacks {
@SuppressLint("StaticFieldLeak")
private static volatile ActivityStack sInstance;
public static ActivityStack getInstance() {
if(sInstance == null) {
synchronized (ActivityStack.class) {
if (sInstance == null) {
sInstance = new ActivityStack();
}
}
}
return sInstance;
}
/** 私有化构造函数 */
private ActivityStack() {}
/**
* 注册 Activity 生命周期监听
*/
public void register(Application application) {
if (application == null) {
return;
}
application.registerActivityLifecycleCallbacks(this);
}
/** 前台 Activity 对象 */
private Activity mForegroundActivity;
public Activity getForegroundActivity() {
return mForegroundActivity;
}
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {}
@Override
public void onActivityStarted(Activity activity) {}
@Override
public void onActivityResumed(Activity activity) {
mForegroundActivity = activity;
}
@Override
public void onActivityPaused(Activity activity) {
if (mForegroundActivity != activity) {
return;
}
mForegroundActivity = null;
}
@Override
public void onActivityStopped(Activity activity) {}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
@Override
public void onActivityDestroyed(Activity activity) {}
}

View File

@ -0,0 +1,26 @@
package com.zs.smarthuman.toast;
import android.app.Activity;
public class ActivityToast extends CustomToast {
/** Toast 实现类 */
private final ToastImpl mToastImpl;
public ActivityToast(Activity activity) {
mToastImpl = new ToastImpl(activity, this);
}
@Override
public void show() {
// 替换成 WindowManager 来显示
mToastImpl.show();
}
@Override
public void cancel() {
// 取消 WindowManager 的显示
mToastImpl.cancel();
}
}

View File

@ -0,0 +1,135 @@
package com.zs.smarthuman.toast;
import android.view.View;
import android.widget.TextView;
import com.zs.smarthuman.toast.config.IToast;
public abstract class CustomToast implements IToast {
/** Toast 布局 */
private View mView;
/** Toast 消息 View */
private TextView mMessageView;
/** Toast 显示重心 */
private int mGravity;
/** Toast 显示时长 */
private int mDuration;
/** 水平偏移 */
private int mXOffset;
/** 垂直偏移 */
private int mYOffset;
/** 水平间距 */
private float mHorizontalMargin;
/** 垂直间距 */
private float mVerticalMargin;
/** Toast 动画 */
private int mAnimations = android.R.style.Animation_Toast;
/** 短吐司显示的时长,参考至 NotificationManagerService.SHORT_DELAY */
private int mShortDuration = 2000;
/** 长吐司显示的时长,参考至 NotificationManagerService.LONG_DELAY */
private int mLongDuration = 3500;
@Override
public void setText(int id) {
if (mView == null) {
return;
}
setText(mView.getResources().getString(id));
}
@Override
public void setText(CharSequence text) {
if (mMessageView == null) {
return;
}
mMessageView.setText(text);
}
@Override
public void setView(View view) {
mView = view;
if (mView == null) {
mMessageView = null;
return;
}
mMessageView = findMessageView(view);
}
@Override
public View getView() {
return mView;
}
@Override
public void setDuration(int duration) {
mDuration = duration;
}
@Override
public int getDuration() {
return mDuration;
}
@Override
public void setGravity(int gravity, int xOffset, int yOffset) {
mGravity = gravity;
mXOffset = xOffset;
mYOffset = yOffset;
}
@Override
public int getGravity() {
return mGravity;
}
@Override
public int getXOffset() {
return mXOffset;
}
@Override
public int getYOffset() {
return mYOffset;
}
@Override
public void setMargin(float horizontalMargin, float verticalMargin) {
mHorizontalMargin = horizontalMargin;
mVerticalMargin = verticalMargin;
}
@Override
public float getHorizontalMargin() {
return mHorizontalMargin;
}
@Override
public float getVerticalMargin() {
return mVerticalMargin;
}
public void setAnimationsId(int animationsId) {
mAnimations = animationsId;
}
public int getAnimationsId() {
return mAnimations;
}
public void setShortDuration(int duration) {
mShortDuration = duration;
}
public int getShortDuration() {
return mShortDuration;
}
public void setLongDuration(int duration) {
mLongDuration = duration;
}
public int getLongDuration() {
return mLongDuration;
}
}

View File

@ -0,0 +1,25 @@
package com.zs.smarthuman.toast;
import android.app.Application;
public class GlobalToast extends CustomToast {
/** Toast 实现类 */
private final ToastImpl mToastImpl;
public GlobalToast(Application application) {
mToastImpl = new ToastImpl(application, this);
}
@Override
public void show() {
// 替换成 WindowManager 来显示
mToastImpl.show();
}
@Override
public void cancel() {
// 取消 WindowManager 的显示
mToastImpl.cancel();
}
}

View File

@ -0,0 +1,32 @@
package com.zs.smarthuman.toast;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
final class NotificationServiceProxy implements InvocationHandler {
/** 被代理的对象 */
private final Object mRealObject;
public NotificationServiceProxy(Object realObject) {
mRealObject = realObject;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
switch (method.getName()) {
case "enqueueToast":
case "enqueueToastEx":
case "cancelToast":
// 将包名修改成系统包名这样就可以绕过系统的拦截
// 部分华为机将 enqueueToast 方法名修改成了 enqueueToastEx
args[0] = "android";
break;
default:
break;
}
// 使用动态代理
return method.invoke(mRealObject, args);
}
}

View File

@ -0,0 +1,60 @@
package com.zs.smarthuman.toast;
import android.annotation.SuppressLint;
import android.app.Application;
import android.widget.Toast;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class NotificationToast extends SystemToast {
/** 是否已经 Hook 了一次通知服务 */
private static boolean sHookService;
public NotificationToast(Application application) {
super(application);
}
@Override
public void show() {
hookNotificationService();
super.show();
}
@SuppressLint({"DiscouragedPrivateApi", "PrivateApi"})
@SuppressWarnings({"JavaReflectionMemberAccess", "SoonBlockedPrivateApi"})
private static void hookNotificationService() {
if (sHookService) {
return;
}
sHookService = true;
try {
// 获取到 Toast 中的 getService 静态方法
Method getServiceMethod = Toast.class.getDeclaredMethod("getService");
getServiceMethod.setAccessible(true);
// 执行方法会返回一个 INotificationManager$Stub$Proxy 类型的对象
final Object notificationManagerSourceObject = getServiceMethod.invoke(null);
if (notificationManagerSourceObject == null) {
return;
}
// 如果这个对象已经被动态代理过了并且已经 Hook 过了则不需要重复 Hook
if (Proxy.isProxyClass(notificationManagerSourceObject.getClass()) &&
Proxy.getInvocationHandler(notificationManagerSourceObject) instanceof NotificationServiceProxy) {
return;
}
Object notificationManagerProxyObject = Proxy.newProxyInstance(
Thread.currentThread().getContextClassLoader(),
new Class[]{Class.forName("android.app.INotificationManager")},
new NotificationServiceProxy(notificationManagerSourceObject));
// 将原来的 INotificationManager$Stub$Proxy 替换掉
Field serviceField = Toast.class.getDeclaredField("sService");
serviceField.setAccessible(true);
serviceField.set(null, notificationManagerProxyObject);
} catch (Exception e) {
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,31 @@
package com.zs.smarthuman.toast;
import android.os.Handler;
import android.os.Message;
import android.view.WindowManager;
@SuppressWarnings("deprecation")
final class SafeHandler extends Handler {
private final Handler mHandler;
SafeHandler(Handler handler) {
mHandler = handler;
}
@Override
public void handleMessage(final Message msg) {
// 捕获这个异常避免程序崩溃
try {
// 目前发现在 Android 7.1 主线程被阻塞之后弹吐司会导致崩溃可使用 Thread.sleep(5000) 进行复现
// 查看源码得知 Google 已经在 Android 8.0 已经修复了此问题
// 主线程阻塞之后 Toast 也会被阻塞Toast 因为显示超时导致 Window Token 失效
mHandler.handleMessage(msg);
} catch (WindowManager.BadTokenException | IllegalStateException e) {
// android.view.WindowManager$BadTokenExceptionUnable to add window -- token android.os.BinderProxy is not valid; is your activity running?
// java.lang.IllegalStateExceptionView android.widget.TextView has already been added to the window manager.
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,61 @@
package com.zs.smarthuman.toast;
import android.annotation.TargetApi;
import android.app.Application;
import android.os.Build;
import android.os.Handler;
import android.widget.Toast;
import java.lang.reflect.Field;
@TargetApi(Build.VERSION_CODES.KITKAT)
@SuppressWarnings("all")
public class SafeToast extends NotificationToast {
/** 是否已经 Hook 了一次 TN 内部类 */
private boolean mHookTN;
public SafeToast(Application application) {
super(application);
}
@Override
public void show() {
hookToastTN();
super.show();
}
private void hookToastTN() {
if (mHookTN) {
return;
}
mHookTN = true;
try {
// 获取 Toast.mTN 字段对象
Field tnField = Toast.class.getDeclaredField("mTN");
tnField.setAccessible(true);
Object tnObject = tnField.get(this);
// 获取 mTN 中的 mHandler 字段对象
Field handlerField = tnField.getType().getDeclaredField("mHandler");
handlerField.setAccessible(true);
Handler handlerObject = (Handler) handlerField.get(tnObject);
// 如果这个对象已经被反射替换过了
if (handlerObject instanceof SafeHandler) {
return;
}
// 偷梁换柱
handlerField.set(tnObject, new SafeHandler(handlerObject));
} catch (IllegalAccessException | NoSuchFieldException e) {
// Android 9.0 上反射会出现报错
// Accessing hidden field Landroid/widget/Toast;->mTN:Landroid/widget/Toast$TN;
// java.lang.NoSuchFieldException: No field mTN in class Landroid/widget/Toast;
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,39 @@
package com.zs.smarthuman.toast;
import android.app.Application;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import com.zs.smarthuman.toast.config.IToast;
@SuppressWarnings("deprecation")
public class SystemToast extends Toast implements IToast {
/** 吐司消息 View */
private TextView mMessageView;
public SystemToast(Application application) {
super(application);
}
@Override
public void setView(View view) {
super.setView(view);
if (view == null) {
mMessageView = null;
return;
}
mMessageView = findMessageView(view);
}
@Override
public void setText(CharSequence text) {
super.setText(text);
if (mMessageView == null) {
return;
}
mMessageView.setText(text);
}
}

View File

@ -0,0 +1,226 @@
package com.zs.smarthuman.toast;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.graphics.PixelFormat;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.widget.Toast;
final class ToastImpl {
private static final String WINDOW_TITLE = "Toast";
private static final Handler HANDLER = new Handler(Looper.getMainLooper());
/** 当前的吐司对象 */
private final CustomToast mToast;
/** WindowManager 辅助类 */
private WindowLifecycle mWindowLifecycle;
/** 当前应用的包名 */
private final String mPackageName;
/** 当前是否已经显示 */
private boolean mShow;
/** 当前是否全局显示 */
private boolean mGlobalShow;
ToastImpl(Activity activity, CustomToast toast) {
this((Context) activity, toast);
mGlobalShow = false;
mWindowLifecycle = new WindowLifecycle(activity);
}
ToastImpl(Application application, CustomToast toast) {
this((Context) application, toast);
mGlobalShow = true;
mWindowLifecycle = new WindowLifecycle(application);
}
private ToastImpl(Context context, CustomToast toast) {
mToast = toast;
mPackageName = context.getPackageName();
}
boolean isShow() {
return mShow;
}
void setShow(boolean show) {
mShow = show;
}
/***
* 显示吐司弹窗
*/
void show() {
if (isShow()) {
return;
}
if (isMainThread()) {
mShowRunnable.run();
} else {
HANDLER.removeCallbacks(mShowRunnable);
HANDLER.post(mShowRunnable);
}
}
/**
* 取消吐司弹窗
*/
void cancel() {
if (!isShow()) {
return;
}
HANDLER.removeCallbacks(mShowRunnable);
if (isMainThread()) {
mCancelRunnable.run();
} else {
HANDLER.removeCallbacks(mCancelRunnable);
HANDLER.post(mCancelRunnable);
}
}
/**
* 判断当前是否在主线程
*/
private boolean isMainThread() {
return Looper.myLooper() == Looper.getMainLooper();
}
/**
* 发送无障碍事件
*/
@SuppressWarnings("deprecation")
private void sendAccessibilityEvent(View view) {
final Context context = view.getContext();
AccessibilityManager accessibilityManager =
(AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
if (!accessibilityManager.isEnabled()) {
return;
}
int eventType = AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED;
AccessibilityEvent event;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
event = new AccessibilityEvent();
event.setEventType(eventType);
} else {
event = AccessibilityEvent.obtain(eventType);
}
event.setClassName(Toast.class.getName());
event.setPackageName(context.getPackageName());
view.dispatchPopulateAccessibilityEvent(event);
// Toast 视为通知因为它们用于向用户宣布短暂的信息
accessibilityManager.sendAccessibilityEvent(event);
}
private final Runnable mShowRunnable = new Runnable() {
@SuppressLint("WrongConstant")
@Override
public void run() {
WindowManager windowManager = mWindowLifecycle.getWindowManager();
if (windowManager == null) {
return;
}
final WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
params.packageName = mPackageName;
params.gravity = mToast.getGravity();
params.x = mToast.getXOffset();
params.y = mToast.getYOffset();
params.verticalMargin = mToast.getVerticalMargin();
params.horizontalMargin = mToast.getHorizontalMargin();
params.windowAnimations = mToast.getAnimationsId();
params.setTitle(WINDOW_TITLE);
// 指定 WindowManager 忽略系统窗口可见性的影响
// 例如下面这些的显示和隐藏都会影响当前 WindowManager 的显示触发位置调整
// WindowInsets.Type.statusBars()状态栏
// WindowInsets.Type.navigationBars()导航栏
// WindowInsets.Type.ime()输入法软键盘
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
params.setFitInsetsIgnoringVisibility(true);
}
// 如果是全局显示
if (mGlobalShow) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
// type 等于 TYPE_APPLICATION_OVERLAY 的时候
// 不能添加 WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE 标记
// 否则会导致在 Android 13 上面会出现 Toast 布局被半透明化的效果
// Github issue 地址https://github.com/getActivity/Toaster/issues/108
params.flags &= ~WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
} else {
params.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
}
}
try {
windowManager.addView(mToast.getView(), params);
// 添加一个移除吐司的任务
HANDLER.postDelayed(() -> cancel(), mToast.getDuration() == Toast.LENGTH_LONG ?
mToast.getLongDuration() : mToast.getShortDuration());
// 注册生命周期管控
mWindowLifecycle.register(ToastImpl.this);
// 当前已经显示
setShow(true);
// 发送无障碍事件
sendAccessibilityEvent(mToast.getView());
} catch (Exception e) {
// 1. 如果这个 View 对象被重复添加到 WindowManager 则会抛出异常
// java.lang.IllegalStateException: View android.widget.TextView has already been added to the window manager.
// 2. 如果 WindowManager 绑定的 Activity 已经销毁则会抛出异常
// android.view.WindowManager$BadTokenException: Unable to add window -- token android.os.BinderProxy@ef1ccb6 is not valid; is your activity running?
/// 3. 厂商的问题也会导致异常的出现Github issue 地址https://github.com/getActivity/Toaster/issues/149
// java.lang.RuntimeException: InputChannel is not initialized.
e.printStackTrace();
}
}
};
private final Runnable mCancelRunnable = new Runnable() {
@Override
public void run() {
try {
WindowManager windowManager = mWindowLifecycle.getWindowManager();
if (windowManager == null) {
return;
}
windowManager.removeViewImmediate(mToast.getView());
} catch (Exception e) {
// 如果当前 WindowManager 没有添加这个 View 则会抛出异常
// java.lang.IllegalArgumentException: View=android.widget.TextView not attached to window manager
e.printStackTrace();
} finally {
// 反注册生命周期管控
mWindowLifecycle.unregister();
// 当前没有显示
setShow(false);
}
}
};
}

View File

@ -0,0 +1,67 @@
package com.zs.smarthuman.toast;
import android.util.Log;
import com.zs.smarthuman.toast.config.IToastInterceptor;
import java.lang.reflect.Modifier;
public class ToastLogInterceptor implements IToastInterceptor {
@Override
public boolean intercept(ToastParams params) {
printToast(params.text);
return false;
}
protected void printToast(CharSequence text) {
if (!isLogEnable()) {
return;
}
// 获取调用的堆栈信息
StackTraceElement[] stackTraces = new Throwable().getStackTrace();
for (StackTraceElement stackTrace : stackTraces) {
// 获取代码行数
int lineNumber = stackTrace.getLineNumber();
if (lineNumber <= 0) {
continue;
}
// 获取类的全路径
String className = stackTrace.getClassName();
try {
Class<?> clazz = Class.forName(className);
if (!filterClass(clazz)) {
printLog("(" + stackTrace.getFileName() + ":" + lineNumber + ") " + text.toString());
// 跳出循环
break;
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
protected boolean isLogEnable() {
return Toaster.isDebugMode();
}
protected void printLog(String msg) {
// 这里解释一下为什么不用 Log.d而用 Log.i因为 Log.d 在魅族 16th 手机上面无法输出日志
Log.i("Toaster", msg);
}
protected boolean filterClass(Class<?> clazz) {
// 排查以下几种情况
// 1. 排除自身及其子类
// 2. 排除 Toaster
// 3. 排除接口类
// 4. 排除抽象类
return IToastInterceptor.class.isAssignableFrom(clazz) ||
Toaster.class.equals(clazz) ||
clazz.isInterface() ||
Modifier.isAbstract(clazz.getModifiers());
}
}

View File

@ -0,0 +1,42 @@
package com.zs.smarthuman.toast;
import com.zs.smarthuman.toast.config.IToastInterceptor;
import com.zs.smarthuman.toast.config.IToastStrategy;
import com.zs.smarthuman.toast.config.IToastStyle;
public class ToastParams {
/** 优先级类型:框架默认选择最优方案来显示,具体实现可以看一下 {@link ToastStrategy#createToast(ToastParams)} */
public static final int PRIORITY_TYPE_DEFAULT = 0;
/** 优先级类型:优先使用全局级 Toast 来显示(显示在所有应用上面,可能需要通知栏权限或者悬浮窗权限) */
public static final int PRIORITY_TYPE_GLOBAL = 1;
/** 优先级类型:优先使用局部的 Toast 来显示(显示在当前 Activity */
public static final int PRIORITY_TYPE_LOCAL = 2;
/** 显示的文本 */
public CharSequence text;
/**
* Toast 显示时长有两种值可选
*
* 短吐司{@link android.widget.Toast#LENGTH_SHORT}
* 长吐司{@link android.widget.Toast#LENGTH_LONG}
*/
public int duration = -1;
/** 延迟显示时间 */
public long delayMillis = 0;
/** 优先级类型 */
public int priorityType = PRIORITY_TYPE_DEFAULT;
/** Toast 样式 */
public IToastStyle<?> style;
/** Toast 处理策略 */
public IToastStrategy strategy;
/** Toast 拦截器 */
public IToastInterceptor interceptor;
}

View File

@ -0,0 +1,348 @@
package com.zs.smarthuman.toast;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AppOpsManager;
import android.app.Application;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.provider.Settings;
import android.widget.Toast;
import com.zs.smarthuman.toast.config.IToast;
import com.zs.smarthuman.toast.config.IToastStrategy;
import com.zs.smarthuman.toast.config.IToastStyle;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class ToastStrategy implements IToastStrategy {
/**
* 即显即示模式默认
*
* 在发起多次 Toast 的显示请求情况下显示下一个 Toast 之前
* 会先立即取消上一个 Toast保证当前显示 Toast 消息是最新的
*/
public static final int SHOW_STRATEGY_TYPE_IMMEDIATELY = 0;
/**
* 不丢消息模式
*
* 在发起多次 Toast 的显示请求情况下等待上一个 Toast 显示 1 秒或者 1.5 秒后
* 然后再显示下一个 Toast不按照 Toast 的显示时长来因为那样等待时间会很长
* 这样既能保证用户能看到每一条 Toast 消息又能保证用户不会等得太久速战速决
*/
public static final int SHOW_STRATEGY_TYPE_QUEUE = 1;
/** Handler 对象 */
private static final Handler HANDLER = new Handler(Looper.getMainLooper());
/** 应用上下文 */
private Application mApplication;
/** Toast 对象 */
private WeakReference<IToast> mToastReference;
/** 吐司显示策略 */
private final int mShowStrategyType;
/** 显示消息 Token */
private final Object mShowMessageToken = new Object();
/** 取消消息 Token */
private final Object mCancelMessageToken = new Object();
/** 上一个 Toast 显示的时间 */
private volatile long mLastShowToastMillis;
public ToastStrategy() {
this(ToastStrategy.SHOW_STRATEGY_TYPE_IMMEDIATELY);
}
public ToastStrategy(int type) {
mShowStrategyType = type;
switch (mShowStrategyType) {
case SHOW_STRATEGY_TYPE_IMMEDIATELY:
case SHOW_STRATEGY_TYPE_QUEUE:
break;
default:
throw new IllegalArgumentException("Please don't pass non-existent toast show strategy");
}
}
@Override
public void registerStrategy(Application application) {
mApplication = application;
}
@Override
public int computeShowDuration(CharSequence text) {
return text.length() > 20 ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT;
}
@Override
public IToast createToast(ToastParams params) {
Activity foregroundActivity = getForegroundActivity();
IToast toast;
if ((params.priorityType == ToastParams.PRIORITY_TYPE_DEFAULT ||
params.priorityType == ToastParams.PRIORITY_TYPE_GLOBAL) &&
VERSION.SDK_INT >= VERSION_CODES.M &&
Settings.canDrawOverlays(mApplication)) {
// 如果有悬浮窗权限就开启全局的 Toast
toast = new GlobalToast(mApplication);
} else if ((params.priorityType == ToastParams.PRIORITY_TYPE_DEFAULT ||
params.priorityType == ToastParams.PRIORITY_TYPE_LOCAL) &&
isActivityAvailable(foregroundActivity)) {
// 如果没有悬浮窗权限就开启一个依附于 Activity Toast
toast = new ActivityToast(foregroundActivity);
} else if (VERSION.SDK_INT == VERSION_CODES.N_MR1) {
// 处理 Android 7.1 Toast 在主线程被阻塞后会导致报错的问题
toast = new SafeToast(mApplication);
} else if (VERSION.SDK_INT < VERSION_CODES.Q &&
!areNotificationsEnabled(mApplication)) {
// 处理 Toast 关闭通知栏权限之后无法弹出的问题
// 通过查看和对比 NotificationManagerService 的源码
// 发现这个问题已经在 Android 10 版本上面修复了
// 但是 Toast 只能在前台显示没有通知栏权限后台 Toast 仍然无法显示
// 并且 Android 10 刚好禁止了 Hook 通知服务
// 已经有通知栏权限不需要 Hook 系统通知服务也能正常显示系统 Toast
toast = new NotificationToast(mApplication);
} else {
toast = new SystemToast(mApplication);
}
if (areSupportCustomToastStyle(toast) || !onlyShowSystemToastStyle()) {
diyToastStyle(toast, params.style);
}
return toast;
}
@Override
public void showToast(ToastParams params) {
switch (mShowStrategyType) {
case SHOW_STRATEGY_TYPE_IMMEDIATELY: {
// 移除之前未显示的 Toast 消息
cancelToast();
long uptimeMillis = SystemClock.uptimeMillis() + params.delayMillis + generateShowDelayTime(params);
getHandler().postAtTime(new ShowToastRunnable(params), mShowMessageToken, uptimeMillis);
break;
}
case SHOW_STRATEGY_TYPE_QUEUE: {
// 计算出这个 Toast 显示时间
long showToastMillis = SystemClock.uptimeMillis() + params.delayMillis + generateShowDelayTime(params);
// 根据吐司的长短计算出等待时间
long waitMillis = generateToastWaitMillis(params);
// 如果当前显示的时间在上一个 Toast 的显示范围之内
// 那么就重新计算 Toast 的显示时间
if (showToastMillis < (mLastShowToastMillis + waitMillis)) {
showToastMillis = mLastShowToastMillis + waitMillis;
}
getHandler().postAtTime(new ShowToastRunnable(params), mShowMessageToken, showToastMillis);
mLastShowToastMillis = showToastMillis;
break;
}
default:
break;
}
}
@Override
public void cancelToast() {
long uptimeMillis = SystemClock.uptimeMillis();
getHandler().postAtTime(new CancelToastRunnable(), mCancelMessageToken, uptimeMillis);
}
/**
* 获取 Handler 对象
*/
protected static Handler getHandler() {
return HANDLER;
}
/**
* 生成默认延迟时间
*/
protected int generateShowDelayTime(ToastParams params) {
// 延迟一段时间之后再执行因为在没有通知栏权限的情况下Toast 只能显示在当前 Activity 上面即使用 ActivityToast
// 如果当前 Activity showToast 之后立马进行 finish 那么这个时候 Toast 可能会显示不出来
// 因为 Toast 会显示在销毁 Activity 界面上而不会显示在新跳转的 Activity 上面所以会导致显示不出来的问题
// 另外有悬浮窗权限的情况下使用全局的 Toast即使用 GlobalToast这种立刻显示也会有一些问题
// 我在小米 12 Android 12 的手机上面测试从权限设置页返回的时候发现 Toast 有几率会从左上的位置显示然后会变回正常的位置
// 如果是系统的 Toast即使用 SystemToastSafeToastNotificationToast 任意一个则不会有这个问题
// 300 只是一个经验值经过很多次验证得出来的值当然你觉得这个值不是自己想要的也可以重写此方法改成自己想要的
return 300;
}
/**
* 是否支持设置自定义 Toast 样式
*/
protected boolean areSupportCustomToastStyle(IToast toast) {
// sdk 版本 >= 30 的情况下在后台显示自定义样式的 Toast 会被系统屏蔽并且日志会输出以下警告
// Blocking custom toast from package com.xxx.xxx due to package not in the foreground
// sdk 版本 < 30 的情况下 new Toast并且不设置视图显示系统会抛出以下异常
// java.lang.RuntimeException: This Toast was not created with Toast.makeText()
return toast instanceof CustomToast || VERSION.SDK_INT < VERSION_CODES.R;
}
/**
* 定制 Toast 的样式
*/
protected void diyToastStyle(IToast toast, IToastStyle<?> style) {
toast.setView(style.createView(mApplication));
toast.setGravity(style.getGravity(), style.getXOffset(), style.getYOffset());
toast.setMargin(style.getHorizontalMargin(), style.getVerticalMargin());
}
/**
* 生成 Toast 等待时间
*/
protected int generateToastWaitMillis(ToastParams params) {
if (params.duration == Toast.LENGTH_SHORT) {
return 1000;
} else if (params.duration == Toast.LENGTH_LONG) {
return 1500;
}
return 0;
}
/**
* 显示任务
*/
private class ShowToastRunnable implements Runnable {
private final ToastParams mToastParams;
private ShowToastRunnable(ToastParams params) {
mToastParams = params;
}
@Override
public void run() {
IToast toast = null;
if (mToastReference != null) {
toast = mToastReference.get();
}
if (toast != null) {
// 取消上一个 Toast 的显示避免出现重叠的效果
toast.cancel();
}
toast = createToast(mToastParams);
// 为什么用 WeakReference而不用 SoftReference
// https://github.com/getActivity/Toaster/issues/79
mToastReference = new WeakReference<>(toast);
toast.setDuration(mToastParams.duration);
toast.setText(mToastParams.text);
toast.show();
}
}
/**
* 取消任务
*/
private class CancelToastRunnable implements Runnable {
@Override
public void run() {
IToast toast = null;
if (mToastReference != null) {
toast = mToastReference.get();
}
if (toast == null) {
return;
}
toast.cancel();
}
}
/**
* 当前是否只能显示系统 Toast 样式
*/
protected boolean onlyShowSystemToastStyle() {
// Github issue 地址https://github.com/getActivity/Toaster/issues/103
// Toast.CHANGE_TEXT_TOASTS_IN_THE_SYSTEM = 147798919L
return isChangeEnabledCompat(147798919L);
}
@SuppressLint("PrivateApi")
protected boolean isChangeEnabledCompat(long changeId) {
// 需要注意的是这个 api 是在 android 11 的时候出现的反射前需要先判断好版本
if (VERSION.SDK_INT < VERSION_CODES.R) {
return false;
}
try {
// 因为 Compatibility.isChangeEnabled() 普通应用根本调用不到反射也不行
// 通过 Toast.isSystemRenderedTextToast 也没有办法反射到
// 最后发现反射 CompatChanges.isChangeEnabled 是可以的
Class<?> clazz = Class.forName("android.app.compat.CompatChanges");
Method method = clazz.getMethod("isChangeEnabled", long.class);
method.setAccessible(true);
return Boolean.parseBoolean(String.valueOf(method.invoke(null, changeId)));
} catch (ClassNotFoundException | InvocationTargetException | NoSuchMethodException | IllegalAccessException e) {
e.printStackTrace();
return false;
}
}
/**
* 是否有通知栏权限
*/
@SuppressWarnings("ConstantConditions")
@SuppressLint("PrivateApi")
protected boolean areNotificationsEnabled(Context context) {
if (VERSION.SDK_INT >= VERSION_CODES.N) {
return context.getSystemService(NotificationManager.class).areNotificationsEnabled();
}
if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
// 参考 Support 库中的方法 NotificationManagerCompat.from(context).areNotificationsEnabled()
AppOpsManager appOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
try {
Method method = appOps.getClass().getMethod("checkOpNoThrow",
Integer.TYPE, Integer.TYPE, String.class);
Field field = appOps.getClass().getDeclaredField("OP_POST_NOTIFICATION");
int value = (int) field.get(Integer.class);
return ((int) method.invoke(appOps, value, context.getApplicationInfo().uid,
context.getPackageName())) == AppOpsManager.MODE_ALLOWED;
} catch (NoSuchMethodException | NoSuchFieldException | InvocationTargetException |
IllegalAccessException | RuntimeException e) {
e.printStackTrace();
return true;
}
}
return true;
}
/**
* 获取前台的 Activity
*/
protected Activity getForegroundActivity() {
return ActivityStack.getInstance().getForegroundActivity();
}
/**
* Activity 是否可用
*/
protected boolean isActivityAvailable(Activity activity) {
if (activity == null) {
return false;
}
if (activity.isFinishing()) {
return false;
}
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
return !activity.isDestroyed();
}
return true;
}
}

View File

@ -0,0 +1,346 @@
package com.zs.smarthuman.toast;
import android.app.Application;
import android.content.pm.ApplicationInfo;
import android.content.res.Resources;
import android.widget.Toast;
import com.zs.smarthuman.toast.config.IToastInterceptor;
import com.zs.smarthuman.toast.config.IToastStrategy;
import com.zs.smarthuman.toast.config.IToastStyle;
import com.zs.smarthuman.toast.style.BlackToastStyle;
import com.zs.smarthuman.toast.style.CustomToastStyle;
import com.zs.smarthuman.toast.style.LocationToastStyle;
import com.zs.smarthuman.toast.style.WhiteToastStyle;
@SuppressWarnings("unused")
public final class Toaster {
/** Application 对象 */
private static Application sApplication;
/** Toast 处理策略 */
private static IToastStrategy sToastStrategy;
/** Toast 样式 */
private static IToastStyle<?> sToastStyle;
/** Toast 拦截器(可空) */
private static IToastInterceptor sToastInterceptor;
/** 调试模式 */
private static Boolean sDebugMode;
/**
* 不允许被外部实例化
*/
private Toaster() {}
/**
* 初始化 Toast需要在 Application.create 中初始化
*
* @param application 应用的上下文
*/
public static void init(Application application) {
init(application, sToastStyle);
}
public static void init(Application application, IToastStrategy strategy) {
init(application, strategy, null);
}
public static void init(Application application, IToastStyle<?> style) {
init(application, null, style);
}
/**
* 初始化 Toast
*
* @param application 应用的上下文
* @param strategy Toast 策略
* @param style Toast 样式
*/
public static void init(Application application, IToastStrategy strategy, IToastStyle<?> style) {
// 如果当前已经初始化过了就不要再重复初始化了
if (isInit()) {
return;
}
sApplication = application;
ActivityStack.getInstance().register(application);
// 初始化 Toast 策略
if (strategy == null) {
strategy = new ToastStrategy();
}
setStrategy(strategy);
// 设置 Toast 样式
if (style == null) {
style = new BlackToastStyle();
}
setStyle(style);
}
/**
* 判断当前框架是否已经初始化
*/
public static boolean isInit() {
return sApplication != null && sToastStrategy != null && sToastStyle != null;
}
/**
* 延迟显示 Toast
*/
public static void delayedShow(int id, long delayMillis) {
delayedShow(stringIdToCharSequence(id), delayMillis);
}
public static void delayedShow(Object object, long delayMillis) {
delayedShow(objectToCharSequence(object), delayMillis);
}
public static void delayedShow(CharSequence text, long delayMillis) {
ToastParams params = new ToastParams();
params.text = text;
params.delayMillis = delayMillis;
show(params);
}
/**
* debug 模式下显示 Toast
*/
public static void debugShow(int id) {
debugShow(stringIdToCharSequence(id));
}
public static void debugShow(Object object) {
debugShow(objectToCharSequence(object));
}
public static void debugShow(CharSequence text) {
if (!isDebugMode()) {
return;
}
ToastParams params = new ToastParams();
params.text = text;
show(params);
}
/**
* 显示一个短 Toast
*/
public static void showShort(int id) {
showShort(stringIdToCharSequence(id));
}
public static void showShort(Object object) {
showShort(objectToCharSequence(object));
}
public static void showShort(CharSequence text) {
ToastParams params = new ToastParams();
params.text = text;
params.duration = Toast.LENGTH_SHORT;
show(params);
}
/**
* 显示一个长 Toast
*/
public static void showLong(int id) {
showLong(stringIdToCharSequence(id));
}
public static void showLong(Object object) {
showLong(objectToCharSequence(object));
}
public static void showLong(CharSequence text) {
ToastParams params = new ToastParams();
params.text = text;
params.duration = Toast.LENGTH_LONG;
show(params);
}
/**
* 显示 Toast
*/
public static void show(int id) {
show(stringIdToCharSequence(id));
}
public static void show(Object object) {
show(objectToCharSequence(object));
}
public static void show(CharSequence text) {
ToastParams params = new ToastParams();
params.text = text;
show(params);
}
public static void show(ToastParams params) {
checkInitStatus();
// 如果是空对象或者空文本就不显示
if (params.text == null || params.text.length() == 0) {
return;
}
if (params.strategy == null) {
params.strategy = sToastStrategy;
}
if (params.interceptor == null) {
if (sToastInterceptor == null) {
sToastInterceptor = new ToastLogInterceptor();
}
params.interceptor = sToastInterceptor;
}
if (params.style == null) {
params.style = sToastStyle;
}
if (params.interceptor.intercept(params)) {
return;
}
if (params.duration == -1) {
params.duration = params.strategy.computeShowDuration(params.text);
}
params.strategy.showToast(params);
}
/**
* 取消吐司的显示
*/
public static void cancel() {
sToastStrategy.cancelToast();
}
/**
* 设置吐司的位置
*
* @param gravity 重心
*/
public static void setGravity(int gravity) {
setGravity(gravity, 0, 0);
}
public static void setGravity(int gravity, int xOffset, int yOffset) {
setGravity(gravity, xOffset, yOffset, 0, 0);
}
public static void setGravity(int gravity, int xOffset, int yOffset, float horizontalMargin, float verticalMargin) {
sToastStyle = new LocationToastStyle(sToastStyle, gravity, xOffset, yOffset, horizontalMargin, verticalMargin);
}
/**
* 给当前 Toast 设置新的布局
*/
public static void setView(int id) {
if (id <= 0) {
return;
}
if (sToastStyle == null) {
return;
}
setStyle(new CustomToastStyle(id, sToastStyle.getGravity(),
sToastStyle.getXOffset(), sToastStyle.getYOffset(),
sToastStyle.getHorizontalMargin(), sToastStyle.getVerticalMargin()));
}
/**
* 初始化全局的 Toast 样式
*
* @param style 样式实现类框架已经实现两种不同的样式
* 黑色样式{@link BlackToastStyle}
* 白色样式{@link WhiteToastStyle}
*/
public static void setStyle(IToastStyle<?> style) {
if (style == null) {
return;
}
sToastStyle = style;
}
public static IToastStyle<?> getStyle() {
return sToastStyle;
}
/**
* 设置 Toast 显示策略
*/
public static void setStrategy(IToastStrategy strategy) {
if (strategy == null) {
return;
}
sToastStrategy = strategy;
sToastStrategy.registerStrategy(sApplication);
}
public static IToastStrategy getStrategy() {
return sToastStrategy;
}
/**
* 设置 Toast 拦截器可以根据显示的内容决定是否拦截这个Toast
* 场景打印 Toast 内容日志根据 Toast 内容是否包含敏感字来动态切换其他方式显示这里可以使用我的另外一套框架 EasyWindow
*/
public static void setInterceptor(IToastInterceptor interceptor) {
sToastInterceptor = interceptor;
}
public static IToastInterceptor getInterceptor() {
return sToastInterceptor;
}
/**
* 是否为调试模式
*/
public static void setDebugMode(boolean debug) {
sDebugMode = debug;
}
/**
* 检查框架初始化状态如果未初始化请先调用{@link Toaster#init(Application)}
*/
private static void checkInitStatus() {
// 框架当前还没有被初始化必须要先调用 init 方法进行初始化
if (sApplication == null) {
throw new IllegalStateException("Toaster has not been initialized");
}
}
static boolean isDebugMode() {
if (sDebugMode == null) {
checkInitStatus();
sDebugMode = (sApplication.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
}
return sDebugMode;
}
private static CharSequence stringIdToCharSequence(int id) {
checkInitStatus();
try {
// 如果这是一个资源 id
return sApplication.getResources().getText(id);
} catch (Resources.NotFoundException ignored) {
// 如果这是一个 int 整数
return String.valueOf(id);
}
}
private static CharSequence objectToCharSequence(Object object) {
return object != null ? object.toString() : "null";
}
}

View File

@ -0,0 +1,129 @@
package com.zs.smarthuman.toast;
import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.view.WindowManager;
final class WindowLifecycle implements Application.ActivityLifecycleCallbacks {
/** 当前 Activity 对象 */
private Activity mActivity;
/** 当前 Application 对象 */
private Application mApplication;
/** 自定义 Toast 实现类 */
private ToastImpl mToastImpl;
WindowLifecycle(Activity activity) {
mActivity = activity;
}
WindowLifecycle(Application application) {
mApplication = application;
}
/**
* 获取 WindowManager 对象
*/
public WindowManager getWindowManager() {
if (mActivity != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && mActivity.isDestroyed()) {
return null;
}
return mActivity.getWindowManager();
} else if (mApplication != null) {
return (WindowManager) mApplication.getSystemService(Context.WINDOW_SERVICE);
}
return null;
}
/**
* {@link Application.ActivityLifecycleCallbacks}
*/
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {}
@Override
public void onActivityStarted(Activity activity) {}
@Override
public void onActivityResumed(Activity activity) {}
// A 跳转 B 页面的生命周期方法执行顺序
// onPause(A) ---> onCreate(B) ---> onStart(B) ---> onResume(B) ---> onStop(A) ---> onDestroyed(A)
@Override
public void onActivityPaused(Activity activity) {
if (mActivity != activity) {
return;
}
if (mToastImpl == null) {
return;
}
// 不能放在 onStop 或者 onDestroyed 方法中因为此时新的 Activity 已经创建完成必须在这个新的 Activity 未创建之前关闭这个 WindowManager
// 调用取消显示会直接导致新的 Activity onCreate 调用显示吐司可能显示不出来的问题又或者有时候会立马显示然后立马消失的那种效果
mToastImpl.cancel();
}
@Override
public void onActivityStopped(Activity activity) {}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
@Override
public void onActivityDestroyed(Activity activity) {
if (mActivity != activity) {
return;
}
if (mToastImpl != null) {
mToastImpl.cancel();
}
unregister();
mActivity = null;
}
/**
* 注册
*/
void register(ToastImpl impl) {
mToastImpl = impl;
if (mActivity == null) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
mActivity.registerActivityLifecycleCallbacks(this);
} else {
mActivity.getApplication().registerActivityLifecycleCallbacks(this);
}
}
/**
* 反注册
*/
void unregister() {
mToastImpl = null;
if (mActivity == null) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
mActivity.unregisterActivityLifecycleCallbacks(this);
} else {
mActivity.getApplication().unregisterActivityLifecycleCallbacks(this);
}
}
}

View File

@ -0,0 +1,109 @@
package com.zs.smarthuman.toast.config;
import android.view.View;
import android.widget.TextView;
@SuppressWarnings("unused")
public interface IToast {
/**
* 显示
*/
void show();
/**
* 取消
*/
void cancel();
/**
* 设置文本
*/
void setText(int id);
void setText(CharSequence text);
/**
* 设置布局
*/
void setView(View view);
/**
* 获取布局
*/
View getView();
/**
* 设置显示时长
*/
void setDuration(int duration);
/**
* 获取显示时长
*/
int getDuration();
/**
* 设置重心偏移
*/
void setGravity(int gravity, int xOffset, int yOffset);
/**
* 获取显示重心
*/
int getGravity();
/**
* 获取水平偏移
*/
int getXOffset();
/**
* 获取垂直偏移
*/
int getYOffset();
/**
* 设置屏幕间距
*/
void setMargin(float horizontalMargin, float verticalMargin);
/**
* 设置水平间距
*/
float getHorizontalMargin();
/**
* 设置垂直间距
*/
float getVerticalMargin();
/**
* 智能获取用于显示消息的 TextView
*/
default TextView findMessageView(View view) {
if (view instanceof TextView) {
if (view.getId() == View.NO_ID) {
view.setId(android.R.id.message);
} else if (view.getId() != android.R.id.message) {
// 必须将 TextView id 值设置成 android.R.id.message
// 否则 Android 11 手机上在后台 toast.setText 的时候会出现报错
// java.lang.RuntimeException: This Toast was not created with Toast.makeText()
throw new IllegalArgumentException("You must set the ID value of TextView to android.R.id.message");
}
return (TextView) view;
}
View messageView = view.findViewById(android.R.id.message);
if (messageView instanceof TextView) {
return ((TextView) messageView);
}
// 如果设置的布局没有包含一个 TextView 则抛出异常必须要包含一个 id 值为 message TextView
// xml 代码 android:id="@android:id/message"
// java 代码 view.setId(android.R.id.message)
throw new IllegalArgumentException("You must include a TextView with an ID value of message "
+ "(xml code: android:id=\"@android:id/message\", java code: view.setId(android.R.id.message))");
}
}

View File

@ -0,0 +1,12 @@
package com.zs.smarthuman.toast.config;
import com.zs.smarthuman.toast.ToastParams;
public interface IToastInterceptor {
/**
* 根据显示的文本决定是否拦截该 Toast
*/
boolean intercept(ToastParams params);
}

View File

@ -0,0 +1,34 @@
package com.zs.smarthuman.toast.config;
import android.app.Application;
import com.zs.smarthuman.toast.ToastParams;
public interface IToastStrategy {
/**
* 注册策略
*/
void registerStrategy(Application application);
/**
* 计算 Toast 显示时长
*/
int computeShowDuration(CharSequence text);
/**
* 创建 Toast
*/
IToast createToast(ToastParams params);
/**
* 显示 Toast
*/
void showToast(ToastParams params);
/**
* 取消 Toast
*/
void cancelToast();
}

View File

@ -0,0 +1,49 @@
package com.zs.smarthuman.toast.config;
import android.content.Context;
import android.view.Gravity;
import android.view.View;
public interface IToastStyle<V extends View> {
/**
* 创建 Toast 视图
*/
V createView(Context context);
/**
* 获取 Toast 显示重心
*/
default int getGravity() {
return Gravity.CENTER;
}
/**
* 获取 Toast 水平偏移
*/
default int getXOffset() {
return 0;
}
/**
* 获取 Toast 垂直偏移
*/
default int getYOffset() {
return 0;
}
/**
* 获取 Toast 水平间距
*/
default float getHorizontalMargin() {
return 0;
}
/**
* 获取 Toast 垂直间距
*/
default float getVerticalMargin() {
return 0;
}
}

View File

@ -0,0 +1,91 @@
package com.zs.smarthuman.toast.style;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import com.zs.smarthuman.toast.config.IToastStyle;
@SuppressWarnings({"unused", "deprecation"})
public class BlackToastStyle implements IToastStyle<View> {
@Override
public View createView(Context context) {
TextView textView = new TextView(context);
textView.setId(android.R.id.message);
textView.setGravity(getTextGravity(context));
textView.setTextColor(getTextColor(context));
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getTextSize(context));
int horizontalPadding = getHorizontalPadding(context);
int verticalPadding = getVerticalPadding(context);
// 适配布局反方向特性
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
textView.setPaddingRelative(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
} else {
textView.setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
}
textView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
Drawable backgroundDrawable = getBackgroundDrawable(context);
// 设置背景
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
textView.setBackground(backgroundDrawable);
} else {
textView.setBackgroundDrawable(backgroundDrawable);
}
// 设置 Z 轴阴影
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
textView.setZ(getTranslationZ(context));
}
return textView;
}
protected int getTextGravity(Context context) {
return Gravity.CENTER;
}
protected int getTextColor(Context context) {
return 0XEEFFFFFF;
}
protected float getTextSize(Context context) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
18, context.getResources().getDisplayMetrics());
}
protected int getHorizontalPadding(Context context) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
24, context.getResources().getDisplayMetrics());
}
protected int getVerticalPadding(Context context) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
16, context.getResources().getDisplayMetrics());
}
protected Drawable getBackgroundDrawable(Context context) {
GradientDrawable drawable = new GradientDrawable();
// 设置颜色
drawable.setColor(0XB3000000);
// 设置圆角
drawable.setCornerRadius(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
10, context.getResources().getDisplayMetrics()));
return drawable;
}
protected float getTranslationZ(Context context) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3, context.getResources().getDisplayMetrics());
}
}

View File

@ -0,0 +1,71 @@
package com.zs.smarthuman.toast.style;
import android.content.Context;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import com.zs.smarthuman.toast.config.IToastStyle;
public class CustomToastStyle implements IToastStyle<View> {
private final int mLayoutId;
private final int mGravity;
private final int mXOffset;
private final int mYOffset;
private final float mHorizontalMargin;
private final float mVerticalMargin;
public CustomToastStyle(int id) {
this(id, Gravity.CENTER);
}
public CustomToastStyle(int id, int gravity) {
this(id, gravity, 0, 0);
}
public CustomToastStyle(int id, int gravity, int xOffset, int yOffset) {
this(id, gravity, xOffset, yOffset, 0f, 0f);
}
public CustomToastStyle(int id, int gravity, int xOffset, int yOffset, float horizontalMargin, float verticalMargin) {
mLayoutId = id;
mGravity = gravity;
mXOffset = xOffset;
mYOffset = yOffset;
mHorizontalMargin = horizontalMargin;
mVerticalMargin = verticalMargin;
}
@Override
public View createView(Context context) {
return LayoutInflater.from(context).inflate(mLayoutId, null);
}
@Override
public int getGravity() {
return mGravity;
}
@Override
public int getXOffset() {
return mXOffset;
}
@Override
public int getYOffset() {
return mYOffset;
}
@Override
public float getHorizontalMargin() {
return mHorizontalMargin;
}
@Override
public float getVerticalMargin() {
return mVerticalMargin;
}
}

View File

@ -0,0 +1,62 @@
package com.zs.smarthuman.toast.style;
import android.content.Context;
import android.view.View;
import com.zs.smarthuman.toast.config.IToastStyle;
@SuppressWarnings("unused")
public class LocationToastStyle implements IToastStyle<View> {
private final IToastStyle<?> mStyle;
private final int mGravity;
private final int mXOffset;
private final int mYOffset;
private final float mHorizontalMargin;
private final float mVerticalMargin;
public LocationToastStyle(IToastStyle<?> style, int gravity) {
this(style, gravity, 0, 0, 0, 0);
}
public LocationToastStyle(IToastStyle<?> style, int gravity, int xOffset, int yOffset, float horizontalMargin, float verticalMargin) {
mStyle = style;
mGravity = gravity;
mXOffset = xOffset;
mYOffset = yOffset;
mHorizontalMargin = horizontalMargin;
mVerticalMargin = verticalMargin;
}
@Override
public View createView(Context context) {
return mStyle.createView(context);
}
@Override
public int getGravity() {
return mGravity;
}
@Override
public int getXOffset() {
return mXOffset;
}
@Override
public int getYOffset() {
return mYOffset;
}
@Override
public float getHorizontalMargin() {
return mHorizontalMargin;
}
@Override
public float getVerticalMargin() {
return mVerticalMargin;
}
}

View File

@ -0,0 +1,26 @@
package com.zs.smarthuman.toast.style;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.util.TypedValue;
public class WhiteToastStyle extends BlackToastStyle {
@Override
protected int getTextColor(Context context) {
return 0XBB000000;
}
@Override
protected Drawable getBackgroundDrawable(Context context) {
GradientDrawable drawable = new GradientDrawable();
// 设置颜色
drawable.setColor(0XFFEAEAEA);
// 设置圆角
drawable.setCornerRadius(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
10, context.getResources().getDisplayMetrics()));
return drawable;
}
}

View File

@ -37,10 +37,12 @@ import com.zs.smarthuman.common.UserInfoManager
import com.zs.smarthuman.databinding.ActivityMainBinding import com.zs.smarthuman.databinding.ActivityMainBinding
import com.zs.smarthuman.http.ApiResult import com.zs.smarthuman.http.ApiResult
import com.zs.smarthuman.http.ApiService import com.zs.smarthuman.http.ApiService
import com.zs.smarthuman.im.chat.MessageContentType
import com.zs.smarthuman.im.chat.bean.SingleMessage import com.zs.smarthuman.im.chat.bean.SingleMessage
import com.zs.smarthuman.kt.releaseIM import com.zs.smarthuman.kt.releaseIM
import com.zs.smarthuman.sherpa.VoiceController import com.zs.smarthuman.sherpa.VoiceController
import com.zs.smarthuman.sherpa.VoiceState import com.zs.smarthuman.sherpa.VoiceState
import com.zs.smarthuman.toast.Toaster
import com.zs.smarthuman.utils.AudioDebugUtil import com.zs.smarthuman.utils.AudioDebugUtil
import com.zs.smarthuman.utils.AudioPcmUtil import com.zs.smarthuman.utils.AudioPcmUtil
@ -52,6 +54,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rxhttp.awaitResult import rxhttp.awaitResult
import rxhttp.toAwaitString import rxhttp.toAwaitString
import rxhttp.wrapper.param.RxHttp import rxhttp.wrapper.param.RxHttp
@ -60,6 +63,7 @@ import java.io.File
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.math.sqrt
/** /**
* @description: * @description:
@ -71,7 +75,7 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
private var voiceController: VoiceController? = null private var voiceController: VoiceController? = null
private var audioRecord: AudioRecord? = null private var audioRecord: AudioRecord? = null
private var isRecording = false private var isRecording = false
private val audioSource = MediaRecorder.AudioSource.MIC private val audioSource = MediaRecorder.AudioSource.VOICE_RECOGNITION
private val sampleRateInHz = 16000 private val sampleRateInHz = 16000
private val channelConfig = AudioFormat.CHANNEL_IN_MONO private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
@ -131,16 +135,11 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
mViewModel?.uploadVoiceLiveData?.observe(this) { mViewModel?.uploadVoiceLiveData?.observe(this) {
when (it) { when (it) {
is ApiResult.Error -> { is ApiResult.Error -> {
ToastUtils.showShort("上传失败") Toaster.showShort("上传失败")
} }
is ApiResult.Success<*> -> { is ApiResult.Success<*> -> {
ToastUtils.showShort("上传成功") Toaster.showShort("上传成功")
UnityPlayerHolder.getInstance()
.sendVoiceToUnity(
voiceInfo = mutableListOf<VoiceBeanResp>().apply {
add(VoiceBeanResp(audioUrl = "https://static.seerteach.net/largemodel/smart_read_audio/intensive_reading/689450143596d58606a106e5/689450143596d58606a106e5_1.mp3"))
}
)
} }
} }
} }
@ -152,24 +151,32 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
assetManager = assets, assetManager = assets,
onWakeup = { onWakeup = {
Log.d("lrs", "当前状态: 唤醒成功wakeup") Log.d("lrs", "当前状态: 唤醒成功wakeup")
UnityPlayerHolder.getInstance().cancel() //每次唤醒前都要把前面的音频停掉
UnityPlayerHolder.getInstance().cancelPCM()
UnityPlayerHolder.getInstance() UnityPlayerHolder.getInstance()
.sendVoiceToUnity( .sendVoiceToUnity(
voiceInfo = mutableListOf<VoiceBeanResp>().apply { voiceInfo = mutableListOf<VoiceBeanResp>().apply {
add(VoiceBeanResp(audioUrl = UserInfoManager.userInfo?.wakeUpAudioUrl?:"")) add(
VoiceBeanResp(
audioUrl = UserInfoManager.userInfo?.wakeUpAudioUrl ?: ""
)
)
} }
) )
}, },
onFinalAudio = { audio -> onFinalAudio = { audio ->
Log.d("lrs", "检测到语音,长度=${audio.size}") Log.d("lrs", "检测到语音,长度=${audio.size}")
// lifecycleScope.launch(Dispatchers.IO) { mViewModel?.uploadVoice(
mViewModel?.uploadVoice(AudioPcmUtil.pcm16ToBase64(AudioPcmUtil.floatToPcm16(audio)),1) AudioPcmUtil.pcm16ToBase64(AudioPcmUtil.floatToPcm16(audio)),
1
)
val file = File( val file = File(
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!.getAbsolutePath(), getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!.getAbsolutePath(),
"xxx.wav" "xxx.wav"
) )
AudioDebugUtil.saveFloatPcmAsWav(audio, file) AudioDebugUtil.saveFloatPcmAsWav(audio, file)
LogUtils.dTag("audioxx", "WAV saved: ${file.path}, samples=${audio.size}") LogUtils.dTag("audioxx", "WAV saved: ${file.path}, samples=${audio.size}")
}, },
onStateChanged = { state -> onStateChanged = { state ->
when (state) { when (state) {
@ -192,6 +199,14 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
} }
override fun receivedIMMsg(msg: SingleMessage) { override fun receivedIMMsg(msg: SingleMessage) {
when (msg.msgContentType) {
MessageContentType.RECEIVE_VOICE_STREAM.msgContentType -> {
lifecycleScope.launch(Dispatchers.IO) {
UnityPlayerHolder.getInstance()
.startTalking(msg.content)
}
}
}
} }
@ -244,11 +259,24 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
//开始录音 //开始录音
fun startRecording() { fun startRecording() {
if (!isRecording) {
audioRecord?.startRecording()
isRecording = true isRecording = true
audioRecord?.startRecording()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
processAudio() val buf = ShortArray(640)
while (isRecording) {
val n = audioRecord?.read(buf, 0, buf.size) ?: 0
if (n > 0) {
val raw = FloatArray(n) { buf[it] / 32768f }
// 播放时 duck
val ducked = if (voiceController?.isPlaying == true) {
FloatArray(n) { raw[it] * 0.4f }
} else raw
voiceController?.acceptAudio(agc(ducked))
}
voiceController?.checkIdleTimeout()
} }
} }
} }
@ -263,31 +291,31 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
} }
} }
private fun agc(
input: FloatArray,
target: Float = 0.035f, // ⬅️ 降
maxGain: Float = 4f // ⬅️ 关键
): FloatArray {
var sum = 0f
for (v in input) sum += v * v
val rms = sqrt(sum / input.size)
if (rms < 1e-6) return input
private fun processAudio() { val gain = (target / rms).coerceAtMost(maxGain)
// val interval = 0.1 return FloatArray(input.size) {
// val bufferSize = (interval * sampleRateInHz).toInt() // in samples (input[it] * gain).coerceIn(-1f, 1f)
// val buffer = ShortArray(bufferSize)
val bufferSize = 512 // in samples
val buffer = ShortArray(bufferSize)
while (isRecording) {
val ret = audioRecord?.read(buffer, 0, buffer.size) ?: 0
if (ret > 0) {
val samples = FloatArray(ret) { buffer[it] / 32768.0f }
voiceController?.acceptAudio(samples)
}
// 每帧检查 IdleTimeout
voiceController?.checkIdleTimeout()
} }
} }
fun onUnityResourcesLoaded(message: String) { // 这是 Unity 调用的资源加载完成回调方法 fun onUnityResourcesLoaded(message: String) { // 这是 Unity 调用的资源加载完成回调方法
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
delay(200) delay(200)
binding.flDigitalHuman.translationY = 0f binding.flDigitalHuman.translationY = 0f
} }
} }
private var promptPlaying = false
fun onAudioProgressUpdated( // Unity 调用此方法传递音频进度 fun onAudioProgressUpdated( // Unity 调用此方法传递音频进度
progress: Float, progress: Float,
state: Int,//0stop 2pause 1play 3complete 4loading 5error state: Int,//0stop 2pause 1play 3complete 4loading 5error
@ -295,21 +323,24 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
word: String, word: String,
audioUrl: String audioUrl: String
) { ) {
if (state == 1) { val wakeupUrl = UserInfoManager.userInfo?.wakeUpAudioUrl ?: return
if (audioUrl == UserInfoManager.userInfo?.wakeUpAudioUrl){
if (audioUrl != wakeupUrl) return
when (state) {
1 -> { // play
if (!promptPlaying) {
promptPlaying = true
voiceController?.onPlayStartPrompt() voiceController?.onPlayStartPrompt()
}else{ }
voiceController?.onPlayStartBackend()
} }
} 3 -> { // complete
if (promptPlaying) {
if (state == 3){ Toaster.showShort("借宿了")
if (audioUrl == UserInfoManager.userInfo?.wakeUpAudioUrl){ promptPlaying = false
voiceController?.onPlayEndPrompt() voiceController?.onPlayEndPrompt()
}
}else{
voiceController?.onPlayEndBackend()
} }
} }
} }
@ -322,10 +353,10 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
isFinal: Boolean isFinal: Boolean
) { ) {
if (state == 1) { if (state == 1) {
// voiceController?.onPlayStart() voiceController?.onPlayStartBackend()
} }
if (state == 3) { if (state == 3) {
// voiceController?.onPlayEnd() voiceController?.onPlayEndBackend()
} }
} }