1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.googlecode.android_scripting.webcam;
18
19import java.io.ByteArrayOutputStream;
20import java.io.File;
21import java.io.FileOutputStream;
22import java.io.IOException;
23import java.io.OutputStream;
24import java.net.InetSocketAddress;
25import java.util.Collections;
26import java.util.Comparator;
27import java.util.HashMap;
28import java.util.List;
29import java.util.Map;
30import java.util.concurrent.CountDownLatch;
31import java.util.concurrent.Executor;
32
33import android.app.Service;
34import android.graphics.ImageFormat;
35import android.graphics.Rect;
36import android.graphics.YuvImage;
37import android.hardware.Camera;
38import android.hardware.Camera.Parameters;
39import android.hardware.Camera.PreviewCallback;
40import android.hardware.Camera.Size;
41import android.util.Base64;
42import android.view.SurfaceHolder;
43import android.view.SurfaceView;
44import android.view.WindowManager;
45import android.view.SurfaceHolder.Callback;
46
47import com.googlecode.android_scripting.BaseApplication;
48import com.googlecode.android_scripting.FutureActivityTaskExecutor;
49import com.googlecode.android_scripting.Log;
50import com.googlecode.android_scripting.SingleThreadExecutor;
51import com.googlecode.android_scripting.SimpleServer.SimpleServerObserver;
52import com.googlecode.android_scripting.facade.EventFacade;
53import com.googlecode.android_scripting.facade.FacadeManager;
54import com.googlecode.android_scripting.future.FutureActivityTask;
55import com.googlecode.android_scripting.jsonrpc.RpcReceiver;
56import com.googlecode.android_scripting.rpc.Rpc;
57import com.googlecode.android_scripting.rpc.RpcDefault;
58import com.googlecode.android_scripting.rpc.RpcOptional;
59import com.googlecode.android_scripting.rpc.RpcParameter;
60
61/**
62 * Manages access to camera streaming.
63 * <br>
64 * <h3>Usage Notes</h3>
65 * <br><b>webCamStart</b> and <b>webCamStop</b> are used to start and stop an Mpeg stream on a given port. <b>webcamAdjustQuality</b> is used to ajust the quality of the streaming video.
66 * <br><b>cameraStartPreview</b> is used to get access to the camera preview screen. It will generate "preview" events as images become available.
67 * <br>The preview has two modes: data or file. If you pass a non-blank, writable file path to the <b>cameraStartPreview</b> it will store jpg images in that folder.
68 * It is up to the caller to clean up these files after the fact. If no file element is provided,
69 * the event will include the image data as a base64 encoded string.
70 * <h3>Event details</h3>
71 * <br>The data element of the preview event will be a map, with the following elements defined.
72 * <ul>
73 * <li><b>format</b> - currently always "jpeg"
74 * <li><b>width</b> - image width (in pixels)
75 * <li><b>height</b> - image height (in pixels)
76 * <li><b>quality</b> - JPEG quality. Number from 1-100
77 * <li><b>filename</b> - Name of file where image was saved. Only relevant if filepath defined.
78 * <li><b>error</b> - included if there was an IOException saving file, ie, disk full or path write protected.
79 * <li><b>encoding</b> - Data encoding. If filepath defined, will be "file" otherwise "base64"
80 * <li><b>data</b> - Base64 encoded image data.
81 * </ul>
82 *<br>Note that "filename", "error" and "data" are mutual exclusive.
83 *<br>
84 *<br>The webcam and preview modes use the same resources, so you can't use them both at the same time. Stop one mode before starting the other.
85 *
86 *
87 */
88public class WebCamFacade extends RpcReceiver {
89
90  private final Service mService;
91  private final Executor mJpegCompressionExecutor = new SingleThreadExecutor();
92  private final ByteArrayOutputStream mJpegCompressionBuffer = new ByteArrayOutputStream();
93
94  private volatile byte[] mJpegData;
95
96  private CountDownLatch mJpegDataReady;
97  private boolean mStreaming;
98  private int mPreviewHeight;
99  private int mPreviewWidth;
100  private int mJpegQuality;
101
102  private MjpegServer mJpegServer;
103  private FutureActivityTask<SurfaceHolder> mPreviewTask;
104  private Camera mCamera;
105  private Parameters mParameters;
106  private final EventFacade mEventFacade;
107  private boolean mPreview;
108  private File mDest;
109
110  private final PreviewCallback mPreviewCallback = new PreviewCallback() {
111    @Override
112    public void onPreviewFrame(final byte[] data, final Camera camera) {
113      mJpegCompressionExecutor.execute(new Runnable() {
114        @Override
115        public void run() {
116          mJpegData = compressYuvToJpeg(data);
117          mJpegDataReady.countDown();
118          if (mStreaming) {
119            camera.setOneShotPreviewCallback(mPreviewCallback);
120          }
121        }
122      });
123    }
124  };
125
126  private final PreviewCallback mPreviewEvent = new PreviewCallback() {
127    @Override
128    public void onPreviewFrame(final byte[] data, final Camera camera) {
129      mJpegCompressionExecutor.execute(new Runnable() {
130        @Override
131        public void run() {
132          mJpegData = compressYuvToJpeg(data);
133          Map<String,Object> map = new HashMap<String, Object>();
134          map.put("format", "jpeg");
135          map.put("width", mPreviewWidth);
136          map.put("height", mPreviewHeight);
137          map.put("quality", mJpegQuality);
138          if (mDest!=null) {
139            try {
140              File dest=File.createTempFile("prv",".jpg",mDest);
141              OutputStream output = new FileOutputStream(dest);
142              output.write(mJpegData);
143              output.close();
144              map.put("encoding","file");
145              map.put("filename",dest.toString());
146            } catch (IOException e) {
147              map.put("error", e.toString());
148            }
149          }
150          else {
151            map.put("encoding","Base64");
152            map.put("data", Base64.encodeToString(mJpegData, Base64.DEFAULT));
153          }
154          mEventFacade.postEvent("preview", map);
155          if (mPreview) {
156            camera.setOneShotPreviewCallback(mPreviewEvent);
157          }
158        }
159      });
160    }
161  };
162
163  public WebCamFacade(FacadeManager manager) {
164    super(manager);
165    mService = manager.getService();
166    mJpegDataReady = new CountDownLatch(1);
167    mEventFacade = manager.getReceiver(EventFacade.class);
168  }
169
170  private byte[] compressYuvToJpeg(final byte[] yuvData) {
171    mJpegCompressionBuffer.reset();
172    YuvImage yuvImage =
173        new YuvImage(yuvData, ImageFormat.NV21, mPreviewWidth, mPreviewHeight, null);
174    yuvImage.compressToJpeg(new Rect(0, 0, mPreviewWidth, mPreviewHeight), mJpegQuality,
175        mJpegCompressionBuffer);
176    return mJpegCompressionBuffer.toByteArray();
177  }
178
179  @Rpc(description = "Starts an MJPEG stream and returns a Tuple of address and port for the stream.")
180  public InetSocketAddress webcamStart(
181      @RpcParameter(name = "resolutionLevel", description = "increasing this number provides higher resolution") @RpcDefault("0") Integer resolutionLevel,
182      @RpcParameter(name = "jpegQuality", description = "a number from 0-100") @RpcDefault("20") Integer jpegQuality,
183      @RpcParameter(name = "port", description = "If port is specified, the webcam service will bind to port, otherwise it will pick any available port.") @RpcDefault("0") Integer port)
184      throws Exception {
185    try {
186      openCamera(resolutionLevel, jpegQuality);
187      return startServer(port);
188    } catch (Exception e) {
189      webcamStop();
190      throw e;
191    }
192  }
193
194  private InetSocketAddress startServer(Integer port) {
195    mJpegServer = new MjpegServer(new JpegProvider() {
196      @Override
197      public byte[] getJpeg() {
198        try {
199          mJpegDataReady.await();
200        } catch (InterruptedException e) {
201          Log.e(e);
202        }
203        return mJpegData;
204      }
205    });
206    mJpegServer.addObserver(new SimpleServerObserver() {
207      @Override
208      public void onDisconnect() {
209        if (mJpegServer.getNumberOfConnections() == 0 && mStreaming) {
210          stopStream();
211        }
212      }
213
214      @Override
215      public void onConnect() {
216        if (!mStreaming) {
217          startStream();
218        }
219      }
220    });
221    return mJpegServer.startPublic(port);
222  }
223
224  private void stopServer() {
225    if (mJpegServer != null) {
226      mJpegServer.shutdown();
227      mJpegServer = null;
228    }
229  }
230
231  @Rpc(description = "Adjusts the quality of the webcam stream while it is running.")
232  public void webcamAdjustQuality(
233      @RpcParameter(name = "resolutionLevel", description = "increasing this number provides higher resolution") @RpcDefault("0") Integer resolutionLevel,
234      @RpcParameter(name = "jpegQuality", description = "a number from 0-100") @RpcDefault("20") Integer jpegQuality)
235      throws Exception {
236    if (mStreaming == false) {
237      throw new IllegalStateException("Webcam not streaming.");
238    }
239    stopStream();
240    releaseCamera();
241    openCamera(resolutionLevel, jpegQuality);
242    startStream();
243  }
244
245  private void openCamera(Integer resolutionLevel, Integer jpegQuality) throws IOException,
246      InterruptedException {
247    mCamera = Camera.open();
248    mParameters = mCamera.getParameters();
249    mParameters.setPictureFormat(ImageFormat.JPEG);
250    mParameters.setPreviewFormat(ImageFormat.JPEG);
251    List<Size> supportedPreviewSizes = mParameters.getSupportedPreviewSizes();
252    Collections.sort(supportedPreviewSizes, new Comparator<Size>() {
253      @Override
254      public int compare(Size o1, Size o2) {
255        return o1.width - o2.width;
256      }
257    });
258    Size previewSize =
259        supportedPreviewSizes.get(Math.min(resolutionLevel, supportedPreviewSizes.size() - 1));
260    mPreviewHeight = previewSize.height;
261    mPreviewWidth = previewSize.width;
262    mParameters.setPreviewSize(mPreviewWidth, mPreviewHeight);
263    mJpegQuality = Math.min(Math.max(jpegQuality, 0), 100);
264    mCamera.setParameters(mParameters);
265    // TODO(damonkohler): Rotate image based on orientation.
266    mPreviewTask = createPreviewTask();
267    mCamera.startPreview();
268  }
269
270  private void startStream() {
271    mStreaming = true;
272    mCamera.setOneShotPreviewCallback(mPreviewCallback);
273  }
274
275  private void stopStream() {
276    mJpegDataReady = new CountDownLatch(1);
277    mStreaming = false;
278    if (mPreviewTask != null) {
279      mPreviewTask.finish();
280      mPreviewTask = null;
281    }
282  }
283
284  private void releaseCamera() {
285    if (mCamera != null) {
286      mCamera.release();
287      mCamera = null;
288    }
289    mParameters = null;
290  }
291
292  @Rpc(description = "Stops the webcam stream.")
293  public void webcamStop() {
294    stopServer();
295    stopStream();
296    releaseCamera();
297  }
298
299  private FutureActivityTask<SurfaceHolder> createPreviewTask() throws IOException,
300      InterruptedException {
301    FutureActivityTask<SurfaceHolder> task = new FutureActivityTask<SurfaceHolder>() {
302      @Override
303      public void onCreate() {
304        super.onCreate();
305        final SurfaceView view = new SurfaceView(getActivity());
306        getActivity().setContentView(view);
307        getActivity().getWindow().setSoftInputMode(
308            WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN);
309        //view.getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
310        view.getHolder().addCallback(new Callback() {
311          @Override
312          public void surfaceDestroyed(SurfaceHolder holder) {
313          }
314
315          @Override
316          public void surfaceCreated(SurfaceHolder holder) {
317            setResult(view.getHolder());
318          }
319
320          @Override
321          public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
322          }
323        });
324      }
325    };
326    FutureActivityTaskExecutor taskExecutor =
327        ((BaseApplication) mService.getApplication()).getTaskExecutor();
328    taskExecutor.execute(task);
329    mCamera.setPreviewDisplay(task.getResult());
330    return task;
331  }
332
333  @Rpc(description = "Start Preview Mode. Throws 'preview' events.",returns="True if successful")
334  public boolean cameraStartPreview(
335          @RpcParameter(name = "resolutionLevel", description = "increasing this number provides higher resolution") @RpcDefault("0") Integer resolutionLevel,
336          @RpcParameter(name = "jpegQuality", description = "a number from 0-100") @RpcDefault("20") Integer jpegQuality,
337          @RpcParameter(name = "filepath", description = "Path to store jpeg files.") @RpcOptional String filepath)
338      throws InterruptedException {
339    mDest=null;
340    if (filepath!=null && (filepath.length()>0)) {
341      mDest = new File(filepath);
342      if (!mDest.exists()) mDest.mkdirs();
343      if (!(mDest.isDirectory() && mDest.canWrite())) {
344        return false;
345      }
346    }
347
348    try {
349      openCamera(resolutionLevel, jpegQuality);
350    } catch (IOException e) {
351      Log.e(e);
352      return false;
353    }
354    startPreview();
355    return true;
356  }
357
358  @Rpc(description = "Stop the preview mode.")
359  public void cameraStopPreview() {
360    stopPreview();
361  }
362
363  private void startPreview() {
364    mPreview = true;
365    mCamera.setOneShotPreviewCallback(mPreviewEvent);
366  }
367
368  private void stopPreview() {
369    mPreview = false;
370    if (mPreviewTask!=null)
371    {
372      mPreviewTask.finish();
373      mPreviewTask=null;
374    }
375    releaseCamera();
376  }
377
378  @Override
379  public void shutdown() {
380    mPreview=false;
381    webcamStop();
382  }
383}
384