2 changed files with 212 additions and 0 deletions
@ -0,0 +1,99 @@ |
|||||||
|
package com.utun.chat.data |
||||||
|
|
||||||
|
import android.content.Intent |
||||||
|
import android.os.Handler |
||||||
|
import android.os.Looper |
||||||
|
import android.widget.Toast |
||||||
|
import androidx.core.content.ContextCompat |
||||||
|
import com.utun.chat.ChatApplication |
||||||
|
import com.utun.chat.R |
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow |
||||||
|
import kotlinx.coroutines.flow.StateFlow |
||||||
|
import java.util.concurrent.atomic.AtomicLong |
||||||
|
|
||||||
|
enum class StreamPhase { STARTING, LIVE, STOPPING, ENDED, FAILED } |
||||||
|
data class StreamState(val id: Long, val group: Long, val phase: StreamPhase, val error: Int? = null) { |
||||||
|
val ongoing get() = phase == StreamPhase.STARTING || phase == StreamPhase.LIVE || phase == StreamPhase.STOPPING |
||||||
|
} |
||||||
|
|
||||||
|
/** Один владелец публикации на процесс. StateFlow и service lifecycle — main thread; camera/audio не держат UI. */ |
||||||
|
object StreamController { |
||||||
|
private val ids = AtomicLong(System.nanoTime().coerceAtLeast(1)) |
||||||
|
fun nextId(): Long = ids.incrementAndGet() |
||||||
|
private val main = Handler(Looper.getMainLooper()) |
||||||
|
private val mutable = MutableStateFlow<StreamState?>(null) |
||||||
|
val state: StateFlow<StreamState?> = mutable |
||||||
|
@Volatile var capturing = false |
||||||
|
private set |
||||||
|
private var service: StreamService? = null |
||||||
|
|
||||||
|
fun start(group: Long, camera: String, audio: Boolean, rotation: Int) { |
||||||
|
check(Looper.myLooper() == Looper.getMainLooper()) |
||||||
|
if (mutable.value?.ongoing == true) { LogManager.addLog("WARN", "stream", "publisher already owned"); return } |
||||||
|
val id = nextId() |
||||||
|
if (!NativeLib.isReady() || group == 0L || CallController.state.value?.ongoing == true || |
||||||
|
RadioAudioEngine.instance().isActive() || NativeLib.voiceIsActive()) { |
||||||
|
LogManager.addLog("WARN", "stream", "start rejected: core/call/radio/voice busy group=$group") |
||||||
|
mutable.value = StreamState(id, group, StreamPhase.FAILED, R.string.stream_resources_busy); return |
||||||
|
} |
||||||
|
capturing = true; mutable.value = StreamState(id, group, StreamPhase.STARTING) |
||||||
|
try { |
||||||
|
ContextCompat.startForegroundService(ChatApplication.instance, Intent(ChatApplication.instance, StreamService::class.java) |
||||||
|
.putExtra("id", id).putExtra("group", group).putExtra("camera", camera).putExtra("audio", audio).putExtra("rotation", rotation)) |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "stream", "service start failed id=$id: ${e.message}") |
||||||
|
capturing = false; mutable.value = StreamState(id, group, StreamPhase.FAILED, R.string.stream_failed) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun serviceReady(id: Long, owner: StreamService): Boolean { |
||||||
|
if (mutable.value?.id != id || mutable.value?.phase != StreamPhase.STARTING) return false |
||||||
|
service = owner; return true |
||||||
|
} |
||||||
|
|
||||||
|
fun opened(id: Long) { |
||||||
|
val st = mutable.value ?: return |
||||||
|
if (st.id == id && st.phase == StreamPhase.STARTING) mutable.value = st.copy(phase = StreamPhase.LIVE) |
||||||
|
} |
||||||
|
|
||||||
|
fun failed(id: Long, detail: String) { |
||||||
|
main.post { |
||||||
|
val st = mutable.value ?: return@post |
||||||
|
if (st.id != id || !st.ongoing) return@post |
||||||
|
LogManager.addLog("ERROR", "stream", "publisher failed id=$id $detail") |
||||||
|
mutable.value = st.copy(error = R.string.stream_failed); stop() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun stop() { |
||||||
|
val st = mutable.value ?: return |
||||||
|
if (!st.ongoing || st.phase == StreamPhase.STOPPING) return |
||||||
|
mutable.value = st.copy(phase = StreamPhase.STOPPING) |
||||||
|
service?.shutdown() // До onStartCommand отменённый start завершит foreground-handshake и остановится. |
||||||
|
} |
||||||
|
|
||||||
|
fun ended(id: Long, owner: StreamService) { |
||||||
|
if (service === owner) service = null |
||||||
|
val st = mutable.value ?: return |
||||||
|
if (st.id != id) return |
||||||
|
capturing = false |
||||||
|
mutable.value = st.copy(phase = if (st.error != null) StreamPhase.FAILED else StreamPhase.ENDED) |
||||||
|
LogManager.addLog("INFO", "stream", "publisher released id=$id") |
||||||
|
} |
||||||
|
|
||||||
|
fun reset() { |
||||||
|
val st = mutable.value |
||||||
|
if (st?.ongoing == true) { |
||||||
|
mutable.value = st.copy(phase = StreamPhase.STOPPING, error = R.string.stream_failed) |
||||||
|
service?.shutdown(abort = true) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Звонок/рация/голосовое не отбирают микрофон у foreground-трансляции. */ |
||||||
|
fun blocksCapture(): Boolean { |
||||||
|
if (!capturing) return false |
||||||
|
LogManager.addLog("WARN", "stream", "microphone/camera owned by stream id=${mutable.value?.id}") |
||||||
|
main.post { Toast.makeText(AppLanguage.context(), R.string.stream_resources_busy, Toast.LENGTH_SHORT).show() } |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,113 @@ |
|||||||
|
package com.utun.chat.data |
||||||
|
|
||||||
|
import android.app.NotificationChannel |
||||||
|
import android.app.NotificationManager |
||||||
|
import android.app.PendingIntent |
||||||
|
import android.app.Service |
||||||
|
import android.content.Intent |
||||||
|
import android.content.pm.ServiceInfo |
||||||
|
import android.os.Build |
||||||
|
import android.os.IBinder |
||||||
|
import android.os.PowerManager |
||||||
|
import androidx.core.app.NotificationCompat |
||||||
|
import androidx.core.app.ServiceCompat |
||||||
|
import com.utun.chat.MainActivity |
||||||
|
import com.utun.chat.R |
||||||
|
import kotlinx.coroutines.CoroutineScope |
||||||
|
import kotlinx.coroutines.Dispatchers |
||||||
|
import kotlinx.coroutines.Job |
||||||
|
import kotlinx.coroutines.SupervisorJob |
||||||
|
import kotlinx.coroutines.cancel |
||||||
|
import kotlinx.coroutines.delay |
||||||
|
import kotlinx.coroutines.launch |
||||||
|
|
||||||
|
/** Foreground-владелец камеры, микрофона и CPU lock; не восстанавливает live session после смерти процесса. */ |
||||||
|
class StreamService : Service() { |
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) |
||||||
|
private var capture: StreamCaptureEngine? = null |
||||||
|
private var lock: PowerManager.WakeLock? = null |
||||||
|
private var id = 0L |
||||||
|
private var stopping = false |
||||||
|
private var aborting = false |
||||||
|
private var poll: Job? = null |
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
||||||
|
if (intent?.action == "stop") { StreamController.stop(); return START_NOT_STICKY } |
||||||
|
if (id != 0L) return START_NOT_STICKY |
||||||
|
id = intent?.getLongExtra("id", 0) ?: 0 |
||||||
|
val audio = intent?.getBooleanExtra("audio", true) ?: true |
||||||
|
try { |
||||||
|
val notifications = getSystemService(NotificationManager::class.java) |
||||||
|
notifications.createNotificationChannel(NotificationChannel("live_stream", getString(R.string.stream_title), NotificationManager.IMPORTANCE_LOW)) |
||||||
|
val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE) |
||||||
|
val stop = PendingIntent.getService(this, 0, Intent(this, StreamService::class.java).setAction("stop"), PendingIntent.FLAG_IMMUTABLE) |
||||||
|
val notification = NotificationCompat.Builder(this, "live_stream").setSmallIcon(android.R.drawable.ic_menu_camera) |
||||||
|
.setContentTitle(getString(R.string.stream_live)).setContentIntent(open).setOngoing(true) |
||||||
|
.addAction(android.R.drawable.ic_media_pause, getString(R.string.stream_stop), stop).build() |
||||||
|
val type = if (Build.VERSION.SDK_INT >= 30) ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or |
||||||
|
(if (audio) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0) else 0 |
||||||
|
ServiceCompat.startForeground(this, 1004, notification, type) |
||||||
|
if (id == 0L || !StreamController.serviceReady(id, this)) { finish(); return START_NOT_STICKY } |
||||||
|
lock = getSystemService(PowerManager::class.java).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:live_stream").apply { |
||||||
|
setReferenceCounted(false); acquire() |
||||||
|
} |
||||||
|
val group = intent!!.getLongExtra("group", 0) |
||||||
|
capture = StreamCaptureEngine(this, id, group, intent.getStringExtra("camera") ?: error("Missing camera"), |
||||||
|
audio, intent.getIntExtra("rotation", 0)) { detail -> StreamController.failed(id, detail) }.also { it.start() } |
||||||
|
poll = scope.launch { |
||||||
|
val deadline = System.nanoTime() + 10_000_000_000 |
||||||
|
var opened = false |
||||||
|
while (true) { |
||||||
|
val event = NativeLib.streamPull(id) |
||||||
|
if (event != null) when (event[0].toInt()) { |
||||||
|
4 -> { opened = true; StreamController.opened(id) } |
||||||
|
5 -> { finish(); return@launch } |
||||||
|
6 -> { StreamController.failed(id, "native session closed"); shutdown(abort = true); return@launch } |
||||||
|
} |
||||||
|
if (!NativeLib.isReady() || (!opened && System.nanoTime() > deadline)) { |
||||||
|
StreamController.failed(id, "core stopped/open timeout"); shutdown(abort = true); return@launch |
||||||
|
} |
||||||
|
delay(20) |
||||||
|
} |
||||||
|
} |
||||||
|
LogManager.addLog("INFO", "stream", "foreground started id=$id group=$group microphone=$audio CPU lock=${lock?.isHeld}") |
||||||
|
} catch (e: Exception) { |
||||||
|
StreamController.failed(id, "foreground service: ${e.message}") |
||||||
|
shutdown(abort = true) |
||||||
|
} |
||||||
|
return START_NOT_STICKY |
||||||
|
} |
||||||
|
|
||||||
|
/** Producers завершатся до FIN; при уничтожении ядра очередь не ждём. */ |
||||||
|
fun shutdown(abort: Boolean = false) { |
||||||
|
aborting = aborting || abort |
||||||
|
if (stopping) return |
||||||
|
stopping = true |
||||||
|
val engine = capture |
||||||
|
if (engine == null) { NativeLib.streamStop(id); finish(); return } |
||||||
|
engine.stop { |
||||||
|
scope.launch { |
||||||
|
capture = null; NativeLib.streamStop(id) |
||||||
|
if (aborting) finish() |
||||||
|
else scope.launch { delay(4000); if (stopping) finish() } |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun finish() { |
||||||
|
poll?.cancel(); poll = null |
||||||
|
lock?.let { if (it.isHeld) it.release() }; lock = null |
||||||
|
StreamController.ended(id, this) |
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() |
||||||
|
} |
||||||
|
|
||||||
|
override fun onDestroy() { |
||||||
|
poll?.cancel() |
||||||
|
capture?.stop { NativeLib.streamStop(id) }; capture = null |
||||||
|
lock?.let { if (it.isHeld) it.release() }; lock = null |
||||||
|
StreamController.ended(id, this); scope.cancel() |
||||||
|
LogManager.addLog("INFO", "stream", "foreground destroyed id=$id") |
||||||
|
super.onDestroy() |
||||||
|
} |
||||||
|
override fun onBind(intent: Intent?): IBinder? = null |
||||||
|
} |
||||||
Loading…
Reference in new issue