Improve camera capture with CameraX.

master
Greyson Parrelli 2019-06-26 18:10:57 -04:00
parent 4593014d00
commit 73b8f11b5a
12 changed files with 2316 additions and 41 deletions

View File

@ -3,7 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"
package="org.thoughtcrime.securesms">
<uses-sdk tools:overrideLibrary="com.amulyakhare.textdrawable,com.astuetz.pagerslidingtabstrip,pl.tajchert.waitingdots,com.h6ah4i.android.multiselectlistpreferencecompat,android.support.v13,com.davemorrissey.labs.subscaleview,com.tomergoldst.tooltips,com.klinker.android.send_message,com.takisoft.colorpicker,android.support.v14.preference"/>
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2"/>
<permission android:name="org.thoughtcrime.securesms.ACCESS_SECRETS"
android:label="Access to TextSecure Secrets"

View File

@ -54,7 +54,7 @@ repositories {
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.appcompat:appcompat:1.1.0-beta01'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
@ -67,6 +67,8 @@ dependencies {
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.0.0'
implementation "androidx.camera:camera-core:1.0.0-alpha02"
implementation "androidx.camera:camera-camera2:1.0.0-alpha02"
implementation('com.google.firebase:firebase-messaging:17.3.4') {
exclude group: 'com.google.firebase', module: 'firebase-core'
@ -173,12 +175,14 @@ dependencyVerification {
'com.tomergoldst.android:tooltips:4c56697dd1ad64b8066535c61f961a6d901e7ae5d97ae27084ba40ad620349b6',
'com.takisoft.fix:colorpicker:f5d0dbabe406a1800498ca9c1faf34db36e021d8488bf10360f29961fe3ab0d1',
'com.codewaves.stickyheadergrid:stickyheadergrid:5b4aa6a52a957cfd55f60f4220c11c0c371385a3cb9786cae03c260dcdef5794',
'androidx.appcompat:appcompat:a3080cdd5e5c56cb72f9d428b1657d4380011ec211cfedf76e084b95f6bf0d03',
'androidx.appcompat:appcompat:49ad229add44f822fcb3c8405c3fddbd72660da6a839ce29e13158f5980514fd',
'com.melnykov:floatingactionbutton:15d58d4fac0f7a288d0e5301bbaf501a146f5b3f5921277811bf99bd3b397263',
'androidx.recyclerview:recyclerview:06956fb1ac014027ca9d2b40469a4b42aa61b4957bb11848e1ff352701ab4548',
'androidx.legacy:legacy-support-v13:65f5fcb57644d381d471a00fdf50f90b808be6b48a8ae57fb4ea39b7da8cca86',
'androidx.cardview:cardview:1193c04c22a3d6b5946dae9f4e8c59d6adde6a71b6bd5d87fb99d82dda1afec7',
'androidx.gridlayout:gridlayout:a7e5dc6f39dbc3dc6ac6d57b02a9c6fd792e80f0e45ddb3bb08e8f03d23c8755',
'androidx.camera:camera-camera2:9dc33e45da983ebd29a888401ac700323ff573821eee3fa4d993dfa3d316ee2e',
'androidx.camera:camera-core:bf32bfcb5d103d865c6af1221a1d82e994c917b53c0bc080f1e9750bdc21cbb9',
'androidx.exifinterface:exifinterface:ee48be10aab8f54efff4c14b77d11e10b9eeee4379d5ef6bf297a2923c55cc11',
'androidx.constraintlayout:constraintlayout:5ff864def9d41cd04e08348d69591143bae3ceff4284cf8608bceb98c36ac830',
'androidx.multidex:multidex:42dd32ff9f97f85771b82a20003a8d70f68ab7b4ba328964312ce0732693db09',
@ -223,41 +227,47 @@ dependencyVerification {
'com.google.android.gms:play-services-stats:5b2d8281adbfd6e74d2295c94bab9ea80fc9a84dfbb397995673f5af4d4c6368',
'com.google.android.gms:play-services-basement:e08bfd1e87c4e50ef76161d7ac76b873aeb975367eeb3afa4abe62ea1887c7c6',
'androidx.legacy:legacy-support-v4:78fec1485f0f388a4749022dd51416857127cd2544ae1c3fd0b16589055480b0',
'androidx.fragment:fragment:65dd32d71fe65a32e77989a6cfb1ad09307038927f82a740c7611162d0b518f8',
'androidx.vectordrawable:vectordrawable-animated:26c3a0cf0a9a9a7d235a0b00f2f37e431d52d9952751e3eb7c90b4b52c236cf1',
'androidx.fragment:fragment:9656d81c472b5142bbc3471ef7259fbc93905dc38e823c63a99e48819881b6e7',
'androidx.appcompat:appcompat-resources:53c0a33d07c4bab48d4c8169bf30053aa14965af4a775b56092a9fc7079802b1',
'androidx.legacy:legacy-support-core-ui:0d1260c6e7e6a337f875df71b516931e703f716e90889817cd3a20fa5ac3d947',
'androidx.drawerlayout:drawerlayout:9402442cdc5a43cf62fb14f8cf98c63342d4d9d9b805c8033c6cf7e802749ac1',
'androidx.legacy:legacy-support-core-utils:a7edcf01d5b52b3034073027bc4775b78a4764bb6202bb91d61c829add8dd1c7',
'androidx.vectordrawable:vectordrawable:4ca358957b9510e52fc388e01c9d33c2d655d406bfe6e71984e9afea9f715ed2',
'androidx.transition:transition:a00a0f763f401abcecda9b0eafcb738929c5801b111a9a414b81a193d0f4008d',
'androidx.media:media:b23b527b2bac870c4a7451e6982d7132e413e88d7f27dbeb1fc7640a720cd9ee',
'androidx.loader:loader:11f735cb3b55c458d470bed9e25254375b518b4b1bad6926783a7026db0f5025',
'androidx.viewpager:viewpager:147af4e14a1984010d8f155e5e19d781f03c1d70dfed02a8e0d18428b8fc8682',
'androidx.loader:loader:11f735cb3b55c458d470bed9e25254375b518b4b1bad6926783a7026db0f5025',
'androidx.activity:activity:0d6bafb56a72da893f3990ca5d819214d047f5f6b5c5f822ed97971c05eeb85a',
'androidx.vectordrawable:vectordrawable-animated:f1613c47f1e6d2cd02ec9a42925f1a964fa63d1d028d34d884364cc3b9ffcb8f',
'androidx.vectordrawable:vectordrawable:b632152304edb506bf7eacb329ef41e43b80164bf5be4c7bb132a249a65cbc26',
'androidx.coordinatorlayout:coordinatorlayout:e508c695489493374d942bf7b4ee02abf7571d25aac4c622e57d6cd5cd29eb73',
'androidx.drawerlayout:drawerlayout:9402442cdc5a43cf62fb14f8cf98c63342d4d9d9b805c8033c6cf7e802749ac1',
'androidx.slidingpanelayout:slidingpanelayout:76bffb7cefbf780794d8817002dad1562f3e27c0a9f746d62401c8edb30aeede',
'androidx.customview:customview:20e5b8f6526a34595a604f56718da81167c0b40a7a94a57daa355663f2594df2',
'androidx.swiperefreshlayout:swiperefreshlayout:9761b3a809c9b093fd06a3c4bbc645756dec0e95b5c9da419bc9f2a3f3026e8d',
'androidx.asynclayoutinflater:asynclayoutinflater:f7eab60c57addd94bb06275832fe7600611beaaae1a1ec597c231956faf96c8b',
'androidx.core:core:b1a90522c22cad8c5fb7a4f912493dbcde463c6a37b4148dfb9423763460f998',
'androidx.versionedparcelable:versionedparcelable:f6438a93ed8016ccddca0e140a70be0b0110e0424edaa1472f84f98fed2e1ce3',
'androidx.collection:collection:9c8d117b5c2bc120a1cdfeb857e05b495b16c36013570372a708f7827e3ac9f9',
'androidx.core:core:45c7a50ad1f366e62db496d8cef7730d5ee1681215007d1a19e6b6d800a12842',
'androidx.cursoradapter:cursoradapter:a81c8fe78815fa47df5b749deb52727ad11f9397da58b16017f4eb2c11e28564',
'androidx.lifecycle:lifecycle-process:d8ff6fd844559743050c9ae010a6df230f2a3dbdf3e14498316f30bd8df836b5',
'androidx.lifecycle:lifecycle-service:cb2b15bb0cf14134e953ed8ead96f94265018643f519367d51fd837f7311e9f8',
'androidx.lifecycle:lifecycle-runtime:e4afc9e636183f6f3e0edf1cf46121a492ffd2c673075bb07f55c7a99dd43cfb',
'androidx.lifecycle:lifecycle-livedata:c82609ced8c498f0a701a30fb6771bb7480860daee84d82e0a81ee86edf7ba39',
'androidx.lifecycle:lifecycle-livedata-core:fde334ec7e22744c0f5bfe7caf1a84c9d717327044400577bdf9bd921ec4f7bc',
'androidx.arch.core:core-runtime:87e65fc767c712b437649c7cee2431ebb4bed6daef82e501d4125b3ed3f65f8e',
'androidx.arch.core:core-common:4b80b337779b526e64b0ee0ca9e0df43b808344d145f8e9b1c42a134dac57ad8',
'androidx.lifecycle:lifecycle-common:7bad7a188804adea6fa1f35d5ef99b705f20bd93ecadde484760ff86b535fefc',
'androidx.lifecycle:lifecycle-viewmodel:d6460aea1b6bad80ab14cf88297e9e43bfde8d87c3e5c28f2c508233ffbcc062',
'androidx.concurrent:concurrent-listenablefuture-callback:14dce0acbffd705cfe9fb378960f851a9d8fc3f293d1157c310c9624a561d0a8',
'androidx.concurrent:concurrent-listenablefuture:f9ef396ca4a43b9685d28bec117b278aa9171de0e446e5138e931074e3462feb',
'com.github.bumptech.glide:gifdecoder:7ee9402ae1c48fac9232b67e81f881c217b907b3252e49ce57bdb97937ebb270',
'androidx.versionedparcelable:versionedparcelable:948c751f6352d4c0f93f15fa1bf506c59083bc7754264dd9a325a6da0e2eec05',
'androidx.collection:collection:632a0e5407461de774409352940e292a291037724207a787820c77daf7d33b72',
'androidx.interpolator:interpolator:33193135a64fe21fa2c35eec6688f1a76e512606c0fc83dc1b689e37add7732a',
'androidx.documentfile:documentfile:865a061ef2fad16522f8433536b8d47208c46ff7c7745197dfa1eeb481869487',
'androidx.localbroadcastmanager:localbroadcastmanager:e71c328ceef5c4a7d76f2d86df1b65d65fe2acf868b1a4efd84a3f34336186d8',
'androidx.print:print:1d5c7f3135a1bba661fc373fd72e11eb0a4adbb3396787826dd8e4190d5d9edd',
'androidx.interpolator:interpolator:33193135a64fe21fa2c35eec6688f1a76e512606c0fc83dc1b689e37add7732a',
'androidx.annotation:annotation:0baae9755f7caf52aa80cd04324b91ba93af55d4d1d17dcc9a7b53d99ef7c016',
'androidx.lifecycle:lifecycle-process:d8ff6fd844559743050c9ae010a6df230f2a3dbdf3e14498316f30bd8df836b5',
'androidx.lifecycle:lifecycle-service:cb2b15bb0cf14134e953ed8ead96f94265018643f519367d51fd837f7311e9f8',
'androidx.lifecycle:lifecycle-runtime:7e6d414d03bb184f3015dacc6233eeaded45fa23f0cf4c1f6d3395d6495fa41c',
'androidx.lifecycle:lifecycle-viewmodel:9f2efb59328027fa9f0c413d4d5910aab68d149b139ca8ce432135105b74833a',
'androidx.savedstate:savedstate:115ac7313095b2d159565d2bc851a7722e43fc00347fc828214ff8917799b5f0',
'androidx.lifecycle:lifecycle-common:76db6be533bd730fb361c2feb12a2c26d9952824746847da82601ef81f082643',
'androidx.arch.core:core-common:fe1237bf029d063e7f29fe39aeaf73ef74c8b0a3658486fc29d3c54326653889',
'androidx.annotation:annotation:d38d63edb30f1467818d50aaf05f8a692dea8b31392a049bfa991b159ad5b692',
'androidx.constraintlayout:constraintlayout-solver:965c177e64fbd81bd1d27b402b66ef9d7bc7b5cb5f718044bf7a453abc542045',
'com.google.auto.value:auto-value-annotations:0e951fee8c31f60270bc46553a8586001b7b93dbb12aec06373aa99a150392c0',
'org.signal:signal-metadata-android:02323bc29317fa9d3b62fab0b507c94ba2e9bcc4a78d588888ffd313853757b3',
'org.whispersystems:signal-service-java:6a1218cd6cebe6afbb613a00110a5c72708b3af5a7896d495ac4ed50ba58f07e',
'com.github.bumptech.glide:disklrucache:4696a81340eb6beee21ab93f703ed6e7ae49fb4ce3bc2fbc546e5bacd21b96b9',

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.thoughtcrime.securesms.mediasend.camerax.CameraXView
android:id="@+id/camerax_camera"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/camerax_controls_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@ -43,7 +43,11 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.io.ByteArrayOutputStream;
public class Camera1Fragment extends Fragment implements TextureView.SurfaceTextureListener,
/**
* Camera capture implemented with the legacy camera API's. Should only be used if sdk < 21.
*/
public class Camera1Fragment extends Fragment implements CameraFragment,
TextureView.SurfaceTextureListener,
Camera1Controller.EventListener
{
@ -317,12 +321,6 @@ public class Camera1Fragment extends Fragment implements TextureView.SurfaceText
}
};
public interface Controller {
void onCameraError();
void onImageCaptured(@NonNull byte[] data, int width, int height);
int getDisplayRotation();
}
private enum Stage {
SURFACE_AVAILABLE, CAMERA_PROPERTIES_AVAILABLE
}

View File

@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.mediasend;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
public interface CameraFragment {
static Fragment newInstance() {
if (Build.VERSION.SDK_INT >= 21) {
return CameraXFragment.newInstance();
} else {
return Camera1Fragment.newInstance();
}
}
interface Controller {
void onCameraError();
void onImageCaptured(@NonNull byte[] data, int width, int height);
int getDisplayRotation();
}
}

View File

@ -0,0 +1,199 @@
package org.thoughtcrime.securesms.mediasend;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.RotateAnimation;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.camera.core.CameraX;
import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageProxy;
import androidx.fragment.app.Fragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXView;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import java.io.IOException;
/**
* Camera captured implemented using the CameraX SDK, which uses Camera2 under the hood. Should be
* preferred whenever possible.
*/
@RequiresApi(21)
public class CameraXFragment extends Fragment implements CameraFragment {
private static final String TAG = Log.tag(CameraXFragment.class);
private CameraXView camera;
private ViewGroup controlsContainer;
private Controller controller;
public static CameraXFragment newInstance() {
return new CameraXFragment();
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
if (!(getActivity() instanceof Controller)) {
throw new IllegalStateException("Parent activity must implement controller interface.");
}
this.controller = (Controller) getActivity();
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.camerax_fragment, container, false);
}
@SuppressLint("MissingPermission")
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
this.camera = view.findViewById(R.id.camerax_camera);
this.controlsContainer = view.findViewById(R.id.camerax_controls_container);
camera.bindToLifecycle(this);
camera.setCameraLensFacing(CameraXUtil.toLensFacing(TextSecurePreferences.getDirectCaptureCameraId(requireContext())));
onOrientationChanged(getResources().getConfiguration().orientation);
}
@Override
public void onResume() {
super.onResume();
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
}
@Override
public void onDestroyView() {
super.onDestroyView();
CameraX.unbindAll();
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onOrientationChanged(newConfig.orientation);
}
private void onOrientationChanged(int orientation) {
int layout = orientation == Configuration.ORIENTATION_PORTRAIT ? R.layout.camera_controls_portrait
: R.layout.camera_controls_landscape;
controlsContainer.removeAllViews();
controlsContainer.addView(LayoutInflater.from(getContext()).inflate(layout, controlsContainer, false));
initControls();
}
@SuppressLint({"ClickableViewAccessibility", "MissingPermission"})
private void initControls() {
View flipButton = requireView().findViewById(R.id.camera_flip_button);
View captureButton = requireView().findViewById(R.id.camera_capture_button);
captureButton.setOnTouchListener((v, event) -> {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Animation shrinkAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_shrink);
shrinkAnimation.setFillAfter(true);
shrinkAnimation.setFillEnabled(true);
captureButton.startAnimation(shrinkAnimation);
onCaptureClicked();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
Animation growAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_grow);
growAnimation.setFillAfter(true);
growAnimation.setFillEnabled(true);
captureButton.startAnimation(growAnimation);
captureButton.setEnabled(false);
break;
}
return true;
});
if (camera.hasCameraWithLensFacing(CameraX.LensFacing.FRONT) && camera.hasCameraWithLensFacing(CameraX.LensFacing.BACK)) {
flipButton.setVisibility(View.VISIBLE);
flipButton.setOnClickListener(v -> {
camera.toggleCamera();
TextSecurePreferences.setDirectCaptureCameraId(getContext(), CameraXUtil.toCameraDirectionInt(camera.getCameraLensFacing()));
Animation animation = new RotateAnimation(0, -180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f);
animation.setDuration(200);
animation.setInterpolator(new DecelerateInterpolator());
flipButton.startAnimation(animation);
});
} else {
flipButton.setVisibility(View.GONE);
}
}
private void onCaptureClicked() {
Stopwatch stopwatch = new Stopwatch("Capture");
camera.takePicture(new ImageCapture.OnImageCapturedListener() {
@Override
public void onCaptureSuccess(ImageProxy image, int rotationDegrees) {
SimpleTask.run(CameraXFragment.this.getLifecycle(), () -> {
stopwatch.split("captured");
try {
byte[] bytes = CameraXUtil.toJpegBytes(image, rotationDegrees, camera.getCameraLensFacing() == CameraX.LensFacing.FRONT);
return new CaptureResult(bytes, image.getWidth(), image.getHeight());
} catch (IOException e) {
return null;
} finally {
image.close();
}
}, result -> {
stopwatch.split("transformed");
stopwatch.stop(TAG);
if (result != null) {
controller.onImageCaptured(result.data, result.width, result.height);
} else {
controller.onCameraError();
}
});
}
@Override
public void onError(ImageCapture.UseCaseError useCaseError, String message, @Nullable Throwable cause) {
controller.onCameraError();
}
});
}
private static final class CaptureResult {
public final byte[] data;
public final int width;
public final int height;
private CaptureResult(byte[] data, int width, int height) {
this.data = data;
this.width = width;
this.height = height;
}
}
}

