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/conversation/ImageActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageActivity.java
index 6b18a113d..796a1b893 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,10 +1,11 @@
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.net.Uri;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.annotation.RequiresApi;
@@ -26,25 +27,15 @@ import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
import com.github.chrisbanes.photoview.PhotoView;
-import org.briarproject.bramble.api.db.DbException;
-import org.briarproject.bramble.api.lifecycle.IoExecutor;
-import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.briar.R;
import org.briarproject.briar.android.activity.ActivityComponent;
import org.briarproject.briar.android.activity.BriarActivity;
import org.briarproject.briar.android.conversation.glide.GlideApp;
import org.briarproject.briar.android.view.PullDownLayout;
-import org.briarproject.briar.api.messaging.Attachment;
-import org.briarproject.briar.api.messaging.MessagingManager;
-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;
@@ -63,28 +54,20 @@ 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 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;
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
implements PullDownLayout.Callback {
- private final static Logger LOG = getLogger(ImageActivity.class.getName());
-
final static String ATTACHMENT = "attachment";
final static String NAME = "name";
final static String DATE = "date";
@Inject
- MessagingManager messagingManager;
- @Inject
- @IoExecutor
- Executor ioExecutor;
+ ViewModelProvider.Factory viewModelFactory;
+ private ImageViewModel viewModel;
private PullDownLayout layout;
private AppBarLayout appBarLayout;
private PhotoView photoView;
@@ -107,6 +90,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);
@@ -186,9 +174,6 @@ public class ImageActivity extends BriarActivity
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.image_actions, menu);
- if (SDK_INT >= 19) {
- menu.findItem(R.id.action_save_image).setVisible(true);
- }
return super.onCreateOptionsMenu(menu);
}
@@ -199,7 +184,7 @@ public class ImageActivity extends BriarActivity
onBackPressed();
return true;
case R.id.action_save_image:
- if (SDK_INT >= 19) startSaveImage();
+ showSaveImageDialog();
return true;
default:
return super.onOptionsItemSelected(item);
@@ -210,7 +195,7 @@ public class ImageActivity extends BriarActivity
protected void onActivityResult(int request, int result, Intent data) {
super.onActivityResult(request, result, data);
if (request == REQUEST_SAVE_ATTACHMENT && result == RESULT_OK) {
- saveImage(data.getData());
+ viewModel.saveImage(attachment, data.getData());
}
}
@@ -250,9 +235,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())
@@ -264,8 +248,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())
@@ -290,11 +273,14 @@ public class ImageActivity extends BriarActivity
drawableTop != appBarLayout.getTop();
}
- @RequiresApi(api = 19)
- private void startSaveImage() {
+ private void showSaveImageDialog() {
OnClickListener okListener = (dialog, which) -> {
- Intent intent = getCreationIntent();
- startActivityForResult(intent, REQUEST_SAVE_ATTACHMENT);
+ 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));
@@ -317,39 +303,16 @@ public class ImageActivity extends BriarActivity
return intent;
}
- private void saveImage(@Nullable Uri uri) {
- if (uri == null) return;
- MessageId messageId = attachment.getMessageId();
- runOnDbThread(() -> {
- try {
- Attachment a = messagingManager.getAttachment(messageId);
- copyImageFromDb(a, uri);
- } catch (DbException e) {
- logException(LOG, WARNING, e);
- onImageSaveError();
- }
- });
- }
-
- private void copyImageFromDb(Attachment a, Uri uri) {
- ioExecutor.execute(() -> {
- try {
- InputStream is = a.getStream();
- OutputStream os = getContentResolver().openOutputStream(uri);
- if (os == null) throw new IOException();
- copyAndClose(is, os);
- } catch (IOException e) {
- logException(LOG, WARNING, e);
- onImageSaveError();
- }
- });
- }
-
- private void onImageSaveError() {
- Snackbar s =
- Snackbar.make(layout, R.string.save_image_error, LENGTH_LONG);
- s.getView().setBackgroundResource(R.color.briar_red);
+ 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..da1735857
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageViewModel.java
@@ -0,0 +1,168 @@
+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(true);
+ } 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.getMimeType().replaceFirst("image/", ".");
+ 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/menu/image_actions.xml b/briar-android/src/main/res/menu/image_actions.xml
index 86831dbe3..1943f6cfd 100644
--- a/briar-android/src/main/res/menu/image_actions.xml
+++ b/briar-android/src/main/res/menu/image_actions.xml
@@ -6,6 +6,5 @@
diff --git a/briar-android/src/main/res/values/strings.xml b/briar-android/src/main/res/values/strings.xml
index 733cb0648..dd4b0f96a 100644
--- a/briar-android/src/main/res/values/strings.xml
+++ b/briar-android/src/main/res/values/strings.xml
@@ -142,6 +142,7 @@
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