capture JieLi wifi Camera video stream
02:21 09 Nov 2025

I bought a small 12V Wi-Fi rear camera from AliExpress. The camera appears to use an AC54 or AC51 chip from JieLi Technology.

The device openes a wifi hot spot on channel 3 and got the ip 192.168.1.1.

You can connect to the cam's wifi and download a description file from http://192.168.1.1:8080/mnt/spiflash/res/dev_desc.txt which is a json like:

{
    "app_list": {
        "match_android_ver": [
            "1.0",
            "2.0"
        ],
        "match_ios_ver": [
            "1.0",
            "2.0"
        ]
    },
    "forward_support": [
        "0",
        "1"
    ],
    "behind_support": [
        "0"
    ],
    "forward_record_support": [
        "0",
        "1"
    ],
    "behind_record_support": [
        "0"
    ],
    "rtsp_forward_support": [
        "0",
        "1"
    ],
    "rtsp_behind_support": [
        "0"
    ],
    "device_type": "1",
    "net_type": "1",
    "rts_type": "0",
    "product_type": "AC521x_wifi_car_camera",
    "support_projection": "0",
    "firmware_version": "1.0.1",
    "match_app_type": "DVRunning 2",
    "uuid": "xxxxxx"
}

I then sniffed the communication between the mobile app and the camera. It uses a TCP channel (port 3333) for commands and sends UDP packets to port 2224 when the video stream is activated, as I observed in Wireshark.

I then wrote a small test app in Kotlin for my Android device to read the video stream. However, I always get corrupted images in my app. The camera’s official companion app works fine, but I want to use my own implementation. Unfortunately, I could not find a downloadable SDK for this camera.

The device sends a MJPEG stream (JFIF JPEGs), but its frames are fragmented, and I suspect it uses a proprietary format.

Here are some documents for this type of device (mostly in Chinese, but translation tools make them mostly readable):

A fun side note: the same chip is also used in a drone.

EDIT:

I’ve uploaded the test app to my GitHub account:
https://github.com/mightymop/camtest/tree/main

The main logic responsible for receiving and reconstructing the video stream is here:
https://github.com/mightymop/camtest/blob/main/app/src/main/java/local/test/camtest/protocol/JFIFMJpegStreamReceiver.kt

package local.test.camtest.protocol

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.Log
import android.view.SurfaceHolder
import local.test.camtest.R
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.RandomAccessFile
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.SocketTimeoutException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.min

class JFIFMJpegStreamReceiver {

    interface StreamListener {
        fun onVideoStarted()
        fun onVideoStopped()
        fun onError(error: String)
        fun onFrameDecoded(width: Int, height: Int)
        fun onStreamInfo(info: String)
        fun onPcapDumpStarted(filePath: String)
        fun onPcapDumpStopped(filePath: String, packetCount: Int)
    }

    private var udpSocket: DatagramSocket? = null
    private var isReceiving = false
    private var listener: StreamListener? = null
    private var surfaceHolder: SurfaceHolder? = null
    private var paint: Paint = Paint()

    private var pcapDumpEnabled: Boolean = false
    private var pcapFile: RandomAccessFile? = null
    private var pcapPacketCount = 0

    private val frameAssembler = RTPFrameAssembler()

    private lateinit var context: Context

    companion object {
        private const val TAG = "JFIFMJpegStreamReceiver"
    }

    fun initialize(holder: SurfaceHolder, listener: StreamListener, context: Context) {
        this.surfaceHolder = holder
        this.listener = listener
        this.context = context
        paint.isFilterBitmap = true
        paint.isAntiAlias = false

        Log.d(TAG, "JFIF MJPEG Receiver initialized")
        listener.onStreamInfo("JFIF MJPEG Ready")
    }

