临时提交
This commit is contained in:
parent
7829e8223b
commit
738b39e9a0
@ -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() {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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<Float>()
|
||||
private val preBuffer = ArrayDeque<Float>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
72
app/src/main/java/com/zs/smarthuman/toast/ActivityStack.java
Normal file
72
app/src/main/java/com/zs/smarthuman/toast/ActivityStack.java
Normal 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) {}
|
||||
}
|
||||
26
app/src/main/java/com/zs/smarthuman/toast/ActivityToast.java
Normal file
26
app/src/main/java/com/zs/smarthuman/toast/ActivityToast.java
Normal 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();
|
||||
}
|
||||
}
|
||||
135
app/src/main/java/com/zs/smarthuman/toast/CustomToast.java
Normal file
135
app/src/main/java/com/zs/smarthuman/toast/CustomToast.java
Normal 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;
|
||||
}
|
||||
}
|
||||
25
app/src/main/java/com/zs/smarthuman/toast/GlobalToast.java
Normal file
25
app/src/main/java/com/zs/smarthuman/toast/GlobalToast.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/com/zs/smarthuman/toast/SafeHandler.java
Normal file
31
app/src/main/java/com/zs/smarthuman/toast/SafeHandler.java
Normal 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$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/src/main/java/com/zs/smarthuman/toast/SafeToast.java
Normal file
61
app/src/main/java/com/zs/smarthuman/toast/SafeToast.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/com/zs/smarthuman/toast/SystemToast.java
Normal file
39
app/src/main/java/com/zs/smarthuman/toast/SystemToast.java
Normal 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);
|
||||
}
|
||||
}
|
||||
226
app/src/main/java/com/zs/smarthuman/toast/ToastImpl.java
Normal file
226
app/src/main/java/com/zs/smarthuman/toast/ToastImpl.java
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
42
app/src/main/java/com/zs/smarthuman/toast/ToastParams.java
Normal file
42
app/src/main/java/com/zs/smarthuman/toast/ToastParams.java
Normal 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;
|
||||
}
|
||||
348
app/src/main/java/com/zs/smarthuman/toast/ToastStrategy.java
Normal file
348
app/src/main/java/com/zs/smarthuman/toast/ToastStrategy.java
Normal 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(即使用 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;
|
||||
}
|
||||
}
|
||||
346
app/src/main/java/com/zs/smarthuman/toast/Toaster.java
Normal file
346
app/src/main/java/com/zs/smarthuman/toast/Toaster.java
Normal 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";
|
||||
}
|
||||
}
|
||||
129
app/src/main/java/com/zs/smarthuman/toast/WindowLifecycle.java
Normal file
129
app/src/main/java/com/zs/smarthuman/toast/WindowLifecycle.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
app/src/main/java/com/zs/smarthuman/toast/config/IToast.java
Normal file
109
app/src/main/java/com/zs/smarthuman/toast/config/IToast.java
Normal 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))");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.zs.smarthuman.toast.config;
|
||||
|
||||
|
||||
import com.zs.smarthuman.toast.ToastParams;
|
||||
|
||||
public interface IToastInterceptor {
|
||||
|
||||
/**
|
||||
* 根据显示的文本决定是否拦截该 Toast
|
||||
*/
|
||||
boolean intercept(ToastParams params);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<ActivityMainBinding, MainViewModel>()
|
||||
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<ActivityMainBinding, MainViewModel>()
|
||||
}
|
||||
}
|
||||
|
||||
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<VoiceBeanResp>().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<ActivityMainBinding, MainViewModel>()
|
||||
assetManager = assets,
|
||||
onWakeup = {
|
||||
Log.d("lrs", "当前状态: 唤醒成功wakeup")
|
||||
UnityPlayerHolder.getInstance().cancel()
|
||||
//每次唤醒前都要把前面的音频停掉
|
||||
UnityPlayerHolder.getInstance().cancelPCM()
|
||||
UnityPlayerHolder.getInstance()
|
||||
.sendVoiceToUnity(
|
||||
voiceInfo = mutableListOf<VoiceBeanResp>().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<ActivityMainBinding, MainViewModel>()
|
||||
}
|
||||
},
|
||||
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
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<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 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<ActivityMainBinding, MainViewModel>()
|
||||
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<ActivityMainBinding, MainViewModel>()
|
||||
isFinal: Boolean
|
||||
) {
|
||||
if (state == 1) {
|
||||
// voiceController?.onPlayStart()
|
||||
voiceController?.onPlayStartBackend()
|
||||
}
|
||||
if (state == 3) {
|
||||
// voiceController?.onPlayEnd()
|
||||
voiceController?.onPlayEndBackend()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user