diff --git a/app/build.gradle b/app/build.gradle index 3036fcdbe..06c527b4e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,11 +1,11 @@ apply plugin: 'com.android.application' android { - compileSdkVersion 30 + compileSdkVersion 29 defaultConfig { applicationId "it.niedermann.nextcloud.deck" minSdkVersion 19 - targetSdkVersion 30 + targetSdkVersion 29 versionCode 1011001 versionName "1.11.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -67,6 +67,10 @@ dependencies { implementation 'com.github.bumptech.glide:glide:4.11.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' + implementation "androidx.camera:camera-camera2:1.0.0-beta11" + implementation "androidx.camera:camera-lifecycle:1.0.0-beta11" + implementation "androidx.camera:camera-view:1.0.0-alpha18" + // Markdown implementation 'com.yydcdut:markdown-processor:0.1.3' implementation 'com.yydcdut:rxmarkdown-wrapper:0.1.3' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d06f329f2..f936fac67 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,12 @@ + + + + + + + + isEmpty = new MutableLiveData<>(true); + @NonNull private final MenuInflater menuInflater; @ColorInt private int mainColor; @@ -45,9 +51,9 @@ public class CardAttachmentAdapter extends RecyclerView.Adapter attachments = new ArrayList<>(); + private final List attachments = new ArrayList<>(); @NonNull private final AttachmentClickedListener attachmentClickedListener; @@ -126,22 +132,41 @@ public int getItemCount() { return attachments.size(); } + private void updateIsEmpty() { + this.isEmpty.postValue(getItemCount() <= 0); + } + + @NonNull + public LiveData isEmpty() { + return distinctUntilChanged(this.isEmpty); + } + public void setAttachments(@NonNull List attachments, @Nullable Long cardRemoteId) { this.cardRemoteId = cardRemoteId; this.attachments.clear(); this.attachments.addAll(attachments); notifyDataSetChanged(); + this.updateIsEmpty(); } public void addAttachment(Attachment a) { - this.attachments.add(a); + this.attachments.add(0, a); notifyItemInserted(this.attachments.size()); + this.updateIsEmpty(); } public void removeAttachment(Attachment a) { final int index = this.attachments.indexOf(a); this.attachments.remove(a); notifyItemRemoved(index); + this.updateIsEmpty(); + } + + public void replaceAttachment(Attachment toReplace, Attachment with) { + final int index = this.attachments.indexOf(toReplace); + this.attachments.remove(toReplace); + this.attachments.add(index, with); + notifyItemChanged(index); } @Override diff --git a/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/CardAttachmentsFragment.java b/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/CardAttachmentsFragment.java index 1c8a84103..d528dc903 100644 --- a/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/CardAttachmentsFragment.java +++ b/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/CardAttachmentsFragment.java @@ -4,6 +4,7 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import android.provider.ContactsContract; import android.util.DisplayMetrics; import android.view.LayoutInflater; import android.view.View; @@ -12,7 +13,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.core.app.SharedElementCallback; +import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.GridLayoutManager; @@ -38,12 +41,21 @@ import it.niedermann.nextcloud.deck.ui.branding.BrandedFragment; import it.niedermann.nextcloud.deck.ui.branding.BrandedSnackbar; import it.niedermann.nextcloud.deck.ui.card.EditCardViewModel; +import it.niedermann.nextcloud.deck.ui.card.attachments.picker.CardAttachmentPicker; +import it.niedermann.nextcloud.deck.ui.card.attachments.picker.CardAttachmentPickerListener; import it.niedermann.nextcloud.deck.ui.exception.ExceptionDialogFragment; +import it.niedermann.nextcloud.deck.ui.takephoto.TakePhotoActivity; +import it.niedermann.nextcloud.deck.util.VCardUtil; +import static android.Manifest.permission.CAMERA; +import static android.Manifest.permission.READ_CONTACTS; import static android.Manifest.permission.READ_EXTERNAL_STORAGE; import static android.app.Activity.RESULT_OK; import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.LOLLIPOP; import static android.os.Build.VERSION_CODES.M; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; import static androidx.core.content.PermissionChecker.PERMISSION_GRANTED; import static androidx.core.content.PermissionChecker.checkSelfPermission; import static it.niedermann.nextcloud.deck.persistence.sync.adapters.db.util.LiveDataHelper.observeOnce; @@ -53,16 +65,22 @@ import static it.niedermann.nextcloud.deck.util.AttachmentUtil.copyContentUriToTempFile; import static java.net.HttpURLConnection.HTTP_CONFLICT; -public class CardAttachmentsFragment extends BrandedFragment implements AttachmentDeletedListener, AttachmentClickedListener { +public class CardAttachmentsFragment extends BrandedFragment implements AttachmentDeletedListener, AttachmentClickedListener, CardAttachmentPickerListener { private FragmentCardEditTabAttachmentsBinding binding; private EditCardViewModel viewModel; private static final int REQUEST_CODE_ADD_FILE = 1; private static final int REQUEST_CODE_ADD_FILE_PERMISSION = 2; + private static final int REQUEST_CODE_CAMERA = 3; + private static final int REQUEST_CODE_CAMERA_PERMISSION = 4; + private static final int REQUEST_CODE_PICK_CONTACT = 5; + private static final int REQUEST_CODE_PICK_CONTACT_PERMISSION = 6; private SyncManager syncManager; private CardAttachmentAdapter adapter; + @Nullable + private DialogFragment picker; private int clickedItemPosition; @@ -90,7 +108,15 @@ public View onCreateView(@NonNull LayoutInflater inflater, viewModel.getFullCard().getLocalId()); binding.attachmentsList.setAdapter(adapter); - updateEmptyContentView(); + adapter.isEmpty().observe(getViewLifecycleOwner(), (isEmpty) -> { + if (isEmpty) { + this.binding.emptyContentView.setVisibility(VISIBLE); + this.binding.attachmentsList.setVisibility(GONE); + } else { + this.binding.emptyContentView.setVisibility(GONE); + this.binding.attachmentsList.setVisibility(VISIBLE); + } + }); final DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); int spanCount = (int) ((displayMetrics.widthPixels / displayMetrics.density) / getResources().getInteger(R.integer.max_dp_attachment_column)); @@ -122,11 +148,13 @@ public void onMapSharedElements(List names, Map sharedElem } }); adapter.setAttachments(viewModel.getFullCard().getAttachments(), viewModel.getFullCard().getId()); - updateEmptyContentView(); } if (viewModel.canEdit()) { - binding.fab.setOnClickListener(v -> pickFile()); + binding.fab.setOnClickListener(v -> { + picker = CardAttachmentPicker.newInstance(); + picker.show(getChildFragmentManager(), CardAttachmentPicker.class.getSimpleName()); + }); binding.fab.show(); binding.attachmentsList.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override @@ -144,110 +172,169 @@ else if (dy < 0) return binding.getRoot(); } + @Override + @RequiresApi(LOLLIPOP) + public void pickCamera() { + if (isPermissionRequestNeeded(CAMERA)) { + requestPermissions(new String[]{CAMERA}, REQUEST_CODE_CAMERA_PERMISSION); + } else { + startActivityForResult(TakePhotoActivity.createIntent(requireContext()), REQUEST_CODE_CAMERA); + } + } + + @Override + public void pickContact() { + if (isPermissionRequestNeeded(READ_CONTACTS)) { + requestPermissions(new String[]{READ_CONTACTS}, REQUEST_CODE_PICK_CONTACT_PERMISSION); + } else { + final Intent intent = new Intent(Intent.ACTION_PICK).setType(ContactsContract.Contacts.CONTENT_TYPE); + if (intent.resolveActivity(requireContext().getPackageManager()) != null) { + startActivityForResult(intent, REQUEST_CODE_PICK_CONTACT); + } + } + } + + @Override public void pickFile() { - if (SDK_INT >= M && checkSelfPermission(requireActivity(), READ_EXTERNAL_STORAGE) != PERMISSION_GRANTED) { + if (isPermissionRequestNeeded(READ_EXTERNAL_STORAGE)) { requestPermissions(new String[]{READ_EXTERNAL_STORAGE}, REQUEST_CODE_ADD_FILE_PERMISSION); } else { - startActivityForResult(new Intent(Intent.ACTION_GET_CONTENT) + Intent intent = new Intent(Intent.ACTION_GET_CONTENT) .addCategory(Intent.CATEGORY_OPENABLE) - .setType("*/*"), REQUEST_CODE_ADD_FILE); + .setType("*/*"); + startActivityForResult(intent, REQUEST_CODE_ADD_FILE); } } + /** + * Checks the current Android version and whether the permission has already been granted. + * + * @param permission see {@link android.Manifest.permission} + * @return whether or not requesting permission is needed + */ + private boolean isPermissionRequestNeeded(@NonNull String permission) { + return SDK_INT >= M && checkSelfPermission(requireActivity(), permission) != PERMISSION_GRANTED; + } + @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - //noinspection SwitchStatementWithTooFewBranches + super.onActivityResult(requestCode, resultCode, data); switch (requestCode) { + case REQUEST_CODE_PICK_CONTACT: + case REQUEST_CODE_CAMERA: case REQUEST_CODE_ADD_FILE: { if (resultCode == RESULT_OK) { - if (data == null) { - ExceptionDialogFragment.newInstance(new UploadAttachmentFailedException("Intent data is null"), viewModel.getAccount()).show(getChildFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); - return; - } - final Uri sourceUri = data.getData(); - if (sourceUri == null) { - ExceptionDialogFragment.newInstance(new UploadAttachmentFailedException("sourceUri is null"), viewModel.getAccount()).show(getChildFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); - return; - } - if (!ContentResolver.SCHEME_CONTENT.equals(sourceUri.getScheme())) { - ExceptionDialogFragment.newInstance(new UploadAttachmentFailedException("Unknown URI scheme: " + sourceUri.getScheme()), viewModel.getAccount()).show(getChildFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); - return; - } - - DeckLog.verbose("--- found content URL " + sourceUri.getPath()); - File fileToUpload; - + final Uri sourceUri = requestCode == REQUEST_CODE_PICK_CONTACT + ? VCardUtil.getVCardContentUri(requireContext(), Uri.parse(data.getDataString())) + : data.getData(); try { - DeckLog.verbose("---- so, now copy & upload: " + sourceUri.getPath()); - fileToUpload = copyContentUriToTempFile(requireContext(), sourceUri, viewModel.getAccount().getId(), viewModel.getFullCard().getCard().getLocalId()); - } catch (IllegalArgumentException | IOException e) { - ExceptionDialogFragment.newInstance(new UploadAttachmentFailedException("Could not copy content URI to temporary file", e), viewModel.getAccount()).show(getChildFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); - return; + uploadNewAttachmentFromUri(sourceUri, requestCode == REQUEST_CODE_CAMERA + ? data.getType() + : requireContext().getContentResolver().getType(sourceUri)); + if (picker != null) { + picker.dismiss(); + } + } catch (Exception e) { + ExceptionDialogFragment.newInstance(e, viewModel.getAccount()).show(getChildFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); } + } + break; + } + default: { + super.onActivityResult(requestCode, resultCode, data); + } + } + } - for (Attachment existingAttachment : viewModel.getFullCard().getAttachments()) { - final String existingPath = existingAttachment.getLocalPath(); - if (existingPath != null && existingPath.equals(fileToUpload.getAbsolutePath())) { - BrandedSnackbar.make(binding.coordinatorLayout, R.string.attachment_already_exists, Snackbar.LENGTH_LONG).show(); - return; - } + private void uploadNewAttachmentFromUri(@NonNull Uri sourceUri, String mimeType) throws UploadAttachmentFailedException, IOException { + if (sourceUri == null) { + throw new UploadAttachmentFailedException("sourceUri is null"); + } + switch (sourceUri.getScheme()) { + case ContentResolver.SCHEME_CONTENT: + case ContentResolver.SCHEME_FILE: { + DeckLog.verbose("--- found content URL " + sourceUri.getPath()); + final File fileToUpload = copyContentUriToTempFile(requireContext(), sourceUri, viewModel.getAccount().getId(), viewModel.getFullCard().getLocalId()); + for (Attachment existingAttachment : viewModel.getFullCard().getAttachments()) { + final String existingPath = existingAttachment.getLocalPath(); + if (existingPath != null && existingPath.equals(fileToUpload.getAbsolutePath())) { + BrandedSnackbar.make(binding.coordinatorLayout, R.string.attachment_already_exists, Snackbar.LENGTH_LONG).show(); + return; } + } - final Instant now = Instant.now(); - final Attachment a = new Attachment(); - a.setMimetype(requireContext().getContentResolver().getType(sourceUri)); - a.setData(fileToUpload.getName()); - a.setFilename(fileToUpload.getName()); - a.setBasename(fileToUpload.getName()); - a.setFilesize(fileToUpload.length()); - a.setLocalPath(fileToUpload.getAbsolutePath()); - a.setLastModifiedLocal(now); - a.setStatusEnum(DBStatus.LOCAL_EDITED); - a.setCreatedAt(now); - viewModel.getFullCard().getAttachments().add(a); - adapter.addAttachment(a); - if (!viewModel.isCreateMode()) { - WrappedLiveData liveData = syncManager.addAttachmentToCard(viewModel.getAccount().getId(), viewModel.getFullCard().getLocalId(), a.getMimetype(), fileToUpload); - observeOnce(liveData, getViewLifecycleOwner(), (next) -> { - if (liveData.hasError()) { - Throwable t = liveData.getError(); - if (t instanceof NextcloudHttpRequestFailedException && ((NextcloudHttpRequestFailedException) t).getStatusCode() == HTTP_CONFLICT) { - // https://github.com/stefan-niedermann/nextcloud-deck/issues/534 - viewModel.getFullCard().getAttachments().remove(a); - adapter.removeAttachment(a); - BrandedSnackbar.make(binding.coordinatorLayout, R.string.attachment_already_exists, Snackbar.LENGTH_LONG).show(); - } else { - ExceptionDialogFragment.newInstance(new UploadAttachmentFailedException("Unknown URI scheme", t), viewModel.getAccount()).show(getChildFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); - } - } else { + final Instant now = Instant.now(); + final Attachment a = new Attachment(); + a.setMimetype(mimeType); + a.setData(fileToUpload.getName()); + a.setFilename(fileToUpload.getName()); + a.setBasename(fileToUpload.getName()); + a.setFilesize(fileToUpload.length()); + a.setLocalPath(fileToUpload.getAbsolutePath()); + a.setLastModifiedLocal(now); + a.setCreatedAt(now); + a.setStatusEnum(DBStatus.LOCAL_EDITED); + viewModel.getFullCard().getAttachments().add(0, a); + adapter.addAttachment(a); + if (!viewModel.isCreateMode()) { + WrappedLiveData liveData = syncManager.addAttachmentToCard(viewModel.getAccount().getId(), viewModel.getFullCard().getLocalId(), a.getMimetype(), fileToUpload); + observeOnce(liveData, getViewLifecycleOwner(), (next) -> { + if (liveData.hasError()) { + Throwable t = liveData.getError(); + if (t instanceof NextcloudHttpRequestFailedException && ((NextcloudHttpRequestFailedException) t).getStatusCode() == HTTP_CONFLICT) { + // https://github.com/stefan-niedermann/nextcloud-deck/issues/534 viewModel.getFullCard().getAttachments().remove(a); adapter.removeAttachment(a); - viewModel.getFullCard().getAttachments().add(next); - adapter.addAttachment(next); + BrandedSnackbar.make(binding.coordinatorLayout, R.string.attachment_already_exists, Snackbar.LENGTH_LONG).show(); + } else { + ExceptionDialogFragment.newInstance(new UploadAttachmentFailedException("Unknown URI scheme", t), viewModel.getAccount()).show(getChildFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); } - }); - } - updateEmptyContentView(); + } else { + viewModel.getFullCard().getAttachments().remove(a); + viewModel.getFullCard().getAttachments().add(0, next); + adapter.replaceAttachment(a, next); + } + }); } break; } default: { - super.onActivityResult(requestCode, resultCode, data); + throw new UploadAttachmentFailedException("Unknown URI scheme: " + sourceUri.getScheme()); } } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - //noinspection SwitchStatementWithTooFewBranches switch (requestCode) { - case REQUEST_CODE_ADD_FILE_PERMISSION: + case REQUEST_CODE_ADD_FILE_PERMISSION: { if (checkSelfPermission(requireActivity(), READ_EXTERNAL_STORAGE) == PERMISSION_GRANTED) { pickFile(); } else { Toast.makeText(requireContext(), R.string.cannot_upload_files_without_permission, Toast.LENGTH_LONG).show(); } break; + } + case REQUEST_CODE_CAMERA_PERMISSION: { + if (checkSelfPermission(requireActivity(), CAMERA) == PERMISSION_GRANTED) { + if (SDK_INT >= LOLLIPOP) { + pickCamera(); + } else { + Toast.makeText(requireContext(), R.string.min_api_21, Toast.LENGTH_SHORT).show(); + } + } else { + Toast.makeText(requireContext(), R.string.cannot_upload_files_without_permission, Toast.LENGTH_LONG).show(); + } + break; + } + case REQUEST_CODE_PICK_CONTACT_PERMISSION: { + if (checkSelfPermission(requireActivity(), READ_CONTACTS) == PERMISSION_GRANTED) { + pickContact(); + } else { + Toast.makeText(requireContext(), R.string.cannot_upload_files_without_permission, Toast.LENGTH_LONG).show(); + } + break; + } default: super.onRequestPermissionsResult(requestCode, permissions, grantResults); } @@ -265,7 +352,6 @@ public void onAttachmentDeleted(Attachment attachment) { } }); } - updateEmptyContentView(); } @Override @@ -273,16 +359,6 @@ public void onAttachmentClicked(int position) { this.clickedItemPosition = position; } - private void updateEmptyContentView() { - if (this.adapter == null || this.adapter.getItemCount() == 0) { - this.binding.emptyContentView.setVisibility(View.VISIBLE); - this.binding.attachmentsList.setVisibility(View.GONE); - } else { - this.binding.emptyContentView.setVisibility(View.GONE); - this.binding.attachmentsList.setVisibility(View.VISIBLE); - } - } - @Override public void applyBrand(int mainColor) { applyBrandToFAB(mainColor, binding.fab); diff --git a/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/picker/CardAttachmentPicker.java b/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/picker/CardAttachmentPicker.java new file mode 100644 index 000000000..bdc620f38 --- /dev/null +++ b/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/picker/CardAttachmentPicker.java @@ -0,0 +1,112 @@ +package it.niedermann.nextcloud.deck.ui.card.attachments.picker; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import it.niedermann.nextcloud.deck.R; +import it.niedermann.nextcloud.deck.databinding.DialogAttachmentPickerBinding; +import it.niedermann.nextcloud.deck.ui.branding.Branded; +import it.niedermann.nextcloud.deck.util.DeckColorUtil; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.view.View.GONE; +import static it.niedermann.nextcloud.deck.ui.branding.BrandingUtil.isBrandingEnabled; +import static it.niedermann.nextcloud.deck.ui.branding.BrandingUtil.readBrandMainColor; + +public class CardAttachmentPicker extends BottomSheetDialogFragment implements Branded { + + private DialogAttachmentPickerBinding binding; + private CardAttachmentPickerListener listener; + + private ImageView[] brandedViews; + + public CardAttachmentPicker() { + + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (getParentFragment() instanceof CardAttachmentPickerListener) { + this.listener = (CardAttachmentPickerListener) getParentFragment(); + } else { + throw new IllegalArgumentException("Caller must implement " + CardAttachmentPickerListener.class.getSimpleName()); + } + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + binding = DialogAttachmentPickerBinding.inflate(inflater, container, false); + brandedViews = new ImageView[]{binding.pickCameraIamge, binding.pickContactIamge, binding.pickFileIamge}; + + if (SDK_INT < LOLLIPOP) { + binding.pickCamera.setVisibility(GONE); + } + + @Nullable Context context = getContext(); + if (context != null) { + if (isBrandingEnabled(context)) { + applyBrand(readBrandMainColor(context)); + } else { // Make sure that without branding the icons are still white + if (SDK_INT >= LOLLIPOP) { + final ColorStateList colorStateList = ColorStateList.valueOf(Color.WHITE); + for (ImageView v : brandedViews) { + v.setImageTintList(colorStateList); + } + } + } + } + + return binding.getRoot(); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + binding.pickCamera.setOnClickListener((v) -> { + if (SDK_INT >= LOLLIPOP) { + listener.pickCamera(); + } else { + Toast.makeText(requireContext(), R.string.min_api_21, Toast.LENGTH_SHORT).show(); + } + }); + binding.pickContact.setOnClickListener((v) -> listener.pickContact()); + binding.pickFile.setOnClickListener((v) -> listener.pickFile()); + } + + public static DialogFragment newInstance() { + return new CardAttachmentPicker(); + } + + @Override + public void applyBrand(int mainColor) { + if (SDK_INT >= LOLLIPOP) { + final ColorStateList backgroundColorStateList = ColorStateList.valueOf(mainColor); + final ColorStateList foregroundColorStateList = ColorStateList.valueOf( + DeckColorUtil.contrastRatioIsSufficient(mainColor, Color.WHITE) + ? Color.WHITE + : Color.BLACK + ); + for (ImageView v : brandedViews) { + v.setBackgroundTintList(backgroundColorStateList); + v.setImageTintList(foregroundColorStateList); + } + } + } +} diff --git a/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/picker/CardAttachmentPickerListener.java b/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/picker/CardAttachmentPickerListener.java new file mode 100644 index 000000000..af61bba5c --- /dev/null +++ b/app/src/main/java/it/niedermann/nextcloud/deck/ui/card/attachments/picker/CardAttachmentPickerListener.java @@ -0,0 +1,13 @@ +package it.niedermann.nextcloud.deck.ui.card.attachments.picker; + +import androidx.annotation.RequiresApi; + +import static android.os.Build.VERSION_CODES.LOLLIPOP; + +public interface CardAttachmentPickerListener { + + @RequiresApi(api = LOLLIPOP) + void pickCamera(); + void pickContact(); + void pickFile(); +} diff --git a/app/src/main/java/it/niedermann/nextcloud/deck/ui/takephoto/TakePhotoActivity.java b/app/src/main/java/it/niedermann/nextcloud/deck/ui/takephoto/TakePhotoActivity.java new file mode 100644 index 000000000..af17464dc --- /dev/null +++ b/app/src/main/java/it/niedermann/nextcloud/deck/ui/takephoto/TakePhotoActivity.java @@ -0,0 +1,182 @@ +package it.niedermann.nextcloud.deck.ui.takephoto; + +import android.content.Context; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.net.Uri; +import android.os.Bundle; +import android.util.Size; +import android.view.OrientationEventListener; +import android.view.Surface; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +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; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.io.File; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.ExecutionException; + +import it.niedermann.nextcloud.deck.DeckLog; +import it.niedermann.nextcloud.deck.databinding.ActivityTakePhotoBinding; +import it.niedermann.nextcloud.deck.ui.branding.BrandedActivity; +import it.niedermann.nextcloud.deck.ui.exception.ExceptionDialogFragment; +import it.niedermann.nextcloud.deck.ui.exception.ExceptionHandler; +import it.niedermann.nextcloud.deck.util.AttachmentUtil; + +import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static it.niedermann.nextcloud.deck.util.MimeTypeUtil.IMAGE_JPEG; + +@RequiresApi(LOLLIPOP) +public class TakePhotoActivity extends BrandedActivity { + + private ActivityTakePhotoBinding binding; + private TakePhotoViewModel viewModel; + + private View[] brandedViews; + + private ListenableFuture cameraProviderFuture; + private OrientationEventListener orientationEventListener; + + private final DateTimeFormatter fileNameFromCameraFormatter = DateTimeFormatter.ofPattern("'JPG_'yyyyMMdd'_'HHmmss'.jpg'"); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Thread.currentThread().setUncaughtExceptionHandler(new ExceptionHandler(this)); + + binding = ActivityTakePhotoBinding.inflate(getLayoutInflater()); + viewModel = new ViewModelProvider(this).get(TakePhotoViewModel.class); + + setContentView(binding.getRoot()); + + cameraProviderFuture = ProcessCameraProvider.getInstance(this); + cameraProviderFuture.addListener(() -> { + try { + final ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); + final Preview previewUseCase = getPreviewUseCase(); + final ImageCapture captureUseCase = getCaptureUseCase(); + final Camera camera = cameraProvider.bindToLifecycle(this, viewModel.getCameraSelector(), captureUseCase, previewUseCase); + + 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(), captureUseCase, previewUseCase); + }); + } catch (ExecutionException | InterruptedException e) { + DeckLog.logError(e); + finish(); + } + }, ContextCompat.getMainExecutor(this)); + + brandedViews = new View[]{binding.takePhoto, binding.switchCamera, binding.toggleTorch}; + } + + private ImageCapture getCaptureUseCase() { + final ImageCapture captureUseCase = 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; + } + + captureUseCase.setTargetRotation(rotation); + } + }; + orientationEventListener.enable(); + + binding.takePhoto.setOnClickListener((v) -> { + binding.takePhoto.setEnabled(false); + final String photoFileName = Instant.now().atZone(ZoneId.systemDefault()).format(fileNameFromCameraFormatter); + try { + final File photoFile = AttachmentUtil.getTempCacheFile(this, "photos/" + photoFileName); + final ImageCapture.OutputFileOptions options = new ImageCapture.OutputFileOptions.Builder(photoFile).build(); + captureUseCase.takePicture(options, ContextCompat.getMainExecutor(this), new ImageCapture.OnImageSavedCallback() { + @Override + public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { + final Uri savedUri = Uri.fromFile(photoFile); + DeckLog.info("onImageSaved - savedUri: " + savedUri.toString()); + setResult(RESULT_OK, new Intent().setDataAndType(savedUri, IMAGE_JPEG)); + finish(); + } + + @Override + public void onError(@NonNull ImageCaptureException e) { + e.printStackTrace(); + //noinspection ResultOfMethodCallIgnored + photoFile.delete(); + binding.takePhoto.setEnabled(true); + } + }); + } catch (Exception e) { + ExceptionDialogFragment.newInstance(e, null).show(getSupportFragmentManager(), ExceptionDialogFragment.class.getSimpleName()); + } + }); + + return captureUseCase; + } + + private Preview getPreviewUseCase() { + Preview previewUseCase = new Preview.Builder().build(); + previewUseCase.setSurfaceProvider(binding.preview.getSurfaceProvider()); + return previewUseCase; + } + + @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(); + } + } + + @RequiresApi(LOLLIPOP) + public static Intent createIntent(@NonNull Context context) { + return new Intent(context, TakePhotoActivity.class).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + } + + @Override + public void applyBrand(int mainColor) { + final ColorStateList colorStateList = ColorStateList.valueOf(mainColor); + for (View v : brandedViews) { + v.setBackgroundTintList(colorStateList); + } + } +} diff --git a/app/src/main/java/it/niedermann/nextcloud/deck/ui/takephoto/TakePhotoViewModel.java b/app/src/main/java/it/niedermann/nextcloud/deck/ui/takephoto/TakePhotoViewModel.java new file mode 100644 index 000000000..a71291ff2 --- /dev/null +++ b/app/src/main/java/it/niedermann/nextcloud/deck/ui/takephoto/TakePhotoViewModel.java @@ -0,0 +1,57 @@ +package it.niedermann.nextcloud.deck.ui.takephoto; + +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 it.niedermann.nextcloud.deck.R; + +import static androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA; +import static androidx.camera.core.CameraSelector.DEFAULT_FRONT_CAMERA; + +public class TakePhotoViewModel 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/it/niedermann/nextcloud/deck/util/AttachmentUtil.java b/app/src/main/java/it/niedermann/nextcloud/deck/util/AttachmentUtil.java index 844a301e8..6a7807d50 100644 --- a/app/src/main/java/it/niedermann/nextcloud/deck/util/AttachmentUtil.java +++ b/app/src/main/java/it/niedermann/nextcloud/deck/util/AttachmentUtil.java @@ -58,34 +58,51 @@ private static String getRemoteUrl(@NonNull String accountUrl, @NonNull Long car return accountUrl + "/index.php/apps/deck/cards/" + cardRemoteId + "/attachment/" + attachmentRemoteId; } - public static File copyContentUriToTempFile(@NonNull Context context, @NonNull Uri currentUri, long accountId, Long localId) throws IOException, IllegalArgumentException { - String fullTempPath = context.getApplicationContext().getFilesDir().getAbsolutePath() + "/attachments/account-" + accountId + "/card-" + (localId == null ? "pending-creation" : localId) + '/' + UriUtils.getDisplayNameForUri(currentUri, context); - DeckLog.verbose("----- fullTempPath: " + fullTempPath); - InputStream inputStream = context.getContentResolver().openInputStream(currentUri); + public static File copyContentUriToTempFile(@NonNull Context context, @NonNull Uri currentUri, long accountId, Long localCardId) throws IOException, IllegalArgumentException { + final InputStream inputStream = context.getContentResolver().openInputStream(currentUri); if (inputStream == null) { throw new IOException("Could not open input stream for " + currentUri.getPath()); } - File cacheFile = new File(fullTempPath); - File tempDir = cacheFile.getParentFile(); + final File cacheFile = getTempCacheFile(context, "attachments/account-" + accountId + "/card-" + (localCardId == null ? "pending-creation" : localCardId) + '/' + UriUtils.getDisplayNameForUri(currentUri, context)); + final FileOutputStream outputStream = new FileOutputStream(cacheFile); + byte[] buffer = new byte[4096]; + + int count; + while ((count = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, count); + } + DeckLog.verbose("----- wrote"); + return cacheFile; + } + + /** + * 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); + + DeckLog.verbose("- Full path for new cache file: " + cacheFile.getAbsolutePath()); + + final File tempDir = cacheFile.getParentFile(); if (tempDir == null) { - throw new FileNotFoundException("could not cacheFile.getPranetFile()"); + throw new FileNotFoundException("could not cacheFile.getParentFile()"); } if (!tempDir.exists()) { - if (!tempDir.mkdirs()) { + DeckLog.verbose("-- The folder in which the new file should be created does not exist yet. Trying to create it..."); + if (tempDir.mkdirs()) { + DeckLog.verbose("--- Creation successful"); + } else { throw new IOException("Directory for temporary file does not exist and could not be created."); } } - if (!cacheFile.createNewFile()) { + + DeckLog.verbose("- Try to create actual cache file"); + if (cacheFile.createNewFile()) { + DeckLog.verbose("-- Successfully created cache file"); + } else { throw new IOException("Failed to create cacheFile"); } - FileOutputStream outputStream = new FileOutputStream(fullTempPath); - byte[] buffer = new byte[4096]; - int count; - while ((count = inputStream.read(buffer)) > 0) { - outputStream.write(buffer, 0, count); - } - DeckLog.verbose("----- wrote"); return cacheFile; } } diff --git a/app/src/main/java/it/niedermann/nextcloud/deck/util/MimeTypeUtil.java b/app/src/main/java/it/niedermann/nextcloud/deck/util/MimeTypeUtil.java index 0390bf96d..04694a058 100644 --- a/app/src/main/java/it/niedermann/nextcloud/deck/util/MimeTypeUtil.java +++ b/app/src/main/java/it/niedermann/nextcloud/deck/util/MimeTypeUtil.java @@ -6,6 +6,7 @@ public class MimeTypeUtil { + public static final String IMAGE_JPEG = "image/jpeg"; public static final String TEXT_PLAIN = "text/plain"; public static final String TEXT_VCARD = "text/vcard"; public static final String APPLICATION_PDF = "application/pdf"; diff --git a/app/src/main/java/it/niedermann/nextcloud/deck/util/VCardUtil.java b/app/src/main/java/it/niedermann/nextcloud/deck/util/VCardUtil.java new file mode 100644 index 000000000..b0e971082 --- /dev/null +++ b/app/src/main/java/it/niedermann/nextcloud/deck/util/VCardUtil.java @@ -0,0 +1,30 @@ +package it.niedermann.nextcloud.deck.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; + +import androidx.annotation.NonNull; + +import java.util.NoSuchElementException; + +public class VCardUtil { + + private VCardUtil() { + // You shall not pass + } + + public static Uri getVCardContentUri(@NonNull Context context, @NonNull Uri contactUri) throws NoSuchElementException { + final ContentResolver cr = context.getContentResolver(); + try (final Cursor cursor = cr.query(contactUri, null, null, null, null)) { + if (cursor.moveToFirst()) { + final String lookupKey = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY)); + return Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey); + } else { + throw new NoSuchElementException("Cursor has zero entries"); + } + } + } +} diff --git a/app/src/main/res/drawable-v21/bottom_sheet_rounded.xml b/app/src/main/res/drawable-v21/bottom_sheet_rounded.xml new file mode 100644 index 000000000..ba266ed32 --- /dev/null +++ b/app/src/main/res/drawable-v21/bottom_sheet_rounded.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/background.png b/app/src/main/res/drawable-xxxhdpi/background.png deleted file mode 100644 index 90856f4c8..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/background.png and /dev/null differ diff --git a/app/src/main/res/drawable/bottom_sheet_rounded.xml b/app/src/main/res/drawable/bottom_sheet_rounded.xml new file mode 100644 index 000000000..cef4c2314 --- /dev/null +++ b/app/src/main/res/drawable/bottom_sheet_rounded.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file 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_photo.xml b/app/src/main/res/layout/activity_take_photo.xml new file mode 100644 index 000000000..76d169507 --- /dev/null +++ b/app/src/main/res/layout/activity_take_photo.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_attachment_picker.xml b/app/src/main/res/layout/dialog_attachment_picker.xml new file mode 100644 index 000000000..150a5a3a5 --- /dev/null +++ b/app/src/main/res/layout/dialog_attachment_picker.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml new file mode 100644 index 000000000..61e9c4157 --- /dev/null +++ b/app/src/main/res/values-v21/styles.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 55812677c..f07146467 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -38,4 +38,6 @@ #ccf5f5f5 #212121 + + #66000000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32f5aa1e2..b7b50682b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -311,4 +311,11 @@ Clone User avatar Unassign + Contact + File + Camera + This feature requires at least Android 5 + 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 c253ae461..9881f8e76 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -7,6 +7,15 @@ @style/toolbarStyle ?attr/colorPrimary @style/Deck.TextAppearance.Headline1 + @style/AppBottomSheetDialogTheme + + + + + + +