diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 359df775..1f255bfc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -32,6 +32,7 @@ android { applicationId = "dev.openstream.app" minSdk = 29 targetSdk = 35 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" versionCode = openStreamVersionCode versionName = openStreamVersionName @@ -110,9 +111,13 @@ kotlin { dependencies { testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test:rules:1.6.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") // Unit tests run on the local JVM against android.jar stubs, where // org.json.JSONObject methods throw "not mocked". Ship the real // org.json implementation for tests only; on-device code keeps using // the platform's built-in org.json. testImplementation("org.json:json:20240303") + androidTestImplementation("androidx.test:core:1.6.1") } diff --git a/android/app/src/androidTest/java/dev/openstream/app/PhoneSessionServiceTest.kt b/android/app/src/androidTest/java/dev/openstream/app/PhoneSessionServiceTest.kt new file mode 100644 index 00000000..984c5889 --- /dev/null +++ b/android/app/src/androidTest/java/dev/openstream/app/PhoneSessionServiceTest.kt @@ -0,0 +1,131 @@ +package dev.openstream.app + +import android.Manifest +import android.os.Build +import android.app.NotificationManager +import android.content.Intent +import androidx.lifecycle.Lifecycle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import androidx.test.rule.ServiceTestRule +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import android.os.SystemClock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PhoneSessionServiceTest { + @get:Rule + val cameraPermission: GrantPermissionRule = GrantPermissionRule.grant( + *buildList { + add(Manifest.permission.CAMERA) + add(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add(Manifest.permission.POST_NOTIFICATIONS) + } + }.toTypedArray(), + ) + + @get:Rule + val serviceRule = ServiceTestRule() + + @Before + fun clearPriorStopState() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + context.getSharedPreferences(PhoneSessionService.PREFS_NAME, 0).edit() + .putBoolean(PhoneSessionService.KEY_STOPPED, false) + .commit() + } + + @Test + fun serviceKeepsTheSameOwnerAcrossActivityRecreationAndStopIsFinal() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val activity = ActivityScenario.launch(MainActivity::class.java) + val binder = serviceRule.bindService(Intent(context, PhoneSessionService::class.java)) + as PhoneSessionService.LocalBinder + val originalRuntime = binder.runtime() + waitForOwnerState(binder, SessionOwnerState.Foreground(null)) + activity.moveToState(Lifecycle.State.CREATED) + assertEquals(SessionOwnerState.Background(null), binder.lifecycleState()) + activity.moveToState(Lifecycle.State.RESUMED) + assertEquals(SessionOwnerState.Foreground(null), binder.lifecycleState()) + activity.recreate() + assertSame(originalRuntime, binder.runtime()) + + binder.stop() + assertEquals(PhoneSessionStatus.Stopped, binder.runtime().phoneSessionState.snapshot.status) + binder.activityResumed() + assertEquals(PhoneSessionStatus.Stopped, binder.runtime().phoneSessionState.snapshot.status) + activity.close() + } + + @Test + fun stoppedActivityRecreationDoesNotRestartForegroundServiceOrSession() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.cancelAll() + context.getSharedPreferences(PhoneSessionService.PREFS_NAME, 0).edit() + .putBoolean(PhoneSessionService.KEY_STOPPED, true) + .commit() + + ActivityScenario.launch(MainActivity::class.java).close() + val activity = ActivityScenario.launch(MainActivity::class.java) + Thread.sleep(6_500) + + activity.onActivity { current -> + val status = current.findViewById(R.id.statusText).text + assertEquals(context.getString(R.string.status_stopped), status) + } + val notifications = notificationManager.activeNotifications + assertEquals(emptyList(), notifications.map { it.id }) + assertEquals(true, context.getSharedPreferences(PhoneSessionService.PREFS_NAME, 0) + .getBoolean(PhoneSessionService.KEY_STOPPED, false)) + activity.close() + } + + @Test + fun revokingCameraPermissionStopsAndReleasesTheOwnedSession() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val activity = ActivityScenario.launch(MainActivity::class.java) + val binder = serviceRule.bindService(Intent(context, PhoneSessionService::class.java)) + as PhoneSessionService.LocalBinder + binder.activityResumed(cameraPermissionGranted = false) + + assertEquals(SessionOwnerState.PermissionRequired, binder.lifecycleState()) + assertEquals(PhoneSessionStatus.Stopped, binder.runtime().phoneSessionState.snapshot.status) + assertEquals(null, binder.runtime().reservationState.confirmedSourceInstanceId) + activity.close() + } + + @Test + fun startWithoutCameraPermissionKeepsAnExplicitStopAndDoesNotStartTheOwner() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val binder = serviceRule.bindService(Intent(context, PhoneSessionService::class.java)) + as PhoneSessionService.LocalBinder + binder.start(cameraPermissionGranted = false) + + assertEquals(SessionOwnerState.PermissionRequired, binder.lifecycleState()) + assertEquals(PhoneSessionStatus.Stopped, binder.runtime().phoneSessionState.snapshot.status) + assertEquals( + true, + context.getSharedPreferences(PhoneSessionService.PREFS_NAME, 0) + .getBoolean(PhoneSessionService.KEY_STOPPED, false), + ) + } + + private fun waitForOwnerState( + binder: PhoneSessionService.LocalBinder, + expected: SessionOwnerState, + ) { + val deadline = SystemClock.elapsedRealtime() + 5_000 + while (binder.lifecycleState() != expected && SystemClock.elapsedRealtime() < deadline) { + SystemClock.sleep(50) + } + assertEquals(expected, binder.lifecycleState()) + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0fbd7ba7..a59e3281 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,8 @@ + + @@ -54,5 +56,9 @@ android:exported="false" android:screenOrientation="portrait" android:configChanges="orientation|screenSize" /> + diff --git a/android/app/src/main/cpp/openstream_srt.cpp b/android/app/src/main/cpp/openstream_srt.cpp index 49adf29f..64f395e2 100644 --- a/android/app/src/main/cpp/openstream_srt.cpp +++ b/android/app/src/main/cpp/openstream_srt.cpp @@ -999,31 +999,37 @@ struct StreamState { bool connected = false; }; -StreamState g_state; +StreamState &streamState() { + // NativeSender calls srt_startup(), which depends on libsrt's own translation-unit + // globals. Construct it on first JNI use, after all shared-library initializers run. + static StreamState state; + return state; +} } // namespace extern "C" JNIEXPORT void JNICALL -Java_dev_openstream_app_stream_SrtNativeBridge_beginSession( +Java_dev_openstream_app_stream_SrtNativeBridge_nativeBeginSession( JNIEnv *, jobject, jlong session_generation) { const uint64_t generation = static_cast(session_generation); - std::lock_guard lock(g_state.mediaMutex); - if (generation < g_state.mediaSessionGeneration) { + StreamState &state = streamState(); + std::lock_guard lock(state.mediaMutex); + if (generation < state.mediaSessionGeneration) { return; } - g_state.mediaSessionGeneration = generation; - g_state.connected = false; - g_state.muxer.reset(); - g_state.codecConfig.clear(); - g_state.audioCodecConfig.clear(); - g_state.sender.advanceLifecycleGeneration(generation); - g_state.sender.disconnect(); + state.mediaSessionGeneration = generation; + state.connected = false; + state.muxer.reset(); + state.codecConfig.clear(); + state.audioCodecConfig.clear(); + state.sender.advanceLifecycleGeneration(generation); + state.sender.disconnect(); } extern "C" JNIEXPORT jboolean JNICALL -Java_dev_openstream_app_stream_SrtNativeBridge_connect( +Java_dev_openstream_app_stream_SrtNativeBridge_nativeConnect( JNIEnv *env, jobject, jstring url, @@ -1046,24 +1052,24 @@ Java_dev_openstream_app_stream_SrtNativeBridge_connect( } { - std::lock_guard lock(g_state.mediaMutex); - if (static_cast(session_generation) != g_state.mediaSessionGeneration) { + std::lock_guard lock(streamState().mediaMutex); + if (static_cast(session_generation) != streamState().mediaSessionGeneration) { return JNI_FALSE; } - g_state.muxer.emplace(*codec); - g_state.muxer->reset(); - g_state.codecConfig.clear(); - g_state.audioCodecConfig.clear(); - g_state.connected = false; + streamState().muxer.emplace(*codec); + streamState().muxer->reset(); + streamState().codecConfig.clear(); + streamState().audioCodecConfig.clear(); + streamState().connected = false; } - g_state.sender.disconnect(); + streamState().sender.disconnect(); const bool connected = - g_state.sender.connect(urlString, static_cast(session_generation)); + streamState().sender.connect(urlString, static_cast(session_generation)); bool published = false; { - std::lock_guard lock(g_state.mediaMutex); - if (static_cast(session_generation) == g_state.mediaSessionGeneration) { - g_state.connected = connected; + std::lock_guard lock(streamState().mediaMutex); + if (static_cast(session_generation) == streamState().mediaSessionGeneration) { + streamState().connected = connected; published = connected; } } @@ -1074,7 +1080,7 @@ Java_dev_openstream_app_stream_SrtNativeBridge_connect( } extern "C" JNIEXPORT jboolean JNICALL -Java_dev_openstream_app_stream_SrtNativeBridge_startListen( +Java_dev_openstream_app_stream_SrtNativeBridge_nativeStartListen( JNIEnv *env, jobject, jstring url, @@ -1097,24 +1103,24 @@ Java_dev_openstream_app_stream_SrtNativeBridge_startListen( } { - std::lock_guard lock(g_state.mediaMutex); - if (static_cast(session_generation) != g_state.mediaSessionGeneration) { + std::lock_guard lock(streamState().mediaMutex); + if (static_cast(session_generation) != streamState().mediaSessionGeneration) { return JNI_FALSE; } - g_state.muxer.emplace(*codec); - g_state.muxer->reset(); - g_state.codecConfig.clear(); - g_state.audioCodecConfig.clear(); - g_state.connected = false; + streamState().muxer.emplace(*codec); + streamState().muxer->reset(); + streamState().codecConfig.clear(); + streamState().audioCodecConfig.clear(); + streamState().connected = false; } - g_state.sender.disconnect(); + streamState().sender.disconnect(); const bool connected = - g_state.sender.startListen(urlString, static_cast(session_generation)); + streamState().sender.startListen(urlString, static_cast(session_generation)); bool published = false; { - std::lock_guard lock(g_state.mediaMutex); - if (static_cast(session_generation) == g_state.mediaSessionGeneration) { - g_state.connected = false; + std::lock_guard lock(streamState().mediaMutex); + if (static_cast(session_generation) == streamState().mediaSessionGeneration) { + streamState().connected = false; published = connected; } } @@ -1125,33 +1131,33 @@ Java_dev_openstream_app_stream_SrtNativeBridge_startListen( } extern "C" JNIEXPORT jint JNICALL -Java_dev_openstream_app_stream_SrtNativeBridge_accept( +Java_dev_openstream_app_stream_SrtNativeBridge_nativeAccept( JNIEnv *, jobject, jlong session_generation) { const uint64_t generation = static_cast(session_generation); { - std::lock_guard lock(g_state.mediaMutex); - if (generation != g_state.mediaSessionGeneration) { + std::lock_guard lock(streamState().mediaMutex); + if (generation != streamState().mediaSessionGeneration) { return NativeSender::kAcceptCancelled; } } - const int result = g_state.sender.acceptPending(generation); + const int result = streamState().sender.acceptPending(generation); { - std::lock_guard lock(g_state.mediaMutex); - if (generation != g_state.mediaSessionGeneration) { + std::lock_guard lock(streamState().mediaMutex); + if (generation != streamState().mediaSessionGeneration) { return NativeSender::kAcceptCancelled; } if (result == NativeSender::kAcceptConnected) { - g_state.connected = true; + streamState().connected = true; } else if (result == NativeSender::kAcceptFailed) { - g_state.connected = false; + streamState().connected = false; } } return result; } extern "C" JNIEXPORT jboolean JNICALL -Java_dev_openstream_app_stream_SrtNativeBridge_sendVideo( +Java_dev_openstream_app_stream_SrtNativeBridge_nativeSendVideo( JNIEnv *env, jobject, jbyteArray data, @@ -1162,55 +1168,55 @@ Java_dev_openstream_app_stream_SrtNativeBridge_sendVideo( std::vector bytes(static_cast(size)); env->GetByteArrayRegion(data, 0, size, reinterpret_cast(bytes.data())); - std::lock_guard lock(g_state.mediaMutex); - if (static_cast(session_generation) != g_state.mediaSessionGeneration || - !g_state.connected || !g_state.muxer) { + std::lock_guard lock(streamState().mediaMutex); + if (static_cast(session_generation) != streamState().mediaSessionGeneration || + !streamState().connected || !streamState().muxer) { return JNI_FALSE; } std::vector annexB = normalizeAnnexB(bytes.data(), bytes.size()); if ((flags & kMediaCodecBufferFlagCodecConfig) != 0) { - g_state.codecConfig = std::move(annexB); + streamState().codecConfig = std::move(annexB); logInfo("Stored codec config for keyframe pre-roll"); return JNI_TRUE; } const bool keyFrame = (flags & kMediaCodecBufferFlagKeyFrame) != 0; - if (keyFrame && !g_state.codecConfig.empty()) { - std::vector withConfig = g_state.codecConfig; + if (keyFrame && !streamState().codecConfig.empty()) { + std::vector withConfig = streamState().codecConfig; withConfig.insert(withConfig.end(), annexB.begin(), annexB.end()); annexB = std::move(withConfig); } std::vector ts = - g_state.muxer->muxAccessUnit(annexB, static_cast(presentation_time_us), keyFrame); - const bool sent = g_state.sender.send(std::move(ts)); + streamState().muxer->muxAccessUnit(annexB, static_cast(presentation_time_us), keyFrame); + const bool sent = streamState().sender.send(std::move(ts)); if (!sent) { - g_state.connected = false; + streamState().connected = false; } return sent ? JNI_TRUE : JNI_FALSE; } extern "C" JNIEXPORT void JNICALL -Java_dev_openstream_app_stream_SrtNativeBridge_disconnect( +Java_dev_openstream_app_stream_SrtNativeBridge_nativeDisconnect( JNIEnv *, jobject, jlong session_generation) { const uint64_t generation = static_cast(session_generation); - std::lock_guard lock(g_state.mediaMutex); - if (generation < g_state.mediaSessionGeneration) { + std::lock_guard lock(streamState().mediaMutex); + if (generation < streamState().mediaSessionGeneration) { return; } - g_state.mediaSessionGeneration = generation; - g_state.sender.advanceLifecycleGeneration(generation); - g_state.muxer.reset(); - g_state.codecConfig.clear(); - g_state.audioCodecConfig.clear(); - g_state.connected = false; - g_state.sender.disconnect(); + streamState().mediaSessionGeneration = generation; + streamState().sender.advanceLifecycleGeneration(generation); + streamState().muxer.reset(); + streamState().codecConfig.clear(); + streamState().audioCodecConfig.clear(); + streamState().connected = false; + streamState().sender.disconnect(); logInfo("SRT bridge disconnected"); } extern "C" JNIEXPORT jboolean JNICALL -Java_dev_openstream_app_stream_SrtNativeBridge_sendAudio( +Java_dev_openstream_app_stream_SrtNativeBridge_nativeSendAudio( JNIEnv *env, jobject, jbyteArray data, @@ -1221,23 +1227,23 @@ Java_dev_openstream_app_stream_SrtNativeBridge_sendAudio( std::vector bytes(static_cast(size)); env->GetByteArrayRegion(data, 0, size, reinterpret_cast(bytes.data())); - std::lock_guard lock(g_state.mediaMutex); - if (static_cast(session_generation) != g_state.mediaSessionGeneration || - !g_state.connected || !g_state.muxer) { + std::lock_guard lock(streamState().mediaMutex); + if (static_cast(session_generation) != streamState().mediaSessionGeneration || + !streamState().connected || !streamState().muxer) { return JNI_FALSE; } if ((flags & kMediaCodecBufferFlagCodecConfig) != 0) { - g_state.audioCodecConfig = std::move(bytes); + streamState().audioCodecConfig = std::move(bytes); logInfo("Stored audio codec config (ASC)"); return JNI_TRUE; } - std::vector ts = g_state.muxer->muxAudioAccessUnit( - bytes, g_state.audioCodecConfig, static_cast(presentation_time_us)); - const bool sent = g_state.sender.send(std::move(ts)); + std::vector ts = streamState().muxer->muxAudioAccessUnit( + bytes, streamState().audioCodecConfig, static_cast(presentation_time_us)); + const bool sent = streamState().sender.send(std::move(ts)); if (!sent) { - g_state.connected = false; + streamState().connected = false; } return sent ? JNI_TRUE : JNI_FALSE; } diff --git a/android/app/src/main/java/dev/openstream/app/MainActivity.kt b/android/app/src/main/java/dev/openstream/app/MainActivity.kt index b2f9eb6c..0a9daf32 100644 --- a/android/app/src/main/java/dev/openstream/app/MainActivity.kt +++ b/android/app/src/main/java/dev/openstream/app/MainActivity.kt @@ -5,6 +5,8 @@ import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.app.Activity import android.content.Intent +import android.content.ComponentName +import android.content.ServiceConnection import android.content.pm.PackageManager import android.graphics.Typeface import android.net.Uri @@ -12,6 +14,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.IBinder import android.util.Log import android.view.Gravity import android.view.MotionEvent @@ -25,21 +28,12 @@ import android.view.WindowManager import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView -import dev.openstream.app.camera.Camera2Controller import dev.openstream.app.camera.CameraLens -import dev.openstream.app.control.CameraControlServer import dev.openstream.app.discovery.DiscoveredObsDevice -import dev.openstream.app.discovery.ObsDiscoveryClient -import dev.openstream.app.discovery.PhoneDiscoveryAdvertiser -import dev.openstream.app.encoder.MediaCodecAudioEncoder -import dev.openstream.app.encoder.MediaCodecVideoEncoder import dev.openstream.app.stream.ConnectionTarget -import dev.openstream.app.stream.ListenerAcceptCoordinator -import dev.openstream.app.stream.SrtAcceptResult import dev.openstream.app.stream.StreamConfig -import dev.openstream.app.stream.SrtStreamClient -class MainActivity : Activity() { +class MainActivity : Activity(), PhoneSessionObserver { // ── Views ── private lateinit var cameraPreview: SurfaceView @@ -62,53 +56,74 @@ class MainActivity : Activity() { private lateinit var identifyOverlay: TextView private lateinit var bottomControls: LinearLayout - // ── Core components ── - private lateinit var camera: Camera2Controller - private lateinit var encoder: MediaCodecVideoEncoder - private lateinit var audioEncoder: MediaCodecAudioEncoder - private lateinit var streamClient: SrtStreamClient - private lateinit var phoneAdvertiser: PhoneDiscoveryAdvertiser - private lateinit var obsDiscoveryClient: ObsDiscoveryClient - private lateinit var controlServer: CameraControlServer - private val sessionWorker = SessionWorker { error -> - Log.e("shin", "Session lifecycle operation failed", error) - } - private val listenerAcceptCoordinator = ListenerAcceptCoordinator(sessionWorker, LISTENER_POLL_MS) - - private val streamConfig = StreamConfig.Default1080p30 + // Core state and resources belong to PhoneSessionService. This Activity only binds views, + // attaches its preview surface, and renders observed session events. + private lateinit var sessionRuntime: PhoneSessionRuntime + private val streamConfig get() = + if (::sessionRuntime.isInitialized) sessionRuntime.streamConfig else StreamConfig.Default1080p30 private val mainHandler = Handler(Looper.getMainLooper()) - private val reservationState = ReservationState() - private val phoneSessionState = PhoneSessionState() - @Volatile private var activeTargetName: String? = null - @Volatile private var phoneServerRunning = false - @Volatile private var phoneConnected = false - @Volatile private var selectedObsHost: String? = null - @Volatile private var listenerThread: Thread? = null - @Volatile private var callerConnecting = false - @Volatile private var callerGeneration = 0L - @Volatile private var pendingListenerStart = false - @Volatile private var listenerGeneration = 0L + private val reservationState get() = sessionRuntime.reservationState + private val phoneSessionState get() = sessionRuntime.phoneSessionState + private var activeTargetName: String? = null + private var phoneConnected = false + private var selectedObsHost: String? = null @Volatile private var activityStarted = false - @Volatile private var sessionWorkGeneration = 0L private var keepScreenOn = false private var displayOff = false private var originalBrightness = -1f private var torchOn = false - private var currentLens: CameraLens = CameraLens.Back + private var currentLens = CameraLens.Back private var availableLenses: List = listOf(CameraLens.Back) private lateinit var scaleGestureDetector: ScaleGestureDetector private var zoomHideRunnable: Runnable? = null private var liveDotAnimator: ObjectAnimator? = null - private var currentPort: Int = ConnectionTarget.DEFAULT_PORT - private var releaseReservationRunnable: Runnable? = null - private var reservationGeneration = 0L - private var lensRestartRunnable: Runnable? = null + private val currentPort: Int get() = sessionRuntime.currentPort private var identifyHideRunnable: Runnable? = null private var pendingConnectAfterSettings = false + private var pendingPairingIntent: Intent? = null private var currentDevices: List = emptyList() - private var activeStreamBitrate: Int = streamConfig.bitrate + private val activeStreamBitrate: Int get() = sessionRuntime.activeStreamBitrate private var lastObsSlotRenderKeys: List? = null - private val callerLifecycleLock = Any() + private var serviceBound = false + private var serviceBinder: PhoneSessionService.LocalBinder? = null + private var pendingPreviewSurface: android.view.Surface? = null + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val ownerBinder = service as? PhoneSessionService.LocalBinder ?: return + serviceBinder = ownerBinder + ownerBinder.activityResumed() + sessionRuntime = ownerBinder.runtime() + activeTargetName = sessionRuntime.activeTargetName + phoneConnected = sessionRuntime.phoneConnected + selectedObsHost = sessionRuntime.selectedObsHost + torchOn = sessionRuntime.torchEnabled + currentLens = sessionRuntime.currentLens + availableLenses = sessionRuntime.availableLenses + currentDevices = sessionRuntime.currentDevices + sessionRuntime.observer = this@MainActivity + pendingPreviewSurface?.let(sessionRuntime::bindPreviewSurface) + if (!sessionRuntime.isNativeSrtAvailable) { + statusDetail.text = "SRT unavailable: ${sessionRuntime.nativeSrtError ?: "missing native lib"}" + } + if (activityStarted) { + sessionRuntime.startComponents() + onLiveStateChanged(sessionRuntime.activeTargetName) + } + pendingPairingIntent?.let(::handlePairingIntent) + pendingPairingIntent = null + renderDisconnectVisibility() + onSessionStateChanged() + if (pendingConnectAfterSettings && activityStarted) { + pendingConnectAfterSettings = false + startStream(connectionTargetFromSettings()) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + serviceBinder = null + if (::sessionRuntime.isInitialized) sessionRuntime.observer = null + } + } private val reservedBy: String? get() = reservationState.confirmedSourceInstanceId @@ -119,8 +134,6 @@ class MainActivity : Activity() { private val advertisedReservationId: String? get() = reservationState.advertisedSourceInstanceId - private fun isPhoneBusy(): Boolean = reservationState.isBusy(phoneConnected) - private val statsTicker = object : Runnable { override fun run() { renderStreamStats() @@ -134,128 +147,32 @@ class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - requestRuntimePermissions() setContentView(R.layout.activity_main) bindViews() setupGestureDetector() - - currentPort = getSharedPreferences(SettingsActivity.PREFS_NAME, MODE_PRIVATE) - .getInt(SettingsActivity.KEY_LISTENING_PORT, ConnectionTarget.DEFAULT_PORT) - .takeIf { it in 1024..65535 } - ?: ConnectionTarget.DEFAULT_PORT - - streamClient = SrtStreamClient() - if (!streamClient.isNativeAvailable) { - statusDetail.text = - "SRT unavailable: ${streamClient.nativeLoadError?.message ?: "missing native lib"}" - } - phoneAdvertiser = PhoneDiscoveryAdvertiser( - context = applicationContext, - config = streamConfig, - port = currentPort, - busyProvider = { isPhoneBusy() }, - reservedByProvider = { advertisedReservationId }, - selectedObsHostProvider = { selectedObsHost }, - ) - obsDiscoveryClient = ObsDiscoveryClient( - context = applicationContext, - onDevicesChanged = { devices -> - currentDevices = devices - if (activityStarted) renderObsSlots(devices) - }, - ) - encoder = createVideoEncoder(activeStreamBitrate) - audioEncoder = MediaCodecAudioEncoder( - context = applicationContext, - sampleRate = streamConfig.audioSampleRate, - channelCount = streamConfig.audioChannelCount, - bitrate = streamConfig.audioBitrate, - onEncodedAccessUnit = { accessUnit -> - val result = streamClient.sendAudioAccessUnit(accessUnit) - if (result.recoveryRequired && - streamClient.isCurrentSessionGeneration(result.sessionGeneration) - ) { - handleMediaTransportFailure(result.sessionGeneration) - } - }, - ) - camera = Camera2Controller( - context = applicationContext, - previewSurfaceProvider = { cameraPreview.holder.surface }, - lensProvider = { currentLens }, - targetFps = streamConfig.fps, - ) - controlServer = CameraControlServer( - cameraProvider = { camera }, - lensListProvider = { availableLenses }, - currentLensProvider = { currentLens }, - onSwitchLens = { lens -> runOnUiThread { selectLens(lens) } }, - onToggleTorch = { enabled -> runOnUiThread { - torchOn = enabled - sessionWorker.submit { camera.setTorch(enabled) } - setTorchUi(enabled) - }}, - reservationProvider = { reservedBy }, - onReserve = { sourceInstanceId, slotLabel, bitrateMbps -> - reserveForSource(sourceInstanceId, slotLabel, bitrateMbps).also { accepted -> - if (accepted) { - runOnUiThread { - statusText.text = "Paired to ${slotLabel.ifBlank { "OBS computer" }}" - statusDetail.text = "OBS acknowledged; waiting to go live" - renderDisconnectVisibility() - renderObsSlots(currentDevices) - } - } - } - }, - onRelease = { sourceInstanceId -> releaseForSource(sourceInstanceId) }, - onIdentify = { label, subtitle -> runOnUiThread { showIdentifyOverlay(label, subtitle) } }, - onError = { message -> runOnUiThread { statusDetail.text = message } }, - ) - - cameraPreview.holder.addCallback(object : SurfaceHolder.Callback { - override fun surfaceCreated(holder: SurfaceHolder) { - sessionWorkGeneration += 1 - initializeLenses() - startPreviewIfAllowed() - startPhoneServerIfAllowed() - } - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - // Fix stretched preview: adjust SurfaceView to maintain camera aspect ratio - adjustPreviewAspectRatio() - } - override fun surfaceDestroyed(holder: SurfaceHolder) { - sessionWorkGeneration += 1 - stopPhoneServer(clearReservation = false, updateStatus = false, stopCamera = true) - } - }) - + requestRuntimePermissions() + bindPreviewSurface() setupButtons() setupNavBarInsets() - handlePairingIntent(intent) + pendingPairingIntent = intent } override fun onStart() { super.onStart() activityStarted = true - sessionWorker.submit { - phoneAdvertiser.start() - obsDiscoveryClient.start() - controlServer.start() - } - startPreviewIfAllowed() - startPhoneServerIfAllowed() + startAndBindSessionService() } override fun onResume() { super.onResume() + serviceBinder?.activityResumed() // Reload listening port from settings if it changed val settingsPrefs = getSharedPreferences(SettingsActivity.PREFS_NAME, MODE_PRIVATE) - val savedPort = settingsPrefs.getInt(SettingsActivity.KEY_LISTENING_PORT, currentPort) - if (savedPort != currentPort && savedPort in 1024..65535) { + val savedPort = settingsPrefs.getInt(SettingsActivity.KEY_LISTENING_PORT, ConnectionTarget.DEFAULT_PORT) + if (::sessionRuntime.isInitialized && savedPort != currentPort && savedPort in 1024..65535) { changePort(savedPort) } - if (pendingConnectAfterSettings) { + if (::sessionRuntime.isInitialized && pendingConnectAfterSettings) { pendingConnectAfterSettings = false startStream(connectionTargetFromSettings()) } @@ -263,30 +180,21 @@ class MainActivity : Activity() { override fun onStop() { activityStarted = false - sessionWorkGeneration += 1 - cancelLensRestart() stopLiveDotAnimation() - val session = phoneSessionState.snapshot - if (session.status == PhoneSessionStatus.Live || - session.status == PhoneSessionStatus.Connecting - ) { - phoneSessionState.connectionLost(session.generation) + mainHandler.removeCallbacks(statsTicker) + serviceBinder?.activityHidden() + if (::sessionRuntime.isInitialized) sessionRuntime.observer = null + if (serviceBound) { + unbindService(serviceConnection) + serviceBound = false } - stopPhoneServer( - clearReservation = false, - updateStatus = false, - stopCamera = true, - stopComponents = true, - ) super.onStop() } override fun onDestroy() { - clearReservation() identifyHideRunnable?.let(mainHandler::removeCallbacks) identifyHideRunnable = null mainHandler.removeCallbacksAndMessages(null) - sessionWorker.close() super.onDestroy() } @@ -309,9 +217,7 @@ class MainActivity : Activity() { if (!audioGranted) { statusDetail.text = "Microphone denied; streaming video without audio" } - initializeLenses() - startPreviewIfAllowed() - startPhoneServerIfAllowed() + startAndBindSessionService() } @Deprecated("Uses the platform Activity result API to avoid an AndroidX dependency") @@ -327,7 +233,7 @@ class MainActivity : Activity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - handlePairingIntent(intent) + if (::sessionRuntime.isInitialized) handlePairingIntent(intent) else pendingPairingIntent = intent } override fun onTouchEvent(event: MotionEvent?): Boolean { @@ -370,6 +276,7 @@ class MainActivity : Activity() { startActivityForResult(intent, SETTINGS_REQUEST_CODE) } btnStop.setOnClickListener { + if (!::sessionRuntime.isInitialized) return@setOnClickListener when (actionForSessionStatus(phoneSessionState.snapshot.status)) { PhoneSessionAction.Start -> startPhoneSession() PhoneSessionAction.Stop -> stopPhoneSession() @@ -381,75 +288,14 @@ class MainActivity : Activity() { screenOffOverlay.setOnClickListener { toggleDisplayOff() } } - private fun createVideoEncoder(bitrate: Int): MediaCodecVideoEncoder { - return MediaCodecVideoEncoder( - preference = streamConfig.codecPreference, - width = streamConfig.width, - height = streamConfig.height, - fps = streamConfig.fps, - bitrate = bitrate, - keyframeIntervalSeconds = streamConfig.keyframeIntervalSeconds, - onEncodedAccessUnit = { accessUnit -> - val result = streamClient.sendVideoAccessUnit(accessUnit) - if (result.recoveryRequired && - streamClient.isCurrentSessionGeneration(result.sessionGeneration) - ) { - handleMediaTransportFailure(result.sessionGeneration) - } - }, - ) - } - private fun handleMediaTransportFailure(sessionGeneration: Long) { - if (!streamClient.isCurrentSessionGeneration(sessionGeneration)) return - mainHandler.post { - // A replacement connect/listen may win the race between the encoder - // callback and this UI task. Never let the old failure tear it down. - if (!streamClient.isCurrentSessionGeneration(sessionGeneration)) return@post - phoneConnected = false - if (phoneServerRunning) { - if (activeTargetName != null) { - statusText.text = "Connection lost" - statusText.setTextColor(getColor(R.color.os_warning)) - statusDetail.text = "Waiting for OBS to reconnect" - } - return@post - } - if (activeTargetName == null && !callerConnecting) return@post - phoneSessionState.connectionLost(phoneSessionState.snapshot.generation) - stopStream(updateStatus = false) - startPreviewIfAllowed() - sessionWorker.submit { - mainHandler.post { startPhoneServerIfAllowed() } - } - statusText.text = "Connection lost" - statusText.setTextColor(getColor(R.color.os_warning)) - statusDetail.text = getString(R.string.status_waiting) - } - } - - private fun useStreamBitrate(bitrateMbps: Int?) { - val nextBitrate = (bitrateMbps ?: streamConfig.bitrateMbps) - .coerceIn(StreamConfig.MIN_BITRATE_MBPS, StreamConfig.MAX_BITRATE_MBPS) * 1_000_000 - synchronized(callerLifecycleLock) { - if (activeStreamBitrate == nextBitrate) return - activeStreamBitrate = nextBitrate - if (activeTargetName == null) { - val previousEncoder = encoder - encoder = createVideoEncoder(activeStreamBitrate) - sessionWorker.submit { previousEncoder.stop() } - } - } + if (::sessionRuntime.isInitialized) sessionRuntime.handleMediaTransportFailure(sessionGeneration) } private fun setupGestureDetector() { scaleGestureDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScale(detector: ScaleGestureDetector): Boolean { - val newZoom = camera.zoomRatio * detector.scaleFactor - sessionWorker.submit { - val appliedZoom = camera.setZoom(newZoom) - mainHandler.post { showZoomLabel(appliedZoom) } - } + if (::sessionRuntime.isInitialized) sessionRuntime.scaleZoom(detector.scaleFactor) return true } }) @@ -463,24 +309,6 @@ class MainActivity : Activity() { // ─────────────────────────── Lens switching ─────────────────────────── - private fun initializeLenses() { - if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) return - val generation = sessionWorkGeneration - sessionWorker.submitIfCurrent(generation, { sessionWorkGeneration }) { - val lenses = camera.availableLenses() - mainHandler.post { - if (sessionWorkGeneration != generation || - checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED - ) return@post - availableLenses = lenses - if (currentLens !in availableLenses) { - currentLens = availableLenses.firstOrNull { it.isBackFacing } ?: availableLenses.first() - } - buildLensButtons() - } - } - } - private fun buildLensButtons() { lensSelectorRow.removeAllViews() for (lens in availableLenses) { @@ -506,61 +334,7 @@ class MainActivity : Activity() { } private fun selectLens(lens: CameraLens) { - if (lens == currentLens) return - // Turn off torch when switching cameras - if (torchOn) { - torchOn = false - setTorchUi(false) - } - val wasStreaming = activeTargetName != null - // Stop the encoder before switching cameras to avoid surface conflicts - currentLens = lens - val lensGeneration = ++sessionWorkGeneration - sessionWorker.submitIfCurrent(lensGeneration, { sessionWorkGeneration }) { - runCatching { - if (wasStreaming) { - camera.stopStreaming() - encoder.stop() - } - camera.switchLens(lens) - }.onFailure { error -> - Log.e("shin", "Lens switch failed", error) - mainHandler.post { - if (sessionWorkGeneration == lensGeneration) { - statusDetail.text = error.message ?: "Lens switch failed" - } - } - } - } - // If we were streaming, re-create the encoder and re-attach after the camera settles. - // MediaCodecList probing is heavy; never run encoder.start() on the UI thread. - if (wasStreaming) { - cancelLensRestart() - val restart = Runnable { - lensRestartRunnable = null - if (!activityStarted || activeTargetName == null) return@Runnable - sessionWorker.submitIfCurrent(lensGeneration, { sessionWorkGeneration }) { - if (!activityStarted || activeTargetName == null) return@submitIfCurrent - runCatching { - synchronized(callerLifecycleLock) { - encoder.start() - camera.startStreaming(encoder.inputSurface()) - } - }.onFailure { error -> - Log.e("shin", "Failed to restart encoder after lens switch", error) - mainHandler.post { - if (sessionWorkGeneration == lensGeneration) { - statusText.text = "Encoder error" - statusDetail.text = error.message ?: "Unknown" - } - } - } - } - } - lensRestartRunnable = restart - mainHandler.postDelayed(restart, LENS_RESTART_DELAY_MS) - } - buildLensButtons() + if (::sessionRuntime.isInitialized) sessionRuntime.switchLens(lens) } private fun flipCamera() { @@ -572,11 +346,6 @@ class MainActivity : Activity() { selectLens(target) } - private fun cancelLensRestart() { - lensRestartRunnable?.let(mainHandler::removeCallbacks) - lensRestartRunnable = null - } - // ─────────────────────────── Keep screen on ─────────────────────────── private fun toggleKeepScreenOn() { @@ -597,11 +366,12 @@ class MainActivity : Activity() { // ─────────────────────────── Torch ─────────────────────────── private fun toggleTorch() { + if (!::sessionRuntime.isInitialized) return // Only works on back-facing cameras if (currentLens.isFrontFacing) return torchOn = !torchOn val enabled = torchOn - sessionWorker.submit { camera.setTorch(enabled) } + sessionRuntime.setTorch(enabled) setTorchUi(torchOn) } @@ -760,398 +530,27 @@ class MainActivity : Activity() { } private fun startStream(target: ConnectionTarget) { - if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped) return - stopStream(updateStatus = false) - // Caller mode and listener mode share one native SRT transport. Fully - // stop the listener before opening a manual caller connection. - stopPhoneServer(clearReservation = true, updateStatus = false) - useStreamBitrate(target.bitrateMbps) - statusText.text = "Connecting…" - statusDetail.text = "${currentLens.displayName} → ${target.name}" - val sessionGeneration = phoneSessionState.beginConnection() - renderDisconnectVisibility() - val generation = callerGeneration + 1 - callerGeneration = generation - callerConnecting = true - val accepted = sessionWorker.submit { - try { - // This operation runs after the queued teardown and owns the - // caller socket, codec, and camera transition as one serial unit. - streamClient.connect( - url = target.toSrtCallerUrl(), - codecMime = encoder.codecName, - width = streamConfig.width, - height = streamConfig.height, - fps = streamConfig.fps, - ) - check(callerGeneration == generation) { "SRT caller connection was cancelled" } - check(phoneSessionState.snapshot.generation == sessionGeneration) { - "Phone session was superseded" - } - synchronized(callerLifecycleLock) { - encoder.start() - startAudioIfAllowed() - camera.startStreaming(encoder.inputSurface()) - } - mainHandler.post { - if (callerGeneration != generation || - phoneSessionState.snapshot.generation != sessionGeneration - ) return@post - phoneSessionState.connected(sessionGeneration) - activeTargetName = target.name - mainHandler.removeCallbacks(statsTicker) - mainHandler.post(statsTicker) - showLiveState(target.name) - } - } catch (error: Throwable) { - synchronized(callerLifecycleLock) { - if (callerGeneration == generation) { - streamClient.disconnect() - camera.stopStreaming() - encoder.stop() - audioEncoder.stop() - } - } - mainHandler.post { - if (callerGeneration != generation) return@post - phoneSessionState.failed(sessionGeneration) - startPreviewIfAllowed() - startPhoneServerIfAllowed() - statusText.text = "Connection failed" - statusDetail.text = error.message ?: "Unknown error" - } - } finally { - if (callerGeneration == generation) callerConnecting = false - } - } - if (!accepted) { - callerConnecting = false - phoneSessionState.failed(sessionGeneration) - statusText.text = "Session busy" - statusDetail.text = "Wait for the current camera transition to finish" - } - } - - private fun startAudioIfAllowed() { - if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - Log.i("shin", "Microphone permission not granted; streaming video without audio") - return - } - runCatching { audioEncoder.start() }.onFailure { e -> - Log.w("shin", "Audio encoder start failed; continuing video-only", e) - } - } - - private fun startPhoneServerIfAllowed() { - if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped) return - if (phoneServerRunning) return - if (listenerThread?.isAlive == true) { - pendingListenerStart = true - Log.w("shin", "Previous SRT listener is still stopping; not starting another") - return - } - if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) return - if (!cameraPreview.holder.surface.isValid) return - - pendingListenerStart = false - val generation = listenerGeneration + 1 - listenerGeneration = generation - phoneServerRunning = true - phoneConnected = false - activeTargetName = null - statusText.text = getString(R.string.status_ready) - statusText.setTextColor(getColor(R.color.os_text_primary)) - statusDetail.text = getString(R.string.status_waiting) - renderDisconnectVisibility() - - val thread = Thread({ - try { - while (isListenerActive(generation)) { - val listenUrl = "srt://0.0.0.0:${currentPort}?mode=listener&latency=${streamConfig.latencyMs}" - val attemptGeneration = phoneSessionState.snapshot.generation - var connectedSessionGeneration: Long? = null - val listenResult = runCatching { - val acceptResult = listenerAcceptCoordinator.awaitAccepted( - expectedGeneration = generation, - currentGeneration = { listenerGeneration }, - startListening = { - streamClient.startListening( - url = listenUrl, - codecMime = encoder.codecName, - width = streamConfig.width, - height = streamConfig.height, - fps = streamConfig.fps, - ) - }, - acceptPending = streamClient::acceptPending, - ) - if (!isListenerActive(generation)) return@runCatching - check(acceptResult == SrtAcceptResult.Connected) { - "SRT listener accept failed: $acceptResult" - } - phoneConnected = true - val sessionGeneration = phoneSessionState.beginConnection() - connectedSessionGeneration = sessionGeneration - cancelReservationRelease() - val liveTargetName = reservedSlotLabel ?: "OBS" - activeTargetName = liveTargetName - runOnUiThread { - if (!isListenerActive(generation)) return@runOnUiThread - showLiveState(liveTargetName) - mainHandler.removeCallbacks(statsTicker) - mainHandler.post(statsTicker) - } - sessionWorker.submitIfCurrent(generation, { listenerGeneration }) { - runCatching { - encoder.start() - startAudioIfAllowed() - camera.startStreaming(encoder.inputSurface()) - check(phoneSessionState.connected(sessionGeneration)) { - "Phone session was superseded" - } - }.onFailure { error -> - if (isListenerActive(generation)) { - phoneConnected = false - phoneSessionState.failed(sessionGeneration) - mainHandler.post { - if (!isListenerActive(generation)) return@post - statusText.text = "Listener error" - statusDetail.text = error.message ?: "Unknown error" - } - sessionWorker.submitCritical { - camera.stopStreaming() - encoder.stop() - audioEncoder.stop() - } - } - } - } - while (isListenerActive(generation) && phoneConnected) { - Thread.sleep(LISTENER_POLL_MS) - } - } - - if (listenResult.isFailure && isListenerActive(generation)) { - val error = listenResult.exceptionOrNull() - phoneSessionState.failed(connectedSessionGeneration ?: attemptGeneration) - runOnUiThread { - if (!isListenerActive(generation)) return@runOnUiThread - statusText.text = "Listener error" - statusDetail.text = error?.message ?: "Unknown" - } - try { - Thread.sleep(LISTENER_RETRY_MS) - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - } - } - - if (isListenerActive(generation)) { - sessionWorker.submitCritical { - camera.stopStreaming() - encoder.stop() - audioEncoder.stop() - } - } - phoneConnected = false - activeTargetName = null - if (isListenerActive(generation)) { - connectedSessionGeneration?.let(phoneSessionState::connectionLost) - scheduleReservationRelease() - runOnUiThread { - if (!isListenerActive(generation)) return@runOnUiThread - hideLiveState() - statusText.text = getString(R.string.status_ready) - statusDetail.text = reservedSlotLabel?.let { "Holding $it for reconnect" } - ?: getString(R.string.status_waiting) - } - } - } - } finally { - if (listenerThread === Thread.currentThread()) { - listenerThread = null - mainHandler.post { - if (pendingListenerStart) { - pendingListenerStart = false - startPhoneServerIfAllowed() - } - } - } - } - }, "shinPhoneSrtListener").apply { - isDaemon = true - } - listenerThread = thread - thread.start() - } - - private fun isListenerActive(generation: Long): Boolean { - return phoneServerRunning && listenerGeneration == generation - } - - private fun stopPhoneServer( - clearReservation: Boolean = true, - updateStatus: Boolean = true, - stopCamera: Boolean = false, - stopComponents: Boolean = false, - ) { - // Flip flags and update views on the caller thread. Socket, camera, - // codec, and thread teardown runs on the bounded session worker. - callerGeneration += 1 - callerConnecting = false - pendingListenerStart = false - listenerGeneration += 1 - phoneServerRunning = false - phoneConnected = false - if (clearReservation) clearReservation() - activeTargetName = null - mainHandler.removeCallbacks(statsTicker) - val thread = listenerThread - thread?.interrupt() - hideLiveState() - // Keep disconnect/release discoverable when Reserved but not live so the - // stuck-reserved state always has a one-tap escape hatch. ReservationState - // covers both pending (advertised) and confirmed selections. - renderDisconnectVisibility() - - val blockingWork: () -> Unit = { - streamClient.disconnect() - if (thread != null && thread !== Thread.currentThread()) { - runCatching { thread.join(LISTENER_STOP_TIMEOUT_MS) } - .onFailure { Thread.currentThread().interrupt() } - } - if (thread?.isAlive == true) { - Log.w("shin", "SRT listener did not stop within ${LISTENER_STOP_TIMEOUT_MS}ms") - } else if (listenerThread === thread) { - listenerThread = null - } - synchronized(callerLifecycleLock) { - // stopActiveEncoding touches views when updateStatus; post that part. - val needsStatus = updateStatus - cancelLensRestart() - // Camera/encoder stops are safe off-UI (synchronized internally). - camera.stopStreaming() - encoder.stop() - audioEncoder.stop() - if (stopCamera) camera.stop() - if (stopComponents) { - obsDiscoveryClient.stop() - phoneAdvertiser.stop() - controlServer.stop() - } - if (needsStatus) { - mainHandler.post { - statusText.text = getString(R.string.status_stopped) - statusDetail.text = "Camera preview remains active" - } - } - } - mainHandler.post { - hideLiveState() - if (pendingListenerStart) startPhoneServerIfAllowed() - } - } - sessionWorker.submitCritical(blockingWork) + if (::sessionRuntime.isInitialized) sessionRuntime.startStream(target) } private fun stopStream(updateStatus: Boolean = true) { - callerGeneration += 1 - callerConnecting = false - activeTargetName = null - mainHandler.removeCallbacks(statsTicker) - phoneConnected = false - sessionWorker.submitCritical { - streamClient.disconnect() - synchronized(callerLifecycleLock) { - cancelLensRestart() - camera.stopStreaming() - encoder.stop() - audioEncoder.stop() - } - if (updateStatus) mainHandler.post { - statusText.text = getString(R.string.status_stopped) - statusDetail.text = "Camera preview remains active" - } - } - hideLiveState() + if (::sessionRuntime.isInitialized) sessionRuntime.stopStream(updateStatus) } // ─────────────────────────── Live state UI ─────────────────────────── - @Synchronized private fun selectForSource( sourceInstanceId: String, slotLabel: String = "", bitrateMbps: Int? = null, ): Boolean { - if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped) return false - val selection = ReservationSelection( + if (!::sessionRuntime.isInitialized) return false + return sessionRuntime.selectForSource( sourceInstanceId = sourceInstanceId, slotLabel = slotLabel, bitrateMbps = bitrateMbps, obsHost = selectedObsHost, ) - if (!reservationState.beginSelection(selection)) return false - phoneSessionState.select(sourceInstanceId) - useStreamBitrate(bitrateMbps) - reservationGeneration += 1 - renderDisconnectVisibility() - if (phoneConnected) { - cancelReservationRelease() - } else { - schedulePendingRelease(sourceInstanceId) - } - return true - } - - @Synchronized - private fun reserveForSource( - sourceInstanceId: String, - slotLabel: String = "", - bitrateMbps: Int? = null, - ): Boolean { - if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped) return false - val currentReservation = reservationState.confirmedSourceInstanceId - if (phoneConnected && currentReservation != null && currentReservation != sourceInstanceId) return false - val effectiveSlot = slotLabel.ifBlank { - reservationState.confirmedReservation?.slotLabel.orEmpty() - } - if (!reservationState.confirm(sourceInstanceId, effectiveSlot, bitrateMbps)) return false - if (!phoneSessionState.reserve(sourceInstanceId)) return false - useStreamBitrate(bitrateMbps) - reservationGeneration += 1 - renderDisconnectVisibility() - if (phoneConnected) { - cancelReservationRelease() - } else { - scheduleReservationRelease() - } - return true - } - - @Synchronized - private fun releaseForSource(sourceInstanceId: String): Boolean { - val confirmed = reservationState.confirmedSourceInstanceId - if (confirmed == sourceInstanceId) { - phoneSessionState.disconnect() - clearReservation() - return true - } - // Allow releasing a matching pending selection so it cannot stick forever. - if (confirmed == null && reservationState.pendingSourceInstanceId == sourceInstanceId) { - phoneSessionState.disconnect() - clearReservation() - return true - } - return confirmed == null && reservationState.pendingSourceInstanceId == null - } - - @Synchronized - private fun clearReservation() { - cancelReservationRelease() - reservationState.clear() - selectedObsHost = null - renderDisconnectVisibility() } private fun renderDisconnectVisibility() { @@ -1175,91 +574,27 @@ class MainActivity : Activity() { } private fun stopPhoneSession() { - phoneSessionState.stop() - stopPhoneServer(clearReservation = true) + requestSessionStop() statusText.text = getString(R.string.status_stopped) statusDetail.text = "Camera is stopped. Tap Start to make it available again." renderDisconnectVisibility() } private fun startPhoneSession() { - phoneSessionState.start() + requestSessionStart() statusText.text = getString(R.string.status_ready) statusDetail.text = getString(R.string.status_waiting) - startPreviewIfAllowed() - startPhoneServerIfAllowed() renderDisconnectVisibility() } private fun disconnectPhoneSession() { - phoneSessionState.disconnect() - clearReservation() + if (!::sessionRuntime.isInitialized) return + sessionRuntime.disconnectReservation() statusText.text = "Disconnected" statusDetail.text = "Available for an OBS connection" renderDisconnectVisibility() } - @Synchronized - private fun schedulePendingRelease(sourceInstanceId: String) { - val generation = reservationGeneration - val sessionGeneration = phoneSessionState.snapshot.generation - cancelReservationRelease() - releaseReservationRunnable = Runnable { - synchronized(this) { - if (!phoneConnected && - reservationGeneration == generation && - reservationState.confirmedSourceInstanceId == null - ) { - reservationState.rollbackPending(sourceInstanceId) - phoneSessionState.timeout(sessionGeneration) - renderDisconnectVisibility() - if (reservationState.pendingSelection == null && - reservationState.confirmedReservation == null - ) { - selectedObsHost = null - statusText.text = "Connection timed out" - statusDetail.text = "Choose an OBS slot to try again" - } - } - } - } - mainHandler.postDelayed(releaseReservationRunnable!!, RECONNECT_RESERVATION_MS) - } - - @Synchronized - private fun scheduleReservationRelease() { - val sourceInstanceId = reservationState.confirmedSourceInstanceId ?: return - val generation = reservationGeneration - val sessionGeneration = phoneSessionState.snapshot.generation - cancelReservationRelease() - releaseReservationRunnable = Runnable { - synchronized(this) { - if (!phoneConnected && - reservationState.confirmedSourceInstanceId == sourceInstanceId && - reservationGeneration == generation - ) { - reservationState.release(sourceInstanceId) - phoneSessionState.timeout(sessionGeneration) - renderDisconnectVisibility() - if (reservationState.confirmedReservation == null && - reservationState.pendingSelection == null - ) { - selectedObsHost = null - statusText.text = "Disconnected" - statusDetail.text = "Choose an OBS slot to reconnect" - } - } - } - } - mainHandler.postDelayed(releaseReservationRunnable!!, RECONNECT_RESERVATION_MS) - } - - @Synchronized - private fun cancelReservationRelease() { - releaseReservationRunnable?.let { mainHandler.removeCallbacks(it) } - releaseReservationRunnable = null - } - private fun showIdentifyOverlay(label: String, subtitle: String) { val text = if (subtitle.isBlank()) label else "$label\n$subtitle" identifyHideRunnable?.let(mainHandler::removeCallbacks) @@ -1303,7 +638,7 @@ class MainActivity : Activity() { private fun renderStreamStats(forceFailure: Boolean = false) { val targetName = activeTargetName ?: return - val stats = streamClient.stats + val stats = sessionRuntime.streamStats val megabits = stats.bytesSent * 8.0 / 1_000_000.0 if (forceFailure || stats.sendFailures > 0) { @@ -1334,21 +669,151 @@ class MainActivity : Activity() { // ─────────────────────────── Utilities ─────────────────────────── - private fun startPreviewIfAllowed() { - if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && - cameraPreview.holder.surface.isValid - ) { - val generation = sessionWorkGeneration - sessionWorker.submitIfCurrent(generation, { sessionWorkGeneration }) { - if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && - cameraPreview.holder.surface.isValid - ) camera.startPreview() + private fun bindPreviewSurface() { + cameraPreview.holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + pendingPreviewSurface = holder.surface + if (!::sessionRuntime.isInitialized) return + sessionRuntime.bindPreviewSurface(holder.surface) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + adjustPreviewAspectRatio() + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + pendingPreviewSurface = null + if (!::sessionRuntime.isInitialized) return + sessionRuntime.bindPreviewSurface(null) + } + }) + cameraPreview.holder.surface.takeIf { it.isValid }?.let { surface -> + pendingPreviewSurface = surface + } + } + + private fun startAndBindSessionService() { + val intent = Intent(this, PhoneSessionService::class.java) + if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + // The Activity is foreground here. Let the service decide whether the persisted + // session is active before it promotes itself, so a prior Stop cannot trigger an + // Android foreground-service start timeout on relaunch. + startService(intent) + } + if (!serviceBound) serviceBound = bindService(intent, serviceConnection, BIND_AUTO_CREATE) + } + + private fun requestSessionStop() { + startService(Intent(this, PhoneSessionService::class.java).setAction(PhoneSessionService.ACTION_STOP)) + } + + private fun requestSessionStart() { + val intent = Intent(this, PhoneSessionService::class.java).setAction(PhoneSessionService.ACTION_START) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent) + else startService(intent) + } + + override fun onDevicesChanged(devices: List) { + runOnUiThread { + currentDevices = devices + if (activityStarted) renderObsSlots(devices) + } + } + + override fun onLensListChanged(lenses: List, selectedLens: CameraLens) { + runOnUiThread { + availableLenses = lenses + currentLens = selectedLens + if (activityStarted) buildLensButtons() + } + } + + override fun onTorchChanged(enabled: Boolean) { + runOnUiThread { + torchOn = enabled + setTorchUi(enabled) + } + } + + override fun onZoomChanged(ratio: Float) { + runOnUiThread { if (activityStarted) showZoomLabel(ratio) } + } + + override fun onReservationChanged() { + runOnUiThread { + if (!activityStarted) return@runOnUiThread + statusText.text = "Paired to ${reservedSlotLabel ?: "OBS computer"}" + statusDetail.text = "OBS acknowledged; waiting to go live" + renderDisconnectVisibility() + renderObsSlots(currentDevices) + } + } + + override fun onSessionStateChanged() { + runOnUiThread { + if (!activityStarted) return@runOnUiThread + when (phoneSessionState.snapshot.status) { + PhoneSessionStatus.Available -> { + statusText.text = getString(R.string.status_ready) + statusDetail.text = getString(R.string.status_waiting) + } + PhoneSessionStatus.Stopped -> { + statusText.text = getString(R.string.status_stopped) + statusDetail.text = "Camera is stopped. Tap Start to make it available again." + } + else -> Unit + } + renderDisconnectVisibility() + } + } + + override fun onIdentify(label: String, subtitle: String) { + runOnUiThread { showIdentifyOverlay(label, subtitle) } + } + + override fun onSessionError(message: String) { + runOnUiThread { if (activityStarted) statusDetail.text = message } + } + + override fun onMediaTransportFailure(sessionGeneration: Long) { + handleMediaTransportFailure(sessionGeneration) + } + + override fun onLiveStateChanged(targetName: String?) { + runOnUiThread { + activeTargetName = targetName + phoneConnected = targetName != null + if (!activityStarted) return@runOnUiThread + if (targetName == null) { + hideLiveState() + mainHandler.removeCallbacks(statsTicker) + } else { + showLiveState(targetName) + mainHandler.removeCallbacks(statsTicker) + mainHandler.post(statsTicker) } } } + override fun onStatusChanged(title: String, detail: String) { + runOnUiThread { + if (!activityStarted) return@runOnUiThread + statusText.text = title + statusDetail.text = detail + } + } + + override fun onDisconnectVisibilityChanged() { + runOnUiThread { if (activityStarted) renderDisconnectVisibility() } + } + private fun requestRuntimePermissions() { - val missing = REQUIRED_PERMISSIONS.filter { + val required = buildList { + add(Manifest.permission.CAMERA) + add(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) add(Manifest.permission.POST_NOTIFICATIONS) + } + val missing = required.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } if (missing.isNotEmpty()) { @@ -1406,36 +871,10 @@ class MainActivity : Activity() { // ─────────────────────────── Port selector ─────────────────────────── private fun changePort(newPort: Int) { + if (!::sessionRuntime.isInitialized) return val clamped = newPort.coerceIn(1024, 65535) if (clamped == currentPort) return - // Gate restart on prior mode: never flip an active caller-mode session - // (manual OBS connection, phoneServerRunning == false) into a listener. - // Caller sessions keep running; the new port applies to discovery - // advertisements and to the next listener start. - val wasListenerMode = phoneServerRunning - currentPort = clamped - if (wasListenerMode) { - // Restart the phone server on the new port - stopPhoneServer(clearReservation = false) - } - // Re-create the advertiser with new port - val previousAdvertiser = phoneAdvertiser - val replacementAdvertiser = PhoneDiscoveryAdvertiser( - context = this, - config = streamConfig, - port = currentPort, - busyProvider = { isPhoneBusy() }, - reservedByProvider = { advertisedReservationId }, - selectedObsHostProvider = { selectedObsHost }, - ) - phoneAdvertiser = replacementAdvertiser - sessionWorker.submit { - previousAdvertiser.stop() - replacementAdvertiser.start() - } - if (wasListenerMode) { - startPhoneServerIfAllowed() - } + sessionRuntime.changePort(clamped) } // ─────────────────────────── Preview aspect ratio fix ─────────────────────────── @@ -1498,13 +937,6 @@ class MainActivity : Activity() { private const val RECONNECT_RESERVATION_MS = 45_000L private const val IDENTIFY_OVERLAY_MS = 3_000L private const val LENS_RESTART_DELAY_MS = 500L - private const val LISTENER_POLL_MS = 250L - private const val LISTENER_RETRY_MS = 750L - private const val LISTENER_STOP_TIMEOUT_MS = 2_000L private const val SETTINGS_REQUEST_CODE = 200 - private val REQUIRED_PERMISSIONS = arrayOf( - Manifest.permission.CAMERA, - Manifest.permission.RECORD_AUDIO, - ) } } diff --git a/android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt b/android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt new file mode 100644 index 00000000..88c6b8ab --- /dev/null +++ b/android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt @@ -0,0 +1,806 @@ +package dev.openstream.app + +import android.content.Context +import android.Manifest +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import android.util.Log +import dev.openstream.app.camera.Camera2Controller +import dev.openstream.app.camera.CameraLens +import dev.openstream.app.control.CameraControlServer +import dev.openstream.app.discovery.DiscoveredObsDevice +import dev.openstream.app.discovery.ObsDiscoveryClient +import dev.openstream.app.discovery.PhoneDiscoveryAdvertiser +import dev.openstream.app.encoder.MediaCodecAudioEncoder +import dev.openstream.app.encoder.MediaCodecVideoEncoder +import dev.openstream.app.stream.ListenerAcceptCoordinator +import dev.openstream.app.stream.ConnectionTarget +import dev.openstream.app.stream.SrtAcceptResult +import dev.openstream.app.stream.StreamConfig +import dev.openstream.app.stream.SrtStreamClient + +/** Activity-facing events; the runtime keeps working while no Activity is attached. */ +internal interface PhoneSessionObserver { + fun onDevicesChanged(devices: List) + fun onLensListChanged(lenses: List, selectedLens: CameraLens) + fun onTorchChanged(enabled: Boolean) + fun onReservationChanged() + fun onSessionStateChanged() + fun onLiveStateChanged(targetName: String?) + fun onStatusChanged(title: String, detail: String) + fun onDisconnectVisibilityChanged() + fun onIdentify(label: String, subtitle: String) + fun onSessionError(message: String) + fun onMediaTransportFailure(sessionGeneration: Long) + fun onZoomChanged(ratio: Float) +} + +/** + * Process session graph created by [PhoneSessionService]. Activities bind a preview and observe + * events; they do not create or dispose the camera, codecs, SRT, discovery, or control objects. + */ +internal class PhoneSessionRuntime( + context: Context, + initialPort: Int, +) { + private val appContext = context.applicationContext + private val mainHandler = Handler(Looper.getMainLooper()) + val streamConfig = StreamConfig.Default1080p30 + val sessionWorker = SessionWorker { error -> + Log.e("shin", "Session lifecycle operation failed", error) + } + val listenerAcceptCoordinator = ListenerAcceptCoordinator(sessionWorker, LISTENER_POLL_MS) + val reservationState = ReservationState() + val phoneSessionState = PhoneSessionState() + @Volatile var currentLens: CameraLens = CameraLens.Back + @Volatile var torchEnabled = false + @Volatile var availableLenses: List = listOf(CameraLens.Back) + @Volatile var currentDevices: List = emptyList() + @Volatile var activeTargetName: String? = null + @Volatile var phoneServerRunning = false + @Volatile var phoneConnected = false + @Volatile var selectedObsHost: String? = null + @Volatile var callerConnecting = false + @Volatile var callerGeneration = 0L + @Volatile var pendingListenerStart = false + @Volatile var listenerGeneration = 0L + @Volatile var sessionWorkGeneration = 0L + @Volatile private var previewBindingGeneration = 0L + @Volatile var currentPort = initialPort + @Volatile var activeStreamBitrate = streamConfig.bitrate + @Volatile var observer: PhoneSessionObserver? = null + @Volatile var listenerThread: Thread? = null + val callerLifecycleLock = Any() + val reservationLock = Any() + @Volatile var releaseReservationRunnable: Runnable? = null + @Volatile var reservationGeneration = 0L + @Volatile private var previewSurfaceAvailable = false + @Volatile private var requestedPreviewSurface: android.view.Surface? = null + private var previewBindingRetry: Runnable? = null + @Volatile private var stopResourcesPending = false + + val streamClient = SrtStreamClient() + val camera = Camera2Controller( + context = appContext, + lensProvider = { currentLens }, + targetFps = streamConfig.fps, + ) + @Volatile var encoder: MediaCodecVideoEncoder = createVideoEncoder(activeStreamBitrate) + private set + val audioEncoder = MediaCodecAudioEncoder( + context = appContext, + sampleRate = streamConfig.audioSampleRate, + channelCount = streamConfig.audioChannelCount, + bitrate = streamConfig.audioBitrate, + onEncodedAccessUnit = { accessUnit -> + val result = streamClient.sendAudioAccessUnit(accessUnit) + if (result.recoveryRequired && streamClient.isCurrentSessionGeneration(result.sessionGeneration)) { + observer?.onMediaTransportFailure(result.sessionGeneration) + } + }, + ) + @Volatile var phoneAdvertiser = createPhoneAdvertiser(currentPort) + private set + val obsDiscoveryClient = ObsDiscoveryClient( + context = appContext, + onDevicesChanged = { devices -> + currentDevices = devices + observer?.onDevicesChanged(devices) + }, + ) + val controlServer = CameraControlServer( + cameraProvider = { camera }, + lensListProvider = { availableLenses }, + currentLensProvider = { currentLens }, + onSwitchLens = ::switchLens, + onToggleTorch = { enabled -> + sessionWorker.submit { + torchEnabled = enabled + camera.setTorch(enabled) + observer?.onTorchChanged(enabled) + } + }, + reservationProvider = { reservationState.confirmedSourceInstanceId }, + onReserve = { sourceInstanceId, slotLabel, bitrateMbps -> + val accepted = reserve(sourceInstanceId, slotLabel, bitrateMbps) + if (accepted) observer?.onReservationChanged() + accepted + }, + onRelease = { sourceInstanceId -> + val released = release(sourceInstanceId) + if (released) observer?.onReservationChanged() + released + }, + onIdentify = { label, subtitle -> observer?.onIdentify(label, subtitle) }, + onError = { message -> observer?.onSessionError(message) }, + ) + + val reservedBy: String? get() = reservationState.confirmedSourceInstanceId + val advertisedReservationId: String? get() = reservationState.advertisedSourceInstanceId + val reservedSlotLabel: String? + get() = reservationState.confirmedReservation?.slotLabel?.takeIf { it.isNotBlank() } + val streamStats get() = streamClient.stats + val isNativeSrtAvailable get() = streamClient.isNativeAvailable + val nativeSrtError get() = streamClient.nativeLoadError?.message + + @Volatile private var componentsStarted = false + + fun startComponents() { + if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped) return + synchronized(this) { + if (componentsStarted) return + componentsStarted = true + } + sessionWorker.submit { + phoneAdvertiser.start() + obsDiscoveryClient.start() + controlServer.start() + } + initializeLenses() + startPreview() + startPhoneServerIfAllowed() + } + + fun stopComponents() { + synchronized(this) { + if (!componentsStarted) return + componentsStarted = false + } + submitCriticalTeardown { + obsDiscoveryClient.stop() + phoneAdvertiser.stop() + controlServer.stop() + } + } + + fun changePort(port: Int) { + val wasListenerMode = phoneServerRunning + if (wasListenerMode) stopPhoneServer(clearReservation = false) + currentPort = port.coerceIn(1024, 65535) + val previous = phoneAdvertiser + val replacement = createPhoneAdvertiser(currentPort) + phoneAdvertiser = replacement + sessionWorker.submit { + previous.stop() + replacement.start() + } + if (wasListenerMode) startPhoneServerIfAllowed() + } + + fun selectForSource( + sourceInstanceId: String, + slotLabel: String, + bitrateMbps: Int?, + obsHost: String?, + ): Boolean { + if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped) return false + val selection = ReservationSelection(sourceInstanceId, slotLabel, bitrateMbps, obsHost) + if (!reservationState.beginSelection(selection)) return false + phoneSessionState.select(sourceInstanceId) + selectedObsHost = obsHost + replaceEncoderForBitrate(bitrateMbps) + synchronized(reservationLock) { reservationGeneration += 1 } + if (phoneConnected) cancelReservationRelease() else schedulePendingRelease(sourceInstanceId) + return true + } + + fun clearReservation() { + cancelReservationRelease() + reservationState.clear() + selectedObsHost = null + synchronized(reservationLock) { reservationGeneration += 1 } + observer?.onDisconnectVisibilityChanged() + } + + fun disconnectReservation() { + phoneSessionState.disconnect() + clearReservation() + observer?.onReservationChanged() + observer?.onSessionStateChanged() + } + + fun stopLiveMedia() { + callerGeneration += 1 + callerConnecting = false + listenerGeneration += 1 + phoneServerRunning = false + phoneConnected = false + pendingListenerStart = false + listenerThread?.interrupt() + activeTargetName = null + submitCriticalTeardown { + streamClient.disconnect() + camera.stopStreaming() + encoder.stop() + audioEncoder.stop() + camera.stop() + } + } + + /** Stops every owned component as one critical worker task before the service exits. */ + fun stopSessionResources(onComplete: () -> Unit) { + synchronized(this) { + if (stopResourcesPending) return + stopResourcesPending = true + } + callerGeneration += 1 + callerConnecting = false + listenerGeneration += 1 + phoneServerRunning = false + phoneConnected = false + pendingListenerStart = false + listenerThread?.interrupt() + activeTargetName = null + synchronized(this) { componentsStarted = false } + val teardown = { + try { + streamClient.disconnect() + val thread = listenerThread + if (thread != null && thread !== Thread.currentThread()) { + runCatching { thread.join(LISTENER_STOP_TIMEOUT_MS) } + } + synchronized(callerLifecycleLock) { + camera.stopStreaming() + encoder.stop() + audioEncoder.stop() + camera.stop() + } + obsDiscoveryClient.stop() + phoneAdvertiser.stop() + controlServer.stop() + } finally { + synchronized(this) { stopResourcesPending = false } + mainHandler.post(onComplete) + } + } + val retry = object : Runnable { + override fun run() { + if (sessionWorker.submitCritical(teardown)) return + if (sessionWorker.isShutdown) { + synchronized(this@PhoneSessionRuntime) { stopResourcesPending = false } + Log.e("shin", "Session worker closed before Stop teardown could be queued") + return + } + synchronized(this@PhoneSessionRuntime) { + if (!stopResourcesPending) return + } + mainHandler.postDelayed(this, TEARDOWN_RETRY_MS) + } + } + retry.run() + } + + fun bindPreviewSurface(surface: android.view.Surface?) { + val requestedSurface = surface?.takeIf(android.view.Surface::isValid) + val generation = ++previewBindingGeneration + requestedPreviewSurface = requestedSurface + previewSurfaceAvailable = requestedSurface != null + if (enqueuePreviewBinding(generation, requestedSurface)) { + synchronized(this) { + previewBindingRetry?.let(mainHandler::removeCallbacks) + previewBindingRetry = null + } + } else { + schedulePreviewBindingRetry() + } + } + + private fun enqueuePreviewBinding(generation: Long, surface: android.view.Surface?): Boolean = + sessionWorker.submitIfCurrent(generation, { previewBindingGeneration }) { + if (generation != previewBindingGeneration) return@submitIfCurrent + camera.bindPreviewSurface(surface) + if (generation != previewBindingGeneration) return@submitIfCurrent + if (surface != null) { + initializeLenses() + if (phoneSessionState.snapshot.status != PhoneSessionStatus.Stopped && + appContext.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + ) camera.startPreview() + startPhoneServerIfAllowed() + } else if (!phoneConnected && activeTargetName == null && !callerConnecting) { + camera.stop() + } + } + + private fun schedulePreviewBindingRetry() { + synchronized(this) { + if (previewBindingRetry != null) return + val retry = object : Runnable { + override fun run() { + synchronized(this@PhoneSessionRuntime) { previewBindingRetry = null } + if (sessionWorker.isShutdown) { + Log.e("shin", "Session worker closed before preview surface could be rebound") + observer?.onSessionError("Camera preview could not be updated") + return + } + val generation = previewBindingGeneration + if (enqueuePreviewBinding(generation, requestedPreviewSurface)) return + schedulePreviewBindingRetry() + } + } + previewBindingRetry = retry + mainHandler.postDelayed(retry, TEARDOWN_RETRY_MS) + } + } + + fun startPreview() { + if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped || !previewSurfaceAvailable || + appContext.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED + ) return + val generation = sessionWorkGeneration + sessionWorker.submitIfCurrent(generation, { sessionWorkGeneration }) { + if (previewSurfaceAvailable && phoneSessionState.snapshot.status != PhoneSessionStatus.Stopped && + appContext.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + ) camera.startPreview() + } + } + + fun scaleZoom(factor: Float) { + sessionWorker.submit { + val ratio = camera.scaleZoom(factor) + observer?.onZoomChanged(ratio) + } + } + + fun startStream(target: ConnectionTarget) { + if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped) return + stopStream(updateStatus = false) + stopPhoneServer(clearReservation = true) + replaceEncoderForBitrate(target.bitrateMbps) + observer?.onStatusChanged("Connecting…", "${currentLens.displayName} → ${target.name}") + val sessionGeneration = phoneSessionState.beginConnection() + val generation = callerGeneration + 1 + callerGeneration = generation + callerConnecting = true + observer?.onDisconnectVisibilityChanged() + val accepted = sessionWorker.submit { + try { + streamClient.connect( + url = target.toSrtCallerUrl(), + codecMime = encoder.codecName, + width = streamConfig.width, + height = streamConfig.height, + fps = streamConfig.fps, + ) + check(callerGeneration == generation) { "SRT caller connection was cancelled" } + check(phoneSessionState.snapshot.generation == sessionGeneration) { "Phone session was superseded" } + synchronized(callerLifecycleLock) { + encoder.start() + startAudioIfAllowed() + camera.startStreaming(encoder.inputSurface()) + } + mainHandler.post { + if (callerGeneration != generation || phoneSessionState.snapshot.generation != sessionGeneration) return@post + phoneSessionState.connected(sessionGeneration) + activeTargetName = target.name + observer?.onLiveStateChanged(target.name) + observer?.onSessionStateChanged() + } + } catch (error: Throwable) { + synchronized(callerLifecycleLock) { + if (callerGeneration == generation) { + streamClient.disconnect() + camera.stopStreaming() + encoder.stop() + audioEncoder.stop() + } + } + mainHandler.post { + if (callerGeneration != generation) return@post + phoneSessionState.failed(sessionGeneration) + startPreview() + startPhoneServerIfAllowed() + observer?.onStatusChanged("Connection failed", error.message ?: "Unknown error") + } + } finally { + if (callerGeneration == generation) callerConnecting = false + } + } + if (!accepted) { + callerConnecting = false + phoneSessionState.failed(sessionGeneration) + observer?.onStatusChanged("Session busy", "Wait for the current camera transition to finish") + observer?.onSessionStateChanged() + } + } + + fun stopStream(updateStatus: Boolean = true) { + callerGeneration += 1 + callerConnecting = false + activeTargetName = null + phoneConnected = false + observer?.onLiveStateChanged(null) + submitCriticalTeardown { + streamClient.disconnect() + synchronized(callerLifecycleLock) { + camera.stopStreaming() + encoder.stop() + audioEncoder.stop() + } + if (updateStatus) observer?.onStatusChanged("Stopped", "Camera preview remains active") + } + } + + fun handleMediaTransportFailure(sessionGeneration: Long) { + if (!streamClient.isCurrentSessionGeneration(sessionGeneration)) return + if (phoneServerRunning) { + if (activeTargetName != null) observer?.onStatusChanged("Connection lost", "Waiting for OBS to reconnect") + return + } + if (activeTargetName == null && !callerConnecting) return + phoneConnected = false + phoneSessionState.connectionLost(phoneSessionState.snapshot.generation) + stopStream(updateStatus = false) + startPreview() + sessionWorker.submit { mainHandler.post { startPhoneServerIfAllowed() } } + observer?.onStatusChanged("Connection lost", "Waiting for OBS") + observer?.onSessionStateChanged() + } + + fun startPhoneServerIfAllowed() { + if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped || phoneServerRunning) return + if (listenerThread?.isAlive == true) { + pendingListenerStart = true + Log.w("shin", "Previous SRT listener is still stopping; not starting another") + return + } + if (appContext.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) return + + pendingListenerStart = false + val generation = listenerGeneration + 1 + listenerGeneration = generation + phoneServerRunning = true + phoneConnected = false + activeTargetName = null + observer?.onStatusChanged("Ready", "Waiting for OBS") + observer?.onLiveStateChanged(null) + observer?.onDisconnectVisibilityChanged() + + val thread = Thread({ + try { + while (isListenerActive(generation)) { + val listenUrl = "srt://0.0.0.0:$currentPort?mode=listener&latency=${streamConfig.latencyMs}" + val attemptGeneration = phoneSessionState.snapshot.generation + var connectedSessionGeneration: Long? = null + val listenResult = runCatching { + val acceptResult = listenerAcceptCoordinator.awaitAccepted( + expectedGeneration = generation, + currentGeneration = { listenerGeneration }, + startListening = { + streamClient.startListening( + url = listenUrl, + codecMime = encoder.codecName, + width = streamConfig.width, + height = streamConfig.height, + fps = streamConfig.fps, + ) + }, + acceptPending = streamClient::acceptPending, + ) + if (!isListenerActive(generation)) return@runCatching + check(acceptResult == SrtAcceptResult.Connected) { + "SRT listener accept failed: $acceptResult" + } + phoneConnected = true + val sessionGeneration = phoneSessionState.beginConnection() + connectedSessionGeneration = sessionGeneration + cancelReservationRelease() + val liveTargetName = reservedSlotLabel ?: "OBS" + activeTargetName = liveTargetName + observer?.onLiveStateChanged(liveTargetName) + sessionWorker.submitIfCurrent(generation, { listenerGeneration }) { + runCatching { + encoder.start() + startAudioIfAllowed() + camera.startStreaming(encoder.inputSurface()) + check(phoneSessionState.connected(sessionGeneration)) { + "Phone session was superseded" + } + }.onFailure { error -> + if (isListenerActive(generation)) { + phoneConnected = false + phoneSessionState.failed(sessionGeneration) + observer?.onStatusChanged("Listener error", error.message ?: "Unknown error") + submitCriticalTeardown { + camera.stopStreaming() + encoder.stop() + audioEncoder.stop() + } + } + } + } + while (isListenerActive(generation) && phoneConnected) Thread.sleep(LISTENER_POLL_MS) + } + + if (listenResult.isFailure && isListenerActive(generation)) { + val error = listenResult.exceptionOrNull() + phoneSessionState.failed(connectedSessionGeneration ?: attemptGeneration) + observer?.onStatusChanged("Listener error", error?.message ?: "Unknown") + try { + Thread.sleep(LISTENER_RETRY_MS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + if (isListenerActive(generation)) { + submitCriticalTeardown { + camera.stopStreaming() + encoder.stop() + audioEncoder.stop() + } + } + phoneConnected = false + activeTargetName = null + if (isListenerActive(generation)) { + connectedSessionGeneration?.let(phoneSessionState::connectionLost) + scheduleReservationRelease() + observer?.onLiveStateChanged(null) + observer?.onStatusChanged("Ready", reservedSlotLabel?.let { "Holding $it for reconnect" } ?: "Waiting for OBS") + } + } + } finally { + if (listenerThread === Thread.currentThread()) { + listenerThread = null + mainHandler.post { + if (pendingListenerStart) { + pendingListenerStart = false + startPhoneServerIfAllowed() + } + } + } + } + }, "shinPhoneSrtListener").apply { isDaemon = true } + listenerThread = thread + thread.start() + } + + fun stopPhoneServer(clearReservation: Boolean, stopCamera: Boolean = false) { + callerGeneration += 1 + callerConnecting = false + pendingListenerStart = false + listenerGeneration += 1 + phoneServerRunning = false + phoneConnected = false + if (clearReservation) clearReservation() + activeTargetName = null + listenerThread?.interrupt() + observer?.onLiveStateChanged(null) + observer?.onDisconnectVisibilityChanged() + submitCriticalTeardown { + val thread = listenerThread + streamClient.disconnect() + if (thread != null && thread !== Thread.currentThread()) runCatching { thread.join(LISTENER_STOP_TIMEOUT_MS) } + synchronized(callerLifecycleLock) { + camera.stopStreaming() + encoder.stop() + audioEncoder.stop() + if (stopCamera) camera.stop() + } + if (pendingListenerStart) startPhoneServerIfAllowed() + } + } + + private fun startAudioIfAllowed() { + if (appContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + Log.i("shin", "Microphone permission not granted; streaming video without audio") + return + } + runCatching { audioEncoder.start() } + .onFailure { error -> Log.w("shin", "Audio encoder start failed; continuing video-only", error) } + } + + private fun isListenerActive(generation: Long): Boolean = + phoneServerRunning && listenerGeneration == generation + + private fun scheduleReservationRelease() { + val sourceInstanceId = reservationState.confirmedSourceInstanceId ?: return + val generation = reservationGeneration + val sessionGeneration = phoneSessionState.snapshot.generation + cancelReservationRelease() + releaseReservationRunnable = Runnable { + synchronized(reservationLock) { + if (!phoneConnected && reservationState.confirmedSourceInstanceId == sourceInstanceId && + reservationGeneration == generation + ) { + reservationState.release(sourceInstanceId) + phoneSessionState.timeout(sessionGeneration) + observer?.onReservationChanged() + if (reservationState.confirmedReservation == null && reservationState.pendingSelection == null) { + selectedObsHost = null + observer?.onStatusChanged("Disconnected", "Choose an OBS slot to reconnect") + } + } + } + } + mainHandler.postDelayed(releaseReservationRunnable!!, RECONNECT_RESERVATION_MS) + } + + private fun cancelReservationRelease() { + releaseReservationRunnable?.let(mainHandler::removeCallbacks) + releaseReservationRunnable = null + } + + /** Retries one bounded critical submission until the worker frees its reserved slot. */ + private fun submitCriticalTeardown(work: () -> Unit) { + val retry = object : Runnable { + override fun run() { + if (sessionWorker.submitCritical(work)) return + if (sessionWorker.isShutdown) { + Log.e("shin", "Session worker closed before critical teardown could be queued") + observer?.onSessionError("Could not stop the camera session cleanly") + return + } + mainHandler.postDelayed(this, TEARDOWN_RETRY_MS) + } + } + retry.run() + } + + private fun schedulePendingRelease(sourceInstanceId: String) { + val generation = reservationGeneration + val sessionGeneration = phoneSessionState.snapshot.generation + cancelReservationRelease() + releaseReservationRunnable = Runnable { + synchronized(reservationLock) { + if (!phoneConnected && reservationGeneration == generation && + reservationState.confirmedSourceInstanceId == null + ) { + reservationState.rollbackPending(sourceInstanceId) + phoneSessionState.timeout(sessionGeneration) + observer?.onReservationChanged() + observer?.onSessionStateChanged() + if (reservationState.pendingSelection == null) { + selectedObsHost = null + observer?.onStatusChanged("Connection timed out", "Choose an OBS slot to try again") + } + } + } + } + mainHandler.postDelayed(releaseReservationRunnable!!, RECONNECT_RESERVATION_MS) + } + + fun createVideoEncoder(bitrate: Int): MediaCodecVideoEncoder = MediaCodecVideoEncoder( + preference = streamConfig.codecPreference, + width = streamConfig.width, + height = streamConfig.height, + fps = streamConfig.fps, + bitrate = bitrate, + keyframeIntervalSeconds = streamConfig.keyframeIntervalSeconds, + onEncodedAccessUnit = { accessUnit -> + val result = streamClient.sendVideoAccessUnit(accessUnit) + if (result.recoveryRequired && streamClient.isCurrentSessionGeneration(result.sessionGeneration)) { + observer?.onMediaTransportFailure(result.sessionGeneration) + } + }, + ) + + fun replaceEncoderForBitrate(bitrateMbps: Int?) { + val nextBitrate = (bitrateMbps ?: streamConfig.bitrateMbps) + .coerceIn(StreamConfig.MIN_BITRATE_MBPS, StreamConfig.MAX_BITRATE_MBPS) * 1_000_000 + synchronized(callerLifecycleLock) { + if (activeStreamBitrate == nextBitrate) return + activeStreamBitrate = nextBitrate + if (activeTargetName == null) { + val previous = encoder + encoder = createVideoEncoder(nextBitrate) + submitCriticalTeardown { previous.stop() } + } + } + } + + fun switchLens(lens: CameraLens) { + if (lens == currentLens) return + val wasStreaming = activeTargetName != null + currentLens = lens + torchEnabled = false + observer?.onTorchChanged(false) + val generation = ++sessionWorkGeneration + observer?.onLensListChanged(availableLenses, lens) + sessionWorker.submitIfCurrent(generation, { sessionWorkGeneration }) { + runCatching { + if (wasStreaming) { + camera.stopStreaming() + encoder.stop() + } + camera.switchLens(lens) + }.onFailure { error -> + observer?.onSessionError(error.message ?: "Lens switch failed") + } + } + if (wasStreaming) { + mainHandler.postDelayed({ + sessionWorker.submitIfCurrent(generation, { sessionWorkGeneration }) { + if (activeTargetName != null) runCatching { + synchronized(callerLifecycleLock) { + encoder.start() + camera.startStreaming(encoder.inputSurface()) + } + }.onFailure { error -> observer?.onSessionError(error.message ?: "Encoder restart failed") } + } + }, LENS_RESTART_DELAY_MS) + } + } + + fun setTorch(enabled: Boolean) { + if (currentLens.isFrontFacing) return + torchEnabled = enabled + sessionWorker.submit { + camera.setTorch(enabled) + observer?.onTorchChanged(enabled) + } + } + + fun initializeLenses() { + if (appContext.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) return + val generation = sessionWorkGeneration + sessionWorker.submitIfCurrent(generation, { sessionWorkGeneration }) { + val lenses = camera.availableLenses() + if (sessionWorkGeneration != generation) return@submitIfCurrent + availableLenses = lenses + if (currentLens !in lenses) currentLens = lenses.firstOrNull { it.isBackFacing } ?: lenses.first() + observer?.onLensListChanged(lenses, currentLens) + } + } + + private fun createPhoneAdvertiser(port: Int) = PhoneDiscoveryAdvertiser( + context = appContext, + config = streamConfig, + port = port, + busyProvider = { reservationState.isBusy(phoneConnected) }, + reservedByProvider = { reservationState.advertisedSourceInstanceId }, + selectedObsHostProvider = { selectedObsHost }, + ) + + private fun reserve(sourceInstanceId: String, slotLabel: String, bitrateMbps: Int?): Boolean { + if (phoneSessionState.snapshot.status == PhoneSessionStatus.Stopped) return false + if (phoneConnected && reservedBy != null && reservedBy != sourceInstanceId) return false + val effectiveSlot = slotLabel.ifBlank { reservedSlotLabel.orEmpty() } + if (!reservationState.confirm(sourceInstanceId, effectiveSlot, bitrateMbps)) return false + if (!phoneSessionState.reserve(sourceInstanceId)) return false + replaceEncoderForBitrate(bitrateMbps) + synchronized(reservationLock) { reservationGeneration += 1 } + if (phoneConnected) cancelReservationRelease() else scheduleReservationRelease() + return true + } + + private fun release(sourceInstanceId: String): Boolean { + val confirmed = reservationState.confirmedSourceInstanceId + val pending = reservationState.pendingSourceInstanceId + if (confirmed == sourceInstanceId || (confirmed == null && pending == sourceInstanceId)) { + phoneSessionState.disconnect() + clearReservation() + return true + } + return confirmed == null && pending == null + } + + companion object { + const val LISTENER_POLL_MS = 250L + private const val TEARDOWN_RETRY_MS = 50L + private const val LENS_RESTART_DELAY_MS = 500L + private const val LISTENER_RETRY_MS = 750L + private const val LISTENER_STOP_TIMEOUT_MS = 2_000L + private const val RECONNECT_RESERVATION_MS = 45_000L + } +} diff --git a/android/app/src/main/java/dev/openstream/app/PhoneSessionService.kt b/android/app/src/main/java/dev/openstream/app/PhoneSessionService.kt new file mode 100644 index 00000000..380f64e5 --- /dev/null +++ b/android/app/src/main/java/dev/openstream/app/PhoneSessionService.kt @@ -0,0 +1,237 @@ +package dev.openstream.app + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.util.Log + +/** Owns the camera/media/network graph for longer than any one Activity instance. */ +class PhoneSessionService : Service() { + inner class LocalBinder : Binder() { + internal fun runtime(): PhoneSessionRuntime = sessionRuntime + internal fun lifecycleState(): SessionOwnerState = ownerState + internal fun stop() = stopSession() + internal fun start(cameraPermissionGranted: Boolean = hasCameraPermission()) = + startSession(cameraPermissionGranted) + internal fun activityHidden() { + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.ActivityHidden) + } + internal fun activityResumed(cameraPermissionGranted: Boolean = hasCameraPermission()) { + ownerState = if (cameraPermissionGranted) { + transitionSessionOwner(ownerState, SessionOwnerEvent.ActivityVisible) + } else { + if (foregroundStarted) stopSessionForPermissionLoss() + else transitionSessionOwner(ownerState, SessionOwnerEvent.CameraPermissionRevoked) + ownerState + } + } + } + + private lateinit var sessionRuntime: PhoneSessionRuntime + @Volatile private var foregroundStarted = false + @Volatile private var ownerState: SessionOwnerState = + transitionSessionOwner(SessionOwnerState.Available, SessionOwnerEvent.ProcessRecreated) + private val binder = LocalBinder() + private val preferences by lazy { getSharedPreferences(PREFS_NAME, MODE_PRIVATE) } + + override fun onCreate() { + super.onCreate() + val savedPort = getSharedPreferences(SettingsActivity.PREFS_NAME, MODE_PRIVATE) + .getInt(SettingsActivity.KEY_LISTENING_PORT, DEFAULT_PORT) + .takeIf { it in 1024..65535 } ?: DEFAULT_PORT + sessionRuntime = PhoneSessionRuntime(applicationContext, savedPort) + if (preferences.getBoolean(KEY_STOPPED, false)) { + sessionRuntime.phoneSessionState.stop() + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.Stop) + } + createNotificationChannel() + } + + override fun onBind(intent: Intent?): IBinder = binder + + override fun onDestroy() { + if (::sessionRuntime.isInitialized) { + if (sessionRuntime.phoneSessionState.snapshot.status != PhoneSessionStatus.Stopped) { + sessionRuntime.phoneSessionState.stop() + sessionRuntime.clearReservation() + sessionRuntime.stopSessionResources { sessionRuntime.sessionWorker.close() } + } else { + sessionRuntime.sessionWorker.close() + } + } + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> stopSession() + ACTION_START -> startSession() + else -> { + when { + !hasCameraPermission() -> { + sessionRuntime.observer?.onSessionError("Camera permission is required to start a session") + stopSelfResult(startId) + } + preferences.getBoolean(KEY_STOPPED, false) -> stopSelfResult(startId) + else -> { + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.Start) + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.ActivityVisible) + startForegroundSession() + if (foregroundStarted) sessionRuntime.startComponents() + } + } + } + } + // Process death discards the runtime and its SRT socket. Android must not auto-restart a + // camera session with an unconfirmed reservation; the next foreground Activity rebuilds it. + return START_NOT_STICKY + } + + override fun onTaskRemoved(rootIntent: Intent?) { + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.TaskRemoved) + if (!preferences.getBoolean(KEY_STOPPED, false) && hasCameraPermission()) { + startForegroundSession() + } + super.onTaskRemoved(rootIntent) + } + + private fun startForegroundSession() { + if (!hasCameraPermission()) { + stopSessionForPermissionLoss() + return + } + val notification = buildNotification() + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + ) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + 0 + } + startForeground(NOTIFICATION_ID, notification, serviceType) + } else { + startForeground(NOTIFICATION_ID, notification) + } + foregroundStarted = true + } catch (error: SecurityException) { + Log.e(TAG, "Could not keep the camera session in the foreground", error) + stopSessionForPermissionLoss() + } + } + + private fun stopSession() { + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.Stop) + preferences.edit().putBoolean(KEY_STOPPED, true).apply() + foregroundStarted = false + sessionRuntime.phoneSessionState.stop() + sessionRuntime.sessionWorkGeneration += 1 + sessionRuntime.clearReservation() + sessionRuntime.observer?.onReservationChanged() + sessionRuntime.observer?.onSessionStateChanged() + sessionRuntime.stopSessionResources { + stopForeground(STOP_FOREGROUND_REMOVE) + foregroundStarted = false + stopSelf() + } + } + + private fun stopSessionForPermissionLoss() { + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.CameraPermissionRevoked) + preferences.edit().putBoolean(KEY_STOPPED, true).apply() + sessionRuntime.phoneSessionState.stop() + sessionRuntime.sessionWorkGeneration += 1 + sessionRuntime.clearReservation() + sessionRuntime.observer?.onSessionError("Camera permission was revoked; the session stopped") + sessionRuntime.observer?.onSessionStateChanged() + sessionRuntime.stopSessionResources { + stopForeground(STOP_FOREGROUND_REMOVE) + foregroundStarted = false + stopSelf() + } + } + + private fun startSession(cameraPermissionGranted: Boolean = hasCameraPermission()) { + if (!cameraPermissionGranted) { + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.CameraPermissionRevoked) + preferences.edit().putBoolean(KEY_STOPPED, true).apply() + sessionRuntime.phoneSessionState.stop() + sessionRuntime.observer?.onSessionError("Camera permission is required to start a session") + sessionRuntime.observer?.onSessionStateChanged() + if (foregroundStarted) stopSessionForPermissionLoss() else stopSelf() + return + } + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.Start) + ownerState = transitionSessionOwner(ownerState, SessionOwnerEvent.ActivityVisible) + preferences.edit().putBoolean(KEY_STOPPED, false).apply() + sessionRuntime.phoneSessionState.start() + startForegroundSession() + if (foregroundStarted) { + sessionRuntime.startComponents() + sessionRuntime.observer?.onSessionStateChanged() + } + } + + private fun hasCameraPermission(): Boolean = + checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + + private fun buildNotification(): Notification { + val openApp = PendingIntent.getActivity( + this, + REQUEST_OPEN, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val stopAction = PendingIntent.getService( + this, + REQUEST_STOP, + Intent(this, PhoneSessionService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") Notification.Builder(this) + } + return builder + .setSmallIcon(android.R.drawable.presence_video_online) + .setContentTitle("OpenStream camera is available") + .setContentText("OpenStream keeps its camera session available in the background") + .setContentIntent(openApp) + .setOngoing(true) + .addAction(android.R.drawable.ic_media_pause, "Stop", stopAction) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Camera session", NotificationManager.IMPORTANCE_LOW), + ) + } + + companion object { + const val ACTION_START = "dev.openstream.app.session.START" + const val ACTION_STOP = "dev.openstream.app.session.STOP" + const val PREFS_NAME = "phone-session-owner" + const val KEY_STOPPED = "stopped-by-user" + private const val TAG = "shinSessionOwner" + private const val CHANNEL_ID = "camera-session" + private const val NOTIFICATION_ID = 42 + private const val REQUEST_OPEN = 1 + private const val REQUEST_STOP = 2 + private const val DEFAULT_PORT = 9000 + } +} diff --git a/android/app/src/main/java/dev/openstream/app/SessionOwnerState.kt b/android/app/src/main/java/dev/openstream/app/SessionOwnerState.kt new file mode 100644 index 00000000..f41dcf8e --- /dev/null +++ b/android/app/src/main/java/dev/openstream/app/SessionOwnerState.kt @@ -0,0 +1,50 @@ +package dev.openstream.app + +/** Lifecycle facts owned by the long-lived phone session, independent of an Activity. */ +internal sealed interface SessionOwnerState { + data object Available : SessionOwnerState + data class Foreground(val reservationId: String?) : SessionOwnerState + data class Background(val reservationId: String?) : SessionOwnerState + data object Stopped : SessionOwnerState + data object PermissionRequired : SessionOwnerState +} + +internal enum class SessionOwnerEvent { + ActivityVisible, + ActivityHidden, + TaskRemoved, + Stop, + Start, + CameraPermissionRevoked, + ProcessRecreated, +} + +/** + * Pure lifecycle transition model. UI visibility never owns the active reservation; explicit Stop, + * permission loss, or process death clears it so a later Activity cannot inherit stale ownership. + */ +internal fun transitionSessionOwner( + state: SessionOwnerState, + event: SessionOwnerEvent, +): SessionOwnerState = when (event) { + SessionOwnerEvent.ActivityVisible -> when (state) { + is SessionOwnerState.Background -> SessionOwnerState.Foreground(state.reservationId) + SessionOwnerState.Available -> SessionOwnerState.Foreground(null) + else -> state + } + SessionOwnerEvent.ActivityHidden, + SessionOwnerEvent.TaskRemoved, + -> when (state) { + is SessionOwnerState.Foreground -> SessionOwnerState.Background(state.reservationId) + else -> state + } + SessionOwnerEvent.Stop -> SessionOwnerState.Stopped + SessionOwnerEvent.Start -> when (state) { + SessionOwnerState.Stopped, SessionOwnerState.PermissionRequired -> SessionOwnerState.Available + else -> state + } + SessionOwnerEvent.CameraPermissionRevoked -> SessionOwnerState.PermissionRequired + // A sticky restart is deliberately not used. If Android recreates the process, the old socket + // and reservation are gone; rebuild an available session only after a foreground UI launch. + SessionOwnerEvent.ProcessRecreated -> SessionOwnerState.Available +} diff --git a/android/app/src/main/java/dev/openstream/app/SessionWorker.kt b/android/app/src/main/java/dev/openstream/app/SessionWorker.kt index d2a15d12..073790cf 100644 --- a/android/app/src/main/java/dev/openstream/app/SessionWorker.kt +++ b/android/app/src/main/java/dev/openstream/app/SessionWorker.kt @@ -42,6 +42,8 @@ internal class SessionWorker( fun submit(work: () -> Unit): Boolean = enqueue(work, critical = false) + val isShutdown: Boolean get() = executor.isShutdown + /** Queues teardown in the reserved slot when all normal waiting slots are occupied. */ fun submitCritical(work: () -> Unit): Boolean = enqueue(work, critical = true) diff --git a/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt b/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt index 54f73820..6205e11c 100644 --- a/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt +++ b/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt @@ -28,7 +28,6 @@ import java.util.concurrent.atomic.AtomicLong */ class Camera2Controller( context: Context, - private val previewSurfaceProvider: () -> Surface, private val lensProvider: () -> CameraLens = { CameraLens.Back }, private val targetFps: Int = 30, ) { @@ -40,6 +39,7 @@ class Camera2Controller( private var camera: CameraDevice? = null private var session: CameraCaptureSession? = null private var streamingSurface: Surface? = null + private var previewSurface: Surface? = null private var activeCameraId: String? = null private var activeLens: CameraLens? = null private val cameraGeneration = AtomicLong() @@ -294,6 +294,14 @@ class Camera2Controller( } } + /** The UI supplies and withdraws preview surfaces without owning the camera session. */ + fun bindPreviewSurface(surface: Surface?) { + synchronized(lifecycleLock) { + previewSurface = surface?.takeIf(Surface::isValid) + if (camera != null) createSession() + } + } + fun stop() { synchronized(lifecycleLock) { desiredRunning = false @@ -301,6 +309,7 @@ class Camera2Controller( cancelCameraRecoveryLocked() closeCamera() streamingSurface = null + previewSurface = null quitCameraThreadLocked() } } @@ -377,8 +386,11 @@ class Camera2Controller( ) { return } - val previewReady = runCatching { previewSurfaceProvider().isValid }.getOrDefault(false) - if (!previewReady) return + if (captureOutputKinds( + hasPreview = previewSurface?.isValid == true, + hasEncoder = streamingSurface?.isValid == true, + ).isEmpty() + ) return cancelCameraRecoveryLocked() startPreviewLocked() } @@ -463,18 +475,25 @@ class Camera2Controller( private fun createSession() { val device = camera ?: return - val preview = runCatching { previewSurfaceProvider() }.getOrNull() - if (preview == null || !preview.isValid) { - Log.w(TAG, "Deferring camera session until preview surface is valid") - return - } + val preview = previewSurface?.takeIf(Surface::isValid) val encoded = streamingSurface if (encoded != null && !encoded.isValid) { Log.w(TAG, "Deferring camera session until encoder surface is valid") return } + val outputKinds = captureOutputKinds(preview != null, encoded != null) + if (outputKinds.isEmpty()) { + sessionGeneration.incrementAndGet() + session?.close() + session = null + Log.i(TAG, "Camera remains open while Activity preview is detached") + return + } val generation = sessionGeneration.incrementAndGet() - val surfaces = if (encoded != null) listOf(preview, encoded) else listOf(preview) + val surfaces = buildList { + if (CameraCaptureOutput.Preview in outputKinds) add(checkNotNull(preview)) + if (CameraCaptureOutput.Encoder in outputKinds) add(checkNotNull(encoded)) + } session?.close() session = null try { @@ -496,10 +515,8 @@ class Camera2Controller( } runCatching { val request = device.createCaptureRequest(template).apply { - addTarget(preview) - if (encoded != null) { - addTarget(encoded) - } + preview?.let(::addTarget) + encoded?.let(::addTarget) set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO) set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) @@ -602,22 +619,19 @@ class Camera2Controller( val device = camera ?: return val currentSession = session ?: return val generation = sessionGeneration.get() - val preview = runCatching { previewSurfaceProvider() }.getOrNull() - if (preview == null || !preview.isValid) { - Log.w(TAG, "Deferring camera request rebuild until preview surface is valid") - return - } + val preview = previewSurface?.takeIf(Surface::isValid) val encoded = streamingSurface if (encoded != null && !encoded.isValid) { Log.w(TAG, "Deferring camera request rebuild until encoder surface is valid") return } + if (preview == null && encoded == null) return val template = if (encoded != null) CameraDevice.TEMPLATE_RECORD else CameraDevice.TEMPLATE_PREVIEW runCatching { val request = device.createCaptureRequest(template).apply { - addTarget(preview) - if (encoded != null) addTarget(encoded) + preview?.let(::addTarget) + encoded?.let(::addTarget) set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO) set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) @@ -732,4 +746,4 @@ class Camera2Controller( } } } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/dev/openstream/app/camera/CaptureOutputs.kt b/android/app/src/main/java/dev/openstream/app/camera/CaptureOutputs.kt new file mode 100644 index 00000000..d4158e7d --- /dev/null +++ b/android/app/src/main/java/dev/openstream/app/camera/CaptureOutputs.kt @@ -0,0 +1,12 @@ +package dev.openstream.app.camera + +internal enum class CameraCaptureOutput { + Preview, + Encoder, +} + +/** A running camera session may target the encoder alone when its Activity preview detaches. */ +internal fun captureOutputKinds(hasPreview: Boolean, hasEncoder: Boolean): Set = buildSet { + if (hasPreview) add(CameraCaptureOutput.Preview) + if (hasEncoder) add(CameraCaptureOutput.Encoder) +} diff --git a/android/app/src/test/java/dev/openstream/app/SessionOwnerStateTest.kt b/android/app/src/test/java/dev/openstream/app/SessionOwnerStateTest.kt new file mode 100644 index 00000000..217080d0 --- /dev/null +++ b/android/app/src/test/java/dev/openstream/app/SessionOwnerStateTest.kt @@ -0,0 +1,54 @@ +package dev.openstream.app + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionOwnerStateTest { + @Test + fun visibleActivityPromotesAvailableOwnerToForeground() { + assertEquals( + SessionOwnerState.Foreground(null), + transitionSessionOwner(SessionOwnerState.Available, SessionOwnerEvent.ActivityVisible), + ) + } + + @Test + fun homeLockSettingsAndTaskRemovalKeepReservationInBackgroundOwner() { + val live = SessionOwnerState.Foreground("obs-source-7") + + assertEquals( + SessionOwnerState.Background("obs-source-7"), + transitionSessionOwner(live, SessionOwnerEvent.ActivityHidden), + ) + assertEquals( + SessionOwnerState.Background("obs-source-7"), + transitionSessionOwner(live, SessionOwnerEvent.TaskRemoved), + ) + assertEquals( + live, + transitionSessionOwner( + transitionSessionOwner(live, SessionOwnerEvent.ActivityHidden), + SessionOwnerEvent.ActivityVisible, + ), + ) + } + + @Test + fun stopPermissionRevocationAndProcessRecreationClearReservation() { + val live = SessionOwnerState.Background("obs-source-7") + + assertEquals(SessionOwnerState.Stopped, transitionSessionOwner(live, SessionOwnerEvent.Stop)) + assertEquals( + SessionOwnerState.PermissionRequired, + transitionSessionOwner(live, SessionOwnerEvent.CameraPermissionRevoked), + ) + assertEquals( + SessionOwnerState.Available, + transitionSessionOwner(live, SessionOwnerEvent.ProcessRecreated), + ) + assertEquals( + SessionOwnerState.Available, + transitionSessionOwner(SessionOwnerState.Stopped, SessionOwnerEvent.Start), + ) + } +} diff --git a/android/app/src/test/java/dev/openstream/app/SessionWorkerTest.kt b/android/app/src/test/java/dev/openstream/app/SessionWorkerTest.kt index c1c531a0..a06ae22f 100644 --- a/android/app/src/test/java/dev/openstream/app/SessionWorkerTest.kt +++ b/android/app/src/test/java/dev/openstream/app/SessionWorkerTest.kt @@ -74,6 +74,42 @@ class SessionWorkerTest { } } + @Test + fun stalePreviewDetachCannotOverrideLatestReattachedSurface() { + var previewGeneration = 4L + var sessionWorkGeneration = 4L + var activeSurface: String? = null + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val completed = CountDownLatch(1) + val worker = SessionWorker() + try { + assertTrue(worker.submit { + entered.countDown() + release.await(2, TimeUnit.SECONDS) + }) + assertTrue(entered.await(2, TimeUnit.SECONDS)) + + assertTrue(worker.submitIfCurrent(4L, { previewGeneration }) { activeSurface = "old-preview" }) + previewGeneration = 5L + assertTrue(worker.submitIfCurrent(5L, { previewGeneration }) { activeSurface = null }) + sessionWorkGeneration += 1 // A lens switch invalidates session work, not this surface. + assertTrue(worker.submitIfCurrent(5L, { previewGeneration }) { + activeSurface = "new-preview" + }) + assertTrue(worker.submit { completed.countDown() }) + release.countDown() + + assertTrue(completed.await(2, TimeUnit.SECONDS)) + assertEquals(5L, previewGeneration) + assertEquals(5L, sessionWorkGeneration) + assertEquals("new-preview", activeSurface) + } finally { + release.countDown() + worker.close() + } + } + @Test fun surfaceLensSettingsAndPreviewTransitionsRunSerially() { val active = AtomicInteger() diff --git a/android/app/src/test/java/dev/openstream/app/camera/CaptureOutputsTest.kt b/android/app/src/test/java/dev/openstream/app/camera/CaptureOutputsTest.kt new file mode 100644 index 00000000..f6e767b0 --- /dev/null +++ b/android/app/src/test/java/dev/openstream/app/camera/CaptureOutputsTest.kt @@ -0,0 +1,27 @@ +package dev.openstream.app.camera + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CaptureOutputsTest { + @Test + fun cameraKeepsEncoderOutputWhenActivityPreviewDetaches() { + assertEquals( + setOf(CameraCaptureOutput.Encoder), + captureOutputKinds(hasPreview = false, hasEncoder = true), + ) + } + + @Test + fun cameraUsesPreviewAndEncoderTogetherWhileActivityIsVisible() { + assertEquals( + setOf(CameraCaptureOutput.Preview, CameraCaptureOutput.Encoder), + captureOutputKinds(hasPreview = true, hasEncoder = true), + ) + } + + @Test + fun cameraHasNoCaptureSessionWhenNeitherOutputExists() { + assertEquals(emptySet(), captureOutputKinds(hasPreview = false, hasEncoder = false)) + } +} diff --git a/docs/testing.md b/docs/testing.md index 100f373d..f6617a82 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -83,6 +83,26 @@ implementation. after Virtual Camera starts and after a reconnect. - Telemetry updates at least once per second. +## Android session lifecycle contract + +`PhoneSessionService` owns the camera, encoders, SRT connection, discovery, control server, and +reservation state. `MainActivity` observes that owner and attaches the preview surface. + +| Event | Expected behavior | Result | +|---|---|---| +| Home, screen lock, Settings, or task removal | The foreground service keeps the session owner and reservation. The Activity detaches its preview. During a live stream, the camera capture session targets the encoder without the preview surface. | INCONCLUSIVE until checked on a physical phone | +| Return to OpenStream | The Activity attaches its new preview surface to the existing owner and shows the current session state. The owner does not create another encoder. | INCONCLUSIVE until checked on a physical phone | +| Notification Stop or in-app Stop | The owner stops media, closes the listener and control services, clears the reservation, and persists Stopped. Returning to the Activity does not restart it. The user must tap Start. | JVM and instrumentation test code added; emulator run unavailable | +| Relaunch after Stop | The Activity binds without starting a foreground service. The service sees the persisted Stopped state and stops the inactive start request without restarting media. | Instrumentation test code added; emulator run unavailable | +| Camera permission revoked | The owner stops media and clears the reservation. After permission is restored, the user must tap Start. | Instrumentation test code added; emulator run unavailable | +| Start while camera permission is denied | The service does not enter the foreground or clear a persisted Stop. The owner remains stopped and requests camera permission. | Instrumentation test code added; emulator run unavailable | +| Process recreation | The foreground service does not restart itself. The next app launch creates an available owner with no reservation. A previously explicit Stop remains stopped. | State transition tested; process-death device check INCONCLUSIVE | + +Screen-off camera access depends on Android version and device policy. The foreground service requests +the camera service type before the Activity leaves the foreground, but this code does not prove that a +phone keeps delivering camera frames while locked. Record the phone model, Android version, APK hash, +OBS version, and stream result for each physical run. + ## Android/OBS compatibility matrix Before changing discovery fields, pairing URLs, authentication, reservations, diff --git a/tests/test_camera_recovery_contract.py b/tests/test_camera_recovery_contract.py index 50c641dc..6dc37cfd 100644 --- a/tests/test_camera_recovery_contract.py +++ b/tests/test_camera_recovery_contract.py @@ -27,7 +27,7 @@ def test_transient_camera_loss_waits_for_availability_before_reopening(): assert "lifecycleGeneration != expectedLifecycleGeneration" in recovery_lock assert "cameraGeneration.get() != cameraRecoveryGeneration" in recovery_lock assert "activeCameraId != cameraId" in recovery_lock - assert "previewSurfaceProvider().isValid" in recovery_lock + assert "previewSurface?.isValid == true" in recovery_lock assert recovery_lock.index("cancelCameraRecoveryLocked()") < recovery_lock.index( "startPreviewLocked()" ) @@ -113,21 +113,18 @@ def test_capture_session_failures_recover_only_current_camera(): def test_session_reconfigure_defers_invalid_output_surfaces_before_teardown(): create_session = block_after(SOURCE, "private fun createSession()") - assert "runCatching { previewSurfaceProvider() }.getOrNull()" in create_session - assert "preview == null || !preview.isValid" in create_session + assert "previewSurface?.takeIf(Surface::isValid)" in create_session + assert "captureOutputKinds(preview != null, encoded != null)" in create_session + assert "outputKinds.isEmpty()" in create_session assert "encoded != null && !encoded.isValid" in create_session - assert create_session.index("preview == null || !preview.isValid") < create_session.index( - "sessionGeneration.incrementAndGet()" - ) - assert create_session.index("encoded != null && !encoded.isValid") < create_session.index( - "session?.close()" - ) + assert create_session.index("outputKinds.isEmpty()") < create_session.index("session?.close()") + assert create_session.index("encoded != null && !encoded.isValid") < create_session.index("session?.close()") rebuild = block_after(SOURCE, "private fun rebuildRepeatingRequest()") - assert "runCatching { previewSurfaceProvider() }.getOrNull()" in rebuild - assert "preview == null || !preview.isValid" in rebuild + assert "previewSurface?.takeIf(Surface::isValid)" in rebuild + assert "if (preview == null && encoded == null) return" in rebuild assert "encoded != null && !encoded.isValid" in rebuild - assert rebuild.index("preview == null || !preview.isValid") < rebuild.index( + assert rebuild.index("if (preview == null && encoded == null) return") < rebuild.index( "device.createCaptureRequest(template)" ) diff --git a/tests/test_prod_hardening_contract.py b/tests/test_prod_hardening_contract.py index 45b8d39a..fe3c8ba6 100644 --- a/tests/test_prod_hardening_contract.py +++ b/tests/test_prod_hardening_contract.py @@ -15,6 +15,8 @@ from pathlib import Path +from _helpers import block_after + ROOT = Path(__file__).resolve().parents[1] @@ -45,21 +47,25 @@ def test_native_load_is_guarded(): def test_teardown_never_blocks_ui_thread(): app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + runtime = read("android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt") + service = read("android/app/src/main/java/dev/openstream/app/PhoneSessionService.kt") worker = read("android/app/src/main/java/dev/openstream/app/SessionWorker.kt") - stop_server = app[app.index("private fun stopPhoneServer(") : app.index("private fun stopStream(")] - - # MainActivity owns a bounded serial worker, and teardown is submitted as a - # unit so native disconnect/camera/codec stop do not run in the UI callback. - assert "private val sessionWorker = SessionWorker" in app - assert "sessionWorker.submitCritical(blockingWork)" in stop_server - assert "streamClient.disconnect()" in stop_server - assert "camera.stopStreaming()" in stop_server - assert "encoder.stop()" in stop_server + + # The foreground service owns the worker. Activity actions delegate to it, + # and aggregate Stop teardown is retried through the reserved worker slot. + assert "sessionRuntime.stopSessionResources" in service + assert "SessionWorker" in runtime + assert "submitCritical(teardown)" in runtime + assert "streamClient.disconnect()" in runtime + assert "camera.stopStreaming()" in runtime + assert "encoder.stop()" in runtime + assert "sessionRuntime.stopStream(updateStatus)" in app assert "ArrayBlockingQueue(normalQueueCapacity + 1)" in worker assert "queueCapacity: Int = 32" in worker assert "Session worker queue is full" in worker assert "fun submitCritical(work: () -> Unit)" in worker - assert "sessionWorker.submitCritical {" in app + assert "fun submitCriticalTeardown(work: () -> Unit)" in runtime + assert "sessionWorker.submitCritical(work)" in runtime assert "fun submitIfCurrent(" in worker @@ -79,10 +85,50 @@ def test_control_bind_errors_surfaced(): def test_reservation_model_preserved(): + runtime = read("android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt") app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") - assert "ReservationState" in app + assert "ReservationState" in runtime assert "advertisedReservationId" in app - assert "isBusy" in app or "isPhoneBusy" in app + assert "reservationState.isBusy(phoneConnected)" in runtime + + +def test_stopped_relaunch_binds_without_requesting_foreground_start(): + activity = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + service = read("android/app/src/main/java/dev/openstream/app/PhoneSessionService.kt") + startup = activity[ + activity.index("private fun startAndBindSessionService()") : + activity.index("private fun requestSessionStop()") + ] + assert "getSharedPreferences" not in startup + assert "startForegroundService(intent)" not in startup + assert "startService(intent)" in startup + assert "bindService(intent, serviceConnection, BIND_AUTO_CREATE)" in startup + start_path = service[ + service.index("override fun onStartCommand") : + service.index("override fun onTaskRemoved") + ] + assert "preferences.getBoolean(KEY_STOPPED, false) -> stopSelfResult(startId)" in start_path + assert "if (foregroundStarted) sessionRuntime.startComponents()" in start_path + + +def test_preview_surface_reconfiguration_runs_on_owner_worker(): + activity = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + runtime = read("android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt") + binding = block_after(runtime, "fun bindPreviewSurface(surface:") + enqueue_start = runtime.index("private fun enqueuePreviewBinding(") + retry_start = runtime.index("private fun schedulePreviewBindingRetry(", enqueue_start) + enqueue = runtime[enqueue_start:retry_start] + retry = block_after(runtime, "private fun schedulePreviewBindingRetry()") + assert "enqueuePreviewBinding(generation, requestedSurface)" in binding + assert "sessionWorker.submitIfCurrent(generation, { previewBindingGeneration })" in enqueue + assert "if (generation != previewBindingGeneration) return@submitIfCurrent" in enqueue + assert "camera.bindPreviewSurface(surface)" in enqueue + assert "previewBindingGeneration" in retry + assert "previewBindingGeneration" in enqueue + assert "requestedPreviewSurface" in retry + assert "schedulePreviewBindingRetry()" in retry + assert "camera.bindPreviewSurface(holder.surface)" not in activity + assert "camera.bindPreviewSurface(null)" not in activity def test_obs_discovery_registry_is_bounded(): diff --git a/tests/test_repo_contract.py b/tests/test_repo_contract.py index bc4bc6eb..f3fc1f13 100644 --- a/tests/test_repo_contract.py +++ b/tests/test_repo_contract.py @@ -15,16 +15,20 @@ def test_architecture_documents_practical_v1_transport() -> None: def test_android_project_declares_camera_media_codec_srt_discovery_boundaries() -> None: app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + runtime = read("android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt") + service = read("android/app/src/main/java/dev/openstream/app/PhoneSessionService.kt") discovery = read("android/app/src/main/java/dev/openstream/app/discovery/PhoneDiscoveryAdvertiser.kt") manifest = read("android/app/src/main/AndroidManifest.xml") - assert "Camera2" in app - assert "MediaCodec" in app - assert "SrtStreamClient" in app + assert "Camera2Controller" in runtime + assert "MediaCodecVideoEncoder" in runtime + assert "SrtStreamClient" in runtime + assert "PhoneSessionRuntime(" in service + assert "PhoneSessionRuntime" in app assert "status_ready" in app - assert "PhoneDiscoveryAdvertiser" in app - assert "startPreviewIfAllowed" in app - assert "startPhoneServerIfAllowed" in app - assert "MediaCodecAudioEncoder" in app + assert "PhoneDiscoveryAdvertiser" in runtime + assert "fun startPreview()" in runtime + assert "startPhoneServerIfAllowed" in runtime + assert "MediaCodecAudioEncoder" in runtime camera = read("android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt") assert "CONTROL_AE_TARGET_FPS_RANGE" in camera assert "targetFps" in camera @@ -44,7 +48,7 @@ def test_android_project_declares_camera_media_codec_srt_discovery_boundaries() assert "advertisedMimeType()" in discovery assert "CHANGE_WIFI_MULTICAST_STATE" in manifest assert "RECORD_AUDIO" in manifest - assert "RECORD_AUDIO" in app + assert "RECORD_AUDIO" in runtime def test_android_connection_target_builds_srt_caller_url_and_pairing_targets() -> None: @@ -86,7 +90,7 @@ def test_audio_path_uses_adts_aac_and_obs_planar_formats() -> None: assert "makeAdtsFrame" in native assert "hasAdtsHeader" in native assert "muxAudioAccessUnit(" in native - assert "g_state.audioCodecConfig" in native + assert "streamState().audioCodecConfig" in native assert "output.reserve" in native assert "pes.reserve" in native assert "AUDIO_FORMAT_FLOAT_PLANAR" in source @@ -147,16 +151,17 @@ def test_obs_discovery_beacons_advertise_slots_not_raw_listener_only() -> None: def test_slot_reservation_allows_owned_busy_phone_and_reconnect_hold() -> None: source = read("obs-plugin/src/openstream-source.cpp") app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + runtime = read("android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt") advertiser = read("android/app/src/main/java/dev/openstream/app/discovery/PhoneDiscoveryAdvertiser.kt") assert "entry.second.reserved_by != source_instance_id" in source assert "found->second.busy && found->second.reserved_by != source_instance_id" in source assert "set_slot_status(ctx, \"Reconnecting\")" in source assert "set_active_phone(ctx, reserved_phone)" in source assert '"reservedBy"' in advertiser - assert "RECONNECT_RESERVATION_MS = 45_000L" in app - assert "Holding $it for reconnect" in app - assert "scheduleReservationRelease" in app - assert "cancelReservationRelease" in app + assert "RECONNECT_RESERVATION_MS = 45_000L" in runtime + assert "Holding $it for reconnect" in runtime + assert "scheduleReservationRelease" in runtime + assert "cancelReservationRelease" in runtime def test_auto_selected_obs_slot_sticks_to_same_phone_during_reconnect_hold() -> None: @@ -177,6 +182,7 @@ def test_android_discovery_ui_parses_and_displays_obs_slots() -> None: device = read("android/app/src/main/java/dev/openstream/app/discovery/DiscoveredObsDevice.kt") discovery = read("android/app/src/main/java/dev/openstream/app/discovery/ObsDiscoveryClient.kt") app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + runtime = read("android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt") layout = read("android/app/src/main/res/layout/activity_main.xml") strings = read("android/app/src/main/res/values/strings.xml") assert "val sourceInstanceId" in device @@ -184,7 +190,7 @@ def test_android_discovery_ui_parses_and_displays_obs_slots() -> None: assert "val slotLabel" in device assert "val pairingUrl" in device assert 'json.optString("slotLabel"' in discovery - assert "ObsDiscoveryClient(" in app + assert "ObsDiscoveryClient(" in runtime assert "renderObsSlots" in app assert "reserveForSlot" in app assert "slotAvailabilityLabel" in app @@ -211,16 +217,18 @@ def test_identify_camera_control_round_trip_exists() -> None: def test_android_control_server_supports_source_reservations() -> None: control = read("android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt") app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + runtime = read("android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt") assert 'path == "/reserve"' in control assert 'path == "/release"' in control assert "reservationProvider" in control - assert "reserveForSource" in app - assert "releaseForSource" in app + assert "onReserve = { sourceInstanceId, slotLabel, bitrateMbps ->" in runtime + assert "val accepted = reserve(sourceInstanceId, slotLabel, bitrateMbps)" in runtime + assert "val released = release(sourceInstanceId)" in runtime assert "selectForSource" in app - assert "isPhoneBusy()" in app + assert "reservationState.isBusy(phoneConnected)" in runtime assert "advertisedReservationId" in app - assert "private var activeStreamBitrate" in app - assert "useStreamBitrate(bitrateMbps)" in app + assert "activeStreamBitrate" in runtime + assert "replaceEncoderForBitrate(bitrateMbps)" in runtime assert "selectForSource(device.sourceInstanceId, device.displayLabel, device.bitrateMbps)" in app assert "val bitrateMbps = if (json.has(\"bitrateMbps\"))" in control diff --git a/tests/test_reservation_contract.py b/tests/test_reservation_contract.py index bb492d25..0e3d8b60 100644 --- a/tests/test_reservation_contract.py +++ b/tests/test_reservation_contract.py @@ -1,34 +1,31 @@ from pathlib import Path -from _helpers import ROOT, function_body, read_text as read +from _helpers import block_after, function_body, read_text as read SERVER = Path( "android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt" ).read_text() MAIN_ACTIVITY = Path("android/app/src/main/java/dev/openstream/app/MainActivity.kt") +RUNTIME = Path("android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt") def test_reservation_expires_if_media_never_connects() -> None: - source = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + source = RUNTIME.read_text(encoding="utf-8") - reserve_start = source.index("private fun reserveForSource") - release_start = source.index("private fun releaseForSource", reserve_start) + reserve_start = source.index("private fun reserve(") + release_start = source.index("private fun release(", reserve_start) reserve = source[reserve_start:release_start] - select_start = source.index("private fun selectForSource") - select_end = source.index("private fun reserveForSource", select_start) + select_start = source.index("fun selectForSource(") + select_end = source.index("fun clearReservation(", select_start) select = source[select_start:select_end] - listener_start = source.index("private fun startPhoneServerIfAllowed") - listener_end = source.index("private fun isListenerActive", listener_start) - listener = source[listener_start:listener_end] + listener = block_after(source, "fun startPhoneServerIfAllowed()") schedule_start = source.index("private fun scheduleReservationRelease") cancel_start = source.index("private fun cancelReservationRelease", schedule_start) schedule = source[schedule_start:cancel_start] - pending_start = source.index("private fun schedulePendingRelease") - pending_end = source.index("private fun scheduleReservationRelease", pending_start) - pending = source[pending_start:pending_end] + pending = block_after(source, "private fun schedulePendingRelease(sourceInstanceId: String)") - connected_branch = "if (phoneConnected) {\n cancelReservationRelease()\n } else {\n scheduleReservationRelease()\n }" + connected_branch = "if (phoneConnected) cancelReservationRelease() else scheduleReservationRelease()" assert connected_branch in reserve assert "schedulePendingRelease(sourceInstanceId)" in select assert "reservationState.beginSelection" in select @@ -49,10 +46,10 @@ def test_reservation_expires_if_media_never_connects() -> None: def test_reservation_renewal_invalidates_an_already_started_expiry() -> None: - source = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + source = RUNTIME.read_text(encoding="utf-8") - reserve_start = source.index("private fun reserveForSource") - release_start = source.index("private fun releaseForSource", reserve_start) + reserve_start = source.index("private fun reserve(") + release_start = source.index("private fun release(", reserve_start) reserve = source[reserve_start:release_start] schedule_start = source.index("private fun scheduleReservationRelease") cancel_start = source.index("private fun cancelReservationRelease", schedule_start) @@ -87,13 +84,15 @@ def test_reservation_release_is_bound_to_generation_token() -> None: def test_android_actions_route_disconnect_stop_and_start_separately() -> None: - source = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + activity = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + service = read("android/app/src/main/java/dev/openstream/app/PhoneSessionService.kt") + runtime = RUNTIME.read_text(encoding="utf-8") state = read("android/app/src/main/java/dev/openstream/app/PhoneSessionState.kt") - button = function_body(source, "setupButtons") - disconnect = function_body(source, "disconnectPhoneSession") - stop = function_body(source, "stopPhoneSession") - start = function_body(source, "startPhoneSession") - listener_start = function_body(source, "startPhoneServerIfAllowed") + button = function_body(activity, "setupButtons") + disconnect = function_body(activity, "disconnectPhoneSession") + stop = function_body(service, "stopSession") + start = function_body(service, "startSession") + listener_start = block_after(runtime, "fun startPhoneServerIfAllowed()") assert "when (actionForSessionStatus(phoneSessionState.snapshot.status))" in button assert "PhoneSessionAction.Disconnect -> disconnectPhoneSession()" in button @@ -107,24 +106,22 @@ def test_android_actions_route_disconnect_stop_and_start_separately() -> None: assert "PhoneSessionStatus.Reconnecting" in reconnecting_mapping assert "PhoneSessionAction.Stop" in reconnecting_mapping - assert "phoneSessionState.disconnect()" in disconnect - assert "clearReservation()" in disconnect + assert "sessionRuntime.disconnectReservation()" in disconnect assert "stopPhoneServer(" not in disconnect assert "startPhoneServerIfAllowed()" not in disconnect assert "phoneSessionState.stop()" in stop - assert "stopPhoneServer(clearReservation = true)" in stop - assert "pendingListenerStart = true" not in stop + assert "sessionRuntime.stopSessionResources" in stop assert "phoneSessionState.start()" in start - assert "startPhoneServerIfAllowed()" in start + assert "sessionRuntime.startComponents()" in start assert "PhoneSessionStatus.Stopped" in listener_start def test_android_reservation_timeouts_update_the_visible_state() -> None: - source = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") - assert 'statusText.text = "Connection timed out"' in source - assert 'statusDetail.text = "Choose an OBS slot to try again"' in source - assert 'statusText.text = "Disconnected"' in source + runtime = RUNTIME.read_text(encoding="utf-8") + activity = MAIN_ACTIVITY.read_text(encoding="utf-8") + assert '"Connection timed out", "Choose an OBS slot to try again"' in runtime + assert '"Disconnected"' in activity def test_reservation_captures_controller_peer(): @@ -290,6 +287,7 @@ def test_duplicate_reserve_retry_cannot_reach_android_lease_scheduler(): activity = Path( "android/app/src/main/java/dev/openstream/app/MainActivity.kt" ).read_text(encoding="utf-8") + runtime = RUNTIME.read_text(encoding="utf-8") reserve = function_body(SERVER, "handleReserve") duplicate_start = reserve.index("if (sameReservationConfig)") @@ -299,13 +297,13 @@ def test_duplicate_reserve_retry_cannot_reach_android_lease_scheduler(): duplicate_block = reserve[duplicate_start:on_reserve] assert "onReserve(" not in duplicate_block - assert "onReserve = { sourceInstanceId, slotLabel, bitrateMbps ->" in activity - assert "reserveForSource(sourceInstanceId, slotLabel, bitrateMbps)" in activity + assert "onReserve = { sourceInstanceId, slotLabel, bitrateMbps ->" in runtime + assert "reserve(sourceInstanceId, slotLabel, bitrateMbps)" in runtime - reserve_for_source = activity.index("private fun reserveForSource(") - schedule = activity.index("scheduleReservationRelease()", reserve_for_source) - generation = activity.index("reservationGeneration += 1", reserve_for_source) - assert reserve_for_source < generation < schedule + selection = block_after(runtime, "fun selectForSource(") + schedule = selection.index("schedulePendingRelease(sourceInstanceId)") + generation = selection.index("reservationGeneration += 1") + assert generation < schedule def test_obs_slot_render_cache_renders_initial_empty_state_and_tracks_bitrate(): diff --git a/tests/test_srt_contract.py b/tests/test_srt_contract.py index b14eb7dc..2d30051d 100644 --- a/tests/test_srt_contract.py +++ b/tests/test_srt_contract.py @@ -1,3 +1,4 @@ +import re from pathlib import Path from _helpers import block_after @@ -116,43 +117,55 @@ def test_native_connect_and_listen_publish_only_current_generation(): assert accept_pending.index("lifecycleGeneration_ != expectedLifecycleGeneration") < accept_pending.index("srt_epoll_uwait(") assert accept_pending.index("srt_epoll_uwait(") < accept_pending.index("srt_accept(") - jni_start_listen = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_startListen") - assert "g_state.sender.startListen(urlString, static_cast(session_generation))" in jni_start_listen - assert jni_start_listen.index("session_generation) != g_state.mediaSessionGeneration") < jni_start_listen.index("g_state.sender.startListen(") - jni_accept = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_accept") - assert "g_state.sender.acceptPending(generation)" in jni_accept - assert jni_accept.index("generation != g_state.mediaSessionGeneration") < jni_accept.index("g_state.sender.acceptPending(") - assert jni_accept.rindex("generation != g_state.mediaSessionGeneration") > jni_accept.index("g_state.sender.acceptPending(") + jni_start_listen = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_nativeStartListen") + assert "streamState().sender.startListen(urlString, static_cast(session_generation))" in jni_start_listen + assert jni_start_listen.index("session_generation) != streamState().mediaSessionGeneration") < jni_start_listen.index("streamState().sender.startListen(") + jni_accept = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_nativeAccept") + assert "streamState().sender.acceptPending(generation)" in jni_accept + assert jni_accept.index("generation != streamState().mediaSessionGeneration") < jni_accept.index("streamState().sender.acceptPending(") + assert jni_accept.rindex("generation != streamState().mediaSessionGeneration") > jni_accept.index("streamState().sender.acceptPending(") + + +def test_native_jni_exports_match_kotlin_external_methods(): + kotlin = KOTLIN.read_text(encoding="utf-8") + native = NATIVE.read_text(encoding="utf-8") + kotlin_methods = set(re.findall(r"private external fun (native\w+)\(", kotlin)) + native_methods = set( + re.findall(r"Java_dev_openstream_app_stream_SrtNativeBridge_(native\w+)\(", native) + ) + + assert kotlin_methods + assert native_methods == kotlin_methods def test_disconnect_invalidates_generation_before_native_teardown(): native = NATIVE.read_text() - body = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_disconnect") - invalidate = "g_state.sender.advanceLifecycleGeneration(generation)" - teardown = "g_state.sender.disconnect()" + body = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_nativeDisconnect") + invalidate = "streamState().sender.advanceLifecycleGeneration(generation)" + teardown = "streamState().sender.disconnect()" assert body.index(invalidate) < body.index(teardown) def test_native_media_and_stale_teardown_are_generation_guarded(): native = NATIVE.read_text() - video = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_sendVideo") - audio = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_sendAudio") - disconnect = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_disconnect") + video = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_nativeSendVideo") + audio = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_nativeSendAudio") + disconnect = block_after(native, "Java_dev_openstream_app_stream_SrtNativeBridge_nativeDisconnect") generation_guard = ( "static_cast(session_generation) != " - "g_state.mediaSessionGeneration" + "streamState().mediaSessionGeneration" ) assert generation_guard in video - assert video.index(generation_guard) < video.index("g_state.muxer->muxAccessUnit") + assert video.index(generation_guard) < video.index("streamState().muxer->muxAccessUnit") assert generation_guard in audio - assert audio.index(generation_guard) < audio.index("g_state.muxer->muxAudioAccessUnit") + assert audio.index(generation_guard) < audio.index("streamState().muxer->muxAudioAccessUnit") # A cancelled older connection must not roll back the native lifecycle or # tear down a newer generation installed by concurrent disconnect(). - assert "generation < g_state.mediaSessionGeneration" in disconnect - assert disconnect.index("generation < g_state.mediaSessionGeneration") < disconnect.index( - "g_state.sender.advanceLifecycleGeneration(generation)" + assert "generation < streamState().mediaSessionGeneration" in disconnect + assert disconnect.index("generation < streamState().mediaSessionGeneration") < disconnect.index( + "streamState().sender.advanceLifecycleGeneration(generation)" ) diff --git a/tests/test_video_transport_contract.py b/tests/test_video_transport_contract.py index 85603022..9ca77df6 100644 --- a/tests/test_video_transport_contract.py +++ b/tests/test_video_transport_contract.py @@ -10,6 +10,7 @@ "android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt" ) MAIN_ACTIVITY = Path("android/app/src/main/java/dev/openstream/app/MainActivity.kt") +SESSION_RUNTIME = Path("android/app/src/main/java/dev/openstream/app/PhoneSessionRuntime.kt") def test_video_callbacks_are_generation_bound_across_restart(): @@ -156,20 +157,14 @@ def test_failed_video_callback_setup_rolls_back_partial_resources(): def test_stale_transport_failure_cannot_clear_replacement_connection_state(): - source = MAIN_ACTIVITY.read_text(encoding="utf-8") - handler = block_after(source, "private fun handleMediaTransportFailure(sessionGeneration: Long)") + runtime = SESSION_RUNTIME.read_text(encoding="utf-8") + handler = block_after(runtime, "fun handleMediaTransportFailure(sessionGeneration: Long)") + generation_guard = "if (!streamClient.isCurrentSessionGeneration(sessionGeneration)) return" - post_block = block_after(handler, "mainHandler.post") - generation_guard = ( - "if (!streamClient.isCurrentSessionGeneration(sessionGeneration)) return@post" - ) - - assert generation_guard in post_block - assert "phoneConnected = false" in post_block - assert post_block.index(generation_guard) < post_block.index("phoneConnected = false") - - before_post = handler[: handler.index("mainHandler.post")] - assert "phoneConnected = false" not in before_post + assert generation_guard in handler + assert "phoneConnected = false" in handler + assert handler.index(generation_guard) < handler.index("phoneConnected = false") + assert handler.index(generation_guard) < handler.index("stopStream(updateStatus = false)") def test_send_failures_preserve_transport_generation_for_recovery(): @@ -215,24 +210,22 @@ def test_video_encoder_selection_is_hardware_avc_only_with_explicit_failure(): assert '"OpenStreamEncoder"' not in choose -def test_main_activity_drops_stale_failure_before_destructive_recovery(): +def test_session_owner_drops_stale_failure_before_destructive_recovery(): + runtime = SESSION_RUNTIME.read_text(encoding="utf-8") activity = MAIN_ACTIVITY.read_text(encoding="utf-8") - audio_callback = block_after(activity, "onEncodedAccessUnit = { accessUnit ->") - video_start = activity.index("private fun createVideoEncoder") + audio_callback = block_after(runtime, "onEncodedAccessUnit = { accessUnit ->") + video_start = runtime.index("fun createVideoEncoder") video_callback = block_after( - activity[video_start:], "onEncodedAccessUnit = { accessUnit ->" + runtime[video_start:], "onEncodedAccessUnit = { accessUnit ->" ) for callback in (audio_callback, video_callback): assert "result.recoveryRequired" in callback assert "isCurrentSessionGeneration(result.sessionGeneration)" in callback - assert "handleMediaTransportFailure(result.sessionGeneration)" in callback + assert "onMediaTransportFailure(result.sessionGeneration)" in callback - handler = block_after(activity, "private fun handleMediaTransportFailure(sessionGeneration: Long)") - assert "if (!streamClient.isCurrentSessionGeneration(sessionGeneration)) return" in handler - posted = block_after(handler, "mainHandler.post") - stale_guard = posted.index( - "if (!streamClient.isCurrentSessionGeneration(sessionGeneration)) return@post" - ) - stop_stream = posted.index("stopStream(updateStatus = false)") + handler = block_after(runtime, "fun handleMediaTransportFailure(sessionGeneration: Long)") + stale_guard = handler.index("if (!streamClient.isCurrentSessionGeneration(sessionGeneration)) return") + stop_stream = handler.index("stopStream(updateStatus = false)") assert stale_guard < stop_stream + assert "if (::sessionRuntime.isInitialized) sessionRuntime.handleMediaTransportFailure(sessionGeneration)" in activity