package dev.mleku.h264cam; import android.app.*; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Color; import android.hardware.camera2.*; import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; import android.os.*; import android.util.Log; import android.view.Surface; import java.io.*; import java.net.*; import java.nio.ByteBuffer; import java.util.*; import java.util.concurrent.CopyOnWriteArrayList; public class StreamService extends Service { static final String TAG = "H264Cam"; static final int PORT = 8080; static final int WIDTH = 1920; static final int HEIGHT = 1080; static final int FPS = 25; static final int BITRATE = 16_000_000; static final int NOTIF_ID = 1; static final String CHANNEL_ID = "h264cam_stream"; static final int AE_INTERVAL_MS = 2000; static final int AE_TARGET = 120; static final int AE_DEADZONE = 15; static final double AE_STEP = 0.02; HandlerThread camThread; Handler camHandler; CameraDevice camera; MediaCodec encoder; Surface encoderSurface; CameraCaptureSession captureSession; ServerSocket server; final CopyOnWriteArrayList clients = new CopyOnWriteArrayList<>(); byte[] sps, pps; int maxIso = 6400; int minIso = 100; long minExposureNs = 100_000L; long maxExposureNs = 100_000_000L; long exposureNs = 500_000L; int isoValue = 100; double currentFrac = 0.0; long manualOverrideUntil = 0; PowerManager.WakeLock wakeLock; Surface previewSurface; StatusListener statusListener; LuminanceSampler luminanceSampler; interface StatusListener { void onStatusChanged(String text); void onExposureChanged(double frac, long exposureNs, int iso); } interface LuminanceSampler { int sample(); } final Runnable autoExposure = new Runnable() { public void run() { if (System.currentTimeMillis() < manualOverrideUntil) { camHandler.postDelayed(this, AE_INTERVAL_MS); return; } int lum = luminanceSampler != null ? luminanceSampler.sample() : -1; if (lum >= 0) { int err = lum - AE_TARGET; if (Math.abs(err) > AE_DEADZONE) { double nudge = err < 0 ? AE_STEP : -AE_STEP; currentFrac = Math.max(0.0, Math.min(1.0, currentFrac + nudge)); exposureFromFrac(currentFrac); applyExposure(); if (statusListener != null) statusListener.onExposureChanged(currentFrac, exposureNs, isoValue); } } camHandler.postDelayed(this, AE_INTERVAL_MS); } }; public class LocalBinder extends Binder { StreamService getService() { return StreamService.this; } } final IBinder binder = new LocalBinder(); @Override public IBinder onBind(Intent intent) { return binder; } @Override public void onCreate() { super.onCreate(); createNotificationChannel(); startForeground(NOTIF_ID, buildNotification("Starting...")); PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "H264Cam::Stream"); wakeLock.acquire(); camThread = new HandlerThread("cam"); camThread.start(); camHandler = new Handler(camThread.getLooper()); readSensorRanges(); setupEncoder(); startHttpServer(); } void readSensorRanges() { try { CameraManager mgr = (CameraManager) getSystemService(CAMERA_SERVICE); CameraCharacteristics chars = mgr.getCameraCharacteristics(mgr.getCameraIdList()[0]); android.util.Range isoRange = chars.get( CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE); if (isoRange != null) { minIso = isoRange.getLower(); maxIso = isoRange.getUpper(); } android.util.Range expRange = chars.get( CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE); if (expRange != null) { minExposureNs = Math.max(expRange.getLower(), 10_000L); maxExposureNs = Math.min(expRange.getUpper(), 100_000_000L); } } catch (Exception e) { Log.e(TAG, "sensor range read failed", e); } double logMin = Math.log(minExposureNs); double logMax = Math.log(maxExposureNs); currentFrac = (Math.log(exposureNs) - logMin) / (logMax - logMin); } void setupEncoder() { try { MediaFormat fmt = MediaFormat.createVideoFormat("video/avc", WIDTH, HEIGHT); fmt.setInteger(MediaFormat.KEY_BIT_RATE, BITRATE); fmt.setInteger(MediaFormat.KEY_FRAME_RATE, FPS); fmt.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); fmt.setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR); fmt.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileHigh); fmt.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); encoder = MediaCodec.createEncoderByType("video/avc"); encoder.setCallback(new MediaCodec.Callback() { public void onInputBufferAvailable(MediaCodec codec, int index) {} public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) { ByteBuffer buf = codec.getOutputBuffer(index); if (buf != null && info.size > 0) { byte[] data = new byte[info.size]; buf.get(data); broadcast(data); } codec.releaseOutputBuffer(index, false); } public void onError(MediaCodec codec, MediaCodec.CodecException e) { Log.e(TAG, "encoder error", e); } public void onOutputFormatChanged(MediaCodec codec, MediaFormat fmt) { ByteBuffer spsBuf = fmt.getByteBuffer("csd-0"); ByteBuffer ppsBuf = fmt.getByteBuffer("csd-1"); if (spsBuf != null) { sps = new byte[spsBuf.remaining()]; spsBuf.get(sps); } if (ppsBuf != null) { pps = new byte[ppsBuf.remaining()]; ppsBuf.get(pps); } Log.i(TAG, "got SPS/PPS"); } }, camHandler); encoder.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); encoderSurface = encoder.createInputSurface(); encoder.start(); } catch (Exception e) { Log.e(TAG, "encoder setup failed", e); } } void openCamera() { try { CameraManager mgr = (CameraManager) getSystemService(CAMERA_SERVICE); String id = mgr.getCameraIdList()[0]; mgr.openCamera(id, new CameraDevice.StateCallback() { public void onOpened(CameraDevice cam) { camera = cam; startCapture(); } public void onDisconnected(CameraDevice cam) { Log.w(TAG, "camera disconnected"); cam.close(); camera = null; } public void onError(CameraDevice cam, int err) { Log.e(TAG, "camera error: " + err); cam.close(); camera = null; } }, camHandler); } catch (Exception e) { Log.e(TAG, "camera open failed", e); } } void startCapture() { try { List targets = new ArrayList<>(); targets.add(encoderSurface); if (previewSurface != null) targets.add(previewSurface); camera.createCaptureSession(targets, new CameraCaptureSession.StateCallback() { public void onConfigured(CameraCaptureSession session) { captureSession = session; try { session.setRepeatingRequest(buildRequest(), null, camHandler); camHandler.removeCallbacks(autoExposure); camHandler.postDelayed(autoExposure, AE_INTERVAL_MS); notifyStatus(); } catch (Exception e) { Log.e(TAG, "capture failed", e); } } public void onConfigureFailed(CameraCaptureSession session) { Log.e(TAG, "session config failed"); } }, camHandler); } catch (Exception e) { Log.e(TAG, "startCapture failed", e); } } CaptureRequest buildRequest() throws CameraAccessException { CaptureRequest.Builder req = camera.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); req.addTarget(encoderSurface); if (previewSurface != null) req.addTarget(previewSurface); req.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF); req.set(CaptureRequest.SENSOR_EXPOSURE_TIME, exposureNs); req.set(CaptureRequest.SENSOR_SENSITIVITY, isoValue); return req.build(); } void exposureFromFrac(double frac) { double logMin = Math.log(minExposureNs); double logMax = Math.log(maxExposureNs); exposureNs = (long) Math.exp(logMin + frac * (logMax - logMin)); double logIsoMin = Math.log(minIso); double logIsoMax = Math.log(maxIso); isoValue = (int) Math.exp(logIsoMin + frac * (logIsoMax - logIsoMin)); } void applyExposure() { if (captureSession == null || camera == null) return; try { captureSession.setRepeatingRequest(buildRequest(), null, camHandler); } catch (Exception e) { Log.e(TAG, "exposure update failed", e); } } void setExposureFrac(double frac) { currentFrac = frac; manualOverrideUntil = System.currentTimeMillis() + 10_000; exposureFromFrac(frac); applyExposure(); } void setPreviewSurface(Surface surface) { previewSurface = surface; if (camera != null) { // rebuild capture session to include/exclude preview startCapture(); } } void removePreviewSurface() { previewSurface = null; if (camera != null) { startCapture(); } } void broadcast(byte[] data) { if (clients.isEmpty()) return; List dead = new ArrayList<>(); for (OutputStream out : clients) { try { out.write(data); out.flush(); } catch (IOException e) { dead.add(out); } } if (!dead.isEmpty()) { clients.removeAll(dead); notifyStatus(); } } void startHttpServer() { new Thread(() -> { try { server = new ServerSocket(PORT); Log.i(TAG, "HTTP server on port " + PORT); notifyStatus(); while (!server.isClosed()) { Socket sock = server.accept(); new Thread(() -> handleClient(sock)).start(); } } catch (IOException e) { Log.e(TAG, "server error", e); } }).start(); } void handleClient(Socket sock) { try { BufferedReader in = new BufferedReader( new InputStreamReader(sock.getInputStream())); String line = in.readLine(); Log.i(TAG, "client: " + sock.getInetAddress() + " " + line); while ((line = in.readLine()) != null && !line.isEmpty()) {} OutputStream out = sock.getOutputStream(); out.write(("HTTP/1.1 200 OK\r\n" + "Content-Type: video/h264\r\n" + "Connection: close\r\n" + "Cache-Control: no-cache\r\n" + "\r\n").getBytes()); if (sps != null) out.write(sps); if (pps != null) out.write(pps); out.flush(); clients.add(out); notifyStatus(); try { while (sock.getInputStream().read() != -1) {} } catch (IOException e) {} clients.remove(out); notifyStatus(); } catch (IOException e) {} } void notifyStatus() { String text = statusText(); updateNotification(text); if (statusListener != null) statusListener.onStatusChanged(text); } String statusText() { return "http://" + getLocalIp() + ":" + PORT + "/video " + WIDTH + "x" + HEIGHT + " " + FPS + "fps H264 " + (BITRATE / 1_000_000) + "Mbps " + clients.size() + " client(s)"; } String getLocalIp() { try { for (Enumeration en = NetworkInterface.getNetworkInterfaces(); en.hasMoreElements();) { NetworkInterface intf = en.nextElement(); for (Enumeration addrs = intf.getInetAddresses(); addrs.hasMoreElements();) { InetAddress addr = addrs.nextElement(); if (!addr.isLoopbackAddress() && addr instanceof Inet4Address) return addr.getHostAddress(); } } } catch (Exception e) {} return "unknown"; } void createNotificationChannel() { NotificationChannel ch = new NotificationChannel( CHANNEL_ID, "Camera Stream", NotificationManager.IMPORTANCE_LOW); ch.setDescription("Active camera streaming"); getSystemService(NotificationManager.class).createNotificationChannel(ch); } Notification buildNotification(String text) { Intent intent = new Intent(this, MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); PendingIntent pi = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE); return new Notification.Builder(this, CHANNEL_ID) .setContentTitle("H264Cam") .setContentText(text) .setSmallIcon(android.R.drawable.ic_menu_camera) .setContentIntent(pi) .setOngoing(true) .build(); } void updateNotification(String text) { getSystemService(NotificationManager.class) .notify(NOTIF_ID, buildNotification(text)); } @Override public void onDestroy() { camHandler.removeCallbacks(autoExposure); if (captureSession != null) captureSession.close(); if (camera != null) camera.close(); if (encoder != null) { encoder.stop(); encoder.release(); } if (server != null) try { server.close(); } catch (IOException e) {} if (camThread != null) camThread.quitSafely(); if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); super.onDestroy(); } }