diff --git a/app/build.gradle b/app/build.gradle index 567567eb5..404d9de15 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -197,6 +197,10 @@ dependencies { ktlint "com.pinterest:ktlint:0.42.1" implementation 'org.conscrypt:conscrypt-android:2.5.2' + implementation 'androidx.camera:camera-camera2:1.0.1' + implementation 'androidx.camera:camera-lifecycle:1.0.1' + implementation 'androidx.camera:camera-view:1.0.0-alpha20' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.biometric:biometric:1.0.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index acca95c7a..557db667a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -142,6 +142,11 @@ android:configChanges="orientation|keyboardHidden|screenSize"> + + diff --git a/app/src/main/java/com/nextcloud/talk/activities/TakePhotoActivity.java b/app/src/main/java/com/nextcloud/talk/activities/TakePhotoActivity.java new file mode 100644 index 000000000..db00dd054 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/activities/TakePhotoActivity.java @@ -0,0 +1,185 @@ +/* + * Nextcloud Talk application + * + * @author Andy Scherzinger + * @author Stefan Niedermann + * Copyright (C) 2021 Andy Scherzinger + * Copyright (C) 2021 Stefan Niedermann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.activities; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.util.Size; +import android.view.OrientationEventListener; +import android.view.Surface; +import android.widget.Toast; + +import com.google.common.util.concurrent.ListenableFuture; +import com.nextcloud.talk.databinding.ActivityTakePictureBinding; +import com.nextcloud.talk.models.TakePictureViewModel; +import com.nextcloud.talk.utils.FileUtils; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.ExecutionException; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.camera.core.Camera; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageCaptureException; +import androidx.camera.core.Preview; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProvider; + +public class TakePhotoActivity extends AppCompatActivity { + + private static final String TAG = TakePhotoActivity.class.getSimpleName(); + + private ActivityTakePictureBinding binding; + private TakePictureViewModel viewModel; + + private ListenableFuture cameraProviderFuture; + private OrientationEventListener orientationEventListener; + + private SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss", Locale.ROOT); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + binding = ActivityTakePictureBinding.inflate(getLayoutInflater()); + viewModel = new ViewModelProvider(this).get(TakePictureViewModel.class); + + setContentView(binding.getRoot()); + + cameraProviderFuture = ProcessCameraProvider.getInstance(this); + cameraProviderFuture.addListener(() -> { + try { + final ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); + final Preview preview = getPreview(); + final ImageCapture imageCapture = getImageCapture(); + final Camera camera = cameraProvider.bindToLifecycle(this, viewModel.getCameraSelector(), imageCapture, preview); + + viewModel.getCameraSelectorToggleButtonImageResource().observe(this, res -> binding.switchCamera.setImageDrawable(ContextCompat.getDrawable(this, res))); + viewModel.getTorchToggleButtonImageResource().observe(this, res -> binding.toggleTorch.setImageDrawable(ContextCompat.getDrawable(this, res))); + viewModel.isTorchEnabled().observe(this, enabled -> camera.getCameraControl().enableTorch(enabled)); + + binding.toggleTorch.setOnClickListener((v) -> viewModel.toggleTorchEnabled()); + binding.switchCamera.setOnClickListener((v) -> { + viewModel.toggleCameraSelector(); + cameraProvider.unbindAll(); + cameraProvider.bindToLifecycle(this, viewModel.getCameraSelector(), imageCapture, preview); + }); + } catch (IllegalArgumentException | ExecutionException | InterruptedException e) { + Log.e(TAG, "Error taking picture", e); + Toast.makeText(this, e.getMessage(), Toast.LENGTH_LONG).show(); + finish(); + } + }, ContextCompat.getMainExecutor(this)); + } + + private ImageCapture getImageCapture() { + final ImageCapture imageCapture = new ImageCapture.Builder().setTargetResolution(new Size(720, 1280)).build(); + + orientationEventListener = new OrientationEventListener(this) { + @Override + public void onOrientationChanged(int orientation) { + int rotation; + + // Monitors orientation values to determine the target rotation value + if (orientation >= 45 && orientation < 135) { + rotation = Surface.ROTATION_270; + } else if (orientation >= 135 && orientation < 225) { + rotation = Surface.ROTATION_180; + } else if (orientation >= 225 && orientation < 315) { + rotation = Surface.ROTATION_90; + } else { + rotation = Surface.ROTATION_0; + } + + imageCapture.setTargetRotation(rotation); + } + }; + orientationEventListener.enable(); + + binding.takePhoto.setOnClickListener((v) -> { + binding.takePhoto.setEnabled(false); + final String photoFileName = dateFormat.format(new Date())+ ".jpg"; + try { + final File photoFile = FileUtils.getTempCacheFile(this, "photos/" + photoFileName); + final ImageCapture.OutputFileOptions options = new ImageCapture.OutputFileOptions.Builder(photoFile).build(); + imageCapture.takePicture(options, ContextCompat.getMainExecutor(this), new ImageCapture.OnImageSavedCallback() { + @Override + public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { + final Uri savedUri = Uri.fromFile(photoFile); + Log.i(TAG, "onImageSaved - savedUri:" + savedUri.toString()); + setResult(RESULT_OK, new Intent().setDataAndType(savedUri, "image/jpeg")); + finish(); + } + + @Override + public void onError(@NonNull ImageCaptureException e) { + Log.e(TAG, "Error", e); + //noinspection ResultOfMethodCallIgnored + photoFile.delete(); + binding.takePhoto.setEnabled(true); + } + }); + } catch (Exception e) { + // TODO replace string with placeholder + Toast.makeText(this, "Error taking picture", Toast.LENGTH_SHORT).show(); + } + }); + + return imageCapture; + } + + private Preview getPreview() { + Preview preview = new Preview.Builder().build(); + preview.setSurfaceProvider(binding.preview.getSurfaceProvider()); + return preview; + } + + @Override + protected void onPause() { + if (this.orientationEventListener != null) { + this.orientationEventListener.disable(); + } + super.onPause(); + } + + @Override + protected void onResume() { + super.onResume(); + if (this.orientationEventListener != null) { + this.orientationEventListener.enable(); + } + } + + public static Intent createIntent(@NonNull Context context) { + return new Intent(context, TakePhotoActivity.class).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + } +} diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index dc80c46e4..294fd925c 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -96,6 +96,7 @@ import com.google.android.flexbox.FlexboxLayout import com.nextcloud.talk.R import com.nextcloud.talk.activities.MagicCallActivity import com.nextcloud.talk.activities.MainActivity +import com.nextcloud.talk.activities.TakePhotoActivity import com.nextcloud.talk.adapters.messages.IncomingLocationMessageViewHolder import com.nextcloud.talk.adapters.messages.IncomingPreviewMessageViewHolder import com.nextcloud.talk.adapters.messages.IncomingVoiceMessageViewHolder @@ -1208,6 +1209,34 @@ class ChatController(args: Bundle) : Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } } + } else if (requestCode == REQUEST_CODE_PICK_CAMERA) { + if (resultCode == RESULT_OK) { + try { + checkNotNull(intent) + filesToUpload.clear() + run { + checkNotNull(intent.data) + intent.data.let { + filesToUpload.add(intent.data.toString()) + } + } + require(filesToUpload.isNotEmpty()) + + if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { + uploadFiles(filesToUpload, false) + } else { + UploadAndShareFilesWorker.requestStoragePermission(this) + } + } catch (e: IllegalStateException) { + Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG) + .show() + Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) + } catch (e: IllegalArgumentException) { + Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG) + .show() + Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) + } + } } } @@ -2535,6 +2564,10 @@ class ChatController(args: Bundle) : } } + fun sendPictureFromCamIntent() { + startActivityForResult(TakePhotoActivity.createIntent(context!!), REQUEST_CODE_PICK_CAMERA) + } + companion object { private const val TAG = "ChatController" private const val CONTENT_TYPE_SYSTEM_MESSAGE: Byte = 1 @@ -2549,6 +2582,7 @@ class ChatController(args: Bundle) : private const val AGE_THREHOLD_FOR_DELETE_MESSAGE: Int = 21600000 // (6 hours in millis = 6 * 3600 * 1000) private const val REQUEST_CODE_CHOOSE_FILE: Int = 555 private const val REQUEST_RECORD_AUDIO_PERMISSION = 222 + private const val REQUEST_CODE_PICK_CAMERA: Int = 333 private const val OBJECT_MESSAGE: String = "{object}" private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000 private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -50 diff --git a/app/src/main/java/com/nextcloud/talk/models/TakePictureViewModel.java b/app/src/main/java/com/nextcloud/talk/models/TakePictureViewModel.java new file mode 100644 index 000000000..e6874cd65 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/TakePictureViewModel.java @@ -0,0 +1,80 @@ +/* + * Nextcloud Talk application + * + * @author Andy Scherzinger + * @author Stefan Niedermann + * Copyright (C) 2021 Andy Scherzinger + * Copyright (C) 2021 Stefan Niedermann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.models; + +import com.nextcloud.talk.R; + +import androidx.annotation.NonNull; +import androidx.camera.core.CameraSelector; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; + +import static androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA; +import static androidx.camera.core.CameraSelector.DEFAULT_FRONT_CAMERA; + +public class TakePictureViewModel extends ViewModel { + + @NonNull + private CameraSelector cameraSelector = DEFAULT_BACK_CAMERA; + @NonNull + private final MutableLiveData cameraSelectorToggleButtonImageResource = new MutableLiveData<>(R.drawable.ic_baseline_camera_front_24); + @NonNull + private final MutableLiveData torchEnabled = new MutableLiveData<>(false); + + @NonNull + public CameraSelector getCameraSelector() { + return this.cameraSelector; + } + + public LiveData getCameraSelectorToggleButtonImageResource() { + return this.cameraSelectorToggleButtonImageResource; + } + + public void toggleCameraSelector() { + if (this.cameraSelector == DEFAULT_BACK_CAMERA) { + this.cameraSelector = DEFAULT_FRONT_CAMERA; + this.cameraSelectorToggleButtonImageResource.postValue(R.drawable.ic_baseline_camera_rear_24); + } else { + this.cameraSelector = DEFAULT_BACK_CAMERA; + this.cameraSelectorToggleButtonImageResource.postValue(R.drawable.ic_baseline_camera_front_24); + } + } + + public void toggleTorchEnabled() { + //noinspection ConstantConditions + this.torchEnabled.postValue(!this.torchEnabled.getValue()); + } + + public LiveData isTorchEnabled() { + return this.torchEnabled; + } + + public LiveData getTorchToggleButtonImageResource() { + return Transformations.map(isTorchEnabled(), enabled -> enabled + ? R.drawable.ic_baseline_flash_off_24 + : R.drawable.ic_baseline_flash_on_24); + } +} + diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt index 51b2959fb..ed6185bbe 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt @@ -54,6 +54,10 @@ class AttachmentDialog(val activity: Activity, var chatController: ChatControlle @JvmField var attachFromCloud: AppCompatTextView? = null + @BindView(R.id.menu_attach_picture_from_cam) + @JvmField + var pictureFromCamItem: LinearLayout? = null + private var unbinder: Unbinder? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -88,6 +92,12 @@ class AttachmentDialog(val activity: Activity, var chatController: ChatControlle chatController.sendSelectLocalFileIntent() dismiss() } + + pictureFromCamItem?.setOnClickListener { + chatController.sendPictureFromCamIntent() + dismiss() + } + attachFromCloud?.setOnClickListener { chatController.showBrowserScreen(BrowserController.BrowserType.DAV_BROWSER) dismiss() diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.java b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.java new file mode 100644 index 000000000..e18e0147b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.java @@ -0,0 +1,46 @@ +package com.nextcloud.talk.utils; + +import android.content.Context; +import android.util.Log; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +import androidx.annotation.NonNull; + +public class FileUtils { + private static final String TAG = FileUtils.class.getSimpleName(); + + /** + * Creates a new {@link File} + */ + public static File getTempCacheFile(@NonNull Context context, String fileName) throws IOException { + File cacheFile = new File(context.getApplicationContext().getFilesDir().getAbsolutePath() + "/" + fileName); + + Log.v(TAG, "Full path for new cache file:" + cacheFile.getAbsolutePath()); + + final File tempDir = cacheFile.getParentFile(); + if (tempDir == null) { + throw new FileNotFoundException("could not cacheFile.getParentFile()"); + } + if (!tempDir.exists()) { + Log.v(TAG, + "The folder in which the new file should be created does not exist yet. Trying to create it…"); + if (tempDir.mkdirs()) { + Log.v(TAG, "Creation successful"); + } else { + throw new IOException("Directory for temporary file does not exist and could not be created."); + } + } + + Log.v(TAG, "- Try to create actual cache file"); + if (cacheFile.createNewFile()) { + Log.v(TAG, "Successfully created cache file"); + } else { + throw new IOException("Failed to create cacheFile"); + } + + return cacheFile; + } +} diff --git a/app/src/main/res/drawable/ic_baseline_camera_front_24.xml b/app/src/main/res/drawable/ic_baseline_camera_front_24.xml new file mode 100644 index 000000000..25c1a79b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_camera_front_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_camera_rear_24.xml b/app/src/main/res/drawable/ic_baseline_camera_rear_24.xml new file mode 100644 index 000000000..51cea2177 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_camera_rear_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_flash_off_24.xml b/app/src/main/res/drawable/ic_baseline_flash_off_24.xml new file mode 100644 index 000000000..2a3b0ff5d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flash_off_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_flash_on_24.xml b/app/src/main/res/drawable/ic_baseline_flash_on_24.xml new file mode 100644 index 000000000..4574d0e20 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flash_on_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_photo_camera_24.xml b/app/src/main/res/drawable/ic_baseline_photo_camera_24.xml new file mode 100644 index 000000000..497db8383 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_photo_camera_24.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/layout/activity_take_picture.xml b/app/src/main/res/layout/activity_take_picture.xml new file mode 100644 index 000000000..d5daa77cf --- /dev/null +++ b/app/src/main/res/layout/activity_take_picture.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_attachment.xml b/app/src/main/res/layout/dialog_attachment.xml index 719393a94..dbc3332ba 100644 --- a/app/src/main/res/layout/dialog_attachment.xml +++ b/app/src/main/res/layout/dialog_attachment.xml @@ -105,6 +105,39 @@ + + + + + + + + #606060 + + #7f000000 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 807345106..cb881b959 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -374,6 +374,7 @@ Add to conversation Upload local file + Upload from camera Share from %1$s Sorry, upload failed Choose files @@ -457,4 +458,8 @@ %1$s (%2$d) Invalid password Do you want to reauthorize or delete this account? + + Take a photo + Switch camera + Toggle torch diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 7b09c2017..029d893be 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -55,6 +55,18 @@ @color/grey950 + + + +