diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..6bf4635 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..c393b28 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index db7ceb8..a3e7efc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -70,6 +70,10 @@ dependencies { implementation 'com.google.android.material:material:1.4.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.2' implementation 'com.google.android.exoplayer:exoplayer:2.16.1' + implementation 'org.mp4parser:isoparser:1.9.39' + implementation 'org.mp4parser:muxer:1.9.39' + compile 'org.slf4j:slf4j-nop:1.7.25' + implementation 'org.jcodec:jcodec:0.2.5' implementation 'io.sentry:sentry-android:4.3.0' implementation 'androidx.preference:preference:1.1.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7df7ca4..515ff8d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ package="com.fpvout.digiview"> + + + diff --git a/app/src/main/java/com/fpvout/digiview/H264Extractor.java b/app/src/main/java/com/fpvout/digiview/H264Extractor.java index 2131349..13fbbd2 100644 --- a/app/src/main/java/com/fpvout/digiview/H264Extractor.java +++ b/app/src/main/java/com/fpvout/digiview/H264Extractor.java @@ -3,6 +3,7 @@ import androidx.annotation.NonNull; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; import com.google.android.exoplayer2.extractor.ExtractorOutput; diff --git a/app/src/main/java/com/fpvout/digiview/MainActivity.java b/app/src/main/java/com/fpvout/digiview/MainActivity.java index 7e6bcfb..d45f336 100644 --- a/app/src/main/java/com/fpvout/digiview/MainActivity.java +++ b/app/src/main/java/com/fpvout/digiview/MainActivity.java @@ -1,5 +1,6 @@ package com.fpvout.digiview; +import android.Manifest; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.LayoutTransition; @@ -9,10 +10,17 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbManager; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; +import android.os.StrictMode; +import android.preference.PreferenceManager; import android.util.Log; import android.view.GestureDetector; import android.view.MotionEvent; @@ -21,13 +29,21 @@ import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; +import android.widget.ImageButton; +import android.widget.RelativeLayout; +import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; -import androidx.preference.PreferenceManager; +import androidx.core.app.ActivityCompat; +import com.fpvout.digiview.dvr.DVR; + +import java.io.File; +import java.io.IOException; import java.util.HashMap; import io.sentry.SentryLevel; @@ -43,6 +59,9 @@ public class MainActivity extends AppCompatActivity implements UsbDeviceListener private int shortAnimationDuration; private float buttonAlpha = 1; private View settingsButton; + private View recordButton; + private ImageButton thumbnail; + private RelativeLayout toolbar; private View watermarkView; private OverlayView overlayView; PendingIntent permissionIntent; @@ -53,10 +72,12 @@ public class MainActivity extends AppCompatActivity implements UsbDeviceListener VideoReaderExoplayer mVideoReader; boolean usbConnected = false; SurfaceView fpvView; + DVR dvr; private GestureDetector gestureDetector; private ScaleGestureDetector scaleGestureDetector; private SharedPreferences sharedPreferences; private static final String ShowWatermark = "ShowWatermark"; + private boolean overlayIsShown = false; ActivityResultLauncher launchDataCollectionActivity = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { @@ -64,7 +85,6 @@ public class MainActivity extends AppCompatActivity implements UsbDeviceListener boolean dataCollectionAccepted = preferences.getBoolean("dataCollectionAccepted", false); if (result.getResultCode() == Activity.RESULT_OK && dataCollectionAccepted) { - Log.d(TAG, "launchDataCollectionActivity: " + dataCollectionAccepted); SentryAndroid.init(getApplicationContext(), options -> options.setBeforeSend((event, hint) -> { if (SentryLevel.DEBUG.equals(event.getLevel())) return null; @@ -78,7 +98,7 @@ private void setupGestureDetectors() { gestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() { @Override public boolean onSingleTapConfirmed(MotionEvent e) { - toggleSettingsButton(); + toggleToolbar(); return super.onSingleTapConfirmed(e); } @@ -131,40 +151,44 @@ private void updateVideoZoom() { } private void cancelButtonAnimation() { - Handler handler = settingsButton.getHandler(); + Handler handler = toolbar.getHandler(); if (handler != null) { - settingsButton.getHandler().removeCallbacksAndMessages(null); + toolbar.getHandler().removeCallbacksAndMessages(null); } } - private void showSettingsButton() { + private void showToolbar() { cancelButtonAnimation(); if (overlayView.getVisibility() == View.VISIBLE) { buttonAlpha = 1; - settingsButton.setAlpha(1); + toolbar.setAlpha(1); } } - private void toggleSettingsButton() { + private void toggleToolbar() { if (buttonAlpha == 1 && overlayView.getVisibility() == View.VISIBLE) return; // cancel any pending delayed animations first cancelButtonAnimation(); + int translation = 0; if (buttonAlpha == 1) { buttonAlpha = 0; + translation = 60; } else { buttonAlpha = 1; } - - settingsButton.animate() + updateDVRThumb(); + toolbar.animate() .alpha(buttonAlpha) + .translationX(translation) .setDuration(shortAnimationDuration) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { - autoHideSettingsButton(); + autoHideToolbar(); + updateDVRThumb(); } }); } @@ -191,6 +215,37 @@ protected void onCreate(Bundle savedInstanceState) { actionBar.hide(); } + thumbnail = findViewById(R.id.thumbnail); + thumbnail.setOnClickListener(view -> { + StrictMode.VmPolicy.Builder builder = new StrictMode.VmPolicy.Builder(); + StrictMode.setVmPolicy(builder.build()); + Intent intent = new Intent(); + intent.setAction(android.content.Intent.ACTION_VIEW); + if (dvr != null) { + intent.setDataAndType(Uri.withAppendedPath(Uri.fromFile(dvr.getDefaultFolder()), ""), "video/*"); + } else { + intent.setType("image/*"); + } + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + }); + toolbar = findViewById(R.id.toolbar); + + recordButton = findViewById(R.id.recordbt); + recordButton.setOnClickListener(view -> { + if (dvr != null) { + updateDVRThumb(); + if (dvr.isRecording()) { + dvr.stop(); + } else { + dvr.start(); + } + } else { + Toast.makeText(this, this.getText(R.string.no_dvr_video), Toast.LENGTH_LONG).show(); + } + }); + + // Prevent screen from sleeping getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); @@ -226,6 +281,12 @@ protected void onCreate(Bundle savedInstanceState) { mVideoReader = new VideoReaderExoplayer(fpvView, this, videoReaderEventListener); + dvr = DVR.getInstance(this, true, new Handler(message -> { + updateDVRThumb(); + return true; + }), mUsbMaskConnection); + updateDVRThumb(); + if (!usbConnected) { if (searchDevice()) { connect(); @@ -250,6 +311,18 @@ public void usbDeviceDetached() { disconnect(); } + private void updateDVRThumb() { + if (dvr != null) { + File file = new File(dvr.getLatestThumbFile()); + if (file.exists()) { + Bitmap bmp = BitmapFactory.decodeFile(dvr.getLatestThumbFile()); + thumbnail.setImageBitmap(bmp); + } else { + thumbnail.setImageBitmap(null); + } + } + } + private boolean searchDevice() { HashMap deviceList = usbManager.getDeviceList(); if (deviceList.size() <= 0) { @@ -275,12 +348,13 @@ private boolean searchDevice() { private void connect() { usbConnected = true; - mUsbMaskConnection.setUsbDevice(usbManager.openDevice(usbDevice), usbDevice); + mUsbMaskConnection.setUsbDevice(usbManager, usbDevice, dvr); mVideoReader.setUsbMaskConnection(mUsbMaskConnection); overlayView.hide(); mVideoReader.start(); + updateDVRThumb(); updateWatermark(); - autoHideSettingsButton(); + autoHideToolbar(); showOverlay(R.string.waiting_for_video, OverlayStatus.Connected); } @@ -301,19 +375,66 @@ public void onResume() { actionBar.hide(); } + + toolbar.setAlpha(1); + autoHideToolbar(); + updateWatermark(); + updateVideoZoom(); + + if(checkStoragePermission()) { + finishStartup(); + } + } + + private void finishStartup(){ + // Init DVR recorder + try { + dvr.init(); + } catch (IOException e) { + Log.i(TAG, "DVR - init failed"); + } if (!usbConnected) { - if (searchDevice()) { + usbDevice = UsbMaskConnection.searchDevice(usbManager, getApplicationContext()); + if (usbDevice != null) { Log.d(TAG, "APP - On Resume usbDevice device found"); + showOverlay(R.string.usb_device_found, OverlayStatus.Connected); connect(); } else { - showOverlay(R.string.waiting_for_usb_device, OverlayStatus.Connected); + showOverlay(R.string.waiting_for_usb_device, OverlayStatus.Disconnected); } } + } - settingsButton.setAlpha(1); - autoHideSettingsButton(); - updateWatermark(); - updateVideoZoom(); + private boolean checkStoragePermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED && + checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED && + checkSelfPermission(Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED) { + return true; + + }else{ + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA }, 1); + return false; + } + } + return true; + } + + @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.show( R.string.storage_rights_required, OverlayStatus.Error); + } + } } private boolean onVideoReaderEvent(VideoReaderExoplayer.VideoReaderEventMessageCode m) { @@ -329,15 +450,19 @@ private boolean onVideoReaderEvent(VideoReaderExoplayer.VideoReaderEventMessageC private void showOverlay(int textId, OverlayStatus connected) { overlayView.show(textId, connected); + overlayIsShown = true; + toolbar.setTranslationX(0); + toolbar.setAlpha(1); updateWatermark(); - showSettingsButton(); + showToolbar(); } private void hideOverlay() { overlayView.hide(); + overlayIsShown = false; updateWatermark(); - showSettingsButton(); - autoHideSettingsButton(); + showToolbar(); + autoHideToolbar(); } private void disconnect() { @@ -373,14 +498,15 @@ protected void onDestroy() { usbConnected = false; } - private void autoHideSettingsButton() { + private void autoHideToolbar() { if (overlayView.getVisibility() == View.VISIBLE) return; if (buttonAlpha == 0) return; - settingsButton.postDelayed(() -> { + toolbar.postDelayed(() -> { buttonAlpha = 0; - settingsButton.animate() + toolbar.animate() .alpha(0) + .translationX(60) .setDuration(shortAnimationDuration); }, 3000); } @@ -400,6 +526,7 @@ private void checkDataCollectionAgreement() { return event; })); } + } } \ No newline at end of file diff --git a/app/src/main/java/com/fpvout/digiview/OverlayView.java b/app/src/main/java/com/fpvout/digiview/OverlayView.java index b1327c8..8a6c736 100644 --- a/app/src/main/java/com/fpvout/digiview/OverlayView.java +++ b/app/src/main/java/com/fpvout/digiview/OverlayView.java @@ -38,6 +38,8 @@ private void showInfo(String text, OverlayStatus status){ int image = R.drawable.ic_goggles_white; switch(status){ + case Connected: + break; case Disconnected: image = R.drawable.ic_goggles_disconnected_white; break; diff --git a/app/src/main/java/com/fpvout/digiview/PerformancePreset.java b/app/src/main/java/com/fpvout/digiview/PerformancePreset.java index 61e6bda..b8cd4a7 100644 --- a/app/src/main/java/com/fpvout/digiview/PerformancePreset.java +++ b/app/src/main/java/com/fpvout/digiview/PerformancePreset.java @@ -70,8 +70,8 @@ public enum PresetType { LEGACY_BUFFERED } - @NonNull @Override + @NonNull public String toString() { return "PerformancePreset{" + "h264ReaderMaxSyncFrameSize=" + h264ReaderMaxSyncFrameSize + diff --git a/app/src/main/java/com/fpvout/digiview/UsbDeviceBroadcastReceiver.java b/app/src/main/java/com/fpvout/digiview/UsbDeviceBroadcastReceiver.java index db64b09..e2eebc9 100644 --- a/app/src/main/java/com/fpvout/digiview/UsbDeviceBroadcastReceiver.java +++ b/app/src/main/java/com/fpvout/digiview/UsbDeviceBroadcastReceiver.java @@ -6,11 +6,12 @@ import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbManager; +import static com.fpvout.digiview.UsbMaskConnection.ACTION_USB_PERMISSION; + public class UsbDeviceBroadcastReceiver extends BroadcastReceiver { - private static final String ACTION_USB_PERMISSION = "com.fpvout.digiview.USB_PERMISSION"; private final UsbDeviceListener listener; - public UsbDeviceBroadcastReceiver(UsbDeviceListener listener ){ + public UsbDeviceBroadcastReceiver(UsbDeviceListener listener) { this.listener = listener; } diff --git a/app/src/main/java/com/fpvout/digiview/UsbMaskConnection.java b/app/src/main/java/com/fpvout/digiview/UsbMaskConnection.java index 1d6d90e..5985c1b 100644 --- a/app/src/main/java/com/fpvout/digiview/UsbMaskConnection.java +++ b/app/src/main/java/com/fpvout/digiview/UsbMaskConnection.java @@ -1,37 +1,59 @@ package com.fpvout.digiview; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbDeviceConnection; import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; + +import com.fpvout.digiview.dvr.DVR; import java.io.IOException; +import java.util.HashMap; import usb.AndroidUSBInputStream; import usb.AndroidUSBOutputStream; public class UsbMaskConnection { + public static final String ACTION_USB_PERMISSION = "com.fpvout.digiview.USB_PERMISSION"; + private static final int VENDOR_ID = 11427; + private static final int PRODUCT_ID = 31; private final byte[] magicPacket = "RMVT".getBytes(); private UsbDeviceConnection usbConnection; - private UsbDevice device; private UsbInterface usbInterface; AndroidUSBInputStream mInputStream; AndroidUSBOutputStream mOutputStream; private boolean ready = false; + private DVR dvr; public UsbMaskConnection() { + } - public void setUsbDevice(UsbDeviceConnection c, UsbDevice d) { - usbConnection = c; - device = d; - usbInterface = device.getInterface(3); + public AndroidUSBInputStream getInputStream(){ + return mInputStream; + } - usbConnection.claimInterface(usbInterface,true); + public static UsbDevice searchDevice(UsbManager usbManager, Context c) { + PendingIntent permissionIntent = PendingIntent.getBroadcast(c, 0, new Intent(ACTION_USB_PERMISSION), 0); - mOutputStream = new AndroidUSBOutputStream(usbInterface.getEndpoint(0), usbConnection); - mInputStream = new AndroidUSBInputStream(usbInterface.getEndpoint(1), usbInterface.getEndpoint(0), usbConnection); - ready = true; + HashMap deviceList = usbManager.getDeviceList(); + if (deviceList.size() <= 0) { + return null; + } + + for (UsbDevice device : deviceList.values()) { + if (device.getVendorId() == VENDOR_ID && device.getProductId() == PRODUCT_ID) { + if (usbManager.hasPermission(device)) { + return device; + } + usbManager.requestPermission(device, permissionIntent); + } + } + return null; } public void start(){ @@ -59,4 +81,16 @@ public void stop() { public boolean isReady() { return ready; } + + public void setUsbDevice(UsbManager usbManager, UsbDevice d, DVR _dvr) { + dvr = _dvr; + usbConnection = usbManager.openDevice(d); + usbInterface = d.getInterface(3); + + usbConnection.claimInterface(usbInterface, true); + + mOutputStream = new AndroidUSBOutputStream(usbInterface.getEndpoint(0), usbConnection); + mInputStream = new AndroidUSBInputStream(usbInterface.getEndpoint(1), usbInterface.getEndpoint(0), usbConnection); + ready = true; + } } diff --git a/app/src/main/java/com/fpvout/digiview/VideoReaderExoplayer.java b/app/src/main/java/com/fpvout/digiview/VideoReaderExoplayer.java index 85a89e5..7877af5 100644 --- a/app/src/main/java/com/fpvout/digiview/VideoReaderExoplayer.java +++ b/app/src/main/java/com/fpvout/digiview/VideoReaderExoplayer.java @@ -28,7 +28,6 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.util.NonNullApi; import com.google.android.exoplayer2.video.VideoSize; import usb.AndroidUSBInputStream; @@ -40,12 +39,13 @@ public class VideoReaderExoplayer { static final String VideoPreset = "VideoPreset"; private final SurfaceView surfaceView; private AndroidUSBInputStream inputStream; - private UsbMaskConnection mUsbMaskConnection; + private UsbMaskConnection mUsbMaskConnection; private boolean zoomedIn; private final Context context; private PerformancePreset performancePreset = PerformancePreset.getPreset(PerformancePreset.PresetType.DEFAULT); static final String VideoZoomedIn = "VideoZoomedIn"; private final SharedPreferences sharedPreferences; + private boolean streaming = false; VideoReaderExoplayer(SurfaceView videoSurface, Context c) { surfaceView = videoSurface; @@ -95,7 +95,6 @@ public void start() { mPlayer.play(); mPlayer.addListener(new Player.Listener() { @Override - @NonNullApi public void onPlayerErrorChanged(@Nullable PlaybackException error) { if (error == null) { Log.e(TAG, "PLAYER_SOURCE - TYPE_UNEXPECTED: no message"); @@ -105,6 +104,7 @@ public void onPlayerErrorChanged(@Nullable PlaybackException error) { Log.e(TAG, "onPlayerErrorChanged: " + e.type); switch (e.type) { case ExoPlaybackException.TYPE_SOURCE: + streaming = false; Log.e(TAG, "PLAYER_SOURCE - TYPE_SOURCE: " + error.getMessage()); (new Handler(Looper.getMainLooper())).postDelayed(() -> restart(), 1000); break; @@ -121,7 +121,7 @@ public void onPlayerErrorChanged(@Nullable PlaybackException error) { } @Override - public void onPlaybackStateChanged(@NonNullApi int state) { + public void onPlaybackStateChanged(@NonNull int state) { switch (state) { case Player.STATE_IDLE: case Player.STATE_READY: diff --git a/app/src/main/java/com/fpvout/digiview/dvr/DVR.java b/app/src/main/java/com/fpvout/digiview/dvr/DVR.java new file mode 100644 index 0000000..7317d9d --- /dev/null +++ b/app/src/main/java/com/fpvout/digiview/dvr/DVR.java @@ -0,0 +1,165 @@ +package com.fpvout.digiview.dvr; + +import android.app.Activity; +import android.media.MediaRecorder; +import android.os.Environment; +import android.os.Handler; +import android.util.Log; +import android.widget.ImageButton; +import android.widget.Toast; + +import com.fpvout.digiview.R; +import com.fpvout.digiview.UsbMaskConnection; +import com.fpvout.digiview.helpers.DataListener; +import com.fpvout.digiview.helpers.Mp4Muxer; +import com.fpvout.digiview.helpers.StreamDumper; +import com.fpvout.digiview.helpers.ThreadPerTaskExecutor; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Calendar; + +public class DVR { + private final Activity activity; + private final boolean recordAmbientAudio; + private MediaRecorder recorder; + private boolean recording = false; + private static DVR instance; + private static final String DVR_LOG_TAG = "DVR"; + private String defaultFolder = ""; + private final StreamDumper streamDumper; + private String videoFile; + private String dvrFile; + private String fileName; + private final UsbMaskConnection connection; + private String ambientAudio; + private static Handler updateAfterRecord; + public static final String LATEST_THUMB_FILE = "latest.jpeg"; + + DVR(Activity activity, boolean recordAmbientAudio, Handler updateAfterRecord, UsbMaskConnection connection) { + this.activity = activity; + this.connection = connection; + this.recordAmbientAudio = recordAmbientAudio; + DVR.updateAfterRecord = updateAfterRecord; + defaultFolder = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + "/" + this.activity.getApplicationInfo().loadLabel(this.activity.getPackageManager()).toString(); + streamDumper = new StreamDumper(activity, defaultFolder); + } + + public static DVR getInstance(Activity context, boolean recordAmbientAudio, Handler updateAfterRecord, UsbMaskConnection connection){ + if (instance == null) { + instance = new DVR(context, recordAmbientAudio, updateAfterRecord, connection); + } + return instance; + } + + public void init() throws IOException { + repairNotFinishedDVR(); + recorder = new MediaRecorder(); + recorder.setAudioSource(MediaRecorder.AudioSource.MIC); + recorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS); + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + } + + private void repairNotFinishedDVR(){ + File dvrFolder = new File(defaultFolder); + if (dvrFolder.exists()) { + File[] files = dvrFolder.listFiles(); + for (int i = 0; i < files.length; ++i) { + File file = files[i]; + if (file.getAbsolutePath().endsWith(".h264")) { + File ambientAudio = new File(file.getAbsolutePath().replace(".h264", ".aac")); + if (ambientAudio.exists()) { + File output = new File(file.getAbsolutePath().replace(".h264", ".mp4")); + new Mp4Muxer(activity, dvrFolder , file, ambientAudio,output, false).start(); + } + } + } + } + } + + public void start() { + if (connection.getInputStream() != null) { + Toast.makeText(activity, activity.getText(R.string.recording_started), Toast.LENGTH_LONG).show(); + ((ImageButton) activity.findViewById(R.id.recordbt)).setImageResource(R.drawable.stop); + + ThreadPerTaskExecutor executor = new ThreadPerTaskExecutor(); + executor.execute(() -> { + connection.getInputStream().setInputStreamListener(new DataListener() { + @Override + public void calllback(byte[] buffer, int offset, int length) { + if (streamDumper != null) { + if (isRecording()) { + if (buffer != null) { + streamDumper.dump(buffer, offset, length); + } + } + } + } + }); + + fileName = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss") + .format(Calendar.getInstance().getTime()); + ambientAudio = "/DigiView_" + fileName + ".aac"; + videoFile = "/DigiView_" + fileName + ".h264"; + dvrFile = "/DigiView_" + fileName + ".mp4"; + + Log.d(DVR_LOG_TAG, "creating folder for dvr saving ..."); + File objFolder = new File(defaultFolder); + if (!objFolder.exists()) + objFolder.mkdir(); + + Log.d(DVR_LOG_TAG, "start recording ..."); + streamDumper.init(videoFile, ambientAudio, dvrFile); + if (recordAmbientAudio) { + Log.d(DVR_LOG_TAG, "starting ambient recording ..."); + recorder.setOutputFile(defaultFolder + ambientAudio); + try { + recorder.prepare(); + recorder.start(); // Ambient Audio Recording is now started + } catch (IOException e) { + e.printStackTrace(); + } + } + //start recording (input stream starts collecting data + this.recording = true; + }); + } else { + Toast.makeText(activity, "Stream not ready", Toast.LENGTH_LONG).show(); + } + } + + public String getLatestThumbFile() { + return defaultFolder + "/" + LATEST_THUMB_FILE; + } + + public boolean isRecording(){ + return recording; + } + + public void stop() { + Log.d(DVR_LOG_TAG, "stop recording ..."); + this.recording = false; + Toast.makeText(activity, activity.getText(R.string.recording_stopped), Toast.LENGTH_LONG).show(); + ((ImageButton) activity.findViewById(R.id.recordbt)).setImageResource(R.drawable.record); + + ThreadPerTaskExecutor executor = new ThreadPerTaskExecutor(); + executor.execute(() -> { + connection.getInputStream().setInputStreamListener(null); //remove listener from raw + streamDumper.stop(updateAfterRecord); + if (recordAmbientAudio) { + recorder.stop(); + } + + try { + init(); + } catch (IOException e) { + e.printStackTrace(); + } + }); + } + + public File getDefaultFolder() { + return new File(defaultFolder); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fpvout/digiview/helpers/DataListener.java b/app/src/main/java/com/fpvout/digiview/helpers/DataListener.java new file mode 100644 index 0000000..84f8336 --- /dev/null +++ b/app/src/main/java/com/fpvout/digiview/helpers/DataListener.java @@ -0,0 +1,5 @@ +package com.fpvout.digiview.helpers; + +public interface DataListener{ + public void calllback(byte[] buffer, int offset, int length); +} diff --git a/app/src/main/java/com/fpvout/digiview/helpers/Mp4Muxer.java b/app/src/main/java/com/fpvout/digiview/helpers/Mp4Muxer.java new file mode 100644 index 0000000..8b2f5c9 --- /dev/null +++ b/app/src/main/java/com/fpvout/digiview/helpers/Mp4Muxer.java @@ -0,0 +1,171 @@ +package com.fpvout.digiview.helpers; + + +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 org.mp4parser.Container; +import org.mp4parser.muxer.FileDataSourceImpl; +import org.mp4parser.muxer.Movie; +import org.mp4parser.muxer.builder.DefaultMp4Builder; +import org.mp4parser.muxer.container.mp4.MovieCreator; +import org.mp4parser.muxer.tracks.AACTrackImpl; +import org.mp4parser.muxer.tracks.ClippedTrack; + +import android.content.Context; +import android.graphics.Bitmap; +import android.media.MediaScannerConnection; +import android.media.ThumbnailUtils; +import android.provider.MediaStore; +import android.util.Log; + +import com.fpvout.digiview.MainActivity; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; + +import static com.fpvout.digiview.dvr.DVR.LATEST_THUMB_FILE; + +public class Mp4Muxer extends Thread { + + private static final int TIMESCALE = 60; + private static final long DURATION = 1; + + private final File h264Dump; + private final File ambientAudioFile; + private final File videoFile; + private final File output; + private final Context context; + private final File dumpDir; + private boolean thumbnail = false; + + + SeekableByteChannel file; + MP4Muxer muxer; + BufferH264ES es; + + public Mp4Muxer(Context context, File dumpDir , File h264Dump, File ambientAudio, File output, boolean thumbnail) { + this.context = context; + this.dumpDir = dumpDir; + this.h264Dump = h264Dump; + this.ambientAudioFile = ambientAudio; + this.videoFile = new File(output.getAbsolutePath() + ".tmp"); + this.output = output; + this.thumbnail = thumbnail; + } + + private void init() throws IOException { + file = NIOUtils.writableChannel(videoFile); + 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; + //save first frame as img (thumb) + + 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(); + + mergeAudioVideoFiles(videoFile, ambientAudioFile, output); + + if (this.thumbnail) { + Bitmap thumb = ThumbnailUtils.createVideoThumbnail(output.getAbsolutePath(), MediaStore.Images.Thumbnails.MINI_KIND); + FileOutputStream thumbOutputStream = new FileOutputStream(new File(dumpDir.getAbsolutePath() + "/" + LATEST_THUMB_FILE)); + thumb.compress(Bitmap.CompressFormat.JPEG, 100, thumbOutputStream); + thumbOutputStream.flush(); + thumbOutputStream.close(); + } + + // add mp4 to gallery + MediaScannerConnection.scanFile(context, + new String[]{output.toString()}, + null, null); + + // cleanup + h264Dump.delete(); + videoFile.delete(); + ambientAudioFile.delete(); + } catch (IOException exception){ + Log.e("DIGIVIEW", "MUXER: " + exception.getMessage()); + } + } + + public void mergeAudioVideoFiles(File _videoFile, File _ambientFile, File _output) throws IOException { + Movie movie = MovieCreator.build(_videoFile.getAbsolutePath()); + AACTrackImpl aacTrack = new AACTrackImpl(new FileDataSourceImpl(_ambientFile)); + ClippedTrack aacCroppedTrack = new ClippedTrack(aacTrack, 1, aacTrack.getSamples().size()); + movie.addTrack(aacCroppedTrack); + + Container mp4file = new DefaultMp4Builder().build(movie); + + FileOutputStream fileOutputStream = new FileOutputStream(_output); + FileChannel fc = fileOutputStream.getChannel(); + mp4file.writeContainer(fc); + fileOutputStream.close(); + } +} diff --git a/app/src/main/java/com/fpvout/digiview/helpers/StreamDumper.java b/app/src/main/java/com/fpvout/digiview/helpers/StreamDumper.java new file mode 100644 index 0000000..f015898 --- /dev/null +++ b/app/src/main/java/com/fpvout/digiview/helpers/StreamDumper.java @@ -0,0 +1,71 @@ +package com.fpvout.digiview.helpers; + +import android.content.Context; +import android.os.Environment; +import android.os.Handler; + +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 File streamAmbient; + private File outFile; + private final Context context; + + public StreamDumper(Context context, String defaultPath){ + this.context = context; + dumpDir = new File(defaultPath); + + dumpDir.mkdirs(); + } + + public void dump(byte[] buffer, int offset, int receivedBytes) { + + try { + fos.write(buffer, offset, receivedBytes); + bytesWritten = true; + } catch (IOException exception) { + exception.printStackTrace(); + } + } + + public void init(String videoFileName, String ambientAudioFileName, String outFileFileName) { + try { + streamDump = new File(dumpDir, videoFileName); + streamAmbient = new File(dumpDir, ambientAudioFileName); + outFile = new File(dumpDir, outFileFileName); + fos = new FileOutputStream(streamDump); + bytesWritten = false; + } catch (IOException exception) { + exception.printStackTrace(); + } + } + + public void stop(Handler completeHandler) { + try { + if(fos != null){ + fos.flush(); + fos.close(); + + if(bytesWritten) { + new Mp4Muxer(this.context, dumpDir, streamDump, streamAmbient,outFile, true).start(); + } + } + if(!bytesWritten){ + streamDump.delete(); + } + completeHandler.sendEmptyMessage(0); + } catch (IOException exception) { + exception.printStackTrace(); + } + } +} diff --git a/app/src/main/java/com/fpvout/digiview/helpers/ThreadPerTaskExecutor.java b/app/src/main/java/com/fpvout/digiview/helpers/ThreadPerTaskExecutor.java new file mode 100644 index 0000000..9e86b2c --- /dev/null +++ b/app/src/main/java/com/fpvout/digiview/helpers/ThreadPerTaskExecutor.java @@ -0,0 +1,9 @@ +package com.fpvout.digiview.helpers; + +import java.util.concurrent.Executor; + +public class ThreadPerTaskExecutor implements Executor { + public void execute(Runnable r) { + new Thread(r).start(); + } +} diff --git a/app/src/main/java/usb/AndroidUSBInputStream.java b/app/src/main/java/usb/AndroidUSBInputStream.java index 0bd94c7..077720a 100644 --- a/app/src/main/java/usb/AndroidUSBInputStream.java +++ b/app/src/main/java/usb/AndroidUSBInputStream.java @@ -18,9 +18,11 @@ import android.hardware.usb.UsbDeviceConnection; import android.hardware.usb.UsbEndpoint; import android.util.Log; +import com.fpvout.digiview.helpers.DataListener; import java.io.IOException; import java.io.InputStream; +import java.util.Arrays; /** * This class acts as a wrapper to read data from the USB Interface in Android @@ -40,15 +42,15 @@ public class AndroidUSBInputStream extends InputStream { private final UsbEndpoint sendEndPoint; private final boolean working = false; - + private DataListener inputListener = null; /** * Class constructor. Instantiates a new {@code AndroidUSBInputStream} * object with the given parameters. * * @param readEndpoint The USB end point to use to read data from. - * @param connection The USB connection to use to read data from. - * + * @param sendEndpoint The USB end point to use to sent data to. + * @param connection The USB connection to use to read data from. * @see UsbDeviceConnection * @see UsbEndpoint */ @@ -61,7 +63,7 @@ public AndroidUSBInputStream( UsbEndpoint readEndpoint, UsbEndpoint sendEndpoint @Override public int read() throws IOException { byte[] buffer = new byte[131072]; - return read(buffer, 0, buffer.length); + return read(buffer, 0, buffer.length); } @Override @@ -72,12 +74,23 @@ public int read(byte[] buffer, int offset, int length) throws IOException { Log.d(TAG, "received buffer empty, sending magic packet again..."); usbConnection.bulkTransfer(sendEndPoint, "RMVT".getBytes(), "RMVT".getBytes().length, 2000); receivedBytes = usbConnection.bulkTransfer(receiveEndPoint, buffer, buffer.length, READ_TIMEOUT); + } else { + if (inputListener != null) { + byte[] bufferCopy = Arrays.copyOf(buffer, buffer.length); + this.inputListener.calllback(bufferCopy, offset, bufferCopy.length); + } } return receivedBytes; } + public void setInputStreamListener(DataListener inputListener) { + this.inputListener = inputListener; + } + @Override - public void close() throws IOException {} + public void close() throws IOException { + super.close(); + } } diff --git a/app/src/main/java/usb/AndroidUSBOutputStream.java b/app/src/main/java/usb/AndroidUSBOutputStream.java index 3225bda..da7f042 100644 --- a/app/src/main/java/usb/AndroidUSBOutputStream.java +++ b/app/src/main/java/usb/AndroidUSBOutputStream.java @@ -1,18 +1,3 @@ -/* - * Copyright 2019, Digi International Inc. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at http://mozilla.org/MPL/2.0/. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ package usb; import android.hardware.usb.UsbDeviceConnection; @@ -20,7 +5,6 @@ import java.io.IOException; import java.io.OutputStream; -import java.util.concurrent.LinkedBlockingQueue; /** * This class acts as a wrapper to write data to the USB Interface in Android @@ -31,15 +15,9 @@ public class AndroidUSBOutputStream extends OutputStream { // Constants. private static final int WRITE_TIMEOUT = 2000; - // Variables. private final UsbDeviceConnection usbConnection; - private final UsbEndpoint sendEndPoint; - private LinkedBlockingQueue writeQueue; - - private final boolean streamOpen = true; - /** * Class constructor. Instantiates a new {@code AndroidUSBOutputStream} * object with the given parameters. @@ -80,10 +58,8 @@ public void write(byte[] buffer) { @Override public void write(byte[] buffer, int offset, int count) { usbConnection.bulkTransfer(sendEndPoint, buffer, count, WRITE_TIMEOUT); - } - @Override public void close() throws IOException { super.close(); diff --git a/app/src/main/java/usb/CircularByteBuffer.java b/app/src/main/java/usb/CircularByteBuffer.java index 8540a57..90f9109 100644 --- a/app/src/main/java/usb/CircularByteBuffer.java +++ b/app/src/main/java/usb/CircularByteBuffer.java @@ -1,18 +1,3 @@ -/* - * Copyright 2019, Digi International Inc. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at http://mozilla.org/MPL/2.0/. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ package usb; /** @@ -58,7 +43,7 @@ public CircularByteBuffer(int size) { * @throws NullPointerException if {@code data == null}. * * @see #read(byte[], int, int) - * @see #skip(int) + // * @see #skip(int) */ public synchronized int write(byte[] data, int offset, int numBytes) { if (data == null) @@ -105,8 +90,8 @@ public synchronized int write(byte[] data, int offset, int numBytes) { * @throws IllegalArgumentException if {@code offset < 0} or * if {@code numBytes < 1}. * @throws NullPointerException if {@code data == null}. - * - * @see #skip(int) + * + // * @see #skip(int) * @see #write(byte[], int, int) */ public synchronized int read(byte[] data, int offset, int numBytes) { @@ -135,54 +120,53 @@ public synchronized int read(byte[] data, int offset, int numBytes) { } else { System.arraycopy(buffer, getReadIndex(), data, offset, buffer.length - getReadIndex()); System.arraycopy(buffer, 0, data, offset + buffer.length - getReadIndex(), numBytes - (buffer.length - getReadIndex())); - readIndex = numBytes-(buffer.length - getReadIndex()); + readIndex = numBytes - (buffer.length - getReadIndex()); } - + // If we have read all bytes, set the buffer as empty. if (readIndex == writeIndex) empty = true; - - return numBytes; - } - /** - * Skips the given number of bytes from the circular byte buffer. - * - * @param numBytes Number of bytes to skip. - * @return The number of bytes actually skipped. - * - * @throws IllegalArgumentException if {@code numBytes < 1}. - * - * @see #read(byte[], int, int) - * @see #write(byte[], int, int) - */ - public synchronized int skip(int numBytes) { - if (numBytes < 1) - throw new IllegalArgumentException("Number of bytes to skip must be greater than 0."); - - // If we are empty, return 0. - if (empty) - return 0; - - if (availableToRead() < numBytes) - return skip(availableToRead()); - if (numBytes < buffer.length - getReadIndex()) - readIndex = getReadIndex() + numBytes; - else - readIndex = numBytes - (buffer.length - getReadIndex()); - - // If we have skipped all bytes, set the buffer as empty. - if (readIndex == writeIndex) - empty = true; - return numBytes; } +// /** +// * Skips the given number of bytes from the circular byte buffer. +// * +// * @param numBytes Number of bytes to skip. +// * @return The number of bytes actually skipped. +// * +// * @throws IllegalArgumentException if {@code numBytes < 1}. +// * +// * @see #read(byte[], int, int) +// * @see #write(byte[], int, int) +// */ +// public synchronized int skip(int numBytes) { +// if (numBytes < 1) +// throw new IllegalArgumentException("Number of bytes to skip must be greater than 0."); +// +// // If we are empty, return 0. +// if (empty) +// return 0; +// +// if (availableToRead() < numBytes) +// return skip(availableToRead()); +// if (numBytes < buffer.length - getReadIndex()) +// readIndex = getReadIndex() + numBytes; +// else +// readIndex = numBytes - (buffer.length - getReadIndex()); +// +// // If we have skipped all bytes, set the buffer as empty. +// if (readIndex == writeIndex) +// empty = true; +// +// return numBytes; +// } + /** * Returns the available number of bytes to read from the byte buffer. - * + * * @return The number of bytes in the buffer available for reading. - * * @see #getCapacity() * @see #read(byte[], int, int) */ @@ -212,22 +196,22 @@ private int getReadIndex() { private int getWriteIndex() { return writeIndex; } - + /** * Returns the circular byte buffer capacity. - * + * * @return The circular byte buffer capacity. */ public int getCapacity() { return buffer.length; } - - /** - * Clears the circular buffer. - */ - public void clearBuffer() { - empty = true; - readIndex = 0; - writeIndex = 0; - } + +// /** +// * Clears the circular buffer. +// */ +// public void clearBuffer() { +// empty = true; +// readIndex = 0; +// writeIndex = 0; +// } } \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/rounded_corners.xml b/app/src/main/res/drawable-hdpi/rounded_corners.xml new file mode 100644 index 0000000..9f9308b --- /dev/null +++ b/app/src/main/res/drawable-hdpi/rounded_corners.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/record.png b/app/src/main/res/drawable-v24/record.png new file mode 100644 index 0000000..3868ddc Binary files /dev/null and b/app/src/main/res/drawable-v24/record.png differ diff --git a/app/src/main/res/drawable-v24/stop.png b/app/src/main/res/drawable-v24/stop.png new file mode 100644 index 0000000..4e9a096 Binary files /dev/null and b/app/src/main/res/drawable-v24/stop.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 3d2145d..363ee98 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -40,17 +40,57 @@ app:layout_constraintBottom_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent" > + - + app:layout_constraintTop_toTopOf="parent"> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dba2270..cd4e64f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,5 +40,12 @@ Copyright Open-Source License MIT License - + Connect + recording started + recording stopped + No stream! recording could not be started + dvr has been saved + merging audio and video of the dvr + Storage access is required. + repairing dvrs ... \ No newline at end of file