Own live camera sessions in an Android foreground service
This commit is contained in:
1 parent
c50d5066a9
commit
e4f2e647d7
2 files changed
+212
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user