mic works and improved theme

This commit is contained in:
srtk 2025-11-30 14:42:37 +05:30
parent 797c5c48d7
commit 55aa3c6510
4 changed files with 1201 additions and 981 deletions

View file

@ -0,0 +1,176 @@
package com.srtk.airlink
import android.Manifest
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import androidx.annotation.RequiresPermission
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
data class AudioQuality(val sampleRate: Int, val label: String) {
companion object {
val LOW = AudioQuality(16000, "Low (16 kHz)")
val MEDIUM = AudioQuality(24000, "Medium (24 kHz)")
val HIGH = AudioQuality(48000, "High (48 kHz)")
val ULTRA = AudioQuality(48000, "Ultra (48 kHz Stereo)")
val ALL = listOf(LOW, MEDIUM, HIGH, ULTRA)
}
val isUltra: Boolean get() = this == ULTRA
}
class AudioStreamer(private val context: Context) {
private val TAG = "AudioStreamer"
private val audioThread = HandlerThread("AudioThread").apply { start() }
private val audioHandler = Handler(audioThread.looper)
private var audioRecord: AudioRecord? = null
private var socket: DatagramSocket? = null
@Volatile private var isStreaming = false
private var currentQuality = AudioQuality.HIGH
companion object {
private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
private const val MAX_PACKET_SIZE = 1400
}
fun setQuality(quality: AudioQuality) {
currentQuality = quality
Log.d(TAG, "Audio quality set to: ${quality.label}")
}
fun start(ip: InetAddress, port: Int) {
audioHandler.post @androidx.annotation.RequiresPermission(android.Manifest.permission.RECORD_AUDIO) {
try {
stopInternal()
Thread.sleep(200)
startInternal(ip, port)
} catch (e: Exception) {
Log.e(TAG, "Start error", e)
}
}
}
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
private fun startInternal(ip: InetAddress, port: Int) {
try {
socket = DatagramSocket()
val channelConfig = if (currentQuality.isUltra)
AudioFormat.CHANNEL_IN_STEREO
else
AudioFormat.CHANNEL_IN_MONO
val minBufferSize = AudioRecord.getMinBufferSize(
currentQuality.sampleRate,
channelConfig,
AUDIO_FORMAT
)
if (minBufferSize == AudioRecord.ERROR_BAD_VALUE || minBufferSize == AudioRecord.ERROR) {
Log.e(TAG, "Invalid buffer size for sample rate: ${currentQuality.sampleRate}")
return
}
val bufferSize = minBufferSize * 4
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
currentQuality.sampleRate,
channelConfig,
AUDIO_FORMAT,
bufferSize
)
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord initialization failed")
return
}
audioRecord?.startRecording()
isStreaming = true
Log.d(TAG, "Audio streaming started: ${currentQuality.label}")
Thread {
streamAudioLoop(ip, port, bufferSize)
}.start()
} catch (e: Exception) {
Log.e(TAG, "Start failed: ${e.message}", e)
stopInternal()
}
}
private fun streamAudioLoop(ip: InetAddress, port: Int, bufferSize: Int) {
val buffer = ByteArray(MAX_PACKET_SIZE)
var packetsDropped = 0
var totalPackets = 0
try {
while (isStreaming) {
val record = audioRecord ?: break
val readBytes = record.read(buffer, 0, buffer.size)
if (readBytes > 0) {
try {
socket?.send(
DatagramPacket(buffer, readBytes, ip, port)
)
totalPackets++
} catch (e: Exception) {
packetsDropped++
if (packetsDropped % 100 == 0) {
Log.w(TAG, "Dropped $packetsDropped/$totalPackets packets")
}
}
} else if (readBytes < 0) {
Log.e(TAG, "AudioRecord read error: $readBytes")
break
}
}
} catch (e: Exception) {
Log.e(TAG, "Audio stream loop error", e)
}
Log.d(TAG, "Audio stream ended. Packets: $totalPackets, Dropped: $packetsDropped")
}
fun stop() {
audioHandler.post { stopInternal() }
}
fun cleanup() {
stop()
audioThread.quitSafely()
}
private fun stopInternal() {
isStreaming = false
try {
audioRecord?.stop()
audioRecord?.release()
audioRecord = null
} catch (e: Exception) {
Log.e(TAG, "AudioRecord stop error", e)
}
try {
socket?.close()
socket = null
} catch (e: Exception) {
Log.e(TAG, "Socket close error", e)
}
}
}

