From 738b39e9a0ebac69048f3146411f9c446329faf2 Mon Sep 17 00:00:00 2001 From: ross <3024454314@qq.com> Date: Tue, 23 Dec 2025 16:50:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=B4=E6=97=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/zs/smarthuman/App.kt | 4 + .../com/zs/smarthuman/common/CommonManager.kt | 5 +- .../zs/smarthuman/sherpa/VoiceController.kt | 260 +++++++------ .../com/zs/smarthuman/sherpa/WakeupManager.kt | 6 +- .../zs/smarthuman/toast/ActivityStack.java | 72 ++++ .../zs/smarthuman/toast/ActivityToast.java | 26 ++ .../com/zs/smarthuman/toast/CustomToast.java | 135 +++++++ .../com/zs/smarthuman/toast/GlobalToast.java | 25 ++ .../toast/NotificationServiceProxy.java | 32 ++ .../smarthuman/toast/NotificationToast.java | 60 +++ .../com/zs/smarthuman/toast/SafeHandler.java | 31 ++ .../com/zs/smarthuman/toast/SafeToast.java | 61 +++ .../com/zs/smarthuman/toast/SystemToast.java | 39 ++ .../com/zs/smarthuman/toast/ToastImpl.java | 226 ++++++++++++ .../smarthuman/toast/ToastLogInterceptor.java | 67 ++++ .../com/zs/smarthuman/toast/ToastParams.java | 42 +++ .../zs/smarthuman/toast/ToastStrategy.java | 348 ++++++++++++++++++ .../java/com/zs/smarthuman/toast/Toaster.java | 346 +++++++++++++++++ .../zs/smarthuman/toast/WindowLifecycle.java | 129 +++++++ .../zs/smarthuman/toast/config/IToast.java | 109 ++++++ .../toast/config/IToastInterceptor.java | 12 + .../toast/config/IToastStrategy.java | 34 ++ .../smarthuman/toast/config/IToastStyle.java | 49 +++ .../toast/style/BlackToastStyle.java | 91 +++++ .../toast/style/CustomToastStyle.java | 71 ++++ .../toast/style/LocationToastStyle.java | 62 ++++ .../toast/style/WhiteToastStyle.java | 26 ++ .../java/com/zs/smarthuman/ui/MainActivity.kt | 145 +++++--- 28 files changed, 2342 insertions(+), 171 deletions(-) create mode 100644 app/src/main/java/com/zs/smarthuman/toast/ActivityStack.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/ActivityToast.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/CustomToast.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/GlobalToast.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/NotificationServiceProxy.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/NotificationToast.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/SafeHandler.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/SafeToast.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/SystemToast.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/ToastImpl.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/ToastLogInterceptor.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/ToastParams.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/ToastStrategy.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/Toaster.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/WindowLifecycle.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/config/IToast.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/config/IToastInterceptor.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/config/IToastStrategy.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/config/IToastStyle.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/style/BlackToastStyle.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/style/CustomToastStyle.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/style/LocationToastStyle.java create mode 100644 app/src/main/java/com/zs/smarthuman/toast/style/WhiteToastStyle.java diff --git a/app/src/main/java/com/zs/smarthuman/App.kt b/app/src/main/java/com/zs/smarthuman/App.kt index 9217f3c..c3821e9 100644 --- a/app/src/main/java/com/zs/smarthuman/App.kt +++ b/app/src/main/java/com/zs/smarthuman/App.kt @@ -6,10 +6,12 @@ import android.content.Context import android.net.ConnectivityManager import android.os.Build import android.text.TextUtils +import android.view.Gravity import androidx.multidex.MultiDex import com.blankj.utilcode.util.LogUtils import com.zs.smarthuman.common.UserInfoManager import com.zs.smarthuman.kt.errorMsg +import com.zs.smarthuman.toast.Toaster import com.zs.smarthuman.utils.AESUtils import com.zs.smarthuman.utils.CrashHandler import okhttp3.OkHttpClient @@ -57,6 +59,8 @@ class App : Application() { LogUtils.getConfig().isLogSwitch = BuildConfig.DEBUG CrashHandler.getInstance().init() initRxHttp() + Toaster.init(this) + Toaster.setGravity(Gravity.CENTER) } private fun initRxHttp() { diff --git a/app/src/main/java/com/zs/smarthuman/common/CommonManager.kt b/app/src/main/java/com/zs/smarthuman/common/CommonManager.kt index 00acf26..a58aab1 100644 --- a/app/src/main/java/com/zs/smarthuman/common/CommonManager.kt +++ b/app/src/main/java/com/zs/smarthuman/common/CommonManager.kt @@ -9,7 +9,8 @@ class CommonManager { companion object { const val BASE_URL = "http://10.10.4.132:8088/" // const val BASE_URL = "https://zs.seerteach.net/" - const val SERVER_URL = "im.seerteach.net" - const val PORT = "8443" +// const val SERVER_URL = "im.seerteach.net" + const val SERVER_URL = "10.10.4.132" + const val PORT = "9000" } } \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/sherpa/VoiceController.kt b/app/src/main/java/com/zs/smarthuman/sherpa/VoiceController.kt index 607361f..9d8c119 100644 --- a/app/src/main/java/com/zs/smarthuman/sherpa/VoiceController.kt +++ b/app/src/main/java/com/zs/smarthuman/sherpa/VoiceController.kt @@ -2,18 +2,22 @@ package com.zs.smarthuman.sherpa import android.content.res.AssetManager import android.util.Log -import com.blankj.utilcode.util.LogUtils +import kotlinx.coroutines.* class VoiceController( assetManager: AssetManager, private val onWakeup: () -> Unit, private val onFinalAudio: (FloatArray) -> Unit, private val idleTimeoutSeconds: Int = 15, + private val maxRecordingSeconds: Int = 10, // ✅ 最大录音时长 private val onStateChanged: ((VoiceState) -> Unit)? = null, - private val stopBackendAudio: (() -> Unit)? = null // 可选:唤醒时打断后台播放 + private val stopBackendAudio: (() -> Unit)? = null ) { + private val TAG = "VoiceController" + /* ================= 状态 ================= */ + private var state: VoiceState = VoiceState.WAIT_WAKEUP set(value) { field = value @@ -21,132 +25,142 @@ class VoiceController( Log.d(TAG, "当前状态: $value") } - /** ================= 唤醒 ================= */ - private var wakeupDiscardEndTime = 0L - private val WAKEUP_DISCARD_BUFFER_MS = 200L - private var isRecordingForWakeup = false // 唤醒词残留标记 + var isPlaying = false + private set + + /* ================= 唤醒 ================= */ + + 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) { - Log.d(TAG, "唤醒触发! 当前状态=$state") - - // 打断后台音频 - if (state == VoiceState.PLAYING_BACKEND) { - stopBackendAudio?.invoke() - Log.d(TAG, "唤醒词触发, 停止后台播放") + val now = System.currentTimeMillis() + if (now - lastWakeupTime < WAKEUP_COOLDOWN_MS) { + Log.d(TAG, "⚠️ 唤醒过于频繁,忽略") + return@WakeupManager } + lastWakeupTime = now - // 重置当前录音/缓存 - if (state == VoiceState.RECORDING || state == VoiceState.WAIT_SPEECH) { - audioBuffer.clear() - preBuffer.clear() - vadManager.reset() - isRecordingForWakeup = true - Log.d(TAG, "唤醒词触发,重置录音缓存") - } + Log.d(TAG, "🔥 唤醒触发") + + stopBackendAudio?.invoke() + isPlaying = false + + audioBuffer.clear() + preBuffer.clear() + vadManager.reset() + vadStarted = false + vadEndPending = false + + wakeupDiscardUntil = now + WAKEUP_DISCARD_MS onWakeup() playLocalPrompt() } - /** ================= VAD ================= */ + /* ================= VAD ================= */ + private val vadManager = VadManager( assetManager, onSpeechStart = { onVadSpeechStart() }, onSpeechEnd = { onVadSpeechEnd() } ) - /** ================= 音频缓存 ================= */ + /* ================= 音频缓存 ================= */ + private val audioBuffer = mutableListOf() private val preBuffer = ArrayDeque() private val PRE_BUFFER_SIZE = 16000 - private var idleTimer = 0L - /** ================= 尾部静音控制 ================= */ + private var idleTimer = 0L + private var vadStarted = false private var vadEndPending = false private var vadEndTime = 0L private val END_SILENCE_MS = 1000L - var isPlaying = false - private set - - /** ================= 外部调用 ================= */ + /* ================= 外部音频输入 ================= */ + private var recordingStartTime = 0L // ✅ 记录录音开始时间 fun acceptAudio(samples: FloatArray) { cachePreBuffer(samples) - wakeupManager.acceptAudio(samples) // 唤醒检测始终喂 + wakeupManager.acceptAudio(samples) val now = System.currentTimeMillis() - val discardAudio = now < wakeupDiscardEndTime || isRecordingForWakeup - - // 播放提示音或后台音频时不喂 VAD - if (state == VoiceState.PLAYING_PROMPT || state == VoiceState.PLAYING_BACKEND) return + if (now < wakeupDiscardUntil) return when (state) { - VoiceState.WAIT_WAKEUP -> Unit - VoiceState.WAIT_SPEECH -> if (!discardAudio) vadManager.accept(samples) - VoiceState.RECORDING -> if (!discardAudio) { + VoiceState.WAIT_SPEECH -> { + vadManager.accept(samples) + } + + VoiceState.RECORDING -> { audioBuffer.addAll(samples.asList()) vadManager.accept(samples) idleTimer = now + // ✅ 最大录音时长判断 + if (now - recordingStartTime >= maxRecordingSeconds * 1000) { + Log.d(TAG, "⚠️ 达到最大录音时长,自动结束录音") + finishSentence() + return + } + if (vadEndPending && now - vadEndTime >= END_SILENCE_MS) { finishSentence() } } - else -> {} + + else -> Unit } } + /* ================= 提示音 ================= */ + + private val PROMPT_DURATION_MS = 3000L + private var promptFallbackJob: Job? = null + fun onPlayStartPrompt() { + if (state == VoiceState.PLAYING_PROMPT) return isPlaying = true 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() { - // 只有当前状态仍是 PLAYING_PROMPT 才处理 - if (state != VoiceState.PLAYING_PROMPT) { - Log.d(TAG, "提示音结束回调忽略,当前状态=$state") - return - } - + promptFallbackJob?.cancel() + if (state != VoiceState.PLAYING_PROMPT) return isPlaying = false state = VoiceState.WAIT_SPEECH idleTimer = System.currentTimeMillis() - - // 设置提示音残留丢弃时间 - wakeupDiscardEndTime = System.currentTimeMillis() + WAKEUP_DISCARD_BUFFER_MS - - // 清掉唤醒标记,让用户语音立即生效 - isRecordingForWakeup = false - - // 清理 pre-buffer 中残留音频,避免干扰 VAD - audioBuffer.clear() - preBuffer.clear() - - Log.d(TAG, "提示音播放结束, 状态变为 WAIT_SPEECH") + Log.d(TAG, "提示音结束 → WAIT_SPEECH") } + /* ================= Backend ================= */ + fun onPlayStartBackend() { - // 如果正在播放提示音,后台音频暂不覆盖状态 - if (state != VoiceState.PLAYING_PROMPT) { - state = VoiceState.PLAYING_BACKEND - } - Log.d(TAG, "播放后台音频, 当前状态=$state") + isPlaying = true + state = VoiceState.PLAYING_BACKEND } fun onPlayEndBackend() { - if (state != VoiceState.PLAYING_BACKEND){ - return - } + if (state != VoiceState.PLAYING_BACKEND) return isPlaying = false state = VoiceState.WAIT_WAKEUP - idleTimer = System.currentTimeMillis() - Log.d(TAG, "后台音频播放结束, 状态变为 WAIT_WAKEUP") } + /* ================= Idle ================= */ + fun checkIdleTimeout() { if (state != VoiceState.WAIT_SPEECH) return - val now = System.currentTimeMillis() - if (now - idleTimer > idleTimeoutSeconds * 1000) { + if (System.currentTimeMillis() - idleTimer > idleTimeoutSeconds * 1000) { reset() } } @@ -156,78 +170,106 @@ class VoiceController( audioBuffer.clear() preBuffer.clear() vadManager.reset() - wakeupManager.reset() - isRecordingForWakeup = false - wakeupDiscardEndTime = 0 - idleTimer = 0 + vadStarted = false vadEndPending = false - Log.d(TAG, "已重置, 状态变为 WAIT_WAKEUP") + wakeupDiscardUntil = 0L + recordingStartTime = 0L // ✅ 重置录音开始时间 + Log.d(TAG, "reset → WAIT_WAKEUP") } fun release() { vadManager.reset() wakeupManager.release() - audioBuffer.clear() - preBuffer.clear() - idleTimer = 0 - isPlaying = false - state = VoiceState.WAIT_WAKEUP - isRecordingForWakeup = false - wakeupDiscardEndTime = 0 - vadEndPending = false } - /** ================= 内部逻辑 ================= */ - private fun playLocalPrompt() { - onPlayStartPrompt() - // 在这里播放提示音,播放结束后调用 onPlayEndPrompt() - } + /* ================= VAD 回调 ================= */ private fun onVadSpeechStart() { - - vadEndPending = false - val now = System.currentTimeMillis() - - // 只有 WAIT_SPEECH 状态才开始录音 if (state != VoiceState.WAIT_SPEECH) return - - // 丢弃提示音残留音频,但保留 VAD 触发 - if (now < wakeupDiscardEndTime) { - Log.d(TAG, "丢弃提示音残留音频") - audioBuffer.clear() // 清掉残留 - } - + vadStarted = true state = VoiceState.RECORDING - // 添加 pre-buffer 音频到当前录音 audioBuffer.addAll(preBuffer) - idleTimer = now - - Log.d(TAG, "VAD开始, 当前状态=$state") + idleTimer = System.currentTimeMillis() + recordingStartTime = System.currentTimeMillis() // ✅ 记录录音开始时间 + Log.d(TAG, "VAD开始 → RECORDING") } - private fun onVadSpeechEnd() { if (state != VoiceState.RECORDING) return vadEndPending = true vadEndTime = System.currentTimeMillis() - Log.d(TAG, "VAD结束, 等待尾部静音") } + /* ================= 录音结束 & 判定 ================= */ + private fun finishSentence() { vadEndPending = false state = VoiceState.WAIT_WAKEUP + val finalAudio = audioBuffer.toFloatArray() audioBuffer.clear() - if (finalAudio.isNotEmpty()) onFinalAudio(finalAudio) - idleTimer = 0 - isRecordingForWakeup = false - Log.d(TAG, "录音结束, 返回 WAIT_WAKEUP") + + if (isValidUserSpeech(finalAudio)) { + onFinalAudio(finalAudio) + 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) { for (s in samples) { preBuffer.addLast(s) - if (preBuffer.size > PRE_BUFFER_SIZE) preBuffer.removeFirst() + if (preBuffer.size > PRE_BUFFER_SIZE) { + preBuffer.removeFirst() + } } } + + private fun playLocalPrompt() { + onPlayStartPrompt() + } } diff --git a/app/src/main/java/com/zs/smarthuman/sherpa/WakeupManager.kt b/app/src/main/java/com/zs/smarthuman/sherpa/WakeupManager.kt index c7fcbce..7454059 100644 --- a/app/src/main/java/com/zs/smarthuman/sherpa/WakeupManager.kt +++ b/app/src/main/java/com/zs/smarthuman/sherpa/WakeupManager.kt @@ -38,9 +38,9 @@ class WakeupManager( fun acceptAudio(samples: FloatArray) { val s = stream ?: return // ⭐ 远讲 / 播放补偿(非常关键) - for (i in samples.indices) { - samples[i] *= 2.5f - } +// for (i in samples.indices) { +// samples[i] *= 2.5f +// } s.acceptWaveform(samples, sampleRate) while (kws.isReady(s)) { diff --git a/app/src/main/java/com/zs/smarthuman/toast/ActivityStack.java b/app/src/main/java/com/zs/smarthuman/toast/ActivityStack.java new file mode 100644 index 0000000..fbc6b13 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/ActivityStack.java @@ -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) {} +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/ActivityToast.java b/app/src/main/java/com/zs/smarthuman/toast/ActivityToast.java new file mode 100644 index 0000000..a46fc37 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/ActivityToast.java @@ -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(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/CustomToast.java b/app/src/main/java/com/zs/smarthuman/toast/CustomToast.java new file mode 100644 index 0000000..9a6858a --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/CustomToast.java @@ -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; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/GlobalToast.java b/app/src/main/java/com/zs/smarthuman/toast/GlobalToast.java new file mode 100644 index 0000000..623750c --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/GlobalToast.java @@ -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(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/NotificationServiceProxy.java b/app/src/main/java/com/zs/smarthuman/toast/NotificationServiceProxy.java new file mode 100644 index 0000000..4936344 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/NotificationServiceProxy.java @@ -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); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/NotificationToast.java b/app/src/main/java/com/zs/smarthuman/toast/NotificationToast.java new file mode 100644 index 0000000..232f8b2 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/NotificationToast.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/SafeHandler.java b/app/src/main/java/com/zs/smarthuman/toast/SafeHandler.java new file mode 100644 index 0000000..330887a --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/SafeHandler.java @@ -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$BadTokenException:Unable to add window -- token android.os.BinderProxy is not valid; is your activity running? + // java.lang.IllegalStateException:View android.widget.TextView has already been added to the window manager. + e.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/SafeToast.java b/app/src/main/java/com/zs/smarthuman/toast/SafeToast.java new file mode 100644 index 0000000..6cb4af6 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/SafeToast.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/SystemToast.java b/app/src/main/java/com/zs/smarthuman/toast/SystemToast.java new file mode 100644 index 0000000..1e360ea --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/SystemToast.java @@ -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); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/ToastImpl.java b/app/src/main/java/com/zs/smarthuman/toast/ToastImpl.java new file mode 100644 index 0000000..77571b6 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/ToastImpl.java @@ -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); + } + } + }; +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/ToastLogInterceptor.java b/app/src/main/java/com/zs/smarthuman/toast/ToastLogInterceptor.java new file mode 100644 index 0000000..676a59a --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/ToastLogInterceptor.java @@ -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()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/ToastParams.java b/app/src/main/java/com/zs/smarthuman/toast/ToastParams.java new file mode 100644 index 0000000..f78a1e8 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/ToastParams.java @@ -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; +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/ToastStrategy.java b/app/src/main/java/com/zs/smarthuman/toast/ToastStrategy.java new file mode 100644 index 0000000..1bd176d --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/ToastStrategy.java @@ -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 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(即使用 SystemToast、SafeToast、NotificationToast 任意一个)则不会有这个问题 + // 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; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/Toaster.java b/app/src/main/java/com/zs/smarthuman/toast/Toaster.java new file mode 100644 index 0000000..891a2b5 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/Toaster.java @@ -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"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/WindowLifecycle.java b/app/src/main/java/com/zs/smarthuman/toast/WindowLifecycle.java new file mode 100644 index 0000000..bb1d3ba --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/WindowLifecycle.java @@ -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); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/config/IToast.java b/app/src/main/java/com/zs/smarthuman/toast/config/IToast.java new file mode 100644 index 0000000..17d9aef --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/config/IToast.java @@ -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))"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/config/IToastInterceptor.java b/app/src/main/java/com/zs/smarthuman/toast/config/IToastInterceptor.java new file mode 100644 index 0000000..2f3a6cd --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/config/IToastInterceptor.java @@ -0,0 +1,12 @@ +package com.zs.smarthuman.toast.config; + + +import com.zs.smarthuman.toast.ToastParams; + +public interface IToastInterceptor { + + /** + * 根据显示的文本决定是否拦截该 Toast + */ + boolean intercept(ToastParams params); +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/config/IToastStrategy.java b/app/src/main/java/com/zs/smarthuman/toast/config/IToastStrategy.java new file mode 100644 index 0000000..083fc56 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/config/IToastStrategy.java @@ -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(); +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/config/IToastStyle.java b/app/src/main/java/com/zs/smarthuman/toast/config/IToastStyle.java new file mode 100644 index 0000000..9f57db7 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/config/IToastStyle.java @@ -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 { + + /** + * 创建 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; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/style/BlackToastStyle.java b/app/src/main/java/com/zs/smarthuman/toast/style/BlackToastStyle.java new file mode 100644 index 0000000..5d7cad7 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/style/BlackToastStyle.java @@ -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 { + + @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()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/style/CustomToastStyle.java b/app/src/main/java/com/zs/smarthuman/toast/style/CustomToastStyle.java new file mode 100644 index 0000000..7ad1560 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/style/CustomToastStyle.java @@ -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 { + + 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; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/style/LocationToastStyle.java b/app/src/main/java/com/zs/smarthuman/toast/style/LocationToastStyle.java new file mode 100644 index 0000000..d548514 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/style/LocationToastStyle.java @@ -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 { + + 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; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/toast/style/WhiteToastStyle.java b/app/src/main/java/com/zs/smarthuman/toast/style/WhiteToastStyle.java new file mode 100644 index 0000000..cbf1557 --- /dev/null +++ b/app/src/main/java/com/zs/smarthuman/toast/style/WhiteToastStyle.java @@ -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; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/smarthuman/ui/MainActivity.kt b/app/src/main/java/com/zs/smarthuman/ui/MainActivity.kt index d62e430..f0c1c0b 100644 --- a/app/src/main/java/com/zs/smarthuman/ui/MainActivity.kt +++ b/app/src/main/java/com/zs/smarthuman/ui/MainActivity.kt @@ -37,10 +37,12 @@ import com.zs.smarthuman.common.UserInfoManager import com.zs.smarthuman.databinding.ActivityMainBinding import com.zs.smarthuman.http.ApiResult 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.kt.releaseIM import com.zs.smarthuman.sherpa.VoiceController import com.zs.smarthuman.sherpa.VoiceState +import com.zs.smarthuman.toast.Toaster import com.zs.smarthuman.utils.AudioDebugUtil import com.zs.smarthuman.utils.AudioPcmUtil @@ -52,6 +54,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import rxhttp.awaitResult import rxhttp.toAwaitString import rxhttp.wrapper.param.RxHttp @@ -60,6 +63,7 @@ import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.concurrent.thread +import kotlin.math.sqrt /** * @description: @@ -71,7 +75,7 @@ class MainActivity : BaseViewModelActivity() private var voiceController: VoiceController? = null private var audioRecord: AudioRecord? = null private var isRecording = false - private val audioSource = MediaRecorder.AudioSource.MIC + private val audioSource = MediaRecorder.AudioSource.VOICE_RECOGNITION private val sampleRateInHz = 16000 private val channelConfig = AudioFormat.CHANNEL_IN_MONO private val audioFormat = AudioFormat.ENCODING_PCM_16BIT @@ -128,19 +132,14 @@ class MainActivity : BaseViewModelActivity() } } - mViewModel?.uploadVoiceLiveData?.observe(this){ - when(it){ + mViewModel?.uploadVoiceLiveData?.observe(this) { + when (it) { is ApiResult.Error -> { - ToastUtils.showShort("上传失败") + Toaster.showShort("上传失败") } + is ApiResult.Success<*> -> { - ToastUtils.showShort("上传成功") - UnityPlayerHolder.getInstance() - .sendVoiceToUnity( - voiceInfo = mutableListOf().apply { - add(VoiceBeanResp(audioUrl = "https://static.seerteach.net/largemodel/smart_read_audio/intensive_reading/689450143596d58606a106e5/689450143596d58606a106e5_1.mp3")) - } - ) + Toaster.showShort("上传成功") } } } @@ -152,24 +151,32 @@ class MainActivity : BaseViewModelActivity() assetManager = assets, onWakeup = { Log.d("lrs", "当前状态: 唤醒成功wakeup") - UnityPlayerHolder.getInstance().cancel() + //每次唤醒前都要把前面的音频停掉 + UnityPlayerHolder.getInstance().cancelPCM() UnityPlayerHolder.getInstance() .sendVoiceToUnity( voiceInfo = mutableListOf().apply { - add(VoiceBeanResp(audioUrl = UserInfoManager.userInfo?.wakeUpAudioUrl?:"")) + add( + VoiceBeanResp( + audioUrl = UserInfoManager.userInfo?.wakeUpAudioUrl ?: "" + ) + ) } ) }, onFinalAudio = { audio -> Log.d("lrs", "检测到语音,长度=${audio.size}") -// lifecycleScope.launch(Dispatchers.IO) { - mViewModel?.uploadVoice(AudioPcmUtil.pcm16ToBase64(AudioPcmUtil.floatToPcm16(audio)),1) - val file = File( - getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!.getAbsolutePath(), - "xxx.wav" - ) - AudioDebugUtil.saveFloatPcmAsWav(audio, file) - LogUtils.dTag("audioxx", "WAV saved: ${file.path}, samples=${audio.size}") + mViewModel?.uploadVoice( + AudioPcmUtil.pcm16ToBase64(AudioPcmUtil.floatToPcm16(audio)), + 1 + ) + val file = File( + getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!.getAbsolutePath(), + "xxx.wav" + ) + AudioDebugUtil.saveFloatPcmAsWav(audio, file) + LogUtils.dTag("audioxx", "WAV saved: ${file.path}, samples=${audio.size}") + }, onStateChanged = { state -> when (state) { @@ -188,10 +195,18 @@ class MainActivity : BaseViewModelActivity() } }, - ) + ) } 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() //开始录音 fun startRecording() { - if (!isRecording) { - audioRecord?.startRecording() - isRecording = true - lifecycleScope.launch(Dispatchers.IO) { - processAudio() + isRecording = true + audioRecord?.startRecording() + + lifecycleScope.launch(Dispatchers.IO) { + 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() } } + 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 interval = 0.1 -// val bufferSize = (interval * sampleRateInHz).toInt() // in samples -// 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() + val gain = (target / rms).coerceAtMost(maxGain) + return FloatArray(input.size) { + (input[it] * gain).coerceIn(-1f, 1f) } } + + fun onUnityResourcesLoaded(message: String) { // 这是 Unity 调用的资源加载完成回调方法 lifecycleScope.launch(Dispatchers.Main) { delay(200) binding.flDigitalHuman.translationY = 0f } } - + private var promptPlaying = false fun onAudioProgressUpdated( // Unity 调用此方法传递音频进度 progress: Float, state: Int,//0stop 2pause 1play 3complete 4loading 5error @@ -295,21 +323,24 @@ class MainActivity : BaseViewModelActivity() word: String, audioUrl: String ) { - if (state == 1) { - if (audioUrl == UserInfoManager.userInfo?.wakeUpAudioUrl){ - voiceController?.onPlayStartPrompt() - }else{ - voiceController?.onPlayStartBackend() + val wakeupUrl = UserInfoManager.userInfo?.wakeUpAudioUrl ?: return + + if (audioUrl != wakeupUrl) return + + when (state) { + 1 -> { // play + if (!promptPlaying) { + promptPlaying = true + voiceController?.onPlayStartPrompt() + } } - } - - if (state == 3){ - if (audioUrl == UserInfoManager.userInfo?.wakeUpAudioUrl){ - voiceController?.onPlayEndPrompt() - - }else{ - voiceController?.onPlayEndBackend() + 3 -> { // complete + if (promptPlaying) { + Toaster.showShort("借宿了") + promptPlaying = false + voiceController?.onPlayEndPrompt() + } } } } @@ -322,10 +353,10 @@ class MainActivity : BaseViewModelActivity() isFinal: Boolean ) { if (state == 1) { -// voiceController?.onPlayStart() + voiceController?.onPlayStartBackend() } if (state == 3) { -// voiceController?.onPlayEnd() + voiceController?.onPlayEndBackend() } }