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
+
+
+
+
+
+
+