    fun startStream(enablePcapDump: Boolean = false) {
        pcapDumpEnabled = enablePcapDump

        Thread {
            try {
                udpSocket = DatagramSocket(this.context.resources.getInteger(R.integer.udpport))
                udpSocket?.soTimeout = 1000
                udpSocket?.receiveBufferSize = 1024 * 1024 * 4
                isReceiving = true

                if (pcapDumpEnabled) startPcapDump()

                Log.d(TAG, "🎥 JFIF MJPEG Stream started - PCAP Dump: $pcapDumpEnabled")
                listener?.onVideoStarted()
                listener?.onStreamInfo("Receiving UDP stream... PCAP: $pcapDumpEnabled")

                val buffer = ByteArray(65536)
                var packetCount = 0
                var frameCount = 0

                while (isReceiving) {
                    try {
                        val packet = DatagramPacket(buffer, buffer.size)
                        udpSocket?.receive(packet)
                        val data = packet.data.copyOf(packet.length)

                        packetCount++
                        if (pcapDumpEnabled) dumpPacketToPcap(
                            data,
                            packet.address.hostAddress!!,
                            packet.port
                        )

                        val frameData = frameAssembler.processPacket(data)
                        if (frameData != null) {
                            frameCount++
                            val bitmap = BitmapFactory.decodeByteArray(frameData, 0, frameData.size)
                            if (bitmap != null) {
                                drawToSurface(bitmap)
                                listener?.onFrameDecoded(bitmap.width, bitmap.height)
                                if (frameCount % 30 == 0) {
                                    listener?.onStreamInfo("Frames decoded: $frameCount")
                                }
                            } else {
                                Log.w(TAG, "Failed to decode bitmap from frame")
                            }
                        }

                    } catch (e: SocketTimeoutException) {
                        // Timeout is normal, continue
                    } catch (e: Exception) {
                        if (isReceiving) Log.w(TAG, "UDP error: ${e.message}")
                    }
                }

            } catch (e: Exception) {
                Log.e(TAG, "Stream error: ${e.message}")
                listener?.onError("Stream failed: ${e.message}")
            } finally {
                stopPcapDump()
                udpSocket?.close()
                Log.d(TAG, "Stream stopped")
            }
        }.start()
    }

    private fun drawToSurface(bitmap: Bitmap) {
        var canvas: Canvas? = null
        try {
            canvas = surfaceHolder?.lockCanvas()
            canvas?.let {
                it.drawColor(Color.BLACK)
                val scaled = scaleToSurface(bitmap, it.width, it.height)
                val x = (it.width - scaled.width) / 2f
                val y = (it.height - scaled.height) / 2f
                it.drawBitmap(scaled, x, y, paint)
                if (scaled != bitmap) scaled.recycle()
            }
        } catch (e: Exception) {
            Log.e(TAG, "Surface draw error: ${e.message}")
        } finally {
            canvas?.let {
                try {
                    surfaceHolder?.unlockCanvasAndPost(it)
                } catch (_: Exception) {
                }
            }
        }
    }

    private fun scaleToSurface(bitmap: Bitmap, surfaceWidth: Int, surfaceHeight: Int): Bitmap {
        val scale =
            min(surfaceWidth.toFloat() / bitmap.width, surfaceHeight.toFloat() / bitmap.height)
        if (scale >= 1f) return bitmap
        return Bitmap.createScaledBitmap(
            bitmap,
            (bitmap.width * scale).toInt(),
            (bitmap.height * scale).toInt(),
            true
        )
    }

    fun stopStream() {
        isReceiving = false
        udpSocket?.close()
        listener?.onVideoStopped()
        stopPcapDump()
    }

    fun release() {
        stopStream()
        surfaceHolder = null
        Log.d(TAG, "Receiver released")
    }

    // ------------------- PCAP Dump -------------------
    private fun startPcapDump() {
        try {
            val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
            val fileName = "mjpeg_stream_${timestamp}.pcap"
            val pcapDir = File(context.getExternalFilesDir(null), "pcap_dumps")
            if (!pcapDir.exists()) pcapDir.mkdirs()
            val pcapFilePath = File(pcapDir, fileName)
            pcapFile = RandomAccessFile(pcapFilePath, "rw")
            Log.d(TAG, "📁 PCAP Dump started: ${pcapFilePath.absolutePath}")
            listener?.onPcapDumpStarted(pcapFilePath.absolutePath)
            pcapPacketCount = 0
        } catch (e: Exception) {
            Log.e(TAG, "Failed to start PCAP dump: ${e.message}")
            pcapDumpEnabled = false
        }
    }

    private fun dumpPacketToPcap(data: ByteArray, sourceIp: String, sourcePort: Int) {
        // Minimal dummy implementation to keep PCAP functionality
        pcapPacketCount++
    }

    private fun stopPcapDump() {
        try {
            pcapFile?.close()
        } catch (_: Exception) {
        }
        listener?.onPcapDumpStopped(pcapFile?.toString() ?: "unknown", pcapPacketCount)
        pcapFile = null
        pcapPacketCount = 0
    }

    // ------------------- Frame Assembler -------------------
    class RTPFrameAssembler {

        private val activeFrames = mutableMapOf()
        private val frameTimeout = 1000L // 1 second timeout

        data class FrameKey(val frameId: Int, val timestamp: Int)

        data class FrameAssembly(
            val fragments: MutableMap = mutableMapOf(),
            var lastUpdate: Long = System.currentTimeMillis()
        )

        fun processPacket(packet: ByteArray): ByteArray? {
            val rtpInfo = parseRTPHeader(packet) ?: return null
            val (frameId, timestamp, fragmentOffset) = rtpInfo

            val payload = packet.copyOfRange(20, packet.size)
            val frameKey = FrameKey(frameId, timestamp)

            Log.d(
                "RTP",
                "Packet: frame=${frameId.toString(16)}, ts=${timestamp.toString(16)}, offset=${
                    fragmentOffset.toString(16)
                }"
            )

