diff --git a/app/build.gradle b/app/build.gradle index 281b0dc..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" @@ -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..0ddaf96 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + 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) { + return true; + + }else{ + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); + return false; + } + } + return true; } @Override @@ -244,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 new file mode 100644 index 0000000..ab55b4e --- /dev/null +++ b/app/src/main/java/com/fpvout/digiview/Mp4Muxer.java @@ -0,0 +1,117 @@ +package com.fpvout.digiview; + +import android.media.MediaScannerConnection; +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(); + + // add mp4 to gallery + MediaScannerConnection.scanFile(MainActivity.getContext(), + new String[]{output.toString()}, + null, null); + + } 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..8b06ec3 --- /dev/null +++ b/app/src/main/java/com/fpvout/digiview/StreamDumper.java @@ -0,0 +1,73 @@ +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; + private String startTimestamp; + + public boolean dumpStream = true; + + public StreamDumper(){ + dumpDir = new File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + "DigiView"); + + dumpDir.mkdirs(); + + init(); + } + + public void dump(byte[] buffer, int offset, int receivedBytes) { + + try { + fos.write(buffer, offset, receivedBytes); + bytesWritten = true; + } catch (IOException exception) { + exception.printStackTrace(); + } + } + + private void init() { + try { + 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) { + exception.printStackTrace(); + } + } + + public void stop() { + try { + if(fos != null){ + fos.flush(); + fos.close(); + + if(bytesWritten) { + File out = new File(dumpDir, "DigiView "+startTimestamp+".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..cbd0b62 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. @@ -137,17 +139,22 @@ public void startReadThread() { working = true; readBuffer = new CircularByteBuffer(READ_BUFFER_SIZE); receiveThread = new Thread() { + StreamDumper streamDumper; @Override public void run() { + 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(); @@ -167,3 +174,4 @@ public void close() throws IOException { super.close(); } } +