/* * Copyright (c) 2022 Proton AG * * This file is part of Proton Mail. * * Proton Mail 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. * * Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/. */ package ch.protonmail.android.activities; import static ch.protonmail.android.attachments.ImportAttachmentsWorkerKt.KEY_INPUT_DATA_DELETE_ORIGINAL_FILE_BOOLEAN; import static ch.protonmail.android.attachments.ImportAttachmentsWorkerKt.KEY_INPUT_DATA_FILE_URIS_STRING_ARRAY; import android.app.Activity; import android.content.ClipData; import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.net.Uri; import android.os.Bundle; import android.os.Environment; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; import android.text.TextUtils; import android.view.Gravity; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; import androidx.appcompat.app.ActionBar; import androidx.core.content.FileProvider; import androidx.lifecycle.ViewModelProvider; import androidx.work.Data; import androidx.work.OneTimeWorkRequest; import androidx.work.WorkManager; import com.squareup.otto.Subscribe; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.concurrent.atomic.AtomicLong; import javax.inject.Inject; import butterknife.BindView; import ch.protonmail.android.R; import ch.protonmail.android.adapters.AttachmentListAdapter; import ch.protonmail.android.attachments.AttachmentsViewModel; import ch.protonmail.android.attachments.AttachmentsViewState; import ch.protonmail.android.attachments.ImportAttachmentsWorker; import ch.protonmail.android.core.Constants; import ch.protonmail.android.core.ProtonMailApplication; import ch.protonmail.android.data.local.model.Attachment; import ch.protonmail.android.data.local.model.LocalAttachment; import ch.protonmail.android.events.DownloadedAttachmentEvent; import ch.protonmail.android.events.PostImportAttachmentEvent; import ch.protonmail.android.events.PostImportAttachmentFailureEvent; import ch.protonmail.android.events.Status; import ch.protonmail.android.utils.DateUtil; import ch.protonmail.android.utils.DownloadUtils; import ch.protonmail.android.utils.Logger; import ch.protonmail.android.utils.extensions.TextExtensions; import ch.protonmail.android.utils.ui.dialogs.DialogUtils; import dagger.hilt.android.AndroidEntryPoint; import kotlin.collections.ArraysKt; import kotlin.collections.CollectionsKt; import timber.log.Timber; @AndroidEntryPoint public class AddAttachmentsActivity extends BaseStoragePermissionActivity implements AttachmentListAdapter.IAttachmentListener { private static final String TAG_ADD_ATTACHMENTS_ACTIVITY = "AddAttachmentsActivity"; public static final String EXTRA_ATTACHMENT_LIST = "EXTRA_ATTACHMENT_LIST"; public static final String EXTRA_DRAFT_ID = "EXTRA_DRAFT_ID"; public static final String EXTRA_DRAFT_CREATED = "EXTRA_DRAFT_CREATED"; private static final String ATTACHMENT_MIME_TYPE = "*/*"; private static final int REQUEST_CODE_ATTACH_FILE = 1; private static final int REQUEST_CODE_TAKE_PHOTO = 2; private static final String STATE_PHOTO_PATH = "STATE_PATH_TO_PHOTO"; private AttachmentListAdapter mAdapter; @BindView(R.id.progress_layout) View mProgressLayout; @BindView(R.id.processing_attachment_layout) View mProcessingAttachmentLayout; @BindView(R.id.no_attachments) View mNoAttachmentsView; @BindView(R.id.num_attachments) TextView mNumAttachmentsView; @BindView(R.id.attachment_list) ListView mListView; @Inject WorkManager workManager; @Inject DownloadUtils downloadUtils; private String mPathToPhoto; private String mDraftId; private boolean mDraftCreated; private List mAttachFileWithoutPermission; private String mAttachTakePhotoWithoutPermission; private boolean openGallery = false; private boolean openCamera = false; @Override protected int getLayoutId() { return R.layout.activity_add_attachments; } @Override public void onHasPermission(Constants.PermissionType type) { if (type == Constants.PermissionType.STORAGE) { super.onHasPermission(type); mHasStoragePermission = true; } } @Override public void onPermissionDenied(Constants.PermissionType type) { super.onPermissionDenied(type); openCamera = false; openGallery = false; DialogUtils.Companion.showInfoDialog(AddAttachmentsActivity.this, getString(R.string.need_permissions_title), getString(R.string.need_storage_permissions_add_attachment_text), unit -> unit); } @Override public void onPermissionConfirmed(Constants.PermissionType type) { super.onPermissionConfirmed(type); if (openGallery) { openGallery(); } if (openCamera) { openCamera(); } } @Override protected void storagePermissionGranted() { mHasStoragePermission = true; if (mAttachFileWithoutPermission != null && mAttachFileWithoutPermission.size() > 0) { mProcessingAttachmentLayout.setVisibility(View.VISIBLE); String[] uriStrings = new String[mAttachFileWithoutPermission.size()]; for (int i = 0; i < mAttachFileWithoutPermission.size(); i++) { uriStrings[i] = mAttachFileWithoutPermission.get(i).toString(); } Data workerData = new Data.Builder() .putStringArray(KEY_INPUT_DATA_FILE_URIS_STRING_ARRAY, uriStrings) .build(); OneTimeWorkRequest importAttachmentsWork = new OneTimeWorkRequest.Builder(ImportAttachmentsWorker.class) .setInputData(workerData) .build(); workManager.enqueue(importAttachmentsWork); mAttachFileWithoutPermission = null; } if (!TextUtils.isEmpty(mAttachTakePhotoWithoutPermission)) { mProcessingAttachmentLayout.setVisibility(View.VISIBLE); handleTakePhotoRequest(mAttachTakePhotoWithoutPermission); mAttachTakePhotoWithoutPermission = null; } } @Override protected boolean checkForPermissionOnStartup() { return true; } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setTitle(R.string.add_attachment); } Intent intent = getIntent(); ArrayList attachmentList = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_LIST); if (attachmentList == null) { attachmentList = new ArrayList<>(); } mDraftId = intent.getStringExtra(EXTRA_DRAFT_ID); mDraftCreated = intent.getBooleanExtra(EXTRA_DRAFT_CREATED, true); int attachmentsCount = attachmentList.size(); int totalEmbeddedImages = countEmbeddedImages(attachmentList); updateAttachmentsCount(attachmentsCount, totalEmbeddedImages); if (mDraftCreated) { mProgressLayout.setVisibility(View.GONE); } else { mProgressLayout.setVisibility(View.VISIBLE); } mAdapter = new AttachmentListAdapter(this, attachmentList, totalEmbeddedImages, workManager); mListView.setAdapter(mAdapter); AttachmentsViewModel viewModel = new ViewModelProvider(this).get(AttachmentsViewModel.class); viewModel.init(); viewModel.getViewState().observe(this, this::viewStateChanged); } @Override protected void onStart() { super.onStart(); ProtonMailApplication.getApplication().getBus().register(this); } @Override protected void onStop() { super.onStop(); ProtonMailApplication.getApplication().getBus().unregister(this); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_attachments, menu); return true; } @Override public boolean onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.take_photo).setVisible(getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA)); menu.findItem(R.id.attach_file).setVisible(mDraftCreated); menu.findItem(R.id.take_photo).setVisible(mDraftCreated); return true; } @Override public void onBackPressed() { Intent intent = new Intent(); intent.putExtra(EXTRA_DRAFT_ID, mDraftId); ArrayList currentAttachments = mAdapter.getData(); intent.putParcelableArrayListExtra(EXTRA_ATTACHMENT_LIST, currentAttachments); setResult(Activity.RESULT_OK, intent); finish(); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case android.R.id.home: onBackPressed(); return true; case R.id.attach_file: { openGallery = true; if (mHasStoragePermission != null && mHasStoragePermission) { return openGallery(); } else { storagePermissionHelper.checkPermission(); return false; } } case R.id.take_photo: { openCamera = true; if (mHasStoragePermission != null && mHasStoragePermission) { return openCamera(); } else { storagePermissionHelper.checkPermission(); return false; } } default: return super.onOptionsItemSelected(item); } } private boolean isAttachmentsSizeAllowed(long newAttachmentSie) { if (mAdapter == null) return false; long currentAttachmentsSize = CollectionsKt.sumOfLong(CollectionsKt.map(mAdapter.getData(), LocalAttachment::getSize)); return currentAttachmentsSize + newAttachmentSie < Constants.MAX_ATTACHMENT_FILE_SIZE_IN_BYTES; } private boolean isAttachmentsCountAllowed() { return mAdapter != null && mAdapter.getCount() < Constants.MAX_ATTACHMENTS; } @Subscribe public void onPostImportAttachmentEvent(PostImportAttachmentEvent event) { mProcessingAttachmentLayout.setVisibility(View.GONE); mNoAttachmentsView.setVisibility(View.GONE); LocalAttachment newAttachment = new LocalAttachment(Uri.parse(event.uri), event.displayName, event.size, event.mimeType); ArrayList currentAttachments = mAdapter.getData(); boolean alreadyExists = false; for (LocalAttachment localAttachment : currentAttachments) { String localAttachmentDisplayName = localAttachment.getDisplayName(); boolean isEmpty = TextUtils.isEmpty(localAttachmentDisplayName); if (!isEmpty && localAttachmentDisplayName.equals(newAttachment.getDisplayName())) { alreadyExists = true; } } if (alreadyExists) { TextExtensions.showToast(AddAttachmentsActivity.this, R.string.attachment_exists, Toast.LENGTH_SHORT); return; } currentAttachments.add(newAttachment); int totalEmbeddedImages = countEmbeddedImages(currentAttachments); mAdapter.updateData(new ArrayList<>(currentAttachments), totalEmbeddedImages); int attachments = currentAttachments.size(); updateAttachmentsCount(attachments, totalEmbeddedImages); } @Subscribe public void onPostImportAttachmentFailureEvent(PostImportAttachmentFailureEvent event) { mProcessingAttachmentLayout.setVisibility(View.GONE); TextExtensions.showToast(this, R.string.problem_selecting_file); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { if (resultCode != RESULT_OK) { super.onActivityResult(requestCode, resultCode, data); return; } if (mHasStoragePermission == null || !mHasStoragePermission) { if (requestCode == REQUEST_CODE_ATTACH_FILE) { mAttachFileWithoutPermission = new ArrayList<>(); ClipData clipData = data.getClipData(); if (clipData != null) { for (int i = 0; i < clipData.getItemCount(); i++) { ClipData.Item item = clipData.getItemAt(i); mAttachFileWithoutPermission.add(item.getUri()); } } else { mAttachFileWithoutPermission.add(data.getData()); } } else if (requestCode == REQUEST_CODE_TAKE_PHOTO) { mAttachTakePhotoWithoutPermission = mPathToPhoto; } storagePermissionHelper.checkPermission(); return; } if (requestCode == REQUEST_CODE_ATTACH_FILE) { handleAttachFileRequest(data.getData(), data.getClipData()); } else if (requestCode == REQUEST_CODE_TAKE_PHOTO) { handleTakePhotoRequest(mPathToPhoto); } } @Override protected void onSaveInstanceState(Bundle outState) { outState.putString(STATE_PHOTO_PATH, mPathToPhoto); super.onSaveInstanceState(outState); } @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); mPathToPhoto = savedInstanceState.getString(STATE_PHOTO_PATH); } @Subscribe public void onDownloadAttachmentEvent(DownloadedAttachmentEvent event) { //once attachment has been downloaded if (event.getStatus().equals(Status.SUCCESS)) { downloadUtils.viewAttachment(event.getFilename(), event.getAttachmentUri()); TextExtensions.showToast(this, String.format(getString(R.string.attachment_download_success), event.getFilename()), Toast.LENGTH_SHORT); } else { TextExtensions.showToast(this, String.format(getString(R.string.attachment_download_failed), event.getFilename()), Toast.LENGTH_SHORT); } } private void viewStateChanged(AttachmentsViewState viewState) { if (viewState instanceof AttachmentsViewState.MissingConnectivity) { onMessageReady(); } if (viewState instanceof AttachmentsViewState.UpdateAttachments) { onMessageReady(); updateDisplayedAttachments( ((AttachmentsViewState.UpdateAttachments) viewState).getAttachments() ); } } private void onMessageReady() { mDraftCreated = true; mProgressLayout.setVisibility(View.GONE); invalidateOptionsMenu(); } private void updateDisplayedAttachments(List attachments) { List localAttachments = new ArrayList<>( LocalAttachment.Companion.createLocalAttachmentList(attachments) ); int totalEmbeddedImages = countEmbeddedImages(localAttachments); mAdapter.updateData(new ArrayList(localAttachments), totalEmbeddedImages); } private void handleAttachFileRequest(Uri uri, ClipData clipData) { String[] uris = null; String uriString = uri != null ? uri.toString() : null; if (uriString != null) { uris = new String[]{uriString}; } if (clipData != null) { uris = new String[clipData.getItemCount()]; for (int i = 0; i < clipData.getItemCount(); i++) { uris[i] = clipData.getItemAt(i).getUri().toString(); } } if (uris != null) { // region Check whether the size of the attachments is within bounds final AtomicLong incrementalSize = new AtomicLong(0); List sizeComplaintUrisList = ArraysKt.filter(uris, fileUriString -> { Uri fileUri = Uri.parse(fileUriString); try { ParcelFileDescriptor fileDescriptor = getContentResolver().openFileDescriptor(fileUri, "r"); if (fileDescriptor == null) { return false; } final long fileSize = fileDescriptor.getStatSize(); return isAttachmentsSizeAllowed(incrementalSize.addAndGet(fileSize)); } catch (FileNotFoundException e) { return false; } }); if (sizeComplaintUrisList.size() < uris.length) { TextExtensions.showToast(this, R.string.max_attachments_size_reached); } // endregion if (sizeComplaintUrisList.size() > 0) { mProcessingAttachmentLayout.setVisibility(View.VISIBLE); String[] sizeComplaintUris = new String[sizeComplaintUrisList.size()]; sizeComplaintUrisList.toArray(sizeComplaintUris); Data workerData = new Data.Builder() .putStringArray(KEY_INPUT_DATA_FILE_URIS_STRING_ARRAY, sizeComplaintUris) .build(); OneTimeWorkRequest importAttachmentsWork = new OneTimeWorkRequest.Builder(ImportAttachmentsWorker.class) .setInputData(workerData) .build(); workManager.enqueue(importAttachmentsWork); } } } private void handleTakePhotoRequest(String path) { if (!TextUtils.isEmpty(path)) { File file = new File(path); // Check whether the size of the attachment is within bounds if (!isAttachmentsSizeAllowed(file.length())) { TextExtensions.showToast(this, R.string.max_attachments_size_reached); return; } mProcessingAttachmentLayout.setVisibility(View.VISIBLE); Uri uri = Uri.fromFile(file); Data data = new Data.Builder() .putStringArray(KEY_INPUT_DATA_FILE_URIS_STRING_ARRAY, new String[]{uri.toString()}) .putBoolean(KEY_INPUT_DATA_DELETE_ORIGINAL_FILE_BOOLEAN, true) .build(); OneTimeWorkRequest importAttachmentsWork = new OneTimeWorkRequest.Builder(ImportAttachmentsWorker.class) .setInputData(data) .build(); workManager.enqueue(importAttachmentsWork); workManager.getWorkInfoByIdLiveData(importAttachmentsWork.getId()).observe(this, workInfo -> { if (workInfo != null) { Timber.d("ImportAttachmentsWorker workInfo = " + workInfo.getState()); } }); } else { TextExtensions.showToast(this, R.string.attaching_photo_failed, Toast.LENGTH_LONG, Gravity.CENTER); } } @Override public void onAttachmentDeleted(int remainingAttachments, int embeddedImagesCount) { updateAttachmentsCount(remainingAttachments, embeddedImagesCount); } @Override public void askStoragePermission() { storagePermissionHelper.checkPermission(); } private void updateAttachmentsCount(int totalAttachmentsCount, int totalEmbeddedImagesCount) { if (totalAttachmentsCount == 0 && totalEmbeddedImagesCount == 0) { mNoAttachmentsView.postDelayed(() -> mNoAttachmentsView.setVisibility(View.VISIBLE), 350); mNumAttachmentsView.setVisibility(View.GONE); } else { int normalAttachments = totalAttachmentsCount - totalEmbeddedImagesCount; if (normalAttachments > 0) { mNumAttachmentsView.setText(getResources().getQuantityString(R.plurals.attachments, normalAttachments, normalAttachments)); mNumAttachmentsView.setVisibility(View.VISIBLE); } else { mNumAttachmentsView.setText(getString(R.string.no_attachments)); mNumAttachmentsView.setVisibility(View.VISIBLE); } } } //TODO extract logic to separate class as it is not dependant on activity private int countEmbeddedImages(List attachments) { int embeddedImages = 0; for (LocalAttachment localAttachment : attachments) { if (localAttachment.isEmbeddedImage()) { embeddedImages++; } } return embeddedImages; } private boolean openGallery() { if (!isAttachmentsCountAllowed()) { TextExtensions.showToast(this, R.string.max_attachments_reached); return true; } Intent target = new Intent(Intent.ACTION_GET_CONTENT); target.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); target.addCategory(Intent.CATEGORY_OPENABLE); target.setType(ATTACHMENT_MIME_TYPE); Intent intent = Intent.createChooser(target, getString(R.string.select_file)); if (intent.resolveActivity(getPackageManager()) != null) { startActivityForResult(intent, REQUEST_CODE_ATTACH_FILE); } else { TextExtensions.showToast(this, R.string.no_application_found); } return true; } private boolean openCamera() { if (!isAttachmentsCountAllowed()) { TextExtensions.showToast(this, R.string.max_attachments_reached); return true; } Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (intent.resolveActivity(getPackageManager()) != null) { String timestamp = DateUtil.generateTimestamp(); timestamp = timestamp.replace("-", ""); timestamp = timestamp.replaceAll("[^A-Za-z0-9]", ""); File directory = getExternalFilesDir(Environment.DIRECTORY_PICTURES); try { if (timestamp.length() < 3) { Random random = new Random(); int number = random.nextInt(99999) + 100; timestamp = timestamp + String.valueOf(number); } File file = File.createTempFile(timestamp, ".jpg", directory); intent.putExtra(MediaStore.EXTRA_OUTPUT, FileProvider.getUriForFile(AddAttachmentsActivity.this, getApplicationContext().getPackageName() + ".provider", file)); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); mPathToPhoto = file.getAbsolutePath(); startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO); } catch (IOException ioe) { Logger.doLogException(TAG_ADD_ATTACHMENTS_ACTIVITY, "Exception creating temporary file for photo", ioe); TextExtensions.showToast(this, R.string.problem_taking_photo); } } return true; } }