View file

@ -5,13 +5,18 @@ import android.content.Context
import android.hardware.camera2.* import android.hardware.camera2.*
import android.media.MediaCodec import android.media.MediaCodec
import android.media.MediaCodecInfo import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaFormat import android.media.MediaFormat
import android.os.Bundle import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import android.util.Log import android.util.Log
import android.util.Range import android.util.Range
import android.util.Size
import android.view.Surface import android.view.Surface
import androidx.annotation.RequiresApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.DatagramPacket import java.net.DatagramPacket
import java.net.DatagramSocket import java.net.DatagramSocket
import java.net.InetAddress import java.net.InetAddress
@ -21,8 +26,20 @@ import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
data class VideoConfig(val width: Int, val height: Int, val fps: Int) { data class VideoConfig(val width: Int, val height: Int, val fps: Int) {
val aspectRatio: String
get() {
val ratio = width.toDouble() / height.toDouble()
return when {
abs(ratio - 1.77) < 0.05 -> "16:9"
abs(ratio - 1.33) < 0.05 -> "4:3"
abs(ratio - 2.0) < 0.1 -> "18:9"
abs(ratio - 2.16) < 0.1 -> "19.5:9"
abs(ratio - 1.0) < 0.05 -> "1:1"
else -> String.format("%.1f:1", ratio)
}
}
override fun toString() = "${height}p" override fun toString() = "${height}p"
fun toDetailedString() = "${height}p (${width}×${height} @ ${fps}fps)" fun toDetailedString() = "${width}×${height} @ ${fps}fps"
} }
data class CameraCapabilities( data class CameraCapabilities(
@ -44,7 +61,6 @@ enum class WhiteBalance(val value: Int, val label: String) {
class CameraStreamer(private val context: Context) { class CameraStreamer(private val context: Context) {
private val TAG = "CameraStreamer" private val TAG = "CameraStreamer"
private val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager private val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
private val cameraThread = HandlerThread("CamThread").apply { start() } private val cameraThread = HandlerThread("CamThread").apply { start() }
private val camHandler = Handler(cameraThread.looper) private val camHandler = Handler(cameraThread.looper)
@ -54,10 +70,12 @@ class CameraStreamer(private val context: Context) {
private var mediaCodec: MediaCodec? = null private var mediaCodec: MediaCodec? = null
private var socket: DatagramSocket? = null private var socket: DatagramSocket? = null
private var currentIp: InetAddress? = null
private var currentPort: Int = 0
@Volatile private var isStreaming = false @Volatile private var isStreaming = false
private val restartLock = Semaphore(1) private val restartLock = Semaphore(1)
private var currentConfig = VideoConfig(960, 720, 30) private var currentConfig = VideoConfig(1280, 720, 30)
private var bitrate = 3000000
private var zoomRatio = 1f private var zoomRatio = 1f
private var flashMode = false private var flashMode = false
@ -65,24 +83,42 @@ class CameraStreamer(private val context: Context) {
private var focusMode = CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO private var focusMode = CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO
private var focusDistance = 0f private var focusDistance = 0f
private var exposureCompensation = 0 private var exposureCompensation = 0
private var onErrorCallback: ((String) -> Unit)? = null private var onErrorCallback: ((String) -> Unit)? = null
private var onDisconnectedCallback: (() -> Unit)? = null private var connectionManager: ConnectionManager? = null
private var lastPacketTime = 0L fun setConnectionManager(manager: ConnectionManager) { connectionManager = manager }
private val connectionTimeout = 5000L // 5 seconds fun setErrorCallback(callback: (String) -> Unit) { onErrorCallback = callback }
fun setErrorCallback(callback: (String) -> Unit) { private fun sendControl(msg: String) {
onErrorCallback = callback val ip = currentIp ?: return
} val port = currentPort
if (port == 0) return
fun setDisconnectedCallback(callback: () -> Unit) { Thread {
onDisconnectedCallback = callback
}
// Test if a resolution actually works with the encoder
private fun testEncoderResolution(width: Int, height: Int): Boolean {
var testCodec: MediaCodec? = null
try { try {
val s = socket ?: DatagramSocket()
val data = msg.toByteArray()
for(i in 1..3) {
s.send(DatagramPacket(data, data.size, ip, port))
Thread.sleep(5)
}
if (socket == null) s.close()
} catch (e: Exception) {}
}.start()
}
/**
* Actually test if encoder can handle this resolution by trying to configure it
*/
private fun canEncodeResolution(width: Int, height: Int, fps: Int): Boolean {
var codec: MediaCodec? = null
return try {
val bitrate = when {
width * height >= 1920 * 1080 -> 4_000_000
width * height >= 1280 * 720 -> 2_500_000
else -> 1_500_000
}
val format = MediaFormat.createVideoFormat( val format = MediaFormat.createVideoFormat(
MediaFormat.MIMETYPE_VIDEO_AVC, MediaFormat.MIMETYPE_VIDEO_AVC,
width, width,
@ -92,123 +128,142 @@ class CameraStreamer(private val context: Context) {
MediaFormat.KEY_COLOR_FORMAT, MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
) )
setInteger(MediaFormat.KEY_BIT_RATE, 2000000) setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
setInteger(MediaFormat.KEY_FRAME_RATE, 30) setInteger(MediaFormat.KEY_FRAME_RATE, fps)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
} }
testCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
testCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
testCodec.release() codec.reset() // Don't actually start, just test config
true
Log.d(TAG, "✅ Resolution ${width}x${height} WORKS")
return true
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "❌ Resolution ${width}x${height} FAILED: ${e.message}") Log.d(TAG, "${width}x${height} @ ${fps}fps - Encoder rejected: ${e.message}")
return false false
} finally { } finally {
try { try {
testCodec?.release() codec?.release()
} catch (e: Exception) {} } catch (e: Exception) {}
} }
} }
private fun is16by9(width: Int, height: Int): Boolean { suspend fun getCameraCapabilities(useFront: Boolean): CameraCapabilities = withContext(Dispatchers.Default) {
val ratio = width.toDouble() / height.toDouble()
return abs(ratio - 16.0 / 9.0) < 0.1
}
private fun is4by3(width: Int, height: Int): Boolean {
val ratio = width.toDouble() / height.toDouble()
return abs(ratio - 4.0 / 3.0) < 0.1
}
fun getCameraCapabilities(useFront: Boolean): CameraCapabilities {
val camId = getCameraId(useFront) val camId = getCameraId(useFront)
val chars = manager.getCameraCharacteristics(camId) val chars = manager.getCameraCharacteristics(camId)
val map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!! val map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
val allSizes = map.getOutputSizes(MediaCodec::class.java) // Get all sizes supported by camera for MediaCodec
.filter { is16by9(it.width, it.height) || is4by3(it.width, it.height) } val cameraSizes = map.getOutputSizes(MediaCodec::class.java)
.filter { it.width <= 1920 && it.height <= 1080 }
.toSet()
Log.d(TAG, "Testing ${cameraSizes.size} camera resolutions against encoder...")
val validConfigs = mutableListOf<VideoConfig>()
// Define common resolutions to prioritize
// val commonResolutions = listOf(
// Size(1920, 1080), // 1080p 16:9
// Size(1280, 720), // 720p 16:9
// Size(960, 720), // 720p 4:3
// Size(640, 480), // 480p 4:3
// Size(854, 480), // 480p 16:9
// Size(1440, 1080), // 1080p 4:3
// Size(800, 600), // 600p 4:3
// Size(320, 240), // 240p 4:3
// )
// Test common resolutions first
// for (size in commonResolutions) {
// if (!cameraSizes.contains(size)) continue
//
// val minFrameDuration = map.getOutputMinFrameDuration(MediaCodec::class.java, size)
// val maxFps = if (minFrameDuration > 0) {
// (1_000_000_000.0 / minFrameDuration).toInt()
// } else 30
//
// val targetFps = min(30, maxFps)
//
// if (targetFps >= 15 && canEncodeResolution(size.width, size.height, targetFps)) {
// validConfigs.add(VideoConfig(size.width, size.height, targetFps))
// Log.d(TAG, "✓ ${size.width}x${size.height} @ ${targetFps}fps")
// }
// }
// Test remaining camera sizes that aren't in common list
for (size in cameraSizes) {
// if (commonResolutions.contains(size)) continue
if (validConfigs.any { it.width == size.width && it.height == size.height }) continue
val ratio = size.width.toDouble() / size.height
// Only accept common aspect ratios
val isCommonAspectRatio = listOf(1.77, 1.33, 2.0, 2.16, 1.0)
.any { abs(ratio - it) < 0.1 }
if (!isCommonAspectRatio) continue
val minFrameDuration = map.getOutputMinFrameDuration(MediaCodec::class.java, size)
val maxFps = if (minFrameDuration > 0) {
(1_000_000_000.0 / minFrameDuration).toInt()
} else 30
val targetFps = min(30, maxFps)
if (targetFps >= 15 && canEncodeResolution(size.width, size.height, targetFps)) {
validConfigs.add(VideoConfig(size.width, size.height, targetFps))
Log.d(TAG, "${size.width}x${size.height} @ ${targetFps}fps")
}
}
// Fallback if nothing found (this should never happen on real devices)
if (validConfigs.isEmpty()) {
Log.w(TAG, "No compatible resolutions found! Adding fallback...")
// Try absolute minimum safe resolution
if (canEncodeResolution(640, 480, 30)) {
validConfigs.add(VideoConfig(640, 480, 30))
} else if (canEncodeResolution(320, 240, 30)) {
validConfigs.add(VideoConfig(320, 240, 30))
}
}
val sortedConfigs = validConfigs
.sortedByDescending { it.width * it.height } .sortedByDescending { it.width * it.height }
.distinctBy { "${it.width}x${it.height}" }
Log.d(TAG, "Testing encoder capabilities...") Log.d(TAG, "✓ Final supported resolutions: ${sortedConfigs.size} configs")
sortedConfigs.forEach {
val configs = mutableListOf<VideoConfig>() Log.d(TAG, " - ${it.toDetailedString()}")
val fpsRanges = chars.get(
CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES
) ?: emptyArray()
for (size in allSizes) {
try {
// TEST if encoder actually supports this resolution
if (!testEncoderResolution(size.width, size.height)) {
continue
} }
val minFrameDuration = map.getOutputMinFrameDuration( CameraCapabilities(
MediaCodec::class.java, sortedConfigs,
size chars.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1f,
) chars.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true,
manager.cameraIdList.any {
if (minFrameDuration <= 0) continue
val maxFpsFromDuration = (1_000_000_000.0 / minFrameDuration).toInt()
val validRanges = fpsRanges.filter {
it.upper <= maxFpsFromDuration && it.upper >= 15
}
val targetFps = min(validRanges.maxOfOrNull { it.upper } ?: 30, 30)
if (targetFps >= 15) {
configs.add(VideoConfig(size.width, size.height, targetFps))
}
} catch (e: Exception) {
Log.w(TAG, "Skipping ${size.width}x${size.height}: ${e.message}")
}
}
if (configs.isEmpty()) {
configs.add(VideoConfig(640, 480, 30))
Log.w(TAG, "No configs found, added 640x480 fallback")
}
val maxZoom = chars.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1f
val hasFlash = chars.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
val hasFront = manager.cameraIdList.any {
manager.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == manager.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) ==
CameraCharacteristics.LENS_FACING_FRONT CameraCharacteristics.LENS_FACING_FRONT
} },
val minFocus = chars.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE)
val exposureRange = chars.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE)
return CameraCapabilities(
configs.sortedByDescending { it.width * it.height },
maxZoom,
hasFlash,
hasFront,
listOf(WhiteBalance.AUTO, WhiteBalance.WARM, WhiteBalance.COOL), listOf(WhiteBalance.AUTO, WhiteBalance.WARM, WhiteBalance.COOL),
if (minFocus != null && minFocus > 0) Range(0f, minFocus) else null, chars.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE)?.let {
exposureRange if (it > 0) Range(0f, it) else null
},
chars.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE)
) )
} }
fun start( @RequiresApi(Build.VERSION_CODES.Q)
ip: InetAddress, fun start(ip: InetAddress, port: Int, config: VideoConfig, useFront: Boolean) {
port: Int,
config: VideoConfig,
useFront: Boolean
) {
currentConfig = config currentConfig = config
currentIp = ip
currentPort = port
val camId = getCameraId(useFront) val camId = getCameraId(useFront)
camHandler.post { camHandler.post {
try { try {
if (restartLock.tryAcquire(3, TimeUnit.SECONDS)) { if (restartLock.tryAcquire(2500, TimeUnit.MILLISECONDS)) {
try { try {
stopInternal() stopInternal(sendBye = false)
Thread.sleep(500) Thread.sleep(100)
startInternal(ip, port, camId) startInternal(ip, port, camId)
} finally { } finally {
restartLock.release() restartLock.release()
@ -216,335 +271,197 @@ class CameraStreamer(private val context: Context) {
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Start error", e) Log.e(TAG, "Start error", e)
onErrorCallback?.invoke("Failed to start: ${e.message}") onErrorCallback?.invoke("Start failed: ${e.message}")
} }
} }
} }
fun updateBitrate(mbps: Int) { @RequiresApi(Build.VERSION_CODES.R)
val newBitrate = mbps * 1_000_000 fun setZoom(z: Float) { zoomRatio = z; updateSession() }
if (bitrate == newBitrate) return @RequiresApi(Build.VERSION_CODES.R)
fun setTorch(on: Boolean) { flashMode = on; updateSession() }
bitrate = newBitrate @RequiresApi(Build.VERSION_CODES.R)
fun setWhiteBalance(wb: WhiteBalance) { wbMode = wb.value; updateSession() }
camHandler.post { @RequiresApi(Build.VERSION_CODES.R)
try {
mediaCodec?.let { codec ->
val bundle = Bundle().apply {
putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bitrate)
}
codec.setParameters(bundle)
Log.d(TAG, "Bitrate updated to ${mbps} Mbps")
}
} catch (e: Exception) {
Log.e(TAG, "Bitrate update failed", e)
}
}
}
fun setZoom(z: Float) {
zoomRatio = z
updateSession()
}
fun setTorch(on: Boolean) {
flashMode = on
updateSession()
}
fun setWhiteBalance(wb: WhiteBalance) {
wbMode = wb.value
updateSession()
}
fun setFocusMode(auto: Boolean) { fun setFocusMode(auto: Boolean) {
focusMode = if (auto) CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO focusMode = if (auto) CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO
else CameraMetadata.CONTROL_AF_MODE_OFF else CameraMetadata.CONTROL_AF_MODE_OFF
updateSession() updateSession()
} }
@RequiresApi(Build.VERSION_CODES.R)
fun setFocusDistance(distance: Float) { fun setFocusDistance(distance: Float) {
focusDistance = distance focusDistance = distance
focusMode = CameraMetadata.CONTROL_AF_MODE_OFF focusMode = CameraMetadata.CONTROL_AF_MODE_OFF
updateSession() updateSession()
} }
@RequiresApi(Build.VERSION_CODES.R)
fun setExposure(value: Int) { exposureCompensation = value; updateSession() }
fun setExposure(value: Int) { fun stopCapture() {
exposureCompensation = value sendControl("PAUSE")
updateSession() camHandler.post {
try {
captureSession?.stopRepeating()
} catch(e: Exception) {}
}
} }
@RequiresApi(Build.VERSION_CODES.Q)
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun startInternal( private fun startInternal(ip: InetAddress, port: Int, camId: String) {
ip: InetAddress,
port: Int,
camId: String
) {
try { try {
socket = DatagramSocket() socket = DatagramSocket()
isStreaming = true isStreaming = true
lastPacketTime = System.currentTimeMillis()
val width = currentConfig.width val width = currentConfig.width
val height = currentConfig.height val height = currentConfig.height
val fps = currentConfig.fps
val pixelCount = width * height val bitrate = when {
val calculatedBitrate = when { width * height >= 1920 * 1080 -> 4_000_000
pixelCount >= 1920 * 1080 -> 8_000_000 width * height >= 1280 * 720 -> 2_500_000
pixelCount >= 1280 * 720 -> 5_000_000
pixelCount >= 640 * 480 -> 2_500_000
else -> 1_500_000 else -> 1_500_000
} }
Log.d(TAG, "Starting: ${width}x${height} @ ${currentConfig.fps}fps") val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
val format = MediaFormat.createVideoFormat( setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
MediaFormat.MIMETYPE_VIDEO_AVC, setInteger(MediaFormat.KEY_FRAME_RATE, fps)
width, setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
height setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
setInteger(MediaFormat.KEY_BIT_RATE, calculatedBitrate)
setInteger(MediaFormat.KEY_FRAME_RATE, currentConfig.fps)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
} }
mediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) mediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
try {
mediaCodec?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) mediaCodec?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
} catch (e: Exception) {
format.removeKey(MediaFormat.KEY_PROFILE)
mediaCodec?.reset()
mediaCodec?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
}
val surface = mediaCodec?.createInputSurface() val surface = mediaCodec?.createInputSurface() ?: throw Exception("Failed to create surface")
?: throw Exception("Failed to create input surface")
mediaCodec?.start() mediaCodec?.start()
Log.d(TAG, "✅ Encoder started with ${width}x${height}")
// Start connection monitor
Thread {
while (isStreaming) {
Thread.sleep(1000)
if (System.currentTimeMillis() - lastPacketTime > connectionTimeout) {
Log.w(TAG, "Server disconnected - no data sent")
stop()
onDisconnectedCallback?.invoke()
break
}
}
}.start()
Thread { Thread {
Thread.currentThread().priority = Thread.MAX_PRIORITY Thread.currentThread().priority = Thread.MAX_PRIORITY
streamLoop(ip, port) streamLoop(ip, port)
}.start() }.start()
manager.openCamera( manager.openCamera(camId, object : CameraDevice.StateCallback() {
camId,
object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) { override fun onOpened(camera: CameraDevice) {
cameraDevice = camera cameraDevice = camera
createSession(camera, surface) createSession(camera, surface)
} }
override fun onDisconnected(camera: CameraDevice) { stopInternal() }
override fun onDisconnected(camera: CameraDevice) {
Log.w(TAG, "Camera disconnected")
stopInternal()
}
override fun onError(camera: CameraDevice, error: Int) { override fun onError(camera: CameraDevice, error: Int) {
Log.e(TAG, "Camera error: $error") onErrorCallback?.invoke("Camera Error: $error")
stopInternal() stopInternal()
onErrorCallback?.invoke("Camera error: $error")
} }
}, }, camHandler)
camHandler
)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Start failed: ${e.message}", e) Log.e(TAG, "Start Failed", e)
onErrorCallback?.invoke("Failed to start: ${e.message}") onErrorCallback?.invoke("Camera failed: ${e.message}")
stopInternal() stopInternal()
} }
} }
private fun createSession(camera: CameraDevice, surface: Surface) { private fun createSession(camera: CameraDevice, surface: Surface) {
try { try {
camera.createCaptureSession( camera.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() {
listOf(surface), @RequiresApi(Build.VERSION_CODES.R)
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) { override fun onConfigured(session: CameraCaptureSession) {
if (!isStreaming) return if (!isStreaming) return
captureSession = session captureSession = session
requestBuilder = camera.createCaptureRequest( try {
CameraDevice.TEMPLATE_RECORD requestBuilder = camera.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
).apply {
addTarget(surface) addTarget(surface)
set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO) set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO)
set(CaptureRequest.CONTROL_AE_MODE, CameraMetadata.CONTROL_AE_MODE_ON) set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(currentConfig.fps, currentConfig.fps))
set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_OFF)
Range(currentConfig.fps, currentConfig.fps))
set(CaptureRequest.CONTROL_AF_MODE, focusMode)
set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE,
CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_ON)
if (focusMode == CameraMetadata.CONTROL_AF_MODE_OFF) {
set(CaptureRequest.LENS_FOCUS_DISTANCE, focusDistance)
}
set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposureCompensation)
} }
updateSession() updateSession()
Log.d(TAG, "Camera session started") } catch (e: Exception) {}
} }
override fun onConfigureFailed(session: CameraCaptureSession) {}
override fun onConfigureFailed(session: CameraCaptureSession) { }, camHandler)
Log.e(TAG, "Session config failed") } catch (e: Exception) {}
onErrorCallback?.invoke("Camera session failed")
}
},
camHandler
)
} catch (e: Exception) {
Log.e(TAG, "Create session error", e)
}
} }
@RequiresApi(Build.VERSION_CODES.R)
private fun updateSession() { private fun updateSession() {
val builder = requestBuilder ?: return val builder = requestBuilder ?: return
val session = captureSession ?: return val session = captureSession ?: return
try { try {
if (zoomRatio > 1f) { if (zoomRatio > 1f) builder.set(CaptureRequest.CONTROL_ZOOM_RATIO, zoomRatio)
builder.set(CaptureRequest.CONTROL_ZOOM_RATIO, zoomRatio) builder.set(CaptureRequest.FLASH_MODE, if (flashMode) CameraMetadata.FLASH_MODE_TORCH else CameraMetadata.FLASH_MODE_OFF)
}
builder.set(
CaptureRequest.FLASH_MODE,
if (flashMode) CameraMetadata.FLASH_MODE_TORCH
else CameraMetadata.FLASH_MODE_OFF
)
builder.set(CaptureRequest.CONTROL_AWB_MODE, wbMode) builder.set(CaptureRequest.CONTROL_AWB_MODE, wbMode)
builder.set(CaptureRequest.CONTROL_AF_MODE, focusMode) builder.set(CaptureRequest.CONTROL_AF_MODE, focusMode)
if (focusMode == CameraMetadata.CONTROL_AF_MODE_OFF) builder.set(CaptureRequest.LENS_FOCUS_DISTANCE, focusDistance)
if (focusMode == CameraMetadata.CONTROL_AF_MODE_OFF) {
builder.set(CaptureRequest.LENS_FOCUS_DISTANCE, focusDistance)
}
builder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposureCompensation) builder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposureCompensation)
session.setRepeatingRequest(builder.build(), null, camHandler) session.setRepeatingRequest(builder.build(), null, camHandler)
} catch (e: Exception) { } catch (e: Exception) {}
Log.e(TAG, "Update session error", e)
}
} }
private fun streamLoop(ip: InetAddress, port: Int) { private fun streamLoop(ip: InetAddress, port: Int) {
val bufferInfo = MediaCodec.BufferInfo() val bufferInfo = MediaCodec.BufferInfo()
val maxChunkSize = 1400 val maxChunkSize = 1400
var lastUpdate = System.currentTimeMillis()
Log.d(TAG, "Stream loop started")
try { try {
while (isStreaming) { while (isStreaming) {
val codec = mediaCodec ?: break val codec = mediaCodec ?: break
val index = try { codec.dequeueOutputBuffer(bufferInfo, 10000) } catch (e: Exception) { -1 }
val index = codec.dequeueOutputBuffer(bufferInfo, 10000) if (index >= 0) {
val buffer = codec.getOutputBuffer(index)
when (index) { if (buffer != null) {
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val format = codec.outputFormat
Log.d(TAG, "Output format: $format")
}
in 0..Int.MAX_VALUE -> {
try {
if (bufferInfo.size <= 0) {
codec.releaseOutputBuffer(index, false)
continue
}
val buffer = codec.getOutputBuffer(index) ?: continue
buffer.position(bufferInfo.offset) buffer.position(bufferInfo.offset)
buffer.limit(bufferInfo.offset + bufferInfo.size) buffer.limit(bufferInfo.offset + bufferInfo.size)
val data = ByteArray(bufferInfo.size)
val frameData = ByteArray(bufferInfo.size) buffer.get(data)
buffer.get(frameData)
var offset = 0 var offset = 0
while (offset < frameData.size && isStreaming) { while (offset < data.size && isStreaming) {
val chunkSize = min(maxChunkSize, frameData.size - offset) val chunkSize = min(maxChunkSize, data.size - offset)
socket?.send( try { socket?.send(DatagramPacket(data, offset, chunkSize, ip, port)) } catch (e: Exception) {}
DatagramPacket(frameData, offset, chunkSize, ip, port)
)
offset += chunkSize offset += chunkSize
} }
lastPacketTime = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastUpdate > 500) {
connectionManager?.updateActivity()
lastUpdate = now
}
}
codec.releaseOutputBuffer(index, false) codec.releaseOutputBuffer(index, false)
} catch (e: Exception) {
Log.e(TAG, "Buffer processing error", e)
} }
} }
} } catch (e: Exception) {}
}
} catch (e: Exception) {
Log.e(TAG, "Stream loop error", e)
} }
Log.d(TAG, "Stream loop ended") fun stop() { camHandler.post { stopInternal(sendBye = true) } }
} fun cleanup() { stop(); cameraThread.quitSafely() }
fun stop() { private fun stopInternal(sendBye: Boolean = true) {
camHandler.post { stopInternal() } if (sendBye && isStreaming) sendControl("BYE")
}
fun cleanup() {
stop()
cameraThread.quitSafely()
}
private fun stopInternal() {
isStreaming = false isStreaming = false
try { try { captureSession?.stopRepeating(); captureSession?.close() } catch (e: Exception) {}
captureSession?.stopRepeating()
captureSession?.close()
captureSession = null captureSession = null
} catch (e: Exception) { try { cameraDevice?.close() } catch (e: Exception) {}
Log.e(TAG, "Session stop error", e)
}
try {
cameraDevice?.close()
cameraDevice = null cameraDevice = null
} catch (e: Exception) { try { mediaCodec?.stop(); mediaCodec?.release() } catch (e: Exception) {}
Log.e(TAG, "Camera close error", e)
}
try {
mediaCodec?.stop()
mediaCodec?.release()
mediaCodec = null mediaCodec = null
} catch (e: Exception) { try { socket?.close() } catch (e: Exception) {}
Log.e(TAG, "Codec stop error", e)
}
try {
socket?.close()
socket = null socket = null
} catch (e: Exception) {
Log.e(TAG, "Socket close error", e)
}
requestBuilder = null
} }
private fun getCameraId(front: Boolean): String { private fun getCameraId(front: Boolean): String {
val target = if (front) val target = if (front) CameraCharacteristics.LENS_FACING_FRONT else CameraCharacteristics.LENS_FACING_BACK
CameraCharacteristics.LENS_FACING_FRONT
else
CameraCharacteristics.LENS_FACING_BACK
return manager.cameraIdList.firstOrNull { return manager.cameraIdList.firstOrNull {
manager.getCameraCharacteristics(it) manager.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == target
.get(CameraCharacteristics.LENS_FACING) == target
} ?: manager.cameraIdList[0] } ?: manager.cameraIdList[0]
} }
} }

