From 2f69a9c38e1c2c7cabe2f9b37053ad2ca681e54f Mon Sep 17 00:00:00 2001 From: Alan Evans Date: Wed, 4 Nov 2020 15:51:30 -0400 Subject: [PATCH] Share media from within Media Preview and share QR code image. --- app/src/main/AndroidManifest.xml | 5 + .../securesms/MediaPreviewActivity.java | 45 +++++-- .../providers/BaseContentProvider.java | 62 ++++++++++ .../providers/BlobContentProvider.java | 110 ++++++++++++++++++ .../securesms/providers/BlobProvider.java | 2 +- .../securesms/providers/MmsBodyProvider.java | 3 +- .../securesms/providers/PartProvider.java | 59 +++++----- .../qr/GroupLinkShareQrDialogFragment.java | 63 +++++++++- .../util/MemoryFileDescriptorProxy.java | 89 ++++++++++++++ .../securesms/util/MemoryFileUtil.java | 36 ++++-- app/src/main/res/menu/media_preview.xml | 3 + app/src/main/res/values/strings.xml | 2 + 12 files changed, 418 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptorProxy.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 62ba85dab3..e955f3897f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -673,6 +673,11 @@ android:exported="false" android:authorities="${applicationId}.part" /> + + = 26); + if (cameFromAllMedia) { menu.findItem(R.id.media_preview__overview).setVisible(false); } @@ -464,16 +492,17 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity } @Override - public boolean onOptionsItemSelected(MenuItem item) { + public boolean onOptionsItemSelected(@NonNull MenuItem item) { super.onOptionsItemSelected(item); - switch (item.getItemId()) { - case R.id.media_preview__overview: showOverview(); return true; - case R.id.media_preview__forward: forward(); return true; - case R.id.save: saveToDisk(); return true; - case R.id.delete: deleteMedia(); return true; - case android.R.id.home: finish(); return true; - } + int itemId = item.getItemId(); + + if (itemId == R.id.media_preview__overview) { showOverview(); return true; } + if (itemId == R.id.media_preview__forward) { forward(); return true; } + if (itemId == R.id.media_preview__share) { share(); return true; } + if (itemId == R.id.save) { saveToDisk(); return true; } + if (itemId == R.id.delete) { deleteMedia(); return true; } + if (itemId == android.R.id.home) { finish(); return true; } return false; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java new file mode 100644 index 0000000000..5e4c6c287a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.providers; + +import android.content.ContentProvider; +import android.content.Context; +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.provider.OpenableColumns; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; + +abstract class BaseContentProvider extends ContentProvider { + + private static final String[] COLUMNS = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; + + /** + * Sanity checks the security like FileProvider does. + */ + @Override + public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) { + super.attachInfo(context, info); + + if (info.exported) { + throw new SecurityException("Provider must not be exported"); + } + if (!info.grantUriPermissions) { + throw new SecurityException("Provider must grant uri permissions"); + } + } + + protected static Cursor createCursor(@Nullable String[] projection, @NonNull String fileName, long fileSize) { + if (projection == null || projection.length == 0) { + projection = COLUMNS; + } + + ArrayList cols = new ArrayList<>(projection.length); + ArrayList values = new ArrayList<>(projection.length); + + for (String col : projection) { + if (OpenableColumns.DISPLAY_NAME.equals(col)) { + cols.add(OpenableColumns.DISPLAY_NAME); + values.add(fileName); + } else if (OpenableColumns.SIZE.equals(col)) { + cols.add(OpenableColumns.SIZE); + values.add(fileSize); + } + } + + MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1); + + cursor.addRow(values.toArray(new Object[0])); + + return cursor; + } + + protected static String createFileNameForMimeType(String mimeType) { + return mimeType.replace('/', '.'); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java new file mode 100644 index 0000000000..23798b6f47 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.providers; + +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.MemoryFile; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.MemoryFileUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public final class BlobContentProvider extends BaseContentProvider { + + private static final String TAG = Log.tag(BlobContentProvider.class); + + @Override + public boolean onCreate() { + Log.i(TAG, "onCreate()"); + return true; + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + Log.i(TAG, "openFile() called: " + uri); + + try { + try (InputStream stream = BlobProvider.getInstance().getStream(ApplicationDependencies.getApplication(), uri)) { + Long fileSize = BlobProvider.getFileSize(uri); + if (fileSize == null) { + Log.w(TAG, "No file size available"); + throw new FileNotFoundException(); + } + + return getParcelStreamForStream(stream, Util.toIntExact(fileSize)); + } + } catch (IOException e) { + throw new FileNotFoundException(); + } + } + + private static @NonNull ParcelFileDescriptor getParcelStreamForStream(@NonNull InputStream in, int fileSize) throws IOException { + MemoryFile memoryFile = new MemoryFile(null, fileSize); + + try (OutputStream out = memoryFile.getOutputStream()) { + Util.copy(in, out); + } + + return MemoryFileUtil.getParcelFileDescriptor(memoryFile); + } + + @Nullable + @Override + public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) { + Log.i(TAG, "query() called: " + uri); + + if (projection == null || projection.length <= 0) return null; + + String mimeType = BlobProvider.getMimeType(uri); + String fileName = BlobProvider.getFileName(uri); + Long fileSize = BlobProvider.getFileSize(uri); + + if (fileSize == null) { + Log.w(TAG, "No file size"); + return null; + } + + if (mimeType == null) { + Log.w(TAG, "No mime type"); + return null; + } + + if (fileName == null) { + fileName = createFileNameForMimeType(mimeType); + } + + return createCursor(projection, fileName, fileSize); + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return BlobProvider.getMimeType(uri); + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + return null; + } + + @Override + public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + + @Override + public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java index bb1eea2303..578ec8e643 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -45,7 +45,7 @@ public class BlobProvider { private static final String MULTI_SESSION_DIRECTORY = "multi_session_blobs"; private static final String SINGLE_SESSION_DIRECTORY = "single_session_blobs"; - public static final String AUTHORITY = BuildConfig.APPLICATION_ID; + public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".blob"; public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/blob"); public static final String PATH = "blob/*/*/*/*/*"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/MmsBodyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/MmsBodyProvider.java index cc98728eec..8195342cc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/MmsBodyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/MmsBodyProvider.java @@ -16,7 +16,6 @@ */ package org.thoughtcrime.securesms.providers; -import android.content.ContentProvider; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; @@ -35,7 +34,7 @@ import java.io.FileNotFoundException; import java.io.InputStream; import java.io.OutputStream; -public class MmsBodyProvider extends ContentProvider { +public final class MmsBodyProvider extends BaseContentProvider { private static final String TAG = MmsBodyProvider.class.getSimpleName(); private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".mms"; private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY + "/mms"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java index 2c9aafd351..f801bcd4f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/PartProvider.java @@ -16,18 +16,16 @@ */ package org.thoughtcrime.securesms.providers; -import android.content.ContentProvider; import android.content.ContentUris; import android.content.ContentValues; import android.content.UriMatcher; import android.database.Cursor; -import android.database.MatrixCursor; import android.net.Uri; import android.os.MemoryFile; import android.os.ParcelFileDescriptor; -import android.provider.OpenableColumns; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.attachments.AttachmentId; @@ -44,9 +42,9 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -public class PartProvider extends ContentProvider { +public final class PartProvider extends BaseContentProvider { - private static final String TAG = PartProvider.class.getSimpleName(); + private static final String TAG = Log.tag(PartProvider.class); private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".part"; private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY + "/part"; @@ -80,8 +78,7 @@ public class PartProvider extends ContentProvider { return null; } - switch (uriMatcher.match(uri)) { - case SINGLE_ROW: + if (uriMatcher.match(uri) == SINGLE_ROW) { Log.i(TAG, "Parting out a single row..."); try { final PartUriParser partUri = new PartUriParser(uri); @@ -105,15 +102,14 @@ public class PartProvider extends ContentProvider { public String getType(@NonNull Uri uri) { Log.i(TAG, "getType() called: " + uri); - switch (uriMatcher.match(uri)) { - case SINGLE_ROW: - PartUriParser partUriParser = new PartUriParser(uri); - DatabaseAttachment attachment = DatabaseFactory.getAttachmentDatabase(getContext()) - .getAttachment(partUriParser.getPartId()); + if (uriMatcher.match(uri) == SINGLE_ROW) { + PartUriParser partUriParser = new PartUriParser(uri); + DatabaseAttachment attachment = DatabaseFactory.getAttachmentDatabase(getContext()).getAttachment(partUriParser.getPartId()); - if (attachment != null) { - return attachment.getContentType(); - } + if (attachment != null) { + Log.i(TAG, "getType() called: " + uri + " It's " + attachment.getContentType()); + return attachment.getContentType(); + } } return null; @@ -126,32 +122,29 @@ public class PartProvider extends ContentProvider { } @Override - public Cursor query(@NonNull Uri url, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + public Cursor query(@NonNull Uri url, @Nullable String[] projection, String selection, String[] selectionArgs, String sortOrder) { Log.i(TAG, "query() called: " + url); - if (projection == null || projection.length <= 0) return null; + if (uriMatcher.match(url) == SINGLE_ROW) { + PartUriParser partUri = new PartUriParser(url); + DatabaseAttachment attachment = DatabaseFactory.getAttachmentDatabase(getContext()).getAttachment(partUri.getPartId()); - switch (uriMatcher.match(url)) { - case SINGLE_ROW: - PartUriParser partUri = new PartUriParser(url); - DatabaseAttachment attachment = DatabaseFactory.getAttachmentDatabase(getContext()).getAttachment(partUri.getPartId()); + if (attachment == null) return null; - if (attachment == null) return null; + long fileSize = attachment.getSize(); - MatrixCursor matrixCursor = new MatrixCursor(projection, 1); - Object[] resultRow = new Object[projection.length]; + if (fileSize <= 0) { + Log.w(TAG, "Empty file " + fileSize); + return null; + } - for (int i=0;i { - // TODO [Alan] GV2 Allow qr image share - }); + // Restricted to API26 because of MemoryFileUtil not supporting lower API levels well + if (Build.VERSION.SDK_INT >= 26) { + shareCodeButton.setVisibility(View.VISIBLE); + + shareCodeButton.setOnClickListener(v -> { + Uri shareUri; + + try { + shareUri = createTemporaryPng(url); + } catch (IOException e) { + Log.w(TAG, e); + return; + } + + Intent intent = ShareCompat.IntentBuilder.from(requireActivity()) + .setType("image/png") + .setStream(shareUri) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + requireContext().startActivity(intent); + }); + } else { + shareCodeButton.setVisibility(View.GONE); + } + } + + private static Uri createTemporaryPng(@Nullable String url) throws IOException { + Bitmap qrBitmap = QrCode.create(url, Color.BLACK, Color.WHITE); + + try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + qrBitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream); + byteArrayOutputStream.flush(); + + byte[] bytes = byteArrayOutputStream.toByteArray(); + + return BlobProvider.getInstance() + .forData(bytes) + .withMimeType("image/png") + .withFileName("SignalGroupQr.png") + .createForSingleSessionInMemory(); + } finally { + qrBitmap.recycle(); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptorProxy.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptorProxy.java new file mode 100644 index 0000000000..19cf28f4ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptorProxy.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.MemoryFile; +import android.os.ParcelFileDescriptor; +import android.os.ProxyFileDescriptorCallback; +import android.os.storage.StorageManager; +import android.system.ErrnoException; +import android.system.OsConstants; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.logging.Log; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +@RequiresApi(api = 26) +final class MemoryFileDescriptorProxy { + + private static final String TAG = Log.tag(MemoryFileDescriptorProxy.class); + + public static ParcelFileDescriptor create(@NonNull Context context, + @NonNull MemoryFile file) + throws IOException + { + StorageManager storageManager = Objects.requireNonNull(context.getSystemService(StorageManager.class)); + HandlerThread thread = new HandlerThread("MemoryFile"); + + thread.start(); + Log.i(TAG, "Thread started"); + + Handler handler = new Handler(thread.getLooper()); + ProxyCallback proxyCallback = new ProxyCallback(file, () -> { + if (thread.quitSafely()) { + Log.i(TAG, "Thread quitSafely true"); + } else { + Log.w(TAG, "Thread quitSafely false"); + } + }); + + ParcelFileDescriptor parcelFileDescriptor = storageManager.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, + proxyCallback, + handler); + + Log.i(TAG, "Created"); + return parcelFileDescriptor; + } + + private static final class ProxyCallback extends ProxyFileDescriptorCallback { + + private final MemoryFile memoryFile; + private final Runnable onClose; + + ProxyCallback(@NonNull MemoryFile memoryFile, Runnable onClose) { + this.memoryFile = memoryFile; + this.onClose = onClose; + } + + @Override + public long onGetSize() { + return memoryFile.length(); + } + + @Override + public int onRead(long offset, int size, byte[] data) throws ErrnoException { + try { + InputStream inputStream = memoryFile.getInputStream(); + if(inputStream.skip(offset) != offset){ + throw new AssertionError(); + } + return inputStream.read(data, 0, size); + } catch (IOException e) { + throw new ErrnoException("onRead", OsConstants.EBADF); + } + } + + @Override + public void onRelease() { + Log.i(TAG, "onRelease()"); + memoryFile.close(); + onClose.run(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java index 3c67478da5..d8725e9c2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java @@ -1,36 +1,50 @@ package org.thoughtcrime.securesms.util; - +import android.annotation.SuppressLint; import android.os.Build; import android.os.MemoryFile; import android.os.ParcelFileDescriptor; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + import java.io.FileDescriptor; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -public class MemoryFileUtil { +public final class MemoryFileUtil { - public static ParcelFileDescriptor getParcelFileDescriptor(MemoryFile file) throws IOException { + private MemoryFileUtil() {} + + public static ParcelFileDescriptor getParcelFileDescriptor(@NonNull MemoryFile file) + throws IOException + { + if (Build.VERSION.SDK_INT >= 26) { + return MemoryFileDescriptorProxy.create(ApplicationDependencies.getApplication(), file); + } else { + return getParcelFileDescriptorLegacy(file); + } + } + + @SuppressWarnings("JavaReflectionMemberAccess") + @SuppressLint("PrivateApi") + public static ParcelFileDescriptor getParcelFileDescriptorLegacy(@NonNull MemoryFile file) + throws IOException + { try { Method method = MemoryFile.class.getDeclaredMethod("getFileDescriptor"); FileDescriptor fileDescriptor = (FileDescriptor) method.invoke(file); - Field field = fileDescriptor.getClass().getDeclaredField("descriptor"); + Field field = fileDescriptor.getClass().getDeclaredField("descriptor"); field.setAccessible(true); int fd = field.getInt(fileDescriptor); return ParcelFileDescriptor.adoptFd(fd); - } catch (IllegalAccessException e) { - throw new IOException(e); - } catch (InvocationTargetException e) { - throw new IOException(e); - } catch (NoSuchMethodException e) { - throw new IOException(e); - } catch (NoSuchFieldException e) { + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | NoSuchFieldException e) { throw new IOException(e); } } diff --git a/app/src/main/res/menu/media_preview.xml b/app/src/main/res/menu/media_preview.xml index 806f1c6fbe..aacd328f13 100644 --- a/app/src/main/res/menu/media_preview.xml +++ b/app/src/main/res/menu/media_preview.xml @@ -5,6 +5,9 @@ android:title="@string/media_preview__forward_title" android:icon="?menu_forward_icon" app:showAsAction="never"/> + This will permanently delete this message. %1$s to %2$s Media no longer available. + Can\'t find an app able to share this media. %1$d new messages in %2$d conversations @@ -2438,6 +2439,7 @@ Save Forward + Share All media