View File

@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.mediasend;
import android.Manifest;
import androidx.fragment.app.FragmentTransaction;
import androidx.lifecycle.ViewModelProviders;
import android.content.Context;
import android.content.Intent;
@ -50,7 +52,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
MediaPickerItemFragment.Controller,
MediaSendFragment.Controller,
ImageEditorFragment.Controller,
Camera1Fragment.Controller
CameraFragment.Controller
{
private static final String TAG = MediaSendActivity.class.getSimpleName();
@ -144,7 +146,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
boolean isCamera = getIntent().getBooleanExtra(KEY_IS_CAMERA, false);
if (isCamera) {
Fragment fragment = Camera1Fragment.newInstance();
Fragment fragment = CameraFragment.newInstance();
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA)
.commit();
@ -216,7 +218,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
@Override
public void onMediaSelected(@NonNull Media media) {
viewModel.onSingleMediaSelected(this, media);
navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale());
navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale(), false);
}
@Override
@ -305,7 +307,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
Log.i(TAG, "Camera capture stored: " + media.getUri().toString());
viewModel.onImageCaptured(media);
navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale());
navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale(), true);
});
}
@ -323,7 +325,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
animateButtonVisibility(countButton, countButton.getVisibility(), buttonState.isVisible() ? View.VISIBLE : View.GONE);
if (buttonState.getCount() > 0) {
countButton.setOnClickListener(v -> navigateToMediaSend(recipient, transport, locale));
countButton.setOnClickListener(v -> navigateToMediaSend(recipient, transport, locale, false));
if (buttonState.isVisible()) {
animateButtonTextChange(countButton);
}
@ -356,7 +358,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
});
}
private void navigateToMediaSend(@NonNull Recipient recipient, @NonNull TransportOption transport, @NonNull Locale locale) {
private void navigateToMediaSend(@NonNull Recipient recipient, @NonNull TransportOption transport, @NonNull Locale locale, boolean fade) {
MediaSendFragment fragment = MediaSendFragment.newInstance(recipient, transport, locale);
String backstackTag = null;
@ -365,11 +367,17 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
backstackTag = TAG_SEND;
}
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right)
.replace(R.id.mediasend_fragment_container, fragment, TAG_SEND)
.addToBackStack(backstackTag)
.commit();
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
if (fade) {
transaction.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_out, R.anim.fade_in);
} else {
transaction.setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right);
}
transaction.replace(R.id.mediasend_fragment_container, fragment, TAG_SEND)
.addToBackStack(backstackTag)
.commit();
}
private void navigateToCamera() {
@ -379,7 +387,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
.withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_photo_camera_white_48dp)
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
.onAllGranted(() -> {
Camera1Fragment fragment = getOrCreateCameraFragment();
Fragment fragment = getOrCreateCameraFragment();
getSupportFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right)
.replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA)
@ -390,11 +398,11 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
.execute();
}
private Camera1Fragment getOrCreateCameraFragment() {
Camera1Fragment fragment = (Camera1Fragment) getSupportFragmentManager().findFragmentByTag(TAG_CAMERA);
private Fragment getOrCreateCameraFragment() {
Fragment fragment = getSupportFragmentManager().findFragmentByTag(TAG_CAMERA);
return fragment != null ? fragment
: Camera1Fragment.newInstance();
: CameraFragment.newInstance();
}
private void animateButtonVisibility(@NonNull View button, int oldVisibility, int newVisibility) {

View File

@ -0,0 +1,797 @@
package org.thoughtcrime.securesms.mediasend.camerax;
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.graphics.SurfaceTexture;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraManager;
import android.os.Looper;
import android.util.Log;
import android.util.Rational;
import android.util.Size;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RequiresPermission;
import androidx.annotation.UiThread;
import androidx.camera.core.CameraInfo;
import androidx.camera.core.CameraInfoUnavailableException;
import androidx.camera.core.CameraOrientationUtil;
import androidx.camera.core.CameraX;
import androidx.camera.core.CameraX.LensFacing;
import androidx.camera.core.FlashMode;
import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageCapture.OnImageCapturedListener;
import androidx.camera.core.ImageCapture.OnImageSavedListener;
import androidx.camera.core.ImageCaptureConfig;
import androidx.camera.core.Preview;
import androidx.camera.core.PreviewConfig;
import androidx.camera.core.VideoCapture;
import androidx.camera.core.VideoCapture.OnVideoSavedListener;
import androidx.camera.core.VideoCaptureConfig;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.OnLifecycleEvent;
import org.thoughtcrime.securesms.mediasend.camerax.CameraXView.CaptureMode;
import java.io.File;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/** CameraX use case operation built on @{link androidx.camera.core}. */
@RequiresApi(21)
@SuppressLint("RestrictedApi")
final class CameraXModule {
public static final String TAG = "CameraXModule";
private static final int MAX_VIEW_DIMENSION = 2000;
private static final float UNITY_ZOOM_SCALE = 1f;
private static final float ZOOM_NOT_SUPPORTED = UNITY_ZOOM_SCALE;
private static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9);
private static final Rational ASPECT_RATIO_4_3 = new Rational(4, 3);
private static final Rational ASPECT_RATIO_9_16 = new Rational(9, 16);
private static final Rational ASPECT_RATIO_3_4 = new Rational(3, 4);
private final CameraManager mCameraManager;
private final PreviewConfig.Builder mPreviewConfigBuilder;
private final VideoCaptureConfig.Builder mVideoCaptureConfigBuilder;
private final ImageCaptureConfig.Builder mImageCaptureConfigBuilder;
private final CameraXView mCameraXView;
final AtomicBoolean mVideoIsRecording = new AtomicBoolean(false);
private CaptureMode mCaptureMode = CaptureMode.IMAGE;
private long mMaxVideoDuration = CameraXView.INDEFINITE_VIDEO_DURATION;
private long mMaxVideoSize = CameraXView.INDEFINITE_VIDEO_SIZE;
private FlashMode mFlash = FlashMode.OFF;
@Nullable
private ImageCapture mImageCapture;
@Nullable
private VideoCapture mVideoCapture;
@Nullable
Preview mPreview;
@Nullable
LifecycleOwner mCurrentLifecycle;
private final LifecycleObserver mCurrentLifecycleObserver =
new LifecycleObserver() {
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
public void onDestroy(LifecycleOwner owner) {
if (owner == mCurrentLifecycle) {
clearCurrentLifecycle();
mPreview.removePreviewOutputListener();
}
}
};
@Nullable
private LifecycleOwner mNewLifecycle;
private float mZoomLevel = UNITY_ZOOM_SCALE;
@Nullable
private Rect mCropRegion;
@Nullable
private CameraX.LensFacing mCameraLensFacing = LensFacing.BACK;
CameraXModule(CameraXView view) {
this.mCameraXView = view;
mCameraManager = (CameraManager) view.getContext().getSystemService(Context.CAMERA_SERVICE);
mPreviewConfigBuilder = new PreviewConfig.Builder().setTargetName("Preview");
mImageCaptureConfigBuilder =
new ImageCaptureConfig.Builder().setTargetName("ImageCapture");
mVideoCaptureConfigBuilder =
new VideoCaptureConfig.Builder().setTargetName("VideoCapture");
}
/**
* Rescales view rectangle with dimensions in [-1000, 1000] to a corresponding rectangle in the
* sensor coordinate frame.
*/
private static Rect rescaleViewRectToSensorRect(Rect view, Rect sensor) {
// Scale width and height.
int newWidth = Math.round(view.width() * sensor.width() / (float) MAX_VIEW_DIMENSION);
int newHeight = Math.round(view.height() * sensor.height() / (float) MAX_VIEW_DIMENSION);
// Scale top/left corner.
int halfViewDimension = MAX_VIEW_DIMENSION / 2;
int leftOffset =
Math.round(
(view.left + halfViewDimension)
* sensor.width()
/ (float) MAX_VIEW_DIMENSION)
+ sensor.left;
int topOffset =
Math.round(
(view.top + halfViewDimension)
* sensor.height()
/ (float) MAX_VIEW_DIMENSION)
+ sensor.top;
// Now, produce the scaled rect.
Rect scaled = new Rect();
scaled.left = leftOffset;
scaled.top = topOffset;
scaled.right = scaled.left + newWidth;
scaled.bottom = scaled.top + newHeight;
return scaled;
}
@RequiresPermission(permission.CAMERA)
public void bindToLifecycle(LifecycleOwner lifecycleOwner) {
mNewLifecycle = lifecycleOwner;
if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) {
bindToLifecycleAfterViewMeasured();
}
}
@RequiresPermission(permission.CAMERA)
void bindToLifecycleAfterViewMeasured() {
if (mNewLifecycle == null) {
return;
}
clearCurrentLifecycle();
mCurrentLifecycle = mNewLifecycle;
mNewLifecycle = null;
if (mCurrentLifecycle.getLifecycle().getCurrentState() == Lifecycle.State.DESTROYED) {
mCurrentLifecycle = null;
throw new IllegalArgumentException("Cannot bind to lifecycle in a destroyed state.");
}
final int cameraOrientation;
try {
String cameraId;
Set<LensFacing> available = getAvailableCameraLensFacing();
if (available.isEmpty()) {
Log.w(TAG, "Unable to bindToLifeCycle since no cameras available");
mCameraLensFacing = null;
}
// Ensure the current camera exists, or default to another camera
if (mCameraLensFacing != null && !available.contains(mCameraLensFacing)) {
Log.w(TAG, "Camera does not exist with direction " + mCameraLensFacing);
// Default to the first available camera direction
mCameraLensFacing = available.iterator().next();
Log.w(TAG, "Defaulting to primary camera with direction " + mCameraLensFacing);
}
// Do not attempt to create use cases for a null cameraLensFacing. This could occur if
// the
// user explicitly sets the LensFacing to null, or if we determined there
// were no available cameras, which should be logged in the logic above.
if (mCameraLensFacing == null) {
return;
}
cameraId = CameraX.getCameraWithLensFacing(mCameraLensFacing);
if (cameraId == null) {
return;
}
CameraInfo cameraInfo = CameraX.getCameraInfo(cameraId);
cameraOrientation = cameraInfo.getSensorRotationDegrees();
} catch (Exception e) {
throw new IllegalStateException("Unable to bind to lifecycle.", e);
}
// Set the preferred aspect ratio as 4:3 if it is IMAGE only mode. Set the preferred aspect
// ratio as 16:9 if it is VIDEO or MIXED mode. Then, it will be WYSIWYG when the view finder
// is
// in CENTER_INSIDE mode.
boolean isDisplayPortrait = getDisplayRotationDegrees() == 0
|| getDisplayRotationDegrees() == 180;
if (getCaptureMode() == CaptureMode.IMAGE) {
mImageCaptureConfigBuilder.setTargetAspectRatio(
isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3);
mPreviewConfigBuilder.setTargetAspectRatio(
isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3);
} else {
mImageCaptureConfigBuilder.setTargetAspectRatio(
isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9);
mPreviewConfigBuilder.setTargetAspectRatio(
isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9);
}
mImageCaptureConfigBuilder.setTargetRotation(getDisplaySurfaceRotation());
mImageCaptureConfigBuilder.setLensFacing(mCameraLensFacing);
mImageCaptureConfigBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode());
mImageCaptureConfigBuilder.setTargetResolution(new Size(1920, 1080));
mImageCapture = new ImageCapture(mImageCaptureConfigBuilder.build());
mVideoCaptureConfigBuilder.setTargetRotation(getDisplaySurfaceRotation());
mVideoCaptureConfigBuilder.setLensFacing(mCameraLensFacing);
mVideoCapture = new VideoCapture(mVideoCaptureConfigBuilder.build());
mPreviewConfigBuilder.setLensFacing(mCameraLensFacing);
int relativeCameraOrientation = getRelativeCameraOrientation(false);
if (relativeCameraOrientation == 90 || relativeCameraOrientation == 270) {
mPreviewConfigBuilder.setTargetResolution(
new Size(getMeasuredHeight(), getMeasuredWidth()));
} else {
mPreviewConfigBuilder.setTargetResolution(
new Size(getMeasuredWidth(), getMeasuredHeight()));
}
mPreview = new Preview(mPreviewConfigBuilder.build());
mPreview.setOnPreviewOutputUpdateListener(
new Preview.OnPreviewOutputUpdateListener() {
@Override
public void onUpdated(Preview.PreviewOutput output) {
boolean needReverse = cameraOrientation != 0 && cameraOrientation != 180;
int textureWidth =
needReverse
? output.getTextureSize().getHeight()
: output.getTextureSize().getWidth();
int textureHeight =
needReverse
? output.getTextureSize().getWidth()
: output.getTextureSize().getHeight();
CameraXModule.this.onPreviewSourceDimensUpdated(textureWidth,
textureHeight);
CameraXModule.this.setSurfaceTexture(output.getSurfaceTexture());
}
});
if (getCaptureMode() == CaptureMode.IMAGE) {
CameraX.bindToLifecycle(mCurrentLifecycle, mImageCapture, mPreview);
} else if (getCaptureMode() == CaptureMode.VIDEO) {
CameraX.bindToLifecycle(mCurrentLifecycle, mVideoCapture, mPreview);
} else {
CameraX.bindToLifecycle(mCurrentLifecycle, mImageCapture, mVideoCapture, mPreview);
}
setZoomLevel(mZoomLevel);
mCurrentLifecycle.getLifecycle().addObserver(mCurrentLifecycleObserver);
// Enable flash setting in ImageCapture after use cases are created and binded.
setFlash(getFlash());
}
public void open() {
throw new UnsupportedOperationException(
"Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead.");
}
public void close() {
throw new UnsupportedOperationException(
"Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead.");
}
public void stopPreview() {
if (mPreview != null) {
mPreview.clear();
}
}
public void takePicture(OnImageCapturedListener listener) {
if (mImageCapture == null) {
return;
}
if (getCaptureMode() == CaptureMode.VIDEO) {
throw new IllegalStateException("Can not take picture under VIDEO capture mode.");
}
if (listener == null) {
throw new IllegalArgumentException("OnImageCapturedListener should not be empty");
}
mImageCapture.takePicture(listener);
}
public void takePicture(File saveLocation, OnImageSavedListener listener) {
if (mImageCapture == null) {
return;
}
if (getCaptureMode() == CaptureMode.VIDEO) {
throw new IllegalStateException("Can not take picture under VIDEO capture mode.");
}
if (listener == null) {
throw new IllegalArgumentException("OnImageSavedListener should not be empty");
}
ImageCapture.Metadata metadata = new ImageCapture.Metadata();
metadata.isReversedHorizontal = mCameraLensFacing == LensFacing.FRONT;
mImageCapture.takePicture(saveLocation, listener, metadata);
}
public void startRecording(File file, final OnVideoSavedListener listener) {
if (mVideoCapture == null) {
return;
}
if (getCaptureMode() == CaptureMode.IMAGE) {
throw new IllegalStateException("Can not record video under IMAGE capture mode.");
}
if (listener == null) {
throw new IllegalArgumentException("OnVideoSavedListener should not be empty");
}
mVideoIsRecording.set(true);
mVideoCapture.startRecording(
file,
new VideoCapture.OnVideoSavedListener() {
@Override
public void onVideoSaved(File savedFile) {
mVideoIsRecording.set(false);
listener.onVideoSaved(savedFile);
}
@Override
public void onError(
VideoCapture.UseCaseError useCaseError,
String message,
@Nullable Throwable cause) {
mVideoIsRecording.set(false);
Log.e(TAG, message, cause);
listener.onError(useCaseError, message, cause);
}
});
}
public void stopRecording() {
if (mVideoCapture == null) {
return;
}
mVideoCapture.stopRecording();
}
public boolean isRecording() {
return mVideoIsRecording.get();
}
// TODO(b/124269166): Rethink how we can handle permissions here.
@SuppressLint("MissingPermission")
public void setCameraLensFacing(@Nullable LensFacing lensFacing) {
// Setting same lens facing is a no-op, so check for that first
if (mCameraLensFacing != lensFacing) {
// If we're not bound to a lifecycle, just update the camera that will be opened when we
// attach to a lifecycle.
mCameraLensFacing = lensFacing;
if (mCurrentLifecycle != null) {
// Re-bind to lifecycle with new camera
bindToLifecycle(mCurrentLifecycle);
}
}
}
@RequiresPermission(permission.CAMERA)
public boolean hasCameraWithLensFacing(LensFacing lensFacing) {
String cameraId;
try {
cameraId = CameraX.getCameraWithLensFacing(lensFacing);
} catch (Exception e) {
throw new IllegalStateException("Unable to query lens facing.", e);
}
return cameraId != null;
}
@Nullable
public LensFacing getLensFacing() {
return mCameraLensFacing;
}
public void toggleCamera() {
// TODO(b/124269166): Rethink how we can handle permissions here.
@SuppressLint("MissingPermission")
Set<LensFacing> availableCameraLensFacing = getAvailableCameraLensFacing();
if (availableCameraLensFacing.isEmpty()) {
return;
}
if (mCameraLensFacing == null) {
setCameraLensFacing(availableCameraLensFacing.iterator().next());
return;
}
if (mCameraLensFacing == LensFacing.BACK
&& availableCameraLensFacing.contains(LensFacing.FRONT)) {
setCameraLensFacing(LensFacing.FRONT);
return;
}
if (mCameraLensFacing == LensFacing.FRONT
&& availableCameraLensFacing.contains(LensFacing.BACK)) {
setCameraLensFacing(LensFacing.BACK);
return;
}
}
public void focus(Rect focus, Rect metering) {
if (mPreview == null) {
// Nothing to focus on since we don't yet have a preview
return;
}
Rect rescaledFocus;
Rect rescaledMetering;
try {
Rect sensorRegion;
if (mCropRegion != null) {
sensorRegion = mCropRegion;
} else {
sensorRegion = getSensorSize(getActiveCamera());
}
rescaledFocus = rescaleViewRectToSensorRect(focus, sensorRegion);
rescaledMetering = rescaleViewRectToSensorRect(metering, sensorRegion);
} catch (Exception e) {
Log.e(TAG, "Failed to rescale the focus and metering rectangles.", e);
return;
}
mPreview.focus(rescaledFocus, rescaledMetering);
}
public float getZoomLevel() {
return mZoomLevel;
}
public void setZoomLevel(float zoomLevel) {
// Set the zoom level in case it is set before binding to a lifecycle
this.mZoomLevel = zoomLevel;
if (mPreview == null) {
// Nothing to zoom on yet since we don't have a preview. Defer calculating crop
// region.
return;
}
Rect sensorSize;
try {
sensorSize = getSensorSize(getActiveCamera());
if (sensorSize == null) {
Log.e(TAG, "Failed to get the sensor size.");
return;
}
} catch (Exception e) {
Log.e(TAG, "Failed to get the sensor size.", e);
return;
}
float minZoom = getMinZoomLevel();
float maxZoom = getMaxZoomLevel();
if (this.mZoomLevel < minZoom) {
Log.e(TAG, "Requested zoom level is less than minimum zoom level.");
}
if (this.mZoomLevel > maxZoom) {
Log.e(TAG, "Requested zoom level is greater than maximum zoom level.");
}
this.mZoomLevel = Math.max(minZoom, Math.min(maxZoom, this.mZoomLevel));
float zoomScaleFactor =
(maxZoom == minZoom) ? minZoom : (this.mZoomLevel - minZoom) / (maxZoom - minZoom);
int minWidth = Math.round(sensorSize.width() / maxZoom);
int minHeight = Math.round(sensorSize.height() / maxZoom);
int diffWidth = sensorSize.width() - minWidth;
int diffHeight = sensorSize.height() - minHeight;
float cropWidth = diffWidth * zoomScaleFactor;
float cropHeight = diffHeight * zoomScaleFactor;
Rect cropRegion =
new Rect(
/*left=*/ (int) Math.ceil(cropWidth / 2 - 0.5f),
/*top=*/ (int) Math.ceil(cropHeight / 2 - 0.5f),
/*right=*/ (int) Math.floor(sensorSize.width() - cropWidth / 2 + 0.5f),
/*bottom=*/ (int) Math.floor(sensorSize.height() - cropHeight / 2 + 0.5f));
if (cropRegion.width() < 50 || cropRegion.height() < 50) {
Log.e(TAG, "Crop region is too small to compute 3A stats, so ignoring further zoom.");
return;
}
this.mCropRegion = cropRegion;
mPreview.zoom(cropRegion);
}
public float getMinZoomLevel() {
return UNITY_ZOOM_SCALE;
}
public float getMaxZoomLevel() {
try {
CameraCharacteristics characteristics =
mCameraManager.getCameraCharacteristics(getActiveCamera());
Float maxZoom =
characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM);
if (maxZoom == null) {
return ZOOM_NOT_SUPPORTED;
}
if (maxZoom == ZOOM_NOT_SUPPORTED) {
return ZOOM_NOT_SUPPORTED;
}
return maxZoom;
} catch (Exception e) {
Log.e(TAG, "Failed to get SCALER_AVAILABLE_MAX_DIGITAL_ZOOM.", e);
}
return ZOOM_NOT_SUPPORTED;
}
public boolean isZoomSupported() {
return getMaxZoomLevel() != ZOOM_NOT_SUPPORTED;
}
// TODO(b/124269166): Rethink how we can handle permissions here.
@SuppressLint("MissingPermission")
private void rebindToLifecycle() {
if (mCurrentLifecycle != null) {
bindToLifecycle(mCurrentLifecycle);
}
}
int getRelativeCameraOrientation(boolean compensateForMirroring) {
int rotationDegrees;
try {
String cameraId = CameraX.getCameraWithLensFacing(getLensFacing());
CameraInfo cameraInfo = CameraX.getCameraInfo(cameraId);
rotationDegrees = cameraInfo.getSensorRotationDegrees(getDisplaySurfaceRotation());
if (compensateForMirroring) {
rotationDegrees = (360 - rotationDegrees) % 360;
}
} catch (Exception e) {
Log.e(TAG, "Failed to query camera", e);
rotationDegrees = 0;
}
return rotationDegrees;
}
public void invalidateView() {
transformPreview();
updateViewInfo();
}
void clearCurrentLifecycle() {
if (mCurrentLifecycle != null) {
// Remove previous use cases
CameraX.unbind(mImageCapture, mVideoCapture, mPreview);
}
mCurrentLifecycle = null;
}
private Rect getSensorSize(String cameraId) throws CameraAccessException {
CameraCharacteristics characteristics = mCameraManager.getCameraCharacteristics(cameraId);
return characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE);
}
String getActiveCamera() throws CameraInfoUnavailableException {
return CameraX.getCameraWithLensFacing(mCameraLensFacing);
}
@UiThread
private void transformPreview() {
int previewWidth = getPreviewWidth();
int previewHeight = getPreviewHeight();
int displayOrientation = getDisplayRotationDegrees();
Matrix matrix = new Matrix();
// Apply rotation of the display
int rotation = -displayOrientation;
int px = (int) Math.round(previewWidth / 2d);
int py = (int) Math.round(previewHeight / 2d);
matrix.postRotate(rotation, px, py);
if (displayOrientation == 90 || displayOrientation == 270) {
// Swap width and height
float xScale = previewWidth / (float) previewHeight;
float yScale = previewHeight / (float) previewWidth;
matrix.postScale(xScale, yScale, px, py);
}
setTransform(matrix);
}
// Update view related information used in use cases
private void updateViewInfo() {
if (mImageCapture != null) {
mImageCapture.setTargetAspectRatio(new Rational(getWidth(), getHeight()));
mImageCapture.setTargetRotation(getDisplaySurfaceRotation());
}
if (mVideoCapture != null) {
mVideoCapture.setTargetRotation(getDisplaySurfaceRotation());
}
}
@RequiresPermission(permission.CAMERA)
private Set<LensFacing> getAvailableCameraLensFacing() {
// Start with all camera directions
Set<LensFacing> available = new LinkedHashSet<>(Arrays.asList(LensFacing.values()));
// If we're bound to a lifecycle, remove unavailable cameras
if (mCurrentLifecycle != null) {
if (!hasCameraWithLensFacing(LensFacing.BACK)) {
available.remove(LensFacing.BACK);
}
if (!hasCameraWithLensFacing(LensFacing.FRONT)) {
available.remove(LensFacing.FRONT);
}
}
return available;
}
public FlashMode getFlash() {
return mFlash;
}
public void setFlash(FlashMode flash) {
this.mFlash = flash;
if (mImageCapture == null) {
// Do nothing if there is no imageCapture
return;
}
mImageCapture.setFlashMode(flash);
}
public void enableTorch(boolean torch) {
if (mPreview == null) {
return;
}
mPreview.enableTorch(torch);
}
public boolean isTorchOn() {
if (mPreview == null) {
return false;
}
return mPreview.isTorchOn();
}
public Context getContext() {
return mCameraXView.getContext();
}
public int getWidth() {
return mCameraXView.getWidth();
}
public int getHeight() {
return mCameraXView.getHeight();
}
public int getDisplayRotationDegrees() {
return CameraOrientationUtil.surfaceRotationToDegrees(getDisplaySurfaceRotation());
}
protected int getDisplaySurfaceRotation() {
return mCameraXView.getDisplaySurfaceRotation();
}
public void setSurfaceTexture(SurfaceTexture st) {
mCameraXView.setSurfaceTexture(st);
}
private int getPreviewWidth() {
return mCameraXView.getPreviewWidth();
}
private int getPreviewHeight() {
return mCameraXView.getPreviewHeight();
}
private int getMeasuredWidth() {
return mCameraXView.getMeasuredWidth();
}
private int getMeasuredHeight() {
return mCameraXView.getMeasuredHeight();
}
void setTransform(final Matrix matrix) {
if (Looper.myLooper() != Looper.getMainLooper()) {
mCameraXView.post(
new Runnable() {
@Override
public void run() {
setTransform(matrix);
}
});
} else {
mCameraXView.setTransform(matrix);
}
}
/**
* Notify the view that the source dimensions have changed.
*
* <p>This will allow the view to layout the preview to display the correct aspect ratio.
*
* @param width width of camera source buffers.
* @param height height of camera source buffers.
*/
void onPreviewSourceDimensUpdated(int width, int height) {
mCameraXView.onPreviewSourceDimensUpdated(width, height);
}
public CaptureMode getCaptureMode() {
return mCaptureMode;
}
public void setCaptureMode(CaptureMode captureMode) {
this.mCaptureMode = captureMode;
rebindToLifecycle();
}
public long getMaxVideoDuration() {
return mMaxVideoDuration;
}
public void setMaxVideoDuration(long duration) {
mMaxVideoDuration = duration;
}
public long getMaxVideoSize() {
return mMaxVideoSize;
}
public void setMaxVideoSize(long size) {
mMaxVideoSize = size;
}
public boolean isPaused() {
return false;
}
}

