diff --git a/briar-android/src/main/AndroidManifest.xml b/briar-android/src/main/AndroidManifest.xml index b8c4a22fb..7e3f9f712 100644 --- a/briar-android/src/main/AndroidManifest.xml +++ b/briar-android/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ + diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/RequestCodes.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/RequestCodes.java index 0fc18c60b..3e1351ab3 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/activity/RequestCodes.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/RequestCodes.java @@ -15,5 +15,6 @@ public interface RequestCodes { int REQUEST_UNLOCK = 11; int REQUEST_KEYGUARD_UNLOCK = 12; int REQUEST_ATTACH_IMAGE = 13; + int REQUEST_SAVE_ATTACHMENT = 14; } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java index 7c077958e..2d85e2668 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentController.java @@ -4,6 +4,7 @@ import android.content.res.Resources; import android.graphics.BitmapFactory; import android.support.annotation.Nullable; import android.support.media.ExifInterface; +import android.webkit.MimeTypeMap; import org.briarproject.bramble.api.Pair; import org.briarproject.bramble.api.db.DatabaseExecutor; @@ -127,12 +128,20 @@ class AttachmentController { } // calculate thumbnail size - Size thumbnailSize = new Size(defaultSize, defaultSize); + Size thumbnailSize = new Size(defaultSize, defaultSize, size.mimeType); if (!size.error) { - thumbnailSize = getThumbnailSize(size.width, size.height); + thumbnailSize = + getThumbnailSize(size.width, size.height, size.mimeType); + } + // get file extension + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String extension = mimeTypeMap.getExtensionFromMimeType(size.mimeType); + if (extension == null) { + return new AttachmentItem(messageId, 0, 0, "", "", 0, 0, true); } return new AttachmentItem(messageId, size.width, size.height, - thumbnailSize.width, thumbnailSize.height, size.error); + size.mimeType, extension, thumbnailSize.width, thumbnailSize.height, + size.error); } /** @@ -151,9 +160,9 @@ class AttachmentController { orientation == ORIENTATION_TRANSVERSE || orientation == ORIENTATION_TRANSPOSE) { //noinspection SuspiciousNameCombination - return new Size(height, width); + return new Size(height, width, "image/jpeg"); } - return new Size(width, height); + return new Size(width, height, "image/jpeg"); } /** @@ -165,10 +174,11 @@ class AttachmentController { BitmapFactory.decodeStream(is, null, options); if (options.outWidth < 1 || options.outHeight < 1) return new Size(); - return new Size(options.outWidth, options.outHeight); + return new Size(options.outWidth, options.outHeight, + options.outMimeType); } - private Size getThumbnailSize(int width, int height) { + private Size getThumbnailSize(int width, int height, String mimeType) { float widthPercentage = maxWidth / (float) width; float heightPercentage = maxHeight / (float) height; float scaleFactor = Math.min(widthPercentage, heightPercentage); @@ -184,24 +194,27 @@ class AttachmentController { if (thumbnailWidth > maxWidth) thumbnailWidth = maxWidth; if (thumbnailHeight > maxHeight) thumbnailHeight = maxHeight; } - return new Size(thumbnailWidth, thumbnailHeight); + return new Size(thumbnailWidth, thumbnailHeight, mimeType); } private static class Size { private final int width; private final int height; + private final String mimeType; private final boolean error; - private Size(int width, int height) { + private Size(int width, int height, String mimeType) { this.width = width; this.height = height; + this.mimeType = mimeType; this.error = false; } private Size() { this.width = 0; this.height = 0; + this.mimeType = ""; this.error = true; } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentItem.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentItem.java index 7f39bfefc..bbc7c1f6b 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentItem.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/AttachmentItem.java @@ -14,6 +14,7 @@ public class AttachmentItem implements Parcelable { private final MessageId messageId; private final int width, height; + private final String mimeType, extension; private final int thumbnailWidth, thumbnailHeight; private final boolean hasError; @@ -30,11 +31,14 @@ public class AttachmentItem implements Parcelable { } }; - AttachmentItem(MessageId messageId, int width, int height, - int thumbnailWidth, int thumbnailHeight, boolean hasError) { + AttachmentItem(MessageId messageId, int width, int height, String mimeType, + String extension, int thumbnailWidth, int thumbnailHeight, + boolean hasError) { this.messageId = messageId; this.width = width; this.height = height; + this.mimeType = mimeType; + this.extension = extension; this.thumbnailWidth = thumbnailWidth; this.thumbnailHeight = thumbnailHeight; this.hasError = hasError; @@ -46,6 +50,8 @@ public class AttachmentItem implements Parcelable { messageId = new MessageId(messageIdByte); width = in.readInt(); height = in.readInt(); + mimeType = in.readString(); + extension = in.readString(); thumbnailWidth = in.readInt(); thumbnailHeight = in.readInt(); hasError = in.readByte() != 0; @@ -63,6 +69,14 @@ public class AttachmentItem implements Parcelable { return height; } + String getMimeType() { + return mimeType; + } + + String getExtension() { + return extension; + } + int getThumbnailWidth() { return thumbnailWidth; } @@ -90,6 +104,8 @@ public class AttachmentItem implements Parcelable { dest.writeByteArray(messageId.getBytes()); dest.writeInt(width); dest.writeInt(height); + dest.writeString(mimeType); + dest.writeString(extension); dest.writeInt(thumbnailWidth); dest.writeInt(thumbnailHeight); dest.writeByte((byte) (hasError ? 1 : 0)); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageActivity.java index fb8e8ad4b..f36cbab0f 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageActivity.java @@ -1,14 +1,23 @@ package org.briarproject.briar.android.conversation; +import android.arch.lifecycle.ViewModelProvider; +import android.arch.lifecycle.ViewModelProviders; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.support.annotation.Nullable; import android.support.annotation.RequiresApi; import android.support.design.widget.AppBarLayout; +import android.support.design.widget.Snackbar; +import android.support.v4.content.ContextCompat; +import android.support.v4.graphics.drawable.DrawableCompat; +import android.support.v7.app.AlertDialog.Builder; import android.support.v7.widget.Toolbar; import android.transition.Fade; import android.transition.Transition; +import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.Window; @@ -26,8 +35,18 @@ import org.briarproject.briar.android.activity.BriarActivity; import org.briarproject.briar.android.conversation.glide.GlideApp; import org.briarproject.briar.android.view.PullDownLayout; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import javax.inject.Inject; + +import static android.content.Intent.ACTION_CREATE_DOCUMENT; +import static android.content.Intent.CATEGORY_OPENABLE; +import static android.content.Intent.EXTRA_TITLE; import static android.graphics.Color.TRANSPARENT; import static android.os.Build.VERSION.SDK_INT; +import static android.support.design.widget.Snackbar.LENGTH_LONG; import static android.view.View.GONE; import static android.view.View.SYSTEM_UI_FLAG_FULLSCREEN; import static android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; @@ -37,6 +56,7 @@ import static android.view.WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS; import static android.widget.ImageView.ScaleType.FIT_START; import static com.bumptech.glide.load.engine.DiskCacheStrategy.NONE; import static java.util.Objects.requireNonNull; +import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_SAVE_ATTACHMENT; import static org.briarproject.briar.android.util.UiUtils.formatDateAbsolute; public class ImageActivity extends BriarActivity @@ -46,9 +66,14 @@ public class ImageActivity extends BriarActivity final static String NAME = "name"; final static String DATE = "date"; + @Inject + ViewModelProvider.Factory viewModelFactory; + + private ImageViewModel viewModel; private PullDownLayout layout; private AppBarLayout appBarLayout; private PhotoView photoView; + private AttachmentItem attachment; @Override public void injectActivity(ActivityComponent component) { @@ -67,6 +92,11 @@ public class ImageActivity extends BriarActivity setSceneTransitionAnimation(transition, null, transition); } + // get View Model + viewModel = ViewModelProviders.of(this, viewModelFactory) + .get(ImageViewModel.class); + viewModel.getSaveState().observe(this, this::onImageSaveStateChanged); + // inflate layout setContentView(R.layout.activity_image); layout = findViewById(R.id.layout); @@ -88,7 +118,7 @@ public class ImageActivity extends BriarActivity TextView dateView = toolbar.findViewById(R.id.dateView); // Intent Extras - AttachmentItem attachment = getIntent().getParcelableExtra(ATTACHMENT); + attachment = getIntent().getParcelableExtra(ATTACHMENT); String name = getIntent().getStringExtra(NAME); long time = getIntent().getLongExtra(DATE, 0); String date = formatDateAbsolute(this, time); @@ -143,17 +173,34 @@ public class ImageActivity extends BriarActivity .into(photoView); } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.image_actions, menu); + return super.onCreateOptionsMenu(menu); + } + @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case android.R.id.home: onBackPressed(); return true; + case R.id.action_save_image: + showSaveImageDialog(); + return true; default: return super.onOptionsItemSelected(item); } } + @Override + protected void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + if (request == REQUEST_SAVE_ATTACHMENT && result == RESULT_OK) { + viewModel.saveImage(attachment, data.getData()); + } + } + @Override public void onPullStart() { appBarLayout.animate() @@ -190,9 +237,8 @@ public class ImageActivity extends BriarActivity @RequiresApi(api = 16) private void hideSystemUi(View decorView) { - decorView.setSystemUiVisibility(SYSTEM_UI_FLAG_LAYOUT_STABLE - | SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | SYSTEM_UI_FLAG_FULLSCREEN + decorView.setSystemUiVisibility(SYSTEM_UI_FLAG_FULLSCREEN | + SYSTEM_UI_FLAG_LAYOUT_STABLE | SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN ); appBarLayout.animate() .translationYBy(-1 * appBarLayout.getHeight()) @@ -204,8 +250,7 @@ public class ImageActivity extends BriarActivity @RequiresApi(api = 16) private void showSystemUi(View decorView) { decorView.setSystemUiVisibility( - SYSTEM_UI_FLAG_LAYOUT_STABLE - | SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + SYSTEM_UI_FLAG_LAYOUT_STABLE | SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN ); appBarLayout.animate() .translationYBy(appBarLayout.getHeight()) @@ -230,4 +275,49 @@ public class ImageActivity extends BriarActivity drawableTop != appBarLayout.getTop(); } + private void showSaveImageDialog() { + OnClickListener okListener = (dialog, which) -> { + if (SDK_INT >= 19) { + Intent intent = getCreationIntent(); + startActivityForResult(intent, REQUEST_SAVE_ATTACHMENT); + } else { + viewModel.saveImage(attachment); + } + }; + Builder builder = new Builder(this, R.style.BriarDialogTheme); + builder.setTitle(getString(R.string.dialog_title_save_image)); + builder.setMessage(getString(R.string.dialog_message_save_image)); + Drawable icon = ContextCompat.getDrawable(this, R.drawable.ic_security); + DrawableCompat.setTint(requireNonNull(icon), + ContextCompat.getColor(this, R.color.color_primary)); + builder.setIcon(icon); + builder.setPositiveButton(R.string.save_image, okListener); + builder.setNegativeButton(R.string.cancel, null); + builder.show(); + } + + @RequiresApi(api = 19) + private Intent getCreationIntent() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", + Locale.getDefault()); + String fileName = sdf.format(new Date()); + Intent intent = new Intent(ACTION_CREATE_DOCUMENT); + intent.addCategory(CATEGORY_OPENABLE); + intent.setType(attachment.getMimeType()); + intent.putExtra(EXTRA_TITLE, fileName); + return intent; + } + + private void onImageSaveStateChanged(@Nullable Boolean error) { + if (error == null) return; + int stringRes = error ? + R.string.save_image_error : R.string.save_image_success; + int colorRes = error ? + R.color.briar_red : R.color.briar_primary; + Snackbar s = Snackbar.make(layout, stringRes, LENGTH_LONG); + s.getView().setBackgroundResource(colorRes); + s.show(); + viewModel.onSaveStateSeen(); + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageViewModel.java new file mode 100644 index 000000000..694887c91 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageViewModel.java @@ -0,0 +1,166 @@ +package org.briarproject.briar.android.conversation; + +import android.app.Application; +import android.arch.lifecycle.AndroidViewModel; +import android.arch.lifecycle.LiveData; +import android.arch.lifecycle.MutableLiveData; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.support.annotation.UiThread; + +import org.briarproject.bramble.api.db.DatabaseExecutor; +import org.briarproject.bramble.api.db.DbException; +import org.briarproject.bramble.api.lifecycle.IoExecutor; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.sync.MessageId; +import org.briarproject.briar.api.messaging.Attachment; +import org.briarproject.briar.api.messaging.MessagingManager; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.inject.Inject; + +import static android.media.MediaScannerConnection.scanFile; +import static android.os.Environment.DIRECTORY_PICTURES; +import static android.os.Environment.getExternalStoragePublicDirectory; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.util.IoUtils.copyAndClose; +import static org.briarproject.bramble.util.LogUtils.logException; + +@NotNullByDefault +public class ImageViewModel extends AndroidViewModel { + + private static Logger LOG = getLogger(ImageViewModel.class.getName()); + + private final MessagingManager messagingManager; + @DatabaseExecutor + private final Executor dbExecutor; + @IoExecutor + private final Executor ioExecutor; + + private MutableLiveData saveState = new MutableLiveData<>(); + + @Inject + public ImageViewModel(Application application, + MessagingManager messagingManager, + @DatabaseExecutor Executor dbExecutor, + @IoExecutor Executor ioExecutor) { + super(application); + this.messagingManager = messagingManager; + this.dbExecutor = dbExecutor; + this.ioExecutor = ioExecutor; + } + + /** + * A LiveData that is true if the image was saved, + * false if there was an error and null otherwise. + * + * Call {@link #onSaveStateSeen()} after consuming an update. + */ + LiveData getSaveState() { + return saveState; + } + + @UiThread + void onSaveStateSeen() { + saveState.setValue(null); + } + + /** + * Saves the attachment to a writeable {@link Uri}. + */ + @UiThread + void saveImage(AttachmentItem attachment, @Nullable Uri uri) { + if (uri == null) { + saveState.setValue(false); + } else { + saveImage(attachment, () -> getOutputStream(uri), null); + } + } + + /** + * Saves the attachment on external storage, + * assuming the permission was granted during install time. + */ + void saveImage(AttachmentItem attachment) { + File file = getImageFile(attachment); + saveImage(attachment, () -> getOutputStream(file), () -> scanFile( + getApplication(), new String[] {file.toString()}, null, null)); + } + + private void saveImage(AttachmentItem attachment, OutputStreamProvider osp, + @Nullable Runnable afterCopy) { + MessageId messageId = attachment.getMessageId(); + dbExecutor.execute(() -> { + try { + Attachment a = messagingManager.getAttachment(messageId); + copyImageFromDb(a, osp, afterCopy); + } catch (DbException e) { + logException(LOG, WARNING, e); + saveState.postValue(true); + } + }); + } + + private void copyImageFromDb(Attachment a, OutputStreamProvider osp, + @Nullable Runnable afterCopy) { + ioExecutor.execute(() -> { + try { + InputStream is = a.getStream(); + OutputStream os = osp.getOutputStream(); + copyAndClose(is, os); + if (afterCopy != null) afterCopy.run(); + saveState.postValue(false); + } catch (IOException e) { + logException(LOG, WARNING, e); + saveState.postValue(true); + } + }); + } + + private String getFileName() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", + Locale.getDefault()); + return sdf.format(new Date()); + } + + private File getImageFile(AttachmentItem attachment) { + File path = getExternalStoragePublicDirectory(DIRECTORY_PICTURES); + //noinspection ResultOfMethodCallIgnored + path.mkdirs(); + String fileName = getFileName(); + String ext = "." + attachment.getExtension(); + File file = new File(path, fileName + ext); + int i = 1; + while (file.exists()) { + file = new File(path, fileName + " (" + i + ")" + ext); + } + return file; + } + + private OutputStream getOutputStream(File file) throws IOException { + return new FileOutputStream(file); + } + + private OutputStream getOutputStream(Uri uri) throws IOException { + OutputStream os = + getApplication().getContentResolver().openOutputStream(uri); + if (os == null) throw new IOException(); + return os; + } + + private interface OutputStreamProvider { + OutputStream getOutputStream() throws IOException; + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/ViewModelModule.java b/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/ViewModelModule.java index c7ded4cab..e525fd059 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/ViewModelModule.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/ViewModelModule.java @@ -4,6 +4,7 @@ import android.arch.lifecycle.ViewModel; import android.arch.lifecycle.ViewModelProvider; import org.briarproject.briar.android.conversation.ConversationViewModel; +import org.briarproject.briar.android.conversation.ImageViewModel; import javax.inject.Singleton; @@ -20,6 +21,12 @@ public abstract class ViewModelModule { abstract ViewModel bindConversationViewModel( ConversationViewModel conversationViewModel); + @Binds + @IntoMap + @ViewModelKey(ImageViewModel.class) + abstract ViewModel bindImageViewModel( + ImageViewModel imageViewModel); + @Binds @Singleton abstract ViewModelProvider.Factory bindViewModelFactory( diff --git a/briar-android/src/main/res/drawable/ic_security.xml b/briar-android/src/main/res/drawable/ic_security.xml new file mode 100644 index 000000000..918642399 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_security.xml @@ -0,0 +1,9 @@ + + + diff --git a/briar-android/src/main/res/menu/image_actions.xml b/briar-android/src/main/res/menu/image_actions.xml new file mode 100644 index 000000000..1943f6cfd --- /dev/null +++ b/briar-android/src/main/res/menu/image_actions.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/briar-android/src/main/res/values/strings.xml b/briar-android/src/main/res/values/strings.xml index f1e45e2b0..dd4b0f96a 100644 --- a/briar-android/src/main/res/values/strings.xml +++ b/briar-android/src/main/res/values/strings.xml @@ -139,6 +139,11 @@ Contact deleted You + Save image + Save Image? + Saving this image will allow other apps to access it.\n\nAre you sure you want to save? + Image was saved + Could not save image Add a Contact