临时提交
This commit is contained in:
parent
7829e8223b
commit
738b39e9a0
@ -6,10 +6,12 @@ import android.content.Context
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
|
import android.view.Gravity
|
||||||
import androidx.multidex.MultiDex
|
import androidx.multidex.MultiDex
|
||||||
import com.blankj.utilcode.util.LogUtils
|
import com.blankj.utilcode.util.LogUtils
|
||||||
import com.zs.smarthuman.common.UserInfoManager
|
import com.zs.smarthuman.common.UserInfoManager
|
||||||
import com.zs.smarthuman.kt.errorMsg
|
import com.zs.smarthuman.kt.errorMsg
|
||||||
|
import com.zs.smarthuman.toast.Toaster
|
||||||
import com.zs.smarthuman.utils.AESUtils
|
import com.zs.smarthuman.utils.AESUtils
|
||||||
import com.zs.smarthuman.utils.CrashHandler
|
import com.zs.smarthuman.utils.CrashHandler
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@ -57,6 +59,8 @@ class App : Application() {
|
|||||||
LogUtils.getConfig().isLogSwitch = BuildConfig.DEBUG
|
LogUtils.getConfig().isLogSwitch = BuildConfig.DEBUG
|
||||||
CrashHandler.getInstance().init()
|
CrashHandler.getInstance().init()
|
||||||
initRxHttp()
|
initRxHttp()
|
||||||
|
Toaster.init(this)
|
||||||
|
Toaster.setGravity(Gravity.CENTER)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initRxHttp() {
|
private fun initRxHttp() {
|
||||||
|
|||||||
@ -9,7 +9,8 @@ class CommonManager {
|
|||||||
companion object {
|
companion object {
|
||||||
const val BASE_URL = "http://10.10.4.132:8088/"
|
const val BASE_URL = "http://10.10.4.132:8088/"
|
||||||
// const val BASE_URL = "https://zs.seerteach.net/"
|
// const val BASE_URL = "https://zs.seerteach.net/"
|
||||||
const val SERVER_URL = "im.seerteach.net"
|
// const val SERVER_URL = "im.seerteach.net"
|
||||||
const val PORT = "8443"
|
const val SERVER_URL = "10.10.4.132"
|
||||||
|
const val PORT = "9000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,18 +2,22 @@ package com.zs.smarthuman.sherpa
|
|||||||
|
|
||||||
import android.content.res.AssetManager
|
import android.content.res.AssetManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.blankj.utilcode.util.LogUtils
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
class VoiceController(
|
class VoiceController(
|
||||||
assetManager: AssetManager,
|
assetManager: AssetManager,
|
||||||
private val onWakeup: () -> Unit,
|
private val onWakeup: () -> Unit,
|
||||||
private val onFinalAudio: (FloatArray) -> Unit,
|
private val onFinalAudio: (FloatArray) -> Unit,
|
||||||
private val idleTimeoutSeconds: Int = 15,
|
private val idleTimeoutSeconds: Int = 15,
|
||||||
|
private val maxRecordingSeconds: Int = 10, // ✅ 最大录音时长
|
||||||
private val onStateChanged: ((VoiceState) -> Unit)? = null,
|
private val onStateChanged: ((VoiceState) -> Unit)? = null,
|
||||||
private val stopBackendAudio: (() -> Unit)? = null // 可选:唤醒时打断后台播放
|
private val stopBackendAudio: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val TAG = "VoiceController"
|
private val TAG = "VoiceController"
|
||||||
|
|
||||||
|
/* ================= 状态 ================= */
|
||||||
|
|
||||||
private var state: VoiceState = VoiceState.WAIT_WAKEUP
|
private var state: VoiceState = VoiceState.WAIT_WAKEUP
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
@ -21,132 +25,142 @@ class VoiceController(
|
|||||||
Log.d(TAG, "当前状态: $value")
|
Log.d(TAG, "当前状态: $value")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ================= 唤醒 ================= */
|
var isPlaying = false
|
||||||
private var wakeupDiscardEndTime = 0L
|
private set
|
||||||
private val WAKEUP_DISCARD_BUFFER_MS = 200L
|
|
||||||
private var isRecordingForWakeup = false // 唤醒词残留标记
|
/* ================= 唤醒 ================= */
|
||||||
|
|
||||||
|
private val WAKEUP_DISCARD_MS = 600L
|
||||||
|
private val WAKEUP_COOLDOWN_MS = 1500L
|
||||||
|
private var wakeupDiscardUntil = 0L
|
||||||
|
private var lastWakeupTime = 0L
|
||||||
|
|
||||||
private val wakeupManager = WakeupManager(assetManager) {
|
private val wakeupManager = WakeupManager(assetManager) {
|
||||||
Log.d(TAG, "唤醒触发! 当前状态=$state")
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastWakeupTime < WAKEUP_COOLDOWN_MS) {
|
||||||
// 打断后台音频
|
Log.d(TAG, "⚠️ 唤醒过于频繁,忽略")
|
||||||
if (state == VoiceState.PLAYING_BACKEND) {
|
return@WakeupManager
|
||||||
stopBackendAudio?.invoke()
|
|
||||||
Log.d(TAG, "唤醒词触发, 停止后台播放")
|
|
||||||
}
|
}
|
||||||
|
lastWakeupTime = now
|
||||||
|
|
||||||
|
Log.d(TAG, "🔥 唤醒触发")
|
||||||
|
|
||||||
|
stopBackendAudio?.invoke()
|
||||||
|
isPlaying = false
|
||||||
|
|
||||||
// 重置当前录音/缓存
|
|
||||||
if (state == VoiceState.RECORDING || state == VoiceState.WAIT_SPEECH) {
|
|
||||||
audioBuffer.clear()
|
audioBuffer.clear()
|
||||||
preBuffer.clear()
|
preBuffer.clear()
|
||||||
vadManager.reset()
|
vadManager.reset()
|
||||||
isRecordingForWakeup = true
|
vadStarted = false
|
||||||
Log.d(TAG, "唤醒词触发,重置录音缓存")
|
vadEndPending = false
|
||||||
}
|
|
||||||
|
wakeupDiscardUntil = now + WAKEUP_DISCARD_MS
|
||||||
|
|
||||||
onWakeup()
|
onWakeup()
|
||||||
playLocalPrompt()
|
playLocalPrompt()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ================= VAD ================= */
|
/* ================= VAD ================= */
|
||||||
|
|
||||||
private val vadManager = VadManager(
|
private val vadManager = VadManager(
|
||||||
assetManager,
|
assetManager,
|
||||||
onSpeechStart = { onVadSpeechStart() },
|
onSpeechStart = { onVadSpeechStart() },
|
||||||
onSpeechEnd = { onVadSpeechEnd() }
|
onSpeechEnd = { onVadSpeechEnd() }
|
||||||
)
|
)
|
||||||
|
|
||||||
/** ================= 音频缓存 ================= */
|
/* ================= 音频缓存 ================= */
|
||||||
|
|
||||||
private val audioBuffer = mutableListOf<Float>()
|
private val audioBuffer = mutableListOf<Float>()
|
||||||
private val preBuffer = ArrayDeque<Float>()
|
private val preBuffer = ArrayDeque<Float>()
|
||||||
private val PRE_BUFFER_SIZE = 16000
|
private val PRE_BUFFER_SIZE = 16000
|
||||||
private var idleTimer = 0L
|
|
||||||
|
|
||||||
/** ================= 尾部静音控制 ================= */
|
private var idleTimer = 0L
|
||||||
|
private var vadStarted = false
|
||||||
private var vadEndPending = false
|
private var vadEndPending = false
|
||||||
private var vadEndTime = 0L
|
private var vadEndTime = 0L
|
||||||
private val END_SILENCE_MS = 1000L
|
private val END_SILENCE_MS = 1000L
|
||||||
|
|
||||||
var isPlaying = false
|
/* ================= 外部音频输入 ================= */
|
||||||
private set
|
private var recordingStartTime = 0L // ✅ 记录录音开始时间
|
||||||
|
|
||||||
/** ================= 外部调用 ================= */
|
|
||||||
fun acceptAudio(samples: FloatArray) {
|
fun acceptAudio(samples: FloatArray) {
|
||||||
cachePreBuffer(samples)
|
cachePreBuffer(samples)
|
||||||
wakeupManager.acceptAudio(samples) // 唤醒检测始终喂
|
wakeupManager.acceptAudio(samples)
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val discardAudio = now < wakeupDiscardEndTime || isRecordingForWakeup
|
if (now < wakeupDiscardUntil) return
|
||||||
|
|
||||||
// 播放提示音或后台音频时不喂 VAD
|
|
||||||
if (state == VoiceState.PLAYING_PROMPT || state == VoiceState.PLAYING_BACKEND) return
|
|
||||||
|
|
||||||
when (state) {
|
when (state) {
|
||||||
VoiceState.WAIT_WAKEUP -> Unit
|
VoiceState.WAIT_SPEECH -> {
|
||||||
VoiceState.WAIT_SPEECH -> if (!discardAudio) vadManager.accept(samples)
|
vadManager.accept(samples)
|
||||||
VoiceState.RECORDING -> if (!discardAudio) {
|
}
|
||||||
|
|
||||||
|
VoiceState.RECORDING -> {
|
||||||
audioBuffer.addAll(samples.asList())
|
audioBuffer.addAll(samples.asList())
|
||||||
vadManager.accept(samples)
|
vadManager.accept(samples)
|
||||||
idleTimer = now
|
idleTimer = now
|
||||||
|
// ✅ 最大录音时长判断
|
||||||
|
if (now - recordingStartTime >= maxRecordingSeconds * 1000) {
|
||||||
|
Log.d(TAG, "⚠️ 达到最大录音时长,自动结束录音")
|
||||||
|
finishSentence()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (vadEndPending && now - vadEndTime >= END_SILENCE_MS) {
|
if (vadEndPending && now - vadEndTime >= END_SILENCE_MS) {
|
||||||
finishSentence()
|
finishSentence()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= 提示音 ================= */
|
||||||
|
|
||||||
|
private val PROMPT_DURATION_MS = 3000L
|
||||||
|
private var promptFallbackJob: Job? = null
|
||||||
|
|
||||||
fun onPlayStartPrompt() {
|
fun onPlayStartPrompt() {
|
||||||
|
if (state == VoiceState.PLAYING_PROMPT) return
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
state = VoiceState.PLAYING_PROMPT
|
state = VoiceState.PLAYING_PROMPT
|
||||||
Log.d(TAG, "播放提示音, 状态变为 PLAYING_PROMPT")
|
|
||||||
|
promptFallbackJob?.cancel()
|
||||||
|
promptFallbackJob = CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
delay(PROMPT_DURATION_MS)
|
||||||
|
if (state == VoiceState.PLAYING_PROMPT) {
|
||||||
|
Log.w(TAG, "⚠️ 提示音 complete 丢失,fallback")
|
||||||
|
onPlayEndPrompt()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPlayEndPrompt() {
|
fun onPlayEndPrompt() {
|
||||||
// 只有当前状态仍是 PLAYING_PROMPT 才处理
|
promptFallbackJob?.cancel()
|
||||||
if (state != VoiceState.PLAYING_PROMPT) {
|
if (state != VoiceState.PLAYING_PROMPT) return
|
||||||
Log.d(TAG, "提示音结束回调忽略,当前状态=$state")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
state = VoiceState.WAIT_SPEECH
|
state = VoiceState.WAIT_SPEECH
|
||||||
idleTimer = System.currentTimeMillis()
|
idleTimer = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "提示音结束 → WAIT_SPEECH")
|
||||||
// 设置提示音残留丢弃时间
|
|
||||||
wakeupDiscardEndTime = System.currentTimeMillis() + WAKEUP_DISCARD_BUFFER_MS
|
|
||||||
|
|
||||||
// 清掉唤醒标记,让用户语音立即生效
|
|
||||||
isRecordingForWakeup = false
|
|
||||||
|
|
||||||
// 清理 pre-buffer 中残留音频,避免干扰 VAD
|
|
||||||
audioBuffer.clear()
|
|
||||||
preBuffer.clear()
|
|
||||||
|
|
||||||
Log.d(TAG, "提示音播放结束, 状态变为 WAIT_SPEECH")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= Backend ================= */
|
||||||
|
|
||||||
fun onPlayStartBackend() {
|
fun onPlayStartBackend() {
|
||||||
// 如果正在播放提示音,后台音频暂不覆盖状态
|
isPlaying = true
|
||||||
if (state != VoiceState.PLAYING_PROMPT) {
|
|
||||||
state = VoiceState.PLAYING_BACKEND
|
state = VoiceState.PLAYING_BACKEND
|
||||||
}
|
}
|
||||||
Log.d(TAG, "播放后台音频, 当前状态=$state")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onPlayEndBackend() {
|
fun onPlayEndBackend() {
|
||||||
if (state != VoiceState.PLAYING_BACKEND){
|
if (state != VoiceState.PLAYING_BACKEND) return
|
||||||
return
|
|
||||||
}
|
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
state = VoiceState.WAIT_WAKEUP
|
state = VoiceState.WAIT_WAKEUP
|
||||||
idleTimer = System.currentTimeMillis()
|
|
||||||
Log.d(TAG, "后台音频播放结束, 状态变为 WAIT_WAKEUP")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= Idle ================= */
|
||||||
|
|
||||||
fun checkIdleTimeout() {
|
fun checkIdleTimeout() {
|
||||||
if (state != VoiceState.WAIT_SPEECH) return
|
if (state != VoiceState.WAIT_SPEECH) return
|
||||||
val now = System.currentTimeMillis()
|
if (System.currentTimeMillis() - idleTimer > idleTimeoutSeconds * 1000) {
|
||||||
if (now - idleTimer > idleTimeoutSeconds * 1000) {
|
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -156,78 +170,106 @@ class VoiceController(
|
|||||||
audioBuffer.clear()
|
audioBuffer.clear()
|
||||||
preBuffer.clear()
|
preBuffer.clear()
|
||||||
vadManager.reset()
|
vadManager.reset()
|
||||||
wakeupManager.reset()
|
vadStarted = false
|
||||||
isRecordingForWakeup = false
|
|
||||||
wakeupDiscardEndTime = 0
|
|
||||||
idleTimer = 0
|
|
||||||
vadEndPending = false
|
vadEndPending = false
|
||||||
Log.d(TAG, "已重置, 状态变为 WAIT_WAKEUP")
|
wakeupDiscardUntil = 0L
|
||||||
|
recordingStartTime = 0L // ✅ 重置录音开始时间
|
||||||
|
Log.d(TAG, "reset → WAIT_WAKEUP")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
vadManager.reset()
|
vadManager.reset()
|
||||||
wakeupManager.release()
|
wakeupManager.release()
|
||||||
audioBuffer.clear()
|
|
||||||
preBuffer.clear()
|
|
||||||
idleTimer = 0
|
|
||||||
isPlaying = false
|
|
||||||
state = VoiceState.WAIT_WAKEUP
|
|
||||||
isRecordingForWakeup = false
|
|
||||||
wakeupDiscardEndTime = 0
|
|
||||||
vadEndPending = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ================= 内部逻辑 ================= */
|
/* ================= VAD 回调 ================= */
|
||||||
private fun playLocalPrompt() {
|
|
||||||
onPlayStartPrompt()
|
|
||||||
// 在这里播放提示音,播放结束后调用 onPlayEndPrompt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onVadSpeechStart() {
|
private fun onVadSpeechStart() {
|
||||||
|
|
||||||
vadEndPending = false
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// 只有 WAIT_SPEECH 状态才开始录音
|
|
||||||
if (state != VoiceState.WAIT_SPEECH) return
|
if (state != VoiceState.WAIT_SPEECH) return
|
||||||
|
vadStarted = true
|
||||||
// 丢弃提示音残留音频,但保留 VAD 触发
|
|
||||||
if (now < wakeupDiscardEndTime) {
|
|
||||||
Log.d(TAG, "丢弃提示音残留音频")
|
|
||||||
audioBuffer.clear() // 清掉残留
|
|
||||||
}
|
|
||||||
|
|
||||||
state = VoiceState.RECORDING
|
state = VoiceState.RECORDING
|
||||||
// 添加 pre-buffer 音频到当前录音
|
|
||||||
audioBuffer.addAll(preBuffer)
|
audioBuffer.addAll(preBuffer)
|
||||||
idleTimer = now
|
idleTimer = System.currentTimeMillis()
|
||||||
|
recordingStartTime = System.currentTimeMillis() // ✅ 记录录音开始时间
|
||||||
Log.d(TAG, "VAD开始, 当前状态=$state")
|
Log.d(TAG, "VAD开始 → RECORDING")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun onVadSpeechEnd() {
|
private fun onVadSpeechEnd() {
|
||||||
if (state != VoiceState.RECORDING) return
|
if (state != VoiceState.RECORDING) return
|
||||||
vadEndPending = true
|
vadEndPending = true
|
||||||
vadEndTime = System.currentTimeMillis()
|
vadEndTime = System.currentTimeMillis()
|
||||||
Log.d(TAG, "VAD结束, 等待尾部静音")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= 录音结束 & 判定 ================= */
|
||||||
|
|
||||||
private fun finishSentence() {
|
private fun finishSentence() {
|
||||||
vadEndPending = false
|
vadEndPending = false
|
||||||
state = VoiceState.WAIT_WAKEUP
|
state = VoiceState.WAIT_WAKEUP
|
||||||
|
|
||||||
val finalAudio = audioBuffer.toFloatArray()
|
val finalAudio = audioBuffer.toFloatArray()
|
||||||
audioBuffer.clear()
|
audioBuffer.clear()
|
||||||
if (finalAudio.isNotEmpty()) onFinalAudio(finalAudio)
|
|
||||||
idleTimer = 0
|
if (isValidUserSpeech(finalAudio)) {
|
||||||
isRecordingForWakeup = false
|
onFinalAudio(finalAudio)
|
||||||
Log.d(TAG, "录音结束, 返回 WAIT_WAKEUP")
|
Log.d(TAG, "✅ 录音有效,上传")
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "❌ 噪音/旁人语音,丢弃")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 关键判定函数 ================= */
|
||||||
|
|
||||||
|
private fun isValidUserSpeech(audio: FloatArray): Boolean {
|
||||||
|
if (!vadStarted) {
|
||||||
|
Log.d(TAG, "❌ VAD 未触发")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1️⃣ 时长:>= 600ms(非常宽松)
|
||||||
|
val durationMs = audio.size * 1000f / 16000f
|
||||||
|
if (durationMs < 600f) {
|
||||||
|
Log.d(TAG, "❌ 太短: ${durationMs}ms")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ 计算 RMS(真实设备极低)
|
||||||
|
var sum = 0f
|
||||||
|
var peak = 0f
|
||||||
|
for (v in audio) {
|
||||||
|
val a = kotlin.math.abs(v)
|
||||||
|
sum += a * a
|
||||||
|
if (a > peak) peak = a
|
||||||
|
}
|
||||||
|
val rms = kotlin.math.sqrt(sum / audio.size)
|
||||||
|
|
||||||
|
Log.d(TAG, "🎤 RMS=$rms peak=$peak duration=${durationMs}ms")
|
||||||
|
|
||||||
|
// 3️⃣ 只排除“纯底噪”
|
||||||
|
// 实测:环境底噪 RMS 通常 < 0.001
|
||||||
|
if (rms < 0.002f && peak < 0.01f) {
|
||||||
|
Log.d(TAG, "❌ 纯环境噪声,丢弃")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 只要不是纯噪声,一律认为是人说话
|
||||||
|
Log.d(TAG, "✅ 判定为有效人声")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ================= 工具 ================= */
|
||||||
|
|
||||||
private fun cachePreBuffer(samples: FloatArray) {
|
private fun cachePreBuffer(samples: FloatArray) {
|
||||||
for (s in samples) {
|
for (s in samples) {
|
||||||
preBuffer.addLast(s)
|
preBuffer.addLast(s)
|
||||||
if (preBuffer.size > PRE_BUFFER_SIZE) preBuffer.removeFirst()
|
if (preBuffer.size > PRE_BUFFER_SIZE) {
|
||||||
|
preBuffer.removeFirst()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun playLocalPrompt() {
|
||||||
|
onPlayStartPrompt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -38,9 +38,9 @@ class WakeupManager(
|
|||||||
fun acceptAudio(samples: FloatArray) {
|
fun acceptAudio(samples: FloatArray) {
|
||||||
val s = stream ?: return
|
val s = stream ?: return
|
||||||
// ⭐ 远讲 / 播放补偿(非常关键)
|
// ⭐ 远讲 / 播放补偿(非常关键)
|
||||||
for (i in samples.indices) {
|
// for (i in samples.indices) {
|
||||||
samples[i] *= 2.5f
|
// samples[i] *= 2.5f
|
||||||
}
|
// }
|
||||||
s.acceptWaveform(samples, sampleRate)
|
s.acceptWaveform(samples, sampleRate)
|
||||||
|
|
||||||
while (kws.isReady(s)) {
|
while (kws.isReady(s)) {
|
||||||
|
|||||||
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.databinding.ActivityMainBinding
|
||||||
import com.zs.smarthuman.http.ApiResult
|
import com.zs.smarthuman.http.ApiResult
|
||||||
import com.zs.smarthuman.http.ApiService
|
import com.zs.smarthuman.http.ApiService
|
||||||
|
import com.zs.smarthuman.im.chat.MessageContentType
|
||||||
import com.zs.smarthuman.im.chat.bean.SingleMessage
|
import com.zs.smarthuman.im.chat.bean.SingleMessage
|
||||||
import com.zs.smarthuman.kt.releaseIM
|
import com.zs.smarthuman.kt.releaseIM
|
||||||
import com.zs.smarthuman.sherpa.VoiceController
|
import com.zs.smarthuman.sherpa.VoiceController
|
||||||
import com.zs.smarthuman.sherpa.VoiceState
|
import com.zs.smarthuman.sherpa.VoiceState
|
||||||
|
import com.zs.smarthuman.toast.Toaster
|
||||||
import com.zs.smarthuman.utils.AudioDebugUtil
|
import com.zs.smarthuman.utils.AudioDebugUtil
|
||||||
import com.zs.smarthuman.utils.AudioPcmUtil
|
import com.zs.smarthuman.utils.AudioPcmUtil
|
||||||
|
|
||||||
@ -52,6 +54,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import rxhttp.awaitResult
|
import rxhttp.awaitResult
|
||||||
import rxhttp.toAwaitString
|
import rxhttp.toAwaitString
|
||||||
import rxhttp.wrapper.param.RxHttp
|
import rxhttp.wrapper.param.RxHttp
|
||||||
@ -60,6 +63,7 @@ import java.io.File
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description:
|
* @description:
|
||||||
@ -71,7 +75,7 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
|
|||||||
private var voiceController: VoiceController? = null
|
private var voiceController: VoiceController? = null
|
||||||
private var audioRecord: AudioRecord? = null
|
private var audioRecord: AudioRecord? = null
|
||||||
private var isRecording = false
|
private var isRecording = false
|
||||||
private val audioSource = MediaRecorder.AudioSource.MIC
|
private val audioSource = MediaRecorder.AudioSource.VOICE_RECOGNITION
|
||||||
private val sampleRateInHz = 16000
|
private val sampleRateInHz = 16000
|
||||||
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||||
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
||||||
@ -131,16 +135,11 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
|
|||||||
mViewModel?.uploadVoiceLiveData?.observe(this) {
|
mViewModel?.uploadVoiceLiveData?.observe(this) {
|
||||||
when (it) {
|
when (it) {
|
||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
ToastUtils.showShort("上传失败")
|
Toaster.showShort("上传失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
is ApiResult.Success<*> -> {
|
is ApiResult.Success<*> -> {
|
||||||
ToastUtils.showShort("上传成功")
|
Toaster.showShort("上传成功")
|
||||||
UnityPlayerHolder.getInstance()
|
|
||||||
.sendVoiceToUnity(
|
|
||||||
voiceInfo = mutableListOf<VoiceBeanResp>().apply {
|
|
||||||
add(VoiceBeanResp(audioUrl = "https://static.seerteach.net/largemodel/smart_read_audio/intensive_reading/689450143596d58606a106e5/689450143596d58606a106e5_1.mp3"))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,24 +151,32 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
|
|||||||
assetManager = assets,
|
assetManager = assets,
|
||||||
onWakeup = {
|
onWakeup = {
|
||||||
Log.d("lrs", "当前状态: 唤醒成功wakeup")
|
Log.d("lrs", "当前状态: 唤醒成功wakeup")
|
||||||
UnityPlayerHolder.getInstance().cancel()
|
//每次唤醒前都要把前面的音频停掉
|
||||||
|
UnityPlayerHolder.getInstance().cancelPCM()
|
||||||
UnityPlayerHolder.getInstance()
|
UnityPlayerHolder.getInstance()
|
||||||
.sendVoiceToUnity(
|
.sendVoiceToUnity(
|
||||||
voiceInfo = mutableListOf<VoiceBeanResp>().apply {
|
voiceInfo = mutableListOf<VoiceBeanResp>().apply {
|
||||||
add(VoiceBeanResp(audioUrl = UserInfoManager.userInfo?.wakeUpAudioUrl?:""))
|
add(
|
||||||
|
VoiceBeanResp(
|
||||||
|
audioUrl = UserInfoManager.userInfo?.wakeUpAudioUrl ?: ""
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onFinalAudio = { audio ->
|
onFinalAudio = { audio ->
|
||||||
Log.d("lrs", "检测到语音,长度=${audio.size}")
|
Log.d("lrs", "检测到语音,长度=${audio.size}")
|
||||||
// lifecycleScope.launch(Dispatchers.IO) {
|
mViewModel?.uploadVoice(
|
||||||
mViewModel?.uploadVoice(AudioPcmUtil.pcm16ToBase64(AudioPcmUtil.floatToPcm16(audio)),1)
|
AudioPcmUtil.pcm16ToBase64(AudioPcmUtil.floatToPcm16(audio)),
|
||||||
|
1
|
||||||
|
)
|
||||||
val file = File(
|
val file = File(
|
||||||
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!.getAbsolutePath(),
|
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!.getAbsolutePath(),
|
||||||
"xxx.wav"
|
"xxx.wav"
|
||||||
)
|
)
|
||||||
AudioDebugUtil.saveFloatPcmAsWav(audio, file)
|
AudioDebugUtil.saveFloatPcmAsWav(audio, file)
|
||||||
LogUtils.dTag("audioxx", "WAV saved: ${file.path}, samples=${audio.size}")
|
LogUtils.dTag("audioxx", "WAV saved: ${file.path}, samples=${audio.size}")
|
||||||
|
|
||||||
},
|
},
|
||||||
onStateChanged = { state ->
|
onStateChanged = { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
@ -192,6 +199,14 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun receivedIMMsg(msg: SingleMessage) {
|
override fun receivedIMMsg(msg: SingleMessage) {
|
||||||
|
when (msg.msgContentType) {
|
||||||
|
MessageContentType.RECEIVE_VOICE_STREAM.msgContentType -> {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
UnityPlayerHolder.getInstance()
|
||||||
|
.startTalking(msg.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,11 +259,24 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
|
|||||||
|
|
||||||
//开始录音
|
//开始录音
|
||||||
fun startRecording() {
|
fun startRecording() {
|
||||||
if (!isRecording) {
|
|
||||||
audioRecord?.startRecording()
|
|
||||||
isRecording = true
|
isRecording = true
|
||||||
|
audioRecord?.startRecording()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
processAudio()
|
val buf = ShortArray(640)
|
||||||
|
while (isRecording) {
|
||||||
|
val n = audioRecord?.read(buf, 0, buf.size) ?: 0
|
||||||
|
if (n > 0) {
|
||||||
|
val raw = FloatArray(n) { buf[it] / 32768f }
|
||||||
|
|
||||||
|
// 播放时 duck
|
||||||
|
val ducked = if (voiceController?.isPlaying == true) {
|
||||||
|
FloatArray(n) { raw[it] * 0.4f }
|
||||||
|
} else raw
|
||||||
|
|
||||||
|
voiceController?.acceptAudio(agc(ducked))
|
||||||
|
}
|
||||||
|
voiceController?.checkIdleTimeout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -263,31 +291,31 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun agc(
|
||||||
|
input: FloatArray,
|
||||||
|
target: Float = 0.035f, // ⬅️ 降
|
||||||
|
maxGain: Float = 4f // ⬅️ 关键
|
||||||
|
): FloatArray {
|
||||||
|
var sum = 0f
|
||||||
|
for (v in input) sum += v * v
|
||||||
|
val rms = sqrt(sum / input.size)
|
||||||
|
if (rms < 1e-6) return input
|
||||||
|
|
||||||
private fun processAudio() {
|
val gain = (target / rms).coerceAtMost(maxGain)
|
||||||
// val interval = 0.1
|
return FloatArray(input.size) {
|
||||||
// val bufferSize = (interval * sampleRateInHz).toInt() // in samples
|
(input[it] * gain).coerceIn(-1f, 1f)
|
||||||
// val buffer = ShortArray(bufferSize)
|
|
||||||
val bufferSize = 512 // in samples
|
|
||||||
val buffer = ShortArray(bufferSize)
|
|
||||||
while (isRecording) {
|
|
||||||
val ret = audioRecord?.read(buffer, 0, buffer.size) ?: 0
|
|
||||||
if (ret > 0) {
|
|
||||||
val samples = FloatArray(ret) { buffer[it] / 32768.0f }
|
|
||||||
voiceController?.acceptAudio(samples)
|
|
||||||
}
|
|
||||||
// 每帧检查 IdleTimeout
|
|
||||||
voiceController?.checkIdleTimeout()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun onUnityResourcesLoaded(message: String) { // 这是 Unity 调用的资源加载完成回调方法
|
fun onUnityResourcesLoaded(message: String) { // 这是 Unity 调用的资源加载完成回调方法
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
delay(200)
|
delay(200)
|
||||||
binding.flDigitalHuman.translationY = 0f
|
binding.flDigitalHuman.translationY = 0f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private var promptPlaying = false
|
||||||
fun onAudioProgressUpdated( // Unity 调用此方法传递音频进度
|
fun onAudioProgressUpdated( // Unity 调用此方法传递音频进度
|
||||||
progress: Float,
|
progress: Float,
|
||||||
state: Int,//0stop 2pause 1play 3complete 4loading 5error
|
state: Int,//0stop 2pause 1play 3complete 4loading 5error
|
||||||
@ -295,21 +323,24 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
|
|||||||
word: String,
|
word: String,
|
||||||
audioUrl: String
|
audioUrl: String
|
||||||
) {
|
) {
|
||||||
if (state == 1) {
|
val wakeupUrl = UserInfoManager.userInfo?.wakeUpAudioUrl ?: return
|
||||||
if (audioUrl == UserInfoManager.userInfo?.wakeUpAudioUrl){
|
|
||||||
|
if (audioUrl != wakeupUrl) return
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
1 -> { // play
|
||||||
|
if (!promptPlaying) {
|
||||||
|
promptPlaying = true
|
||||||
voiceController?.onPlayStartPrompt()
|
voiceController?.onPlayStartPrompt()
|
||||||
}else{
|
}
|
||||||
voiceController?.onPlayStartBackend()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
3 -> { // complete
|
||||||
|
if (promptPlaying) {
|
||||||
if (state == 3){
|
Toaster.showShort("借宿了")
|
||||||
if (audioUrl == UserInfoManager.userInfo?.wakeUpAudioUrl){
|
promptPlaying = false
|
||||||
voiceController?.onPlayEndPrompt()
|
voiceController?.onPlayEndPrompt()
|
||||||
|
}
|
||||||
}else{
|
|
||||||
voiceController?.onPlayEndBackend()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -322,10 +353,10 @@ class MainActivity : BaseViewModelActivity<ActivityMainBinding, MainViewModel>()
|
|||||||
isFinal: Boolean
|
isFinal: Boolean
|
||||||
) {
|
) {
|
||||||
if (state == 1) {
|
if (state == 1) {
|
||||||
// voiceController?.onPlayStart()
|
voiceController?.onPlayStartBackend()
|
||||||
}
|
}
|
||||||
if (state == 3) {
|
if (state == 3) {
|
||||||
// voiceController?.onPlayEnd()
|
voiceController?.onPlayEndBackend()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user