20 changed files with 924 additions and 565 deletions
@ -0,0 +1,359 @@ |
|||||||
|
package com.utun.chat.data |
||||||
|
|
||||||
|
import android.content.Intent |
||||||
|
import android.os.Looper |
||||||
|
import android.os.SystemClock |
||||||
|
import com.utun.chat.ChatApplication |
||||||
|
import com.utun.chat.MainActivity |
||||||
|
import kotlinx.coroutines.CoroutineScope |
||||||
|
import kotlinx.coroutines.Dispatchers |
||||||
|
import kotlinx.coroutines.SupervisorJob |
||||||
|
import kotlinx.coroutines.delay |
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow |
||||||
|
import kotlinx.coroutines.flow.StateFlow |
||||||
|
import kotlinx.coroutines.launch |
||||||
|
import java.nio.ByteBuffer |
||||||
|
import java.nio.ByteOrder |
||||||
|
|
||||||
|
/** |
||||||
|
* Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread. |
||||||
|
* Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow. |
||||||
|
* События поступают напрямую из AppEventHandler без replay старых звонков новым подписчикам. |
||||||
|
*/ |
||||||
|
object CallController : TelecomCallManager.Listener { |
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) |
||||||
|
private val _state = MutableStateFlow<CallState?>(null) |
||||||
|
val state: StateFlow<CallState?> = _state |
||||||
|
private val _muted = MutableStateFlow(false) |
||||||
|
val muted: StateFlow<Boolean> = _muted |
||||||
|
private val _route = MutableStateFlow(AudioRoute.SPEAKER) |
||||||
|
val route: StateFlow<AudioRoute> = _route |
||||||
|
private val _headset = MutableStateFlow(false) |
||||||
|
val headset: StateFlow<Boolean> = _headset |
||||||
|
private val _earpiece = MutableStateFlow(true) |
||||||
|
val earpiece: StateFlow<Boolean> = _earpiece |
||||||
|
private val proximity = ProximityManager(ChatApplication.instance) |
||||||
|
val proximityNear: StateFlow<Boolean> = proximity.near |
||||||
|
|
||||||
|
private var service: CallService? = null |
||||||
|
private var serviceRequested = false |
||||||
|
private var accepting = false |
||||||
|
private var acceptSent = false |
||||||
|
private var answerWhenVisible = 0L |
||||||
|
private var activityVisible = false |
||||||
|
private var coreReady = false |
||||||
|
private var startedAt = 0L |
||||||
|
private var incomingAt = 0L |
||||||
|
private var initialized = false |
||||||
|
|
||||||
|
fun init() { |
||||||
|
checkMain() |
||||||
|
if (initialized) return |
||||||
|
initialized = true |
||||||
|
TelecomCallManager.setListener(this) |
||||||
|
scope.launch { |
||||||
|
while (true) { |
||||||
|
delay(500) |
||||||
|
val st = _state.value ?: continue |
||||||
|
val now = SystemClock.elapsedRealtime() |
||||||
|
if (st.phase == CallPhase.ACTIVE) { |
||||||
|
_state.value = st.copy(durationSeconds = ((now - startedAt) / 1000).toInt()) |
||||||
|
} else if (st.phase == CallPhase.INCOMING && now - incomingAt > 50_000) { |
||||||
|
LogManager.addLog("WARN", "Call", "incoming timeout id=${st.callId}; declining") |
||||||
|
end(st.callId) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
LogManager.addLog("INFO", "Call", "controller initialized; session independent of UI") |
||||||
|
} |
||||||
|
|
||||||
|
fun setActivityVisible(visible: Boolean) { |
||||||
|
checkMain() |
||||||
|
activityVisible = visible |
||||||
|
syncCoreActivity() |
||||||
|
if (visible && answerWhenVisible != 0L) { |
||||||
|
val id = answerWhenVisible |
||||||
|
answerWhenVisible = 0L |
||||||
|
if (_state.value?.callId == id && _state.value?.phase == CallPhase.INCOMING) accept() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun syncCoreActivity() { |
||||||
|
val ongoing = _state.value?.ongoing == true |
||||||
|
val active = activityVisible || ongoing |
||||||
|
LogManager.addLog("INFO", "Call", "core activity=$active visible=$activityVisible call=$ongoing") |
||||||
|
if (coreReady && NativeLib.isReady()) NativeLib.setActive(active) |
||||||
|
} |
||||||
|
|
||||||
|
fun start(channelId: String, peerNodeId: Long, peerName: String) { |
||||||
|
checkMain() |
||||||
|
if (channelId.isEmpty() || peerNodeId == 0L) { |
||||||
|
LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId") |
||||||
|
return |
||||||
|
} |
||||||
|
if (_state.value?.ongoing == true || !coreReady || !NativeLib.isReady()) { |
||||||
|
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}") |
||||||
|
return |
||||||
|
} |
||||||
|
var id = java.security.SecureRandom().nextLong() |
||||||
|
if (id == 0L) id = 1 |
||||||
|
_state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, CallPhase.OUTGOING) |
||||||
|
syncCoreActivity() |
||||||
|
requestService(id) |
||||||
|
} |
||||||
|
|
||||||
|
fun accept() { |
||||||
|
checkMain() |
||||||
|
val st = _state.value ?: return |
||||||
|
if (st.phase != CallPhase.INCOMING || accepting) { |
||||||
|
LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting") |
||||||
|
return |
||||||
|
} |
||||||
|
accepting = true |
||||||
|
requestService(st.callId) |
||||||
|
} |
||||||
|
|
||||||
|
private fun requestService(id: Long) { |
||||||
|
serviceRequested = true |
||||||
|
try { |
||||||
|
CallService.start(ChatApplication.instance, id) |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "Call", "service request failed id=$id: ${e.message}") |
||||||
|
end(id, 7) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Вызывается сервисом после startForeground и приобретения wake lock, до запуска сигналинга/микрофона. */ |
||||||
|
fun serviceReady(id: Long, owner: CallService): Boolean { |
||||||
|
checkMain() |
||||||
|
val st = _state.value |
||||||
|
if (st == null || st.callId != id || !st.ongoing || !serviceRequested) { |
||||||
|
LogManager.addLog("WARN", "Call", "discard stale service start id=$id current=${st?.callId}") |
||||||
|
return false |
||||||
|
} |
||||||
|
if (service != null) { |
||||||
|
if (service !== owner) LogManager.addLog("ERROR", "Call", "service owner conflict id=$id") |
||||||
|
return service === owner |
||||||
|
} |
||||||
|
service = owner |
||||||
|
LogManager.addLog("INFO", "Call", "service ready id=$id phase=${st.phase}") |
||||||
|
if (st.phase == CallPhase.OUTGOING) { |
||||||
|
NativeLib.callStart(st.channelId, st.peerNodeId, id) |
||||||
|
TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName) |
||||||
|
CallRingback.startConnecting() |
||||||
|
} else if (st.phase == CallPhase.INCOMING && accepting) { |
||||||
|
IncomingCallManager.stopRinging() |
||||||
|
acceptSent = true |
||||||
|
NativeLib.callAccept(id) |
||||||
|
} |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
fun wantsService(id: Long): Boolean = _state.value?.let { it.callId == id && it.ongoing && serviceRequested } == true |
||||||
|
|
||||||
|
fun serviceLost(id: Long, owner: CallService) { |
||||||
|
checkMain() |
||||||
|
if (service !== owner) return |
||||||
|
service = null |
||||||
|
LogManager.addLog("ERROR", "Call", "service destroyed during call id=$id") |
||||||
|
end(id, 7) |
||||||
|
} |
||||||
|
|
||||||
|
fun audioFailed(id: Long, detail: String) { |
||||||
|
checkMain() |
||||||
|
if (_state.value?.callId != id || _state.value?.ongoing != true) return |
||||||
|
LogManager.addLog("ERROR", "Call", "audio failed id=$id: $detail") |
||||||
|
end(id, 7) |
||||||
|
} |
||||||
|
|
||||||
|
/** Завершение из UI, Telecom и уведомления; идентификатор защищает от действий старого звонка. */ |
||||||
|
fun end(id: Long = _state.value?.callId ?: 0L, reason: Int = 0) { |
||||||
|
checkMain() |
||||||
|
val st = _state.value ?: return |
||||||
|
if (st.callId != id || !st.ongoing) { |
||||||
|
LogManager.addLog("WARN", "Call", "end ignored id=$id current=${st.callId} phase=${st.phase}") |
||||||
|
return |
||||||
|
} |
||||||
|
if (NativeLib.isReady()) { |
||||||
|
if (st.phase == CallPhase.INCOMING && !acceptSent) NativeLib.callDecline(id) else NativeLib.callHangup(id) |
||||||
|
} |
||||||
|
finish(id, reason) |
||||||
|
} |
||||||
|
|
||||||
|
private fun finish(id: Long, reason: Int) { |
||||||
|
val st = _state.value ?: return |
||||||
|
if (st.callId != id || !st.ongoing) return |
||||||
|
val owner = service |
||||||
|
service = null |
||||||
|
serviceRequested = false |
||||||
|
accepting = false |
||||||
|
acceptSent = false |
||||||
|
answerWhenVisible = 0L |
||||||
|
owner?.shutdown() |
||||||
|
proximity.stop() |
||||||
|
TelecomCallManager.setDisconnected(id, reason) |
||||||
|
TelecomCallManager.removeConnection(id) |
||||||
|
CallRingback.stop() |
||||||
|
IncomingCallManager.stopRinging() |
||||||
|
_muted.value = false |
||||||
|
_route.value = AudioRoute.SPEAKER |
||||||
|
_headset.value = false |
||||||
|
_earpiece.value = true |
||||||
|
_state.value = st.copy(phase = CallPhase.ENDED, reason = reason) |
||||||
|
syncCoreActivity() |
||||||
|
LogManager.addLog("INFO", "Call", "finished id=$id reason=$reason; audio/service/proximity released") |
||||||
|
scope.launch { |
||||||
|
delay(2500) |
||||||
|
if (_state.value?.callId == id && _state.value?.phase == CallPhase.ENDED) _state.value = null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun dismiss() { |
||||||
|
checkMain() |
||||||
|
if (_state.value?.ongoing == true) end() |
||||||
|
_state.value = null |
||||||
|
} |
||||||
|
|
||||||
|
/** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */ |
||||||
|
fun reset() { |
||||||
|
checkMain() |
||||||
|
coreReady = false |
||||||
|
end() |
||||||
|
_state.value = null |
||||||
|
} |
||||||
|
|
||||||
|
fun toggleMute() { |
||||||
|
checkMain() |
||||||
|
val audio = service?.audio ?: return |
||||||
|
if (!audio.isActive()) return |
||||||
|
audio.setMuted(!audio.isMuted()) |
||||||
|
_muted.value = audio.isMuted() |
||||||
|
} |
||||||
|
|
||||||
|
fun setRoute(route: AudioRoute) { |
||||||
|
checkMain() |
||||||
|
service?.audio?.router?.setRoute(route) |
||||||
|
_state.value?.let { TelecomCallManager.setRoute(it.callId, route) } |
||||||
|
scope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(route) } |
||||||
|
} |
||||||
|
|
||||||
|
private fun routeChanged(route: AudioRoute, headset: Boolean) { |
||||||
|
_route.value = route |
||||||
|
_headset.value = headset |
||||||
|
if (route == AudioRoute.EARPIECE && _state.value?.phase == CallPhase.ACTIVE) proximity.start() else proximity.stop() |
||||||
|
service?.audio?.syncAecForRoute(route) |
||||||
|
} |
||||||
|
|
||||||
|
/** Только живые события ядра, сериализованные AppEventHandler на main thread. */ |
||||||
|
fun onEvent(type: Int, data: ByteArray?) { |
||||||
|
checkMain() |
||||||
|
if (type == 12) { |
||||||
|
coreReady = NativeLib.isReady() |
||||||
|
syncCoreActivity() |
||||||
|
LogManager.addLog("INFO", "Call", "core started ready=$coreReady") |
||||||
|
return |
||||||
|
} |
||||||
|
if (type == 13) { reset(); return } |
||||||
|
if (type !in 34..40 && type != 42) return |
||||||
|
if (!coreReady || !NativeLib.isReady()) { |
||||||
|
LogManager.addLog("WARN", "Call", "ignore event=$type while core stopped/restarting") |
||||||
|
return |
||||||
|
} |
||||||
|
if (type == 34) { |
||||||
|
if (data == null || data.size < 17) { badEvent(type, data); return } |
||||||
|
val len = data[0].toInt() and 0xFF |
||||||
|
if (len == 0 || data.size < 17 + len) { badEvent(type, data); return } |
||||||
|
val channel = String(data, 1, len) |
||||||
|
val buf = ByteBuffer.wrap(data, 1 + len, 16).order(ByteOrder.LITTLE_ENDIAN) |
||||||
|
val peer = buf.long |
||||||
|
val id = buf.long |
||||||
|
if (peer == 0L || id == 0L) { badEvent(type, data); return } |
||||||
|
val current = _state.value |
||||||
|
if (current?.callId == id) return |
||||||
|
if (current?.ongoing == true) { |
||||||
|
LogManager.addLog("WARN", "Call", "busy; decline incoming id=$id current=${current.callId}") |
||||||
|
NativeLib.callDecline(id) |
||||||
|
return |
||||||
|
} |
||||||
|
incomingAt = SystemClock.elapsedRealtime() |
||||||
|
// Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро. |
||||||
|
val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name |
||||||
|
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer) |
||||||
|
_state.value = CallState(id, channel, peer, name, CallPhase.INCOMING) |
||||||
|
syncCoreActivity() |
||||||
|
IncomingCallManager.show(id, channel, peer) |
||||||
|
TelecomCallManager.addIncomingCall(id, peer, name) |
||||||
|
return |
||||||
|
} |
||||||
|
if (data == null || data.size < 8 || ((type == 37 || type == 38 || type == 40 || type == 42) && data.size < 9) || |
||||||
|
(type == 39 && data.size != 24)) { badEvent(type, data); return } |
||||||
|
val id = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long |
||||||
|
val st = _state.value |
||||||
|
if (st == null || st.callId != id || !st.ongoing) { |
||||||
|
LogManager.addLog("DEBUG", "Call", "ignore event=$type id=$id current=${st?.callId} phase=${st?.phase}") |
||||||
|
return |
||||||
|
} |
||||||
|
when (type) { |
||||||
|
35 -> if (st.phase == CallPhase.OUTGOING) { |
||||||
|
_state.value = st.copy(phase = CallPhase.RINGING) |
||||||
|
CallRingback.startRinging() |
||||||
|
} |
||||||
|
36 -> { |
||||||
|
if (st.phase == CallPhase.ACTIVE) return |
||||||
|
val owner = service |
||||||
|
if (owner == null || !owner.startAudio(id)) { |
||||||
|
LogManager.addLog("ERROR", "Call", "accepted without working audio/service id=$id") |
||||||
|
end(id, 7) |
||||||
|
return |
||||||
|
} |
||||||
|
startedAt = SystemClock.elapsedRealtime() |
||||||
|
accepting = false |
||||||
|
CallRingback.stop() |
||||||
|
IncomingCallManager.stopRinging() |
||||||
|
TelecomCallManager.setActive(id) |
||||||
|
_state.value = st.copy(phase = CallPhase.ACTIVE, durationSeconds = 0) |
||||||
|
owner.audio.router.onChanged = { route, headset -> routeChanged(route, headset) } |
||||||
|
_earpiece.value = owner.audio.router.hasEarpiece |
||||||
|
routeChanged(owner.audio.router.route, owner.audio.router.headsetAvailable) |
||||||
|
owner.updateNotification() |
||||||
|
LogManager.addLog("INFO", "Call", "active id=$id; service owns audio and CPU wake lock") |
||||||
|
} |
||||||
|
37, 38 -> finish(id, data[8].toInt() and 0xFF) |
||||||
|
40 -> { |
||||||
|
LogManager.addLog("ERROR", "Call", "native error id=$id code=${data[8]} ${String(data, 9, data.size - 9)}") |
||||||
|
finish(id, 7) |
||||||
|
} |
||||||
|
39 -> if (st.phase == CallPhase.ACTIVE) { |
||||||
|
val buf = ByteBuffer.wrap(data, 8, 16).order(ByteOrder.BIG_ENDIAN) |
||||||
|
fun metric() = buf.short.toInt() and 0xFFFF |
||||||
|
_state.value = st.copy(stats = CallStats(metric(), metric(), metric(), metric(), metric(), metric(), metric(), metric())) |
||||||
|
} |
||||||
|
42 -> _state.value = st.copy(bgpPath = String(data, 8, data.size - 8)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun badEvent(type: Int, data: ByteArray?) { |
||||||
|
LogManager.addLog("WARN", "Call", "invalid event=$type size=${data?.size ?: 0}") |
||||||
|
} |
||||||
|
|
||||||
|
override fun onAnswer(callId: Long) { |
||||||
|
if (_state.value?.callId != callId || _state.value?.phase != CallPhase.INCOMING) return |
||||||
|
if (activityVisible) { accept(); return } |
||||||
|
// Ответ гарнитурой в фоне: сначала видимый UI, затем разрешённый запуск microphone FGS. |
||||||
|
answerWhenVisible = callId |
||||||
|
try { |
||||||
|
ChatApplication.instance.startActivity(Intent(ChatApplication.instance, MainActivity::class.java).apply { |
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) |
||||||
|
putExtra(IncomingCallManager.EXTRA_CALL_ID, callId) |
||||||
|
}) |
||||||
|
LogManager.addLog("INFO", "Call", "Telecom answer id=$callId; waiting for visible Activity before microphone service") |
||||||
|
} catch (e: Exception) { |
||||||
|
answerWhenVisible = 0L |
||||||
|
LogManager.addLog("ERROR", "Call", "cannot show incoming UI for Telecom answer id=$callId: ${e.message}") |
||||||
|
} |
||||||
|
} |
||||||
|
override fun onReject(callId: Long) { end(callId, 2) } |
||||||
|
override fun onDisconnect(callId: Long) { end(callId) } |
||||||
|
|
||||||
|
private fun checkMain() = check(Looper.myLooper() == Looper.getMainLooper()) { "CallController requires main thread" } |
||||||
|
} |
||||||
@ -0,0 +1,199 @@ |
|||||||
|
package com.utun.chat.data |
||||||
|
|
||||||
|
import android.app.Notification |
||||||
|
import android.app.NotificationChannel |
||||||
|
import android.app.NotificationManager |
||||||
|
import android.app.PendingIntent |
||||||
|
import android.app.Service |
||||||
|
import android.content.Context |
||||||
|
import android.content.BroadcastReceiver |
||||||
|
import android.content.Intent |
||||||
|
import android.content.IntentFilter |
||||||
|
import android.content.pm.ServiceInfo |
||||||
|
import android.net.Uri |
||||||
|
import android.os.Build |
||||||
|
import android.os.IBinder |
||||||
|
import android.os.PowerManager |
||||||
|
import androidx.core.app.NotificationCompat |
||||||
|
import androidx.core.app.ServiceCompat |
||||||
|
import androidx.core.content.ContextCompat |
||||||
|
import com.utun.chat.ChatApplication |
||||||
|
import com.utun.chat.MainActivity |
||||||
|
|
||||||
|
/** |
||||||
|
* Владелец аудиодвижка и PARTIAL_WAKE_LOCK на время исходящего/принятого звонка. |
||||||
|
* Сервис запускается действием пользователя до сигналинга и захвата микрофона. |
||||||
|
* Не восстанавливает звонок после смерти процесса: native-сессия тогда тоже утрачена. |
||||||
|
*/ |
||||||
|
class CallService : Service() { |
||||||
|
companion object { |
||||||
|
private const val CHANNEL = "ongoing_call" |
||||||
|
private const val NOTIFICATION_ID = 1002 |
||||||
|
private const val EXTRA_ID = "call_id" |
||||||
|
private const val ACTION_END = "com.utun.chat.action.END_CALL" |
||||||
|
|
||||||
|
fun start(context: Context, callId: Long) { |
||||||
|
ContextCompat.startForegroundService(context, |
||||||
|
Intent(context, CallService::class.java).putExtra(EXTRA_ID, callId)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
val audio = CallAudioEngine() |
||||||
|
private var callId = 0L |
||||||
|
private var wakeLock: PowerManager.WakeLock? = null |
||||||
|
private var powerEventsRegistered = false |
||||||
|
private val powerEvents = object : BroadcastReceiver() { |
||||||
|
override fun onReceive(context: Context, intent: Intent) { |
||||||
|
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager |
||||||
|
LogManager.addLog("INFO", "CallService", |
||||||
|
"power event=${intent.action} id=$callId interactive=${pm.isInteractive} " + |
||||||
|
"idle=${pm.isDeviceIdleMode} save=${pm.isPowerSaveMode} lock=${wakeLock?.isHeld} ${audio.diagnostics}") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
override fun onCreate() { |
||||||
|
super.onCreate() |
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel( |
||||||
|
NotificationChannel(CHANNEL, "Текущий звонок", NotificationManager.IMPORTANCE_LOW).apply { |
||||||
|
setSound(null, null) |
||||||
|
}) |
||||||
|
audio.onFailure = { id, detail -> CallController.audioFailed(id, detail) } |
||||||
|
LogManager.addLog("INFO", "CallService", "created") |
||||||
|
} |
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
||||||
|
val id = intent?.getLongExtra(EXTRA_ID, 0L) ?: 0L |
||||||
|
if (intent?.action == ACTION_END) { |
||||||
|
CallController.end(id) |
||||||
|
return START_NOT_STICKY |
||||||
|
} |
||||||
|
if (!CallController.wantsService(id)) { |
||||||
|
LogManager.addLog("WARN", "CallService", "stale start id=$id owned=$callId") |
||||||
|
if (callId == 0L) { |
||||||
|
// Даже отменённый startForegroundService должен завершить foreground-handshake. |
||||||
|
try { |
||||||
|
promote(false) |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "CallService", "cancelled foreground handshake failed id=$id: ${e.message}") |
||||||
|
} finally { |
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE) |
||||||
|
stopSelf(startId) |
||||||
|
} |
||||||
|
} |
||||||
|
return START_NOT_STICKY |
||||||
|
} |
||||||
|
if (callId == id) return START_NOT_STICKY |
||||||
|
if (callId != 0L) { |
||||||
|
LogManager.addLog("ERROR", "CallService", "start conflicts id=$id owned=$callId") |
||||||
|
CallController.audioFailed(id, "service already owns another call") |
||||||
|
return START_NOT_STICKY |
||||||
|
} |
||||||
|
callId = id |
||||||
|
try { |
||||||
|
promote(true) |
||||||
|
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager |
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:ongoing_call").apply { |
||||||
|
setReferenceCounted(false) |
||||||
|
} |
||||||
|
wakeLock!!.acquire() |
||||||
|
LogManager.addLog("INFO", "CallService", |
||||||
|
"foreground phoneCall|microphone; CPU lock acquired id=$id held=${wakeLock?.isHeld}") |
||||||
|
val filter = IntentFilter().apply { |
||||||
|
addAction(Intent.ACTION_SCREEN_OFF) |
||||||
|
addAction(Intent.ACTION_SCREEN_ON) |
||||||
|
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) |
||||||
|
addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) |
||||||
|
} |
||||||
|
ContextCompat.registerReceiver(this, powerEvents, filter, ContextCompat.RECEIVER_NOT_EXPORTED) |
||||||
|
powerEventsRegistered = true |
||||||
|
if (!CallController.serviceReady(id, this)) shutdown() |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "CallService", "start failed id=$id: ${e.message}") |
||||||
|
CallController.audioFailed(id, "foreground/wake lock failed: ${e.message}") |
||||||
|
shutdown() |
||||||
|
} |
||||||
|
return START_NOT_STICKY |
||||||
|
} |
||||||
|
|
||||||
|
private fun promote(microphone: Boolean) { |
||||||
|
val micType = if (microphone && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) |
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0 |
||||||
|
val types = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { |
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or micType |
||||||
|
} else 0 |
||||||
|
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types) |
||||||
|
} |
||||||
|
|
||||||
|
fun startAudio(id: Long): Boolean { |
||||||
|
if (id != callId || wakeLock?.isHeld != true) { |
||||||
|
LogManager.addLog("ERROR", "CallService", "audio start rejected id=$id owned=$callId lock=${wakeLock?.isHeld}") |
||||||
|
return false |
||||||
|
} |
||||||
|
audio.router.setPreferredRoute(ChatApplication.instance.configProvider.getCallAudioRoute()) |
||||||
|
return audio.start(id) |
||||||
|
} |
||||||
|
|
||||||
|
fun updateNotification() { |
||||||
|
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification()) |
||||||
|
} |
||||||
|
|
||||||
|
private fun notification(): Notification { |
||||||
|
val st = CallController.state.value |
||||||
|
val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), |
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) |
||||||
|
val end = PendingIntent.getService(this, 0, |
||||||
|
Intent(this, CallService::class.java).setAction(ACTION_END) |
||||||
|
.setData(Uri.parse("utun-call:$callId")).putExtra(EXTRA_ID, callId), |
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) |
||||||
|
return NotificationCompat.Builder(this, CHANNEL) |
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_call) |
||||||
|
.setContentTitle(st?.peerName ?: "vibechat") |
||||||
|
.setContentText(if (st?.phase == CallPhase.ACTIVE) "Разговор" else "Установка звонка") |
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL) |
||||||
|
.setOngoing(true) |
||||||
|
.setContentIntent(open) |
||||||
|
.addAction(0, "Завершить", end) |
||||||
|
.build() |
||||||
|
} |
||||||
|
|
||||||
|
/** Идемпотентное освобождение до остановки сервиса; выполняется в main thread. */ |
||||||
|
fun shutdown() { |
||||||
|
releaseResources() |
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE) |
||||||
|
stopSelf() |
||||||
|
} |
||||||
|
|
||||||
|
private fun releaseResources() { |
||||||
|
val id = callId |
||||||
|
if (powerEventsRegistered) { |
||||||
|
try { unregisterReceiver(powerEvents) } |
||||||
|
catch (e: Exception) { LogManager.addLog("WARN", "CallService", "power receiver cleanup failed id=$id: ${e.message}") } |
||||||
|
powerEventsRegistered = false |
||||||
|
} |
||||||
|
audio.router.onChanged = null |
||||||
|
try { |
||||||
|
audio.stop() |
||||||
|
} finally { |
||||||
|
wakeLock?.let { lock -> |
||||||
|
try { |
||||||
|
if (lock.isHeld) lock.release() |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "CallService", "wake lock release failed id=$id: ${e.message}") |
||||||
|
} |
||||||
|
} |
||||||
|
wakeLock = null |
||||||
|
callId = 0L |
||||||
|
if (id != 0L) LogManager.addLog("INFO", "CallService", "resources released id=$id; CPU lock cleared") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
override fun onDestroy() { |
||||||
|
val id = callId |
||||||
|
releaseResources() |
||||||
|
CallController.serviceLost(id, this) |
||||||
|
LogManager.addLog("INFO", "CallService", "destroyed id=$id") |
||||||
|
super.onDestroy() |
||||||
|
} |
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null |
||||||
|
} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
package com.utun.chat.data |
||||||
|
|
||||||
|
/** Состояние единственной звонковой сессии; не зависит от Activity или ViewModel. */ |
||||||
|
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED } |
||||||
|
|
||||||
|
data class CallStats( |
||||||
|
val rttMs: Int, val bufferMs: Int, val tempoX100: Int, val dropped: Int, |
||||||
|
val underruns: Int, val minMs: Int, val maxMs: Int, val reserveMs: Int |
||||||
|
) |
||||||
|
|
||||||
|
data class CallState( |
||||||
|
val callId: Long = 0, |
||||||
|
val channelId: String = "", |
||||||
|
val peerNodeId: Long = 0, |
||||||
|
val peerName: String = "", |
||||||
|
val phase: CallPhase = CallPhase.NONE, |
||||||
|
val reason: Int = -1, |
||||||
|
val durationSeconds: Int = 0, |
||||||
|
val bgpPath: String = "", |
||||||
|
val stats: CallStats? = null |
||||||
|
) { |
||||||
|
val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED |
||||||
|
} |
||||||
Loading…
Reference in new issue