View File

@ -0,0 +1,122 @@
package org.thoughtcrime.securesms.mediasend.camerax;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapRegionDecoder;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.hardware.Camera;
import android.os.Build;
import android.util.Size;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.camera.core.CameraX;
import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageProxy;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.Stopwatch;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
public class CameraXUtil {
private static final String TAG = Log.tag(CameraXUtil.class);
@RequiresApi(21)
public static byte[] toJpegBytes(@NonNull ImageProxy image, int rotation, boolean flip) throws IOException {
ImageProxy.PlaneProxy[] planes = image.getPlanes();
ByteBuffer buffer = planes[0].getBuffer();
Rect cropRect = shouldCropImage(image) ? image.getCropRect() : null;
byte[] data = new byte[buffer.capacity()];
buffer.get(data);
if (cropRect != null || rotation != 0 || flip) {
data = transformByteArray(data, cropRect, rotation, flip);
}
return data;
}
public static int toCameraDirectionInt(@Nullable CameraX.LensFacing facing) {
if (facing == CameraX.LensFacing.FRONT) {
return Camera.CameraInfo.CAMERA_FACING_FRONT;
} else {
return Camera.CameraInfo.CAMERA_FACING_BACK;
}
}
public static @NonNull CameraX.LensFacing toLensFacing(int cameraDirectionInt) {
if (cameraDirectionInt == Camera.CameraInfo.CAMERA_FACING_FRONT) {
return CameraX.LensFacing.FRONT;
} else {
return CameraX.LensFacing.BACK;
}
}
public static @NonNull ImageCapture.CaptureMode getOptimalCaptureMode() {
return FastCameraModels.contains(Build.MODEL) ? ImageCapture.CaptureMode.MAX_QUALITY
: ImageCapture.CaptureMode.MIN_LATENCY;
}
private static byte[] transformByteArray(@NonNull byte[] data, @Nullable Rect cropRect, int rotation, boolean flip) throws IOException {
Stopwatch stopwatch = new Stopwatch("transform");
Bitmap in;
if (cropRect != null) {
BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(data, 0, data.length, false);
in = decoder.decodeRegion(cropRect, new BitmapFactory.Options());
decoder.recycle();
stopwatch.split("crop");
} else {
in = BitmapFactory.decodeByteArray(data, 0, data.length);
}
Bitmap out = in;
if (rotation != 0 || flip) {
Matrix matrix = new Matrix();
matrix.postRotate(rotation);
if (flip) {
matrix.postScale(-1, 1);
matrix.postTranslate(in.getWidth(), 0);
}
out = Bitmap.createBitmap(in, 0, 0, in.getWidth(), in.getHeight(), matrix, true);
}
byte[] transformedData = toJpegBytes(out);
stopwatch.split("transcode");
in.recycle();
out.recycle();
stopwatch.stop(TAG);
return transformedData;
}
@RequiresApi(21)
private static boolean shouldCropImage(@NonNull ImageProxy image) {
Size sourceSize = new Size(image.getWidth(), image.getHeight());
Size targetSize = new Size(image.getCropRect().width(), image.getCropRect().height());
return !targetSize.equals(sourceSize);
}
private static byte[] toJpegBytes(@NonNull Bitmap bitmap) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)) {
throw new IOException("Failed to compress bitmap.");
}
return out.toByteArray();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.mediasend.camerax;
import androidx.annotation.NonNull;
import java.util.HashSet;
import java.util.Set;
/**
* A set of {@link android.os.Build#MODEL} that are known to both benefit from
* {@link androidx.camera.core.ImageCapture.CaptureMode#MAX_QUALITY} and execute it quickly.
*
*/
public class FastCameraModels {
private static final Set<String> MODELS = new HashSet<String>() {{
add("Pixel 2");
add("Pixel 2 XL");
add("Pixel 3");
add("Pixel 3 XL");
add("Pixel 3a");
add("Pixel 3a XL");
}};
/**
* @param model Should be a {@link android.os.Build#MODEL}.
*/
public static boolean contains(@NonNull String model) {
return MODELS.contains(model);
}
}

View File

@ -5,6 +5,7 @@ import android.app.AlarmManager;
import android.app.NotificationManager;
import android.app.job.JobScheduler;
import android.content.Context;
import android.hardware.display.DisplayManager;
import android.media.AudioManager;
import android.net.ConnectivityManager;
import android.os.Build;
@ -55,6 +56,10 @@ public class ServiceUtil {
return (Vibrator)context.getSystemService(Context.VIBRATOR_SERVICE);
}
public static DisplayManager getDisplayManager(@NonNull Context context) {
return (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE);
}
@RequiresApi(26)
public static JobScheduler getJobScheduler(Context context) {
return (JobScheduler) context.getSystemService(JobScheduler.class);