MainActivity.java raw
1 package dev.mleku.h264cam;
2
3 import android.Manifest;
4 import android.app.Activity;
5 import android.content.pm.PackageManager;
6 import android.graphics.Matrix;
7 import android.graphics.RectF;
8 import android.graphics.SurfaceTexture;
9 import android.hardware.camera2.*;
10 import android.media.MediaCodec;
11 import android.media.MediaCodecInfo;
12 import android.media.MediaFormat;
13 import android.os.Bundle;
14 import android.os.Handler;
15 import android.os.HandlerThread;
16 import android.util.Log;
17 import android.view.Surface;
18 import android.view.TextureView;
19 import android.view.WindowManager;
20 import android.widget.TextView;
21
22 import java.io.*;
23 import java.net.*;
24 import java.nio.ByteBuffer;
25 import java.util.*;
26 import java.util.concurrent.CopyOnWriteArrayList;
27
28 public class MainActivity extends Activity {
29 static final String TAG = "H264Cam";
30 static final int PORT = 8080;
31 static final int WIDTH = 1920;
32 static final int HEIGHT = 1080;
33 static final int FPS = 25;
34 static final int BITRATE = 16_000_000;
35
36 TextureView preview;
37 TextView status, modeBtn;
38 boolean dayMode = true;
39 Surface previewSurface;
40 HandlerThread camThread;
41 Handler camHandler;
42 CameraDevice camera;
43 MediaCodec encoder;
44 Surface encoderSurface;
45 CameraCaptureSession captureSession;
46 ServerSocket server;
47 final CopyOnWriteArrayList<OutputStream> clients = new CopyOnWriteArrayList<>();
48 byte[] sps, pps;
49 int maxIso = 6400;
50
51 @Override
52 protected void onCreate(Bundle saved) {
53 super.onCreate(saved);
54 getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
55 getWindow().getDecorView().setSystemUiVisibility(
56 android.view.View.SYSTEM_UI_FLAG_FULLSCREEN |
57 android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
58 android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
59 android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
60 android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
61 if (getActionBar() != null) getActionBar().hide();
62 setContentView(R.layout.activity_main);
63 preview = findViewById(R.id.preview);
64 status = findViewById(R.id.status);
65 modeBtn = findViewById(R.id.modeBtn);
66 modeBtn.setOnClickListener(v -> toggleMode());
67
68 if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
69 requestPermissions(new String[]{Manifest.permission.CAMERA}, 1);
70 } else {
71 init();
72 }
73 }
74
75 @Override
76 public void onRequestPermissionsResult(int req, String[] perms, int[] results) {
77 if (results.length > 0 && results[0] == PackageManager.PERMISSION_GRANTED) {
78 init();
79 } else {
80 status.setText("camera permission denied");
81 }
82 }
83
84 void init() {
85 camThread = new HandlerThread("cam");
86 camThread.start();
87 camHandler = new Handler(camThread.getLooper());
88
89 try {
90 CameraManager mgr = (CameraManager) getSystemService(CAMERA_SERVICE);
91 CameraCharacteristics chars = mgr.getCameraCharacteristics(mgr.getCameraIdList()[0]);
92 android.util.Range<Integer> isoRange = chars.get(
93 CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE);
94 if (isoRange != null) maxIso = isoRange.getUpper();
95 Log.i(TAG, "max ISO: " + maxIso);
96 } catch (Exception e) {}
97
98 setupEncoder();
99 startHttpServer();
100
101 preview.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
102 public void onSurfaceTextureAvailable(SurfaceTexture st, int w, int h) {
103 configureTransform(w, h);
104 openCamera();
105 }
106 public void onSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) {
107 configureTransform(w, h);
108 }
109 public boolean onSurfaceTextureDestroyed(SurfaceTexture st) { return true; }
110 public void onSurfaceTextureUpdated(SurfaceTexture st) {}
111 });
112 }
113
114 // From Google's Camera2Basic sample
115 void configureTransform(int viewWidth, int viewHeight) {
116 int rotation = getWindowManager().getDefaultDisplay().getRotation();
117 Matrix matrix = new Matrix();
118 RectF viewRect = new RectF(0, 0, viewWidth, viewHeight);
119 RectF bufferRect = new RectF(0, 0, HEIGHT, WIDTH);
120 float cx = viewRect.centerX();
121 float cy = viewRect.centerY();
122
123 if (rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) {
124 bufferRect.offset(cx - bufferRect.centerX(), cy - bufferRect.centerY());
125 matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL);
126 float scale = Math.min(
127 (float) viewHeight / HEIGHT,
128 (float) viewWidth / WIDTH);
129 matrix.postScale(scale, scale, cx, cy);
130 matrix.postRotate(90 * (rotation - 2), cx, cy);
131 } else if (rotation == Surface.ROTATION_180) {
132 matrix.postRotate(180, cx, cy);
133 }
134
135 preview.setTransform(matrix);
136 }
137
138 void setupEncoder() {
139 try {
140 MediaFormat fmt = MediaFormat.createVideoFormat("video/avc", WIDTH, HEIGHT);
141 fmt.setInteger(MediaFormat.KEY_BIT_RATE, BITRATE);
142 fmt.setInteger(MediaFormat.KEY_FRAME_RATE, FPS);
143 fmt.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
144 fmt.setInteger(MediaFormat.KEY_BITRATE_MODE,
145 MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR);
146 fmt.setInteger(MediaFormat.KEY_PROFILE,
147 MediaCodecInfo.CodecProfileLevel.AVCProfileHigh);
148 fmt.setInteger(MediaFormat.KEY_COLOR_FORMAT,
149 MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
150
151 encoder = MediaCodec.createEncoderByType("video/avc");
152 encoder.setCallback(new MediaCodec.Callback() {
153 public void onInputBufferAvailable(MediaCodec codec, int index) {}
154 public void onOutputBufferAvailable(MediaCodec codec, int index,
155 MediaCodec.BufferInfo info) {
156 ByteBuffer buf = codec.getOutputBuffer(index);
157 if (buf != null && info.size > 0) {
158 byte[] data = new byte[info.size];
159 buf.get(data);
160 broadcast(data);
161 }
162 codec.releaseOutputBuffer(index, false);
163 }
164 public void onError(MediaCodec codec, MediaCodec.CodecException e) {
165 Log.e(TAG, "encoder error", e);
166 }
167 public void onOutputFormatChanged(MediaCodec codec, MediaFormat fmt) {
168 ByteBuffer spsBuf = fmt.getByteBuffer("csd-0");
169 ByteBuffer ppsBuf = fmt.getByteBuffer("csd-1");
170 if (spsBuf != null) { sps = new byte[spsBuf.remaining()]; spsBuf.get(sps); }
171 if (ppsBuf != null) { pps = new byte[ppsBuf.remaining()]; ppsBuf.get(pps); }
172 Log.i(TAG, "got SPS/PPS");
173 }
174 }, camHandler);
175 encoder.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
176 encoderSurface = encoder.createInputSurface();
177 encoder.start();
178 } catch (Exception e) {
179 Log.e(TAG, "encoder setup failed", e);
180 }
181 }
182
183 void openCamera() {
184 try {
185 CameraManager mgr = (CameraManager) getSystemService(CAMERA_SERVICE);
186 String id = mgr.getCameraIdList()[0];
187 mgr.openCamera(id, new CameraDevice.StateCallback() {
188 public void onOpened(CameraDevice cam) {
189 camera = cam;
190 startCapture();
191 }
192 public void onDisconnected(CameraDevice cam) { cam.close(); camera = null; }
193 public void onError(CameraDevice cam, int err) { cam.close(); camera = null; }
194 }, camHandler);
195 } catch (Exception e) {
196 Log.e(TAG, "camera open failed", e);
197 }
198 }
199
200 CaptureRequest buildRequest() throws CameraAccessException {
201 CaptureRequest.Builder req = camera.createCaptureRequest(
202 CameraDevice.TEMPLATE_RECORD);
203 req.addTarget(previewSurface);
204 req.addTarget(encoderSurface);
205 if (dayMode) {
206 req.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF);
207 req.set(CaptureRequest.SENSOR_EXPOSURE_TIME, 1_000_000L); // 1ms shutter
208 req.set(CaptureRequest.SENSOR_SENSITIVITY, 100); // lowest practical ISO
209 } else {
210 req.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF);
211 req.set(CaptureRequest.SENSOR_EXPOSURE_TIME, 100_000_000L); // 100ms
212 req.set(CaptureRequest.SENSOR_SENSITIVITY, maxIso);
213 }
214 return req.build();
215 }
216
217 void toggleMode() {
218 dayMode = !dayMode;
219 modeBtn.setText(dayMode ? "DAY" : "NIGHT");
220 if (captureSession == null || camera == null) return;
221 try {
222 captureSession.setRepeatingRequest(buildRequest(), null, camHandler);
223 } catch (Exception e) {
224 Log.e(TAG, "mode switch failed", e);
225 }
226 }
227
228 void startCapture() {
229 try {
230 SurfaceTexture tex = preview.getSurfaceTexture();
231 tex.setDefaultBufferSize(WIDTH, HEIGHT);
232 previewSurface = new Surface(tex);
233
234 camera.createCaptureSession(
235 Arrays.asList(previewSurface, encoderSurface),
236 new CameraCaptureSession.StateCallback() {
237 public void onConfigured(CameraCaptureSession session) {
238 captureSession = session;
239 try {
240 session.setRepeatingRequest(buildRequest(), null, camHandler);
241 runOnUiThread(() -> showStatus());
242 } catch (Exception e) {
243 Log.e(TAG, "capture failed", e);
244 }
245 }
246 public void onConfigureFailed(CameraCaptureSession session) {
247 Log.e(TAG, "session config failed");
248 }
249 }, camHandler);
250 } catch (Exception e) {
251 Log.e(TAG, "startCapture failed", e);
252 }
253 }
254
255 void broadcast(byte[] data) {
256 if (clients.isEmpty()) return;
257 List<OutputStream> dead = new ArrayList<>();
258 for (OutputStream out : clients) {
259 try {
260 out.write(data);
261 out.flush();
262 } catch (IOException e) {
263 dead.add(out);
264 }
265 }
266 clients.removeAll(dead);
267 if (!dead.isEmpty()) runOnUiThread(this::showStatus);
268 }
269
270 void startHttpServer() {
271 new Thread(() -> {
272 try {
273 server = new ServerSocket(PORT);
274 Log.i(TAG, "HTTP server on port " + PORT);
275 runOnUiThread(this::showStatus);
276 while (!server.isClosed()) {
277 Socket sock = server.accept();
278 new Thread(() -> handleClient(sock)).start();
279 }
280 } catch (IOException e) {
281 Log.e(TAG, "server error", e);
282 }
283 }).start();
284 }
285
286 void handleClient(Socket sock) {
287 try {
288 BufferedReader in = new BufferedReader(
289 new InputStreamReader(sock.getInputStream()));
290 String line = in.readLine();
291 Log.i(TAG, "client: " + sock.getInetAddress() + " " + line);
292
293 while ((line = in.readLine()) != null && !line.isEmpty()) {}
294
295 OutputStream out = sock.getOutputStream();
296 out.write(("HTTP/1.1 200 OK\r\n" +
297 "Content-Type: video/h264\r\n" +
298 "Connection: close\r\n" +
299 "Cache-Control: no-cache\r\n" +
300 "\r\n").getBytes());
301
302 if (sps != null) out.write(sps);
303 if (pps != null) out.write(pps);
304 out.flush();
305
306 clients.add(out);
307 runOnUiThread(this::showStatus);
308
309 try {
310 while (sock.getInputStream().read() != -1) {}
311 } catch (IOException e) {}
312
313 clients.remove(out);
314 runOnUiThread(this::showStatus);
315 } catch (IOException e) {}
316 }
317
318 void showStatus() {
319 String ip = getLocalIp();
320 status.setText("http://" + ip + ":" + PORT + "/video " +
321 WIDTH + "x" + HEIGHT + " " + FPS + "fps H264 " +
322 (BITRATE/1_000_000) + "Mbps " +
323 clients.size() + " client(s)");
324 }
325
326 String getLocalIp() {
327 try {
328 for (Enumeration<NetworkInterface> en = NetworkInterface.getNetworkInterfaces();
329 en.hasMoreElements();) {
330 NetworkInterface intf = en.nextElement();
331 for (Enumeration<InetAddress> addrs = intf.getInetAddresses();
332 addrs.hasMoreElements();) {
333 InetAddress addr = addrs.nextElement();
334 if (!addr.isLoopbackAddress() && addr instanceof Inet4Address) {
335 return addr.getHostAddress();
336 }
337 }
338 }
339 } catch (Exception e) {}
340 return "unknown";
341 }
342
343 @Override
344 protected void onDestroy() {
345 super.onDestroy();
346 if (captureSession != null) captureSession.close();
347 if (camera != null) camera.close();
348 if (encoder != null) { encoder.stop(); encoder.release(); }
349 if (server != null) try { server.close(); } catch (IOException e) {}
350 if (camThread != null) camThread.quitSafely();
351 }
352 }
353