StreamService.java raw
1 package dev.mleku.h264cam;
2
3 import android.app.*;
4 import android.content.Intent;
5 import android.graphics.Bitmap;
6 import android.graphics.Color;
7 import android.hardware.camera2.*;
8 import android.media.MediaCodec;
9 import android.media.MediaCodecInfo;
10 import android.media.MediaFormat;
11 import android.os.*;
12 import android.util.Log;
13 import android.view.Surface;
14
15 import java.io.*;
16 import java.net.*;
17 import java.nio.ByteBuffer;
18 import java.util.*;
19 import java.util.concurrent.CopyOnWriteArrayList;
20
21 public class StreamService extends Service {
22 static final String TAG = "H264Cam";
23 static final int PORT = 8080;
24 static final int WIDTH = 1920;
25 static final int HEIGHT = 1080;
26 static final int FPS = 25;
27 static final int BITRATE = 16_000_000;
28 static final int NOTIF_ID = 1;
29 static final String CHANNEL_ID = "h264cam_stream";
30 static final int AE_INTERVAL_MS = 2000;
31 static final int AE_TARGET = 120;
32 static final int AE_DEADZONE = 15;
33 static final double AE_STEP = 0.02;
34
35 HandlerThread camThread;
36 Handler camHandler;
37 CameraDevice camera;
38 MediaCodec encoder;
39 Surface encoderSurface;
40 CameraCaptureSession captureSession;
41 ServerSocket server;
42 final CopyOnWriteArrayList<OutputStream> clients = new CopyOnWriteArrayList<>();
43 byte[] sps, pps;
44 int maxIso = 6400;
45 int minIso = 100;
46 long minExposureNs = 100_000L;
47 long maxExposureNs = 100_000_000L;
48 long exposureNs = 500_000L;
49 int isoValue = 100;
50 double currentFrac = 0.0;
51 long manualOverrideUntil = 0;
52 PowerManager.WakeLock wakeLock;
53
54 Surface previewSurface;
55 StatusListener statusListener;
56 LuminanceSampler luminanceSampler;
57
58 interface StatusListener {
59 void onStatusChanged(String text);
60 void onExposureChanged(double frac, long exposureNs, int iso);
61 }
62
63 interface LuminanceSampler {
64 int sample();
65 }
66
67 final Runnable autoExposure = new Runnable() {
68 public void run() {
69 if (System.currentTimeMillis() < manualOverrideUntil) {
70 camHandler.postDelayed(this, AE_INTERVAL_MS);
71 return;
72 }
73 int lum = luminanceSampler != null ? luminanceSampler.sample() : -1;
74 if (lum >= 0) {
75 int err = lum - AE_TARGET;
76 if (Math.abs(err) > AE_DEADZONE) {
77 double nudge = err < 0 ? AE_STEP : -AE_STEP;
78 currentFrac = Math.max(0.0, Math.min(1.0, currentFrac + nudge));
79 exposureFromFrac(currentFrac);
80 applyExposure();
81 if (statusListener != null)
82 statusListener.onExposureChanged(currentFrac, exposureNs, isoValue);
83 }
84 }
85 camHandler.postDelayed(this, AE_INTERVAL_MS);
86 }
87 };
88
89 public class LocalBinder extends Binder {
90 StreamService getService() { return StreamService.this; }
91 }
92 final IBinder binder = new LocalBinder();
93
94 @Override
95 public IBinder onBind(Intent intent) { return binder; }
96
97 @Override
98 public void onCreate() {
99 super.onCreate();
100 createNotificationChannel();
101 startForeground(NOTIF_ID, buildNotification("Starting..."));
102
103 PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
104 wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "H264Cam::Stream");
105 wakeLock.acquire();
106
107 camThread = new HandlerThread("cam");
108 camThread.start();
109 camHandler = new Handler(camThread.getLooper());
110
111 readSensorRanges();
112 setupEncoder();
113 startHttpServer();
114 }
115
116 void readSensorRanges() {
117 try {
118 CameraManager mgr = (CameraManager) getSystemService(CAMERA_SERVICE);
119 CameraCharacteristics chars = mgr.getCameraCharacteristics(mgr.getCameraIdList()[0]);
120 android.util.Range<Integer> isoRange = chars.get(
121 CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE);
122 if (isoRange != null) {
123 minIso = isoRange.getLower();
124 maxIso = isoRange.getUpper();
125 }
126 android.util.Range<Long> expRange = chars.get(
127 CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE);
128 if (expRange != null) {
129 minExposureNs = Math.max(expRange.getLower(), 10_000L);
130 maxExposureNs = Math.min(expRange.getUpper(), 100_000_000L);
131 }
132 } catch (Exception e) {
133 Log.e(TAG, "sensor range read failed", e);
134 }
135 double logMin = Math.log(minExposureNs);
136 double logMax = Math.log(maxExposureNs);
137 currentFrac = (Math.log(exposureNs) - logMin) / (logMax - logMin);
138 }
139
140 void setupEncoder() {
141 try {
142 MediaFormat fmt = MediaFormat.createVideoFormat("video/avc", WIDTH, HEIGHT);
143 fmt.setInteger(MediaFormat.KEY_BIT_RATE, BITRATE);
144 fmt.setInteger(MediaFormat.KEY_FRAME_RATE, FPS);
145 fmt.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
146 fmt.setInteger(MediaFormat.KEY_BITRATE_MODE,
147 MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR);
148 fmt.setInteger(MediaFormat.KEY_PROFILE,
149 MediaCodecInfo.CodecProfileLevel.AVCProfileHigh);
150 fmt.setInteger(MediaFormat.KEY_COLOR_FORMAT,
151 MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
152
153 encoder = MediaCodec.createEncoderByType("video/avc");
154 encoder.setCallback(new MediaCodec.Callback() {
155 public void onInputBufferAvailable(MediaCodec codec, int index) {}
156 public void onOutputBufferAvailable(MediaCodec codec, int index,
157 MediaCodec.BufferInfo info) {
158 ByteBuffer buf = codec.getOutputBuffer(index);
159 if (buf != null && info.size > 0) {
160 byte[] data = new byte[info.size];
161 buf.get(data);
162 broadcast(data);
163 }
164 codec.releaseOutputBuffer(index, false);
165 }
166 public void onError(MediaCodec codec, MediaCodec.CodecException e) {
167 Log.e(TAG, "encoder error", e);
168 }
169 public void onOutputFormatChanged(MediaCodec codec, MediaFormat fmt) {
170 ByteBuffer spsBuf = fmt.getByteBuffer("csd-0");
171 ByteBuffer ppsBuf = fmt.getByteBuffer("csd-1");
172 if (spsBuf != null) { sps = new byte[spsBuf.remaining()]; spsBuf.get(sps); }
173 if (ppsBuf != null) { pps = new byte[ppsBuf.remaining()]; ppsBuf.get(pps); }
174 Log.i(TAG, "got SPS/PPS");
175 }
176 }, camHandler);
177 encoder.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
178 encoderSurface = encoder.createInputSurface();
179 encoder.start();
180 } catch (Exception e) {
181 Log.e(TAG, "encoder setup failed", e);
182 }
183 }
184
185 void openCamera() {
186 try {
187 CameraManager mgr = (CameraManager) getSystemService(CAMERA_SERVICE);
188 String id = mgr.getCameraIdList()[0];
189 mgr.openCamera(id, new CameraDevice.StateCallback() {
190 public void onOpened(CameraDevice cam) {
191 camera = cam;
192 startCapture();
193 }
194 public void onDisconnected(CameraDevice cam) {
195 Log.w(TAG, "camera disconnected");
196 cam.close();
197 camera = null;
198 }
199 public void onError(CameraDevice cam, int err) {
200 Log.e(TAG, "camera error: " + err);
201 cam.close();
202 camera = null;
203 }
204 }, camHandler);
205 } catch (Exception e) {
206 Log.e(TAG, "camera open failed", e);
207 }
208 }
209
210 void startCapture() {
211 try {
212 List<Surface> targets = new ArrayList<>();
213 targets.add(encoderSurface);
214 if (previewSurface != null) targets.add(previewSurface);
215
216 camera.createCaptureSession(targets,
217 new CameraCaptureSession.StateCallback() {
218 public void onConfigured(CameraCaptureSession session) {
219 captureSession = session;
220 try {
221 session.setRepeatingRequest(buildRequest(), null, camHandler);
222 camHandler.removeCallbacks(autoExposure);
223 camHandler.postDelayed(autoExposure, AE_INTERVAL_MS);
224 notifyStatus();
225 } catch (Exception e) {
226 Log.e(TAG, "capture failed", e);
227 }
228 }
229 public void onConfigureFailed(CameraCaptureSession session) {
230 Log.e(TAG, "session config failed");
231 }
232 }, camHandler);
233 } catch (Exception e) {
234 Log.e(TAG, "startCapture failed", e);
235 }
236 }
237
238 CaptureRequest buildRequest() throws CameraAccessException {
239 CaptureRequest.Builder req = camera.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
240 req.addTarget(encoderSurface);
241 if (previewSurface != null) req.addTarget(previewSurface);
242 req.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF);
243 req.set(CaptureRequest.SENSOR_EXPOSURE_TIME, exposureNs);
244 req.set(CaptureRequest.SENSOR_SENSITIVITY, isoValue);
245 return req.build();
246 }
247
248 void exposureFromFrac(double frac) {
249 double logMin = Math.log(minExposureNs);
250 double logMax = Math.log(maxExposureNs);
251 exposureNs = (long) Math.exp(logMin + frac * (logMax - logMin));
252 double logIsoMin = Math.log(minIso);
253 double logIsoMax = Math.log(maxIso);
254 isoValue = (int) Math.exp(logIsoMin + frac * (logIsoMax - logIsoMin));
255 }
256
257 void applyExposure() {
258 if (captureSession == null || camera == null) return;
259 try {
260 captureSession.setRepeatingRequest(buildRequest(), null, camHandler);
261 } catch (Exception e) {
262 Log.e(TAG, "exposure update failed", e);
263 }
264 }
265
266 void setExposureFrac(double frac) {
267 currentFrac = frac;
268 manualOverrideUntil = System.currentTimeMillis() + 10_000;
269 exposureFromFrac(frac);
270 applyExposure();
271 }
272
273 void setPreviewSurface(Surface surface) {
274 previewSurface = surface;
275 if (camera != null) {
276 // rebuild capture session to include/exclude preview
277 startCapture();
278 }
279 }
280
281 void removePreviewSurface() {
282 previewSurface = null;
283 if (camera != null) {
284 startCapture();
285 }
286 }
287
288 void broadcast(byte[] data) {
289 if (clients.isEmpty()) return;
290 List<OutputStream> dead = new ArrayList<>();
291 for (OutputStream out : clients) {
292 try {
293 out.write(data);
294 out.flush();
295 } catch (IOException e) {
296 dead.add(out);
297 }
298 }
299 if (!dead.isEmpty()) {
300 clients.removeAll(dead);
301 notifyStatus();
302 }
303 }
304
305 void startHttpServer() {
306 new Thread(() -> {
307 try {
308 server = new ServerSocket(PORT);
309 Log.i(TAG, "HTTP server on port " + PORT);
310 notifyStatus();
311 while (!server.isClosed()) {
312 Socket sock = server.accept();
313 new Thread(() -> handleClient(sock)).start();
314 }
315 } catch (IOException e) {
316 Log.e(TAG, "server error", e);
317 }
318 }).start();
319 }
320
321 void handleClient(Socket sock) {
322 try {
323 BufferedReader in = new BufferedReader(
324 new InputStreamReader(sock.getInputStream()));
325 String line = in.readLine();
326 Log.i(TAG, "client: " + sock.getInetAddress() + " " + line);
327 while ((line = in.readLine()) != null && !line.isEmpty()) {}
328
329 OutputStream out = sock.getOutputStream();
330 out.write(("HTTP/1.1 200 OK\r\n" +
331 "Content-Type: video/h264\r\n" +
332 "Connection: close\r\n" +
333 "Cache-Control: no-cache\r\n" +
334 "\r\n").getBytes());
335 if (sps != null) out.write(sps);
336 if (pps != null) out.write(pps);
337 out.flush();
338
339 clients.add(out);
340 notifyStatus();
341
342 try {
343 while (sock.getInputStream().read() != -1) {}
344 } catch (IOException e) {}
345
346 clients.remove(out);
347 notifyStatus();
348 } catch (IOException e) {}
349 }
350
351 void notifyStatus() {
352 String text = statusText();
353 updateNotification(text);
354 if (statusListener != null) statusListener.onStatusChanged(text);
355 }
356
357 String statusText() {
358 return "http://" + getLocalIp() + ":" + PORT + "/video " +
359 WIDTH + "x" + HEIGHT + " " + FPS + "fps H264 " +
360 (BITRATE / 1_000_000) + "Mbps " +
361 clients.size() + " client(s)";
362 }
363
364 String getLocalIp() {
365 try {
366 for (Enumeration<NetworkInterface> en = NetworkInterface.getNetworkInterfaces();
367 en.hasMoreElements();) {
368 NetworkInterface intf = en.nextElement();
369 for (Enumeration<InetAddress> addrs = intf.getInetAddresses();
370 addrs.hasMoreElements();) {
371 InetAddress addr = addrs.nextElement();
372 if (!addr.isLoopbackAddress() && addr instanceof Inet4Address)
373 return addr.getHostAddress();
374 }
375 }
376 } catch (Exception e) {}
377 return "unknown";
378 }
379
380 void createNotificationChannel() {
381 NotificationChannel ch = new NotificationChannel(
382 CHANNEL_ID, "Camera Stream", NotificationManager.IMPORTANCE_LOW);
383 ch.setDescription("Active camera streaming");
384 getSystemService(NotificationManager.class).createNotificationChannel(ch);
385 }
386
387 Notification buildNotification(String text) {
388 Intent intent = new Intent(this, MainActivity.class);
389 intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
390 PendingIntent pi = PendingIntent.getActivity(this, 0, intent,
391 PendingIntent.FLAG_IMMUTABLE);
392 return new Notification.Builder(this, CHANNEL_ID)
393 .setContentTitle("H264Cam")
394 .setContentText(text)
395 .setSmallIcon(android.R.drawable.ic_menu_camera)
396 .setContentIntent(pi)
397 .setOngoing(true)
398 .build();
399 }
400
401 void updateNotification(String text) {
402 getSystemService(NotificationManager.class)
403 .notify(NOTIF_ID, buildNotification(text));
404 }
405
406 @Override
407 public void onDestroy() {
408 camHandler.removeCallbacks(autoExposure);
409 if (captureSession != null) captureSession.close();
410 if (camera != null) camera.close();
411 if (encoder != null) { encoder.stop(); encoder.release(); }
412 if (server != null) try { server.close(); } catch (IOException e) {}
413 if (camThread != null) camThread.quitSafely();
414 if (wakeLock != null && wakeLock.isHeld()) wakeLock.release();
415 super.onDestroy();
416 }
417 }
418