diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamController.kt new file mode 100644 index 00000000..a7154a5a --- /dev/null +++ b/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(null) + val state: StateFlow = 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 + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamService.kt new file mode 100644 index 00000000..d4e5a149 --- /dev/null +++ b/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 +}