From f3111e97335600b782291833e8a49b2ac7c67e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20F=C3=B6lser?= Date: Thu, 20 May 2021 15:57:51 +0200 Subject: [PATCH 1/5] initial implementation of a simple dvr, capturing direct usb stream and muxing into mp4. creates a single file per received stream in Pictures/Digiview --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 1 + .../com/fpvout/digiview/MainActivity.java | 18 +++ .../java/com/fpvout/digiview/Mp4Muxer.java | 111 ++++++++++++++++++ .../com/fpvout/digiview/StreamDumper.java | 74 ++++++++++++ .../main/java/usb/AndroidUSBInputStream.java | 9 +- 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/fpvout/digiview/Mp4Muxer.java create mode 100644 app/src/main/java/com/fpvout/digiview/StreamDumper.java diff --git a/app/build.gradle b/app/build.gradle index 281b0dc..0e8a248 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'com.google.android.material:material:1.3.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'com.google.android.exoplayer:exoplayer:2.13.3' + implementation 'org.jcodec:jcodec:0.2.5' testImplementation 'junit:junit:4.+' androidTestImplementation 'androidx.test.ext:junit:1.1.2' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f5bb3de..65bf767 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + = Build.VERSION_CODES.M) { + if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); + + } + } } @Override diff --git a/app/src/main/java/com/fpvout/digiview/Mp4Muxer.java b/app/src/main/java/com/fpvout/digiview/Mp4Muxer.java new file mode 100644 index 0000000..103db9b --- /dev/null +++ b/app/src/main/java/com/fpvout/digiview/Mp4Muxer.java @@ -0,0 +1,111 @@ +package com.fpvout.digiview; + +import android.util.Log; + +import org.jcodec.codecs.h264.BufferH264ES; +import org.jcodec.codecs.h264.H264Decoder; +import org.jcodec.common.Codec; +import org.jcodec.common.MuxerTrack; +import org.jcodec.common.VideoCodecMeta; +import org.jcodec.common.io.NIOUtils; +import org.jcodec.common.io.SeekableByteChannel; +import org.jcodec.common.model.Packet; +import org.jcodec.containers.mp4.muxer.MP4Muxer; + +import java.io.File; +import java.io.IOException; + +public class Mp4Muxer extends Thread { + + private static final int TIMESCALE = 60; + private static final long DURATION = 1; + + private final File h264Dump; + private final File output; + + SeekableByteChannel file; + MP4Muxer muxer; + BufferH264ES es; + + public Mp4Muxer(File h264Dump, File output) { + this.h264Dump = h264Dump; + this.output = output; + } + + private void init() throws IOException { + file = NIOUtils.writableChannel(output); + muxer = MP4Muxer.createMP4MuxerToChannel(file); + + es = new BufferH264ES(NIOUtils.mapFile(h264Dump)); + } + + + private MuxerTrack initVideoTrack(Packet frame){ + VideoCodecMeta md = new H264Decoder().getCodecMeta(frame.getData()); + return muxer.addVideoTrack(Codec.H264, md); + } + + private Packet skipToFirstValidFrame(){ + return nextValidFrame(null, null); + } + + /** + * Seek next valid frame. + * For every invalid frame, insert placeholder frame into track + */ + private Packet nextValidFrame(Packet placeholder, MuxerTrack track){ + Packet frame = null; + // drop invalid frames + while (frame == null) { + try{ + frame = es.nextFrame(); + if(frame == null){ + return null; // end of input + } + }catch (Exception ignore){ + try { + if(track != null){ + track.addFrame(placeholder); + } + } catch (IOException ignored) { } + // invalid frames can cause a variety of exceptions on read + // continue + } + } + return frame; + } + + @Override + public void run() { + + try{ + + init(); + + Packet frame = skipToFirstValidFrame(); + + MuxerTrack track = null; + while (frame != null) { + if (track == null) { + track = initVideoTrack(frame); + } + + frame.setTimescale(TIMESCALE); + frame.setDuration(DURATION); + track.addFrame(frame); + + frame = nextValidFrame(frame, track); + } + + muxer.finish(); + + file.close(); + + // cleanup + h264Dump.delete(); + + } catch (IOException exception){ + Log.e("DIGIVIEW", "MUXER: " + exception.getMessage()); + } + } +} diff --git a/app/src/main/java/com/fpvout/digiview/StreamDumper.java b/app/src/main/java/com/fpvout/digiview/StreamDumper.java new file mode 100644 index 0000000..46acff8 --- /dev/null +++ b/app/src/main/java/com/fpvout/digiview/StreamDumper.java @@ -0,0 +1,74 @@ +package com.fpvout.digiview; + +import android.os.Environment; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Calendar; + +public class StreamDumper { + + private FileOutputStream fos; + private boolean bytesWritten = false; + + private final File dumpDir; + private File streamDump; + + public boolean dumpStream = true; + + public StreamDumper(){ + dumpDir = new File( + Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES), + "DigiView"); + dumpDir.mkdir(); + + init(); + } + + public void dump(byte[] buffer, int offset, int receivedBytes) { + + if(fos == null) init(); + + try { + fos.write(buffer, offset, receivedBytes); + bytesWritten = true; + } catch (IOException exception) { + exception.printStackTrace(); + } + } + + private void init() { + try { + streamDump = new File(dumpDir, "DigiView-"+System.currentTimeMillis()+".h264"); + fos = new FileOutputStream(streamDump); + bytesWritten = false; + } catch (IOException exception) { + exception.printStackTrace(); + } + } + + public void stop() { + try { + if(fos != null){ + fos.flush(); + fos.close(); + + if(bytesWritten) { + String timestamp = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss") + .format(Calendar.getInstance().getTime()); + File out = new File(dumpDir, "DigiView "+timestamp+".mp4"); + new Mp4Muxer(streamDump, out).start(); + } + } + if(!bytesWritten){ + streamDump.delete(); + } + + } catch (IOException exception) { + exception.printStackTrace(); + } + } +} diff --git a/app/src/main/java/usb/AndroidUSBInputStream.java b/app/src/main/java/usb/AndroidUSBInputStream.java index d57eeac..f86703f 100644 --- a/app/src/main/java/usb/AndroidUSBInputStream.java +++ b/app/src/main/java/usb/AndroidUSBInputStream.java @@ -21,6 +21,8 @@ import android.hardware.usb.UsbDeviceConnection; import android.hardware.usb.UsbEndpoint; +import com.fpvout.digiview.StreamDumper; + /** * This class acts as a wrapper to read data from the USB Interface in Android * behaving like an {@code InputputStream} class. @@ -139,15 +141,18 @@ public void startReadThread() { receiveThread = new Thread() { @Override public void run() { + StreamDumper streamDumper = new StreamDumper(); while (working) { byte[] buffer = new byte[1024]; int receivedBytes = usbConnection.bulkTransfer(receiveEndPoint, buffer, buffer.length, READ_TIMEOUT) - OFFSET; if (receivedBytes > 0) { - byte[] data = new byte[receivedBytes]; - System.arraycopy(buffer, OFFSET, data, 0, receivedBytes); readBuffer.write(buffer, OFFSET, receivedBytes); + if(streamDumper.dumpStream){ + streamDumper.dump(buffer, OFFSET, receivedBytes); + } } } + streamDumper.stop(); } }; receiveThread.start(); From a0ad321b1137d0c974fdd3b8e69397bcecd65af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20F=C3=B6lser?= Date: Thu, 20 May 2021 17:17:36 +0200 Subject: [PATCH 2/5] prettier dump file paths, cleanup --- .../main/java/com/fpvout/digiview/StreamDumper.java | 11 +++++------ app/src/main/java/usb/AndroidUSBInputStream.java | 5 ++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/fpvout/digiview/StreamDumper.java b/app/src/main/java/com/fpvout/digiview/StreamDumper.java index 46acff8..8d787a3 100644 --- a/app/src/main/java/com/fpvout/digiview/StreamDumper.java +++ b/app/src/main/java/com/fpvout/digiview/StreamDumper.java @@ -15,6 +15,7 @@ public class StreamDumper { private final File dumpDir; private File streamDump; + private String startTimestamp; public boolean dumpStream = true; @@ -30,8 +31,6 @@ public StreamDumper(){ public void dump(byte[] buffer, int offset, int receivedBytes) { - if(fos == null) init(); - try { fos.write(buffer, offset, receivedBytes); bytesWritten = true; @@ -42,7 +41,9 @@ public void dump(byte[] buffer, int offset, int receivedBytes) { private void init() { try { - streamDump = new File(dumpDir, "DigiView-"+System.currentTimeMillis()+".h264"); + startTimestamp = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss") + .format(Calendar.getInstance().getTime()); + streamDump = new File(dumpDir, "DigiView "+startTimestamp+".h264"); fos = new FileOutputStream(streamDump); bytesWritten = false; } catch (IOException exception) { @@ -57,9 +58,7 @@ public void stop() { fos.close(); if(bytesWritten) { - String timestamp = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss") - .format(Calendar.getInstance().getTime()); - File out = new File(dumpDir, "DigiView "+timestamp+".mp4"); + File out = new File(dumpDir, "DigiView "+startTimestamp+".mp4"); new Mp4Muxer(streamDump, out).start(); } } diff --git a/app/src/main/java/usb/AndroidUSBInputStream.java b/app/src/main/java/usb/AndroidUSBInputStream.java index f86703f..cbd0b62 100644 --- a/app/src/main/java/usb/AndroidUSBInputStream.java +++ b/app/src/main/java/usb/AndroidUSBInputStream.java @@ -139,9 +139,10 @@ public void startReadThread() { working = true; readBuffer = new CircularByteBuffer(READ_BUFFER_SIZE); receiveThread = new Thread() { + StreamDumper streamDumper; @Override public void run() { - StreamDumper streamDumper = new StreamDumper(); + streamDumper = new StreamDumper(); while (working) { byte[] buffer = new byte[1024]; int receivedBytes = usbConnection.bulkTransfer(receiveEndPoint, buffer, buffer.length, READ_TIMEOUT) - OFFSET; @@ -153,6 +154,7 @@ public void run() { } } streamDumper.stop(); + } }; receiveThread.start(); @@ -172,3 +174,4 @@ public void close() throws IOException { super.close(); } } + From 59573052ce4cf28ae8033527a381983b3deb1b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20F=C3=B6lser?= Date: Thu, 20 May 2021 21:17:50 +0200 Subject: [PATCH 3/5] reduced targetSdkVersion to 29 and set requestLegacyExternalStorage to be able to use legacy file access api --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 1 + app/src/main/java/com/fpvout/digiview/StreamDumper.java | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0e8a248..ed57f43 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,7 +9,7 @@ android { defaultConfig { applicationId "com.fpvout.digiview" minSdkVersion 21 - targetSdkVersion 30 + targetSdkVersion 29 versionCode 1 versionName "1.0" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 65bf767..0ddaf96 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Digiview" + android:requestLegacyExternalStorage="true" > Date: Thu, 20 May 2021 21:18:34 +0200 Subject: [PATCH 4/5] implemented proper permission check on startup --- .../com/fpvout/digiview/MainActivity.java | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/fpvout/digiview/MainActivity.java b/app/src/main/java/com/fpvout/digiview/MainActivity.java index 03f6269..dd48ab7 100644 --- a/app/src/main/java/com/fpvout/digiview/MainActivity.java +++ b/app/src/main/java/com/fpvout/digiview/MainActivity.java @@ -22,6 +22,7 @@ import android.view.ViewGroup; import android.view.WindowManager; +import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; @@ -85,7 +86,7 @@ protected void onCreate(Bundle savedInstanceState) { fpvView = findViewById(R.id.fpvView); // Enable resizing animations - ((ViewGroup)findViewById(R.id.mainLayout)).getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING); + ((ViewGroup) findViewById(R.id.mainLayout)).getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING); gestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() { @Override @@ -119,7 +120,13 @@ public void onScaleEnd(ScaleGestureDetector detector) { mUsbMaskConnection = new UsbMaskConnection(); mVideoReader = new VideoReaderExoplayer(fpvView, overlayView, this); - checkStoragePermission(); + if(checkStoragePermission()){ + // permission already granted + finishStartup(); + } + } + + private void finishStartup() { if (!usbConnected) { if (searchDevice()) { @@ -131,15 +138,33 @@ public void onScaleEnd(ScaleGestureDetector detector) { } - private void checkStoragePermission() { + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if(requestCode == 1){ + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + + finishStartup(); + } + else { + overlayView.showOpaque("Storage access is required.", OverlayStatus.Error); + } + } + } + + private boolean checkStoragePermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { + == PackageManager.PERMISSION_GRANTED) { + return true; + }else{ ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); - + return false; } } + return true; } @Override From 11242a6acfa6deec7cd61afc859df663c0985373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20F=C3=B6lser?= Date: Thu, 20 May 2021 23:56:59 +0200 Subject: [PATCH 5/5] add mp4 to gallery upon completion --- app/src/main/java/com/fpvout/digiview/MainActivity.java | 7 +++++++ app/src/main/java/com/fpvout/digiview/Mp4Muxer.java | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/app/src/main/java/com/fpvout/digiview/MainActivity.java b/app/src/main/java/com/fpvout/digiview/MainActivity.java index dd48ab7..2f6c8ae 100644 --- a/app/src/main/java/com/fpvout/digiview/MainActivity.java +++ b/app/src/main/java/com/fpvout/digiview/MainActivity.java @@ -4,6 +4,7 @@ import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.LayoutTransition; +import android.app.Application; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; @@ -30,6 +31,7 @@ import java.util.HashMap; public class MainActivity extends AppCompatActivity implements UsbDeviceListener { + private static AppCompatActivity instance; private static final String ACTION_USB_PERMISSION = "com.fpvout.digiview.USB_PERMISSION"; private static final String TAG = "DIGIVIEW"; private static final int VENDOR_ID = 11427; @@ -52,6 +54,7 @@ public class MainActivity extends AppCompatActivity implements UsbDeviceListener @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + instance = this; Log.d(TAG, "APP - On Create"); setContentView(R.layout.activity_main); @@ -287,4 +290,8 @@ protected void onDestroy() { mVideoReader.stop(); usbConnected = false; } + + public static Context getContext() { + return instance.getApplicationContext(); + } } \ No newline at end of file diff --git a/app/src/main/java/com/fpvout/digiview/Mp4Muxer.java b/app/src/main/java/com/fpvout/digiview/Mp4Muxer.java index 103db9b..ab55b4e 100644 --- a/app/src/main/java/com/fpvout/digiview/Mp4Muxer.java +++ b/app/src/main/java/com/fpvout/digiview/Mp4Muxer.java @@ -1,5 +1,6 @@ package com.fpvout.digiview; +import android.media.MediaScannerConnection; import android.util.Log; import org.jcodec.codecs.h264.BufferH264ES; @@ -104,6 +105,11 @@ public void run() { // cleanup h264Dump.delete(); + // add mp4 to gallery + MediaScannerConnection.scanFile(MainActivity.getContext(), + new String[]{output.toString()}, + null, null); + } catch (IOException exception){ Log.e("DIGIVIEW", "MUXER: " + exception.getMessage()); }