            // Clean up old frames
            cleanupOldFrames()

            // Add fragment to frame (or start new frame)
            val frameAssembly = activeFrames.getOrPut(frameKey) { FrameAssembly() }
            frameAssembly.fragments[fragmentOffset] = payload
            frameAssembly.lastUpdate = System.currentTimeMillis()

            Log.d(
                "RTP",
                "Frame ${frameId.toString(16)} now has ${frameAssembly.fragments.size} fragments"
            )

            // Check if frame is complete
            if (isFrameComplete(frameAssembly)) {
                val frameData = assembleFrame(frameAssembly)
                activeFrames.remove(frameKey)
                return frameData
            }

            return null
        }

        private fun isFrameComplete(frame: FrameAssembly): Boolean {
            val fragments = frame.fragments

            // Check if we have at least one fragment with SOI and one with EOF
            val hasSOI = fragments.values.any { hasSOIMarker(it) }
            val hasEOF = fragments.values.any { hasEOFMarker(it) }

            if (!hasSOI || !hasEOF) {
                Log.d("RTP", "Frame incomplete: SOI=$hasSOI, EOF=$hasEOF")
                return false
            }

            // Try to assemble frame and check if it's a valid JPEG
            val assembledFrame = tryAssembleFrame(fragments)
            return isValidJpegFrame(assembledFrame)
        }

        private fun tryAssembleFrame(fragments: Map): ByteArray {
            val sortedFragments = fragments.entries.sortedBy { it.key }
            val buffer = ByteArrayOutputStream()

            for ((offset, fragmentData) in sortedFragments) {
                buffer.write(fragmentData)
            }

            return buffer.toByteArray()
        }

        private fun isValidJpegFrame(data: ByteArray): Boolean {
            if (data.size < 100) {
                Log.d("JPEG", "Frame too small: ${data.size} bytes")
                return false
            }

            val hasSOI = hasSOIMarker(data)
            val hasEOF = hasEOFMarker(data)

            if (!hasSOI) {
                Log.w("JPEG", "Invalid JPEG: Missing SOI marker")
                return false
            }

            if (!hasEOF) {
                Log.w("JPEG", "Invalid JPEG: Missing EOF marker")
                return false
            }

            // Additional check: SOI at start and EOF at end
            val soiAtStart = data[0] == 0xFF.toByte() && data[1] == 0xD8.toByte()
            val eofAtEnd =
                data[data.size - 2] == 0xFF.toByte() && data[data.size - 1] == 0xD9.toByte()

            if (!soiAtStart) {
                Log.w("JPEG", "Invalid JPEG: SOI not at start")
                // Could be repaired, but currently considered invalid
                return false
            }

            Log.d(
                "JPEG",
                "Valid JPEG: ${data.size} bytes, SOI at start: $soiAtStart, EOF at end: $eofAtEnd"
            )
            return true
        }

        private fun hasSOIMarker(data: ByteArray): Boolean {
            // Check if SOI marker exists anywhere in the data
            for (i in 0 until data.size - 1) {
                if (data[i].toInt() and 0xFF == 0xFF && data[i + 1].toInt() and 0xFF == 0xD8) {
                    return true
                }
            }
            return false
        }

        private fun hasEOFMarker(data: ByteArray): Boolean {
            // Check if EOF marker exists anywhere in the data
            for (i in 0 until data.size - 1) {
                if (data[i].toInt() and 0xFF == 0xFF && data[i + 1].toInt() and 0xFF == 0xD9) {
                    return true
                }
            }
            return false
        }

        private fun assembleFrame(frame: FrameAssembly): ByteArray {
            val sortedFragments = frame.fragments.entries.sortedBy { it.key }
            val buffer = ByteArrayOutputStream()
            var currentPosition = 0

            for ((offset, fragmentData) in sortedFragments) {
                // Fill gap if necessary
                if (offset > currentPosition) {
                    val gapSize = offset - currentPosition
                    Log.w(
                        "RTP",
                        "Filling gap: ${currentPosition.toString(16)}->${offset.toString(16)} (${gapSize} bytes)"
                    )
                    buffer.write(ByteArray(gapSize))
                }

                buffer.write(fragmentData)
                currentPosition = offset + fragmentData.size
            }

            val frameData = buffer.toByteArray()
            Log.d(
                "RTP",
                "Frame assembled: ${frameData.size} bytes from ${sortedFragments.size} fragments"
            )
            return frameData
        }

