Browse Source

Own live camera sessions in an Android foreground service

master
evgeny 1 week ago
parent
commit
e4f2e647d7
  1. 99
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt
  2. 113
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamService.kt

99
tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt

@ -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
}
}

113
tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamService.kt

@ -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…
Cancel
Save