View file

@ -0,0 +1,129 @@
package com.srtk.airlink
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
data class ConnectionState(
val isConnected: Boolean = false,
val error: String? = null
)
class ConnectionManager {
private val TAG = "ConnectionManager"
private val _connectionState = MutableStateFlow(ConnectionState())
val connectionState: StateFlow<ConnectionState> = _connectionState
private var heartbeatSocket: DatagramSocket? = null
private var heartbeatJob: Job? = null
private var serverIp: InetAddress? = null
private var serverPort: Int = 0
@Volatile private var lastDataReceived = 0L
/**
* Tries to ping the server. Returns true if server responds with "PONG".
*/
suspend fun handshake(ip: InetAddress, port: Int): Boolean = withContext(Dispatchers.IO) {
var socket: DatagramSocket? = null
try {
socket = DatagramSocket()
socket.soTimeout = 1000 // 1 second timeout
val msg = "PING".toByteArray()
val packet = DatagramPacket(msg, msg.size, ip, port)
// Try 2 times
for (i in 1..2) {
socket.send(packet)
val buffer = ByteArray(64)
val p = DatagramPacket(buffer, buffer.size)
try {
socket.receive(p)
val response = String(p.data, 0, p.length)
if (response.contains("PONG") || response.contains("ACK")) {
return@withContext true
}
} catch (e: Exception) {
// Timeout, retry
}
}
return@withContext false
} catch (e: Exception) {
return@withContext false
} finally {
socket?.close()
}
}
fun startMonitoring(ip: InetAddress, port: Int) {
stopMonitoring()
serverIp = ip
serverPort = port
lastDataReceived = System.currentTimeMillis()
try {
heartbeatSocket = DatagramSocket()
heartbeatSocket?.soTimeout = 1000
} catch (e: Exception) {
Log.e(TAG, "Socket error", e)
return
}
_connectionState.value = ConnectionState(isConnected = true)
heartbeatJob = CoroutineScope(Dispatchers.IO).launch {
val buffer = ByteArray(64)
val p = DatagramPacket(buffer, buffer.size)
while (isActive) {
// 1. Send Heartbeat
try {
val msg = "HEARTBEAT".toByteArray()
heartbeatSocket?.send(DatagramPacket(msg, msg.size, serverIp, serverPort))
} catch (e: Exception) {}
// 2. Listen
try {
heartbeatSocket?.receive(p)
val response = String(p.data, 0, p.length)
if (response.contains("BYE")) {
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState(false, "Server stopped")
}
break
}
updateActivity()
} catch (e: Exception) { } // Timeout
// 3. Timeout Check (5s)
if (System.currentTimeMillis() - lastDataReceived > 5000) {
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState(false, "Connection lost")
}
break
}
}
}
}
fun updateActivity() {
lastDataReceived = System.currentTimeMillis()
}
fun stopMonitoring() {
heartbeatJob?.cancel()
heartbeatSocket?.close()
heartbeatSocket = null
_connectionState.value = ConnectionState()
}
fun reportError(error: String) {
_connectionState.value = ConnectionState(isConnected = false, error = error)
}
}

File diff suppressed because it is too large Load diff