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