        private fun cleanupOldFrames() {
            val now = System.currentTimeMillis()
            val toRemove = activeFrames.filter { (_, frame) ->
                now - frame.lastUpdate > frameTimeout
            }.keys

            toRemove.forEach { key ->
                Log.w(
                    "RTP",
                    "Frame timeout: ${key.frameId.toString(16)} with ${activeFrames[key]?.fragments?.size} fragments"
                )
                activeFrames.remove(key)
            }
        }

        private fun parseRTPHeader(data: ByteArray): RTPInfo? {
            if (data.size < 20) return null

            return try {
                // Frame ID (Bytes 4-5)
                val frameId = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)

                // Timestamp (Bytes 6-11)
                val timestamp = ((data[6].toInt() and 0xFF) shl 24) or
                        ((data[7].toInt() and 0xFF) shl 16) or
                        ((data[8].toInt() and 0xFF) shl 8) or
                        (data[9].toInt() and 0xFF)

                // Fragment Offset (Bytes 12-13)
                val fragmentOffset =
                    ((data[12].toInt() and 0xFF) shl 8) or (data[13].toInt() and 0xFF)

                RTPInfo(frameId, timestamp, fragmentOffset)
            } catch (e: Exception) {
                Log.e("RTP", "Error parsing RTP header: ${e.message}")
                null
            }
        }
    }

    data class RTPInfo(val frameId: Int, val timestamp: Int, val fragmentOffset: Int)
}

// These classes are defined outside but kept for reference
data class Fragment(
    val offset: Int,
    val payload: ByteArray,
    val type: FragmentType
)

enum class FragmentType {
    START,          // Fragment with FRAG_START offset
    START_SOI,      // Fragment with SOI marker (could be middle fragment)
    MIDDLE,         // Middle fragment
    END,            // Fragment with FRAG_END offset
    END_EOF         // Fragment with EOF marker (could be middle fragment)
}

I’ve also added a PCAP file that contains a short segment of the video stream and some resulting images

EDIT#2:
I have added the possibility to dump udp packets in my app.
then i ran an pythonscript over the packets payloads. Here i have some reported data:

# UDP Payload Header Analysis
# PCAP File: mjpeg_stream.pcap
# Generated: 2025-11-09 03:30:44
# Format: PacketNumber | TotalSize | Magic/Type | Sequence | Timestamp | Fragment | Reserved | HasJPEG
#====================================================================================================
     1 |   7360 | 02 00 ac 05 | bc 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
     2 |   7360 | 02 00 ac 05 | bc 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
     3 |   2920 | 02 00 ac 05 | bc 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
     4 |   7360 | 02 00 ac 05 | bd 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
     5 |   7360 | 02 00 ac 05 | bd 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
     6 |   2920 | 02 00 ac 05 | bd 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
     7 |   7360 | 02 00 ac 05 | be 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
     8 |   7360 | 02 00 ac 05 | be 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
     9 |   2920 | 02 00 ac 05 | be 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
    10 |   7360 | 02 00 ac 05 | bf 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
    11 |   7360 | 02 00 ac 05 | bf 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
    12 |   2920 | 02 00 ac 05 | bf 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
    13 |   7360 | 02 00 ac 05 | c0 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
    14 |   7360 | 02 00 ac 05 | c0 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
    15 |   2920 | 02 00 ac 05 | c0 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
    16 |   7360 | 02 00 ac 05 | c1 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
    17 |   7360 | 02 00 ac 05 | c1 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
    18 |   2920 | 02 00 ac 05 | c1 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
    19 |   7360 | 02 00 ac 05 | c2 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
    20 |   7360 | 02 00 ac 05 | c2 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
    21 |   2920 | 02 00 ac 05 | c2 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
    22 |   7360 | 02 00 ac 05 | c3 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
    23 |   7360 | 02 00 ac 05 | c3 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
    24 |   2920 | 02 00 ac 05 | c3 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
    25 |   7360 | 02 00 ac 05 | c4 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
    26 |   7360 | 02 00 ac 05 | c4 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
    27 |   2920 | 02 00 ac 05 | c4 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
    28 |   7360 | 02 00 ac 05 | c5 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  
    29 |   7360 | 02 00 ac 05 | c5 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO   
    30 |   2920 | 02 00 ac 05 | c5 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO   
    31 |   7360 | 02 00 ac 05 | c6 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES  

The first 20 bytes of each payload are a proprietary header. I then tried implementing this in the RTPFrameAssembler class. The fragments marked with HasJPEG = YES contain the SOI marker (FF D8), and the fragments with the highest offset contain the EOF marker (FF D9), so the frames themselves are valid. The frames are assembled using the fragment offsets and then rendered onto the surface. My problem is that the resulting JPEGs are corrupted. Am I missing something?

android kotlin camera wifi mjpeg