Commit a6b1ad48 authored by Torsten Grote's avatar Torsten Grote

[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.
parent 77299a68
Pipeline #2861 passed with stage
in 11 minutes and 27 seconds
......@@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission-sdk-23 android:name="android.permission.USE_BIOMETRIC" />
......
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();
}
}
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<Boolean> 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<Boolean> 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;
}
}
......@@ -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(
......
......@@ -6,6 +6,5 @@
<item
android:id="@+id/action_save_image"
android:title="@string/save_image"
android:visible="false"
app:showAsAction="never"/>
</menu>
......@@ -142,6 +142,7 @@
<string name="save_image">Save image</string>
<string name="dialog_title_save_image">Save Image?</string>
<string name="dialog_message_save_image">Saving this image will allow other apps to access it.\n\nAre you sure you want to save?</string>
<string name="save_image_success">Image was saved</string>
<string name="save_image_error">Could not save image</string>
<!-- Adding Contacts -->
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment