From a6b1ad48c39b0c5d9007399047a9b1664bcd1fb4 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 27 Nov 2018 16:25:33 -0200 Subject: [PATCH] [android] Add support for saving image attachments on API < 19 This is done by using the WRITE_EXTERNAL_STORAGE permission to write the file directly without using the system activity. --- briar-android/src/main/AndroidManifest.xml | 1 + .../android/conversation/ImageActivity.java | 97 ++++------ .../android/conversation/ImageViewModel.java | 168 ++++++++++++++++++ .../android/viewmodel/ViewModelModule.java | 7 + .../src/main/res/menu/image_actions.xml | 1 - briar-android/src/main/res/values/strings.xml | 1 + 6 files changed, 207 insertions(+), 68 deletions(-) create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageViewModel.java 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