From f34ef4db13bb8b95e041172c7d54531cdc53988a Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 4 Nov 2021 12:23:17 +0100 Subject: [PATCH] Target SDK/API 30 (#2087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Target SDK 30 * Pull changes from Signal * Bugfix, make photo view rail be shown again * Fix capturing images using an external camera * Make backups work. Unfortunately, I did this by copying the backup file to the private storage first. * Fix: Show the correct folder name when exporting attachment Before, after exporting an attachment, on newer Android versions (and maybe also on older ones) the toast always said `File exported to "media"`. * Update src/org/thoughtcrime/securesms/WelcomeActivity.java Co-authored-by: Asiel Díaz Benítez Co-authored-by: bjoern Co-authored-by: Asiel Díaz Benítez --- AndroidManifest.xml | 20 +- build.gradle | 6 +- gradle.properties | 1 + gradle/wrapper/gradle-wrapper.properties | 6 +- .../securesms/LogViewFragment.java | 13 +- .../securesms/MediaPreviewActivity.java | 26 +- .../securesms/MessageSelectorFragment.java | 44 +-- .../securesms/WelcomeActivity.java | 105 +++++-- .../components/AttachmentTypeSelector.java | 2 +- .../securesms/components/AvatarSelector.java | 2 +- .../components/RecentPhotoViewRail.java | 18 +- .../database/loaders/RecentPhotosLoader.java | 7 +- .../securesms/mms/AttachmentManager.java | 25 +- .../securesms/util/MediaUtil.java | 20 +- .../securesms/util/SaveAttachmentTask.java | 261 ++++++++++++++---- .../securesms/util/StorageUtil.java | 50 +++- .../securesms/util/StreamUtil.java | 25 ++ 17 files changed, 476 insertions(+), 155 deletions(-) create mode 100644 src/org/thoughtcrime/securesms/util/StreamUtil.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 5f5d85c4a..d18fe8b8f 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -45,7 +45,6 @@ android:theme="@style/TextSecure.LightTheme" android:largeHeap="true" tools:ignore="GoogleAppIndexingWarning" - android:requestLegacyExternalStorage="true" > - - - - + + + + - + + + + + + diff --git a/build.gradle b/build.gradle index 68b75b403..a37033059 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ buildscript { } } dependencies { - classpath 'com.android.tools.build:gradle:3.4.1' + classpath 'com.android.tools.build:gradle:7.0.3' } } @@ -91,7 +91,7 @@ dependencies { android { flavorDimensions "none" - compileSdkVersion 29 + compileSdkVersion 30 useLibrary 'org.apache.http.legacy' dexOptions { @@ -106,7 +106,7 @@ android { multiDexEnabled true minSdkVersion 16 - targetSdkVersion 29 + targetSdkVersion 30 vectorDrawables.useSupportLibrary = true diff --git a/gradle.properties b/gradle.properties index dbb7bf70d..2510a2d94 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,3 @@ android.enableJetifier=true android.useAndroidX=true +org.gradle.jvmargs=-Xmx4608m \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 194b59eb4..ee5f512e7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Jul 09 18:28:04 CEST 2019 +#Wed Oct 27 11:37:14 CEST 2021 distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip +zipStoreBase=GRADLE_USER_HOME diff --git a/src/org/thoughtcrime/securesms/LogViewFragment.java b/src/org/thoughtcrime/securesms/LogViewFragment.java index 7992948ee..946b37bda 100644 --- a/src/org/thoughtcrime/securesms/LogViewFragment.java +++ b/src/org/thoughtcrime/securesms/LogViewFragment.java @@ -26,10 +26,8 @@ import android.os.Build; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.Bundle; +import android.os.Environment; import android.os.PowerManager; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; import android.text.TextUtils; import android.util.Log; import android.util.TypedValue; @@ -38,14 +36,15 @@ import android.view.View; import android.view.ViewGroup; import android.widget.EditText; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + import com.b44t.messenger.DcContext; import org.thoughtcrime.securesms.connect.DcHelper; -import org.thoughtcrime.securesms.database.NoExternalStorageException; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.Prefs; import org.thoughtcrime.securesms.util.Scrubber; -import org.thoughtcrime.securesms.util.StorageUtil; import java.io.BufferedReader; import java.io.BufferedWriter; @@ -116,7 +115,7 @@ public class LogViewFragment extends Fragment { String logFileName = "deltachat-log-" + dateFormat.format(now) + ".txt"; try { - outputDir = StorageUtil.getDownloadDir(); + outputDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); String logText = logPreview.getText().toString(); if(!logText.trim().equals("")){ File logFile = new File(outputDir + "/" + logFileName); @@ -128,7 +127,7 @@ public class LogViewFragment extends Fragment { logFileBufferWriter.write(logText); logFileBufferWriter.close(); } - } catch (IOException | NoExternalStorageException e) { + } catch (IOException e) { e.printStackTrace(); return false; } diff --git a/src/org/thoughtcrime/securesms/MediaPreviewActivity.java b/src/org/thoughtcrime/securesms/MediaPreviewActivity.java index efeab7920..cfc50f6a0 100644 --- a/src/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/src/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -45,14 +45,6 @@ import androidx.loader.content.Loader; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; - import com.b44t.messenger.DcChat; import com.b44t.messenger.DcContext; import com.b44t.messenger.DcMediaGalleryElement; @@ -73,6 +65,7 @@ import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; +import org.thoughtcrime.securesms.util.StorageUtil; import org.thoughtcrime.securesms.util.Util; import java.io.IOException; @@ -300,20 +293,29 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity if (mediaItem != null) { SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> { + if (StorageUtil.canWriteToMediaStore(this)) { + performSavetoDisk(mediaItem); + return; + } + Permissions.with(this) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .ifNecessary() .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) .onAllGranted(() -> { - SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); - long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis(); - saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null)); + performSavetoDisk(mediaItem); }) .execute(); }); } } + private void performSavetoDisk(@NonNull MediaItem mediaItem) { + SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); + long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis(); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null)); + } + private void showInChat() { MediaItem mediaItem = getCurrentMediaItem(); if (mediaItem == null || mediaItem.msgId == DcMsg.DC_MSG_NO_ID) { diff --git a/src/org/thoughtcrime/securesms/MessageSelectorFragment.java b/src/org/thoughtcrime/securesms/MessageSelectorFragment.java index bd06ea49b..a87af4410 100644 --- a/src/org/thoughtcrime/securesms/MessageSelectorFragment.java +++ b/src/org/thoughtcrime/securesms/MessageSelectorFragment.java @@ -6,8 +6,8 @@ import android.net.Uri; import android.os.AsyncTask; import android.view.Menu; import android.view.View; -import android.widget.EditText; import android.widget.TextView; + import androidx.appcompat.app.AlertDialog; import androidx.appcompat.view.ActionMode; import androidx.fragment.app.Fragment; @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.connect.DcEventCenter; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.SaveAttachmentTask; +import org.thoughtcrime.securesms.util.StorageUtil; import java.util.Set; @@ -75,26 +76,33 @@ public abstract class MessageSelectorFragment protected void handleSaveAttachment(final Set messageRecords) { SaveAttachmentTask.showWarningDialog(getContext(), (dialogInterface, i) -> { - Permissions.with(getActivity()) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) - .ifNecessary() - .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) - .onAllGranted(() -> { - SaveAttachmentTask.Attachment[] attachments = new SaveAttachmentTask.Attachment[messageRecords.size()]; - int index = 0; - for (DcMsg message : messageRecords) { - attachments[index] = new SaveAttachmentTask.Attachment( - Uri.fromFile(message.getFileAsFile()), message.getFilemime(), message.getDateReceived(), message.getFilename()); - index++; - } - SaveAttachmentTask saveTask = new SaveAttachmentTask(getContext()); - saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, attachments); - if (actionMode != null) actionMode.finish(); - }) - .execute(); + if (StorageUtil.canWriteToMediaStore(getContext())) { + performSave(messageRecords); + return; + } + + Permissions.with(getActivity()) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) + .onAllGranted(() -> performSave(messageRecords)) + .execute(); }); } + private void performSave(Set messageRecords) { + SaveAttachmentTask.Attachment[] attachments = new SaveAttachmentTask.Attachment[messageRecords.size()]; + int index = 0; + for (DcMsg message : messageRecords) { + attachments[index] = new SaveAttachmentTask.Attachment( + Uri.fromFile(message.getFileAsFile()), message.getFilemime(), message.getDateReceived(), message.getFilename()); + index++; + } + SaveAttachmentTask saveTask = new SaveAttachmentTask(getContext()); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, attachments); + if (actionMode != null) actionMode.finish(); + } + protected void handleShowInChat(final DcMsg dcMsg) { Intent intent = new Intent(getContext(), ConversationActivity.class); intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, dcMsg.getChatId()); diff --git a/src/org/thoughtcrime/securesms/WelcomeActivity.java b/src/org/thoughtcrime/securesms/WelcomeActivity.java index c318097f2..1b2819d67 100644 --- a/src/org/thoughtcrime/securesms/WelcomeActivity.java +++ b/src/org/thoughtcrime/securesms/WelcomeActivity.java @@ -5,14 +5,18 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.content.DialogInterface; import android.content.Intent; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.text.util.Linkify; +import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import com.b44t.messenger.DcContext; @@ -24,6 +28,7 @@ import com.google.zxing.integration.android.IntentResult; import org.thoughtcrime.securesms.connect.AccountManager; import org.thoughtcrime.securesms.connect.DcEventCenter; import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.mms.AttachmentManager; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.qr.RegistrationQrActivity; import org.thoughtcrime.securesms.service.GenericForegroundService; @@ -31,12 +36,22 @@ import org.thoughtcrime.securesms.service.NotificationController; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.StreamUtil; +import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.views.ProgressDialog; import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; public class WelcomeActivity extends BaseActionBarActivity implements DcEventCenter.DcEventDelegate { public static final String QR_ACCOUNT_EXTRA = "qr_account_extra"; + public static final int PICK_BACKUP = 20574; + private final static String TAG = WelcomeActivity.class.getSimpleName(); + public static final String TMP_BACKUP_FILE = "tmp-backup-file"; private boolean manualConfigure = true; // false: configure by QR account creation private ProgressDialog progressDialog = null; @@ -106,29 +121,31 @@ public class WelcomeActivity extends BaseActionBarActivity implements DcEventCen .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_storage_denied)) .onAllGranted(() -> { File imexDir = DcHelper.getImexDir(); - final String backupFile = dcContext.imexHasBackup(imexDir.getAbsolutePath()); - if (backupFile != null) { - new AlertDialog.Builder(this) - .setTitle(R.string.import_backup_title) - .setMessage(String.format(getResources().getString(R.string.import_backup_ask), backupFile)) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - startImport(backupFile); - }) - .show(); - } - else { - new AlertDialog.Builder(this) - .setTitle(R.string.import_backup_title) - .setMessage(String.format(getResources().getString(R.string.import_backup_no_backup_found), imexDir.getAbsolutePath())) - .setPositiveButton(android.R.string.ok, null) - .show(); + if (Build.VERSION.SDK_INT >= 29) { + AttachmentManager.selectMediaType(this, "application/x-tar", null, PICK_BACKUP, StorageUtil.getDownloadUri()); + } else { + final String backupFile = dcContext.imexHasBackup(imexDir.getAbsolutePath()); + if (backupFile != null) { + new AlertDialog.Builder(this) + .setTitle(R.string.import_backup_title) + .setMessage(String.format(getResources().getString(R.string.import_backup_ask), backupFile)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dialog, which) -> startImport(backupFile, null)) + .show(); + } + else { + new AlertDialog.Builder(this) + .setTitle(R.string.import_backup_title) + .setMessage(String.format(getResources().getString(R.string.import_backup_no_backup_found), imexDir.getAbsolutePath())) + .setPositiveButton(android.R.string.ok, null) + .show(); + } } }) .execute(); } - private void startImport(final String backupFile) + private void startImport(@Nullable final String backupFile, final @Nullable Uri backupFileUri) { notificationController = GenericForegroundService.startForegroundTask(this, getString(R.string.import_backup_title)); if( progressDialog!=null ) { @@ -143,11 +160,36 @@ public class WelcomeActivity extends BaseActionBarActivity implements DcEventCen progressDialog.setButton(DialogInterface.BUTTON_NEGATIVE, getResources().getString(android.R.string.cancel), (dialog, which) -> { dcContext.stopOngoingProcess(); notificationController.close(); + cleanupTempBackupFile(); }); progressDialog.show(); - DcHelper.getEventCenter(this).captureNextError(); - dcContext.imex(DcContext.DC_IMEX_IMPORT_BACKUP, backupFile); + Util.runOnBackground(() -> { + String file = backupFile; + if (backupFile == null) { + try { + file = copyToCacheDir(backupFileUri).getAbsolutePath(); + } catch (IOException e) { + e.printStackTrace(); + notificationController.close(); + cleanupTempBackupFile(); + return; + } + } + + DcHelper.getEventCenter(this).captureNextError(); + dcContext.imex(DcContext.DC_IMEX_IMPORT_BACKUP, file); + }); + } + + private File copyToCacheDir(Uri uri) throws IOException { + try (InputStream inputStream = getContentResolver().openInputStream(uri)) { + File file = File.createTempFile(TMP_BACKUP_FILE, ".tmp", getCacheDir()); + try (OutputStream outputStream = new FileOutputStream(file)) { + StreamUtil.copy(inputStream, outputStream); + } + return file; + } } private void startQrAccountCreation(String qrCode) @@ -230,6 +272,7 @@ public class WelcomeActivity extends BaseActionBarActivity implements DcEventCen if (progress==0/*error/aborted*/) { progressError(DcHelper.getEventCenter(this).getCapturedError()); notificationController.close(); + cleanupTempBackupFile(); } else if (progress<1000/*progress in permille*/) { progressUpdate((int)progress); @@ -239,6 +282,7 @@ public class WelcomeActivity extends BaseActionBarActivity implements DcEventCen DcHelper.getAccounts(this).startIo(); progressSuccess(false); notificationController.close(); + cleanupTempBackupFile(); } } else if (manualConfigure && eventId==DcContext.DC_EVENT_CONFIGURE_PROGRESS) { @@ -291,6 +335,27 @@ public class WelcomeActivity extends BaseActionBarActivity implements DcEventCen .show(); break; } + } else if (requestCode == PICK_BACKUP) { + Uri uri = (data != null ? data.getData() : null); + if (uri == null) { + Log.e(TAG, " Can't import null URI"); + return; + } + startImport(null, uri); + } + } + + private void cleanupTempBackupFile() { + try { + File[] files = getCacheDir().listFiles((dir, name) -> name.startsWith(TMP_BACKUP_FILE)); + for (File file : files) { + if (file.getName().endsWith("tmp")) { + Log.i(TAG, "Deleting temp backup file " + file); + file.delete(); + } + } + } catch (Exception e) { + e.printStackTrace(); } } diff --git a/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java b/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java index 7f7f34450..a0824f47c 100644 --- a/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java +++ b/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java @@ -117,7 +117,7 @@ public class AttachmentTypeSelector extends PopupWindow { } public void show(@NonNull Activity activity, final @NonNull View anchor) { - if (Permissions.hasAll(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + if (Permissions.hasAll(activity, Manifest.permission.READ_EXTERNAL_STORAGE)) { recentRail.setVisibility(View.VISIBLE); loaderManager.restartLoader(1, null, recentRail); } else { diff --git a/src/org/thoughtcrime/securesms/components/AvatarSelector.java b/src/org/thoughtcrime/securesms/components/AvatarSelector.java index bef8315bc..ecf987462 100644 --- a/src/org/thoughtcrime/securesms/components/AvatarSelector.java +++ b/src/org/thoughtcrime/securesms/components/AvatarSelector.java @@ -80,7 +80,7 @@ public class AvatarSelector extends PopupWindow { } public void show(@NonNull Activity activity, final @NonNull View anchor) { - if (Permissions.hasAll(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + if (Permissions.hasAll(activity, Manifest.permission.READ_EXTERNAL_STORAGE)) { recentRail.setVisibility(View.VISIBLE); loaderManager.restartLoader(1, null, recentRail); } else { diff --git a/src/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java b/src/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java index f20099a92..4e5eee16c 100644 --- a/src/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java +++ b/src/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java @@ -1,11 +1,19 @@ package org.thoughtcrime.securesms.components; +import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; import android.provider.MediaStore; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.loader.app.LoaderManager; @@ -13,12 +21,6 @@ import androidx.loader.content.Loader; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.ImageView; import com.bumptech.glide.load.Key; import com.bumptech.glide.load.engine.DiskCacheStrategy; @@ -102,13 +104,13 @@ public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.Lo public void onBindItemViewHolder(RecentPhotoViewHolder viewHolder, @NonNull Cursor cursor) { viewHolder.imageView.setImageDrawable(null); - long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)); + long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID)); long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)); long dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_MODIFIED)); String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.MIME_TYPE)); int orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION)); - final Uri uri = Uri.withAppendedPath(baseUri, Long.toString(id)); + final Uri uri = ContentUris.withAppendedId(RecentPhotosLoader.BASE_URL, rowId); Key signature = new MediaStoreSignature(mimeType, dateModified, orientation); diff --git a/src/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java b/src/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java index c2b71c528..4b760e57e 100644 --- a/src/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java +++ b/src/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java @@ -5,7 +5,9 @@ import android.Manifest; import android.content.Context; import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.provider.MediaStore; + import androidx.loader.content.CursorLoader; import org.thoughtcrime.securesms.permissions.Permissions; @@ -22,6 +24,9 @@ public class RecentPhotosLoader extends CursorLoader { MediaStore.Images.ImageColumns.MIME_TYPE }; + private static final String SELECTION = Build.VERSION.SDK_INT > 28 ? MediaStore.Images.Media.IS_PENDING + " != 1" + : MediaStore.Images.Media.DATA + " IS NULL"; + private final Context context; public RecentPhotosLoader(Context context) { @@ -33,7 +38,7 @@ public class RecentPhotosLoader extends CursorLoader { public Cursor loadInBackground() { if (Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - PROJECTION, null, null, + PROJECTION, SELECTION, null, MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC"); } else { return null; diff --git a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java index a0a682001..e45fee21b 100644 --- a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -27,6 +27,7 @@ import android.graphics.PorterDuff; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; +import android.provider.DocumentsContract; import android.provider.MediaStore; import android.provider.OpenableColumns; import android.util.Log; @@ -53,6 +54,7 @@ import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.PersistentBlobProvider; import org.thoughtcrime.securesms.scribbles.ScribbleActivity; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.StorageUtil; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; @@ -383,17 +385,12 @@ public class AttachmentManager { } public static void selectDocument(Activity activity, int requestCode) { - Permissions.with(activity) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) - .ifNecessary() - .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_storage_denied)) - .onAllGranted(() -> selectMediaType(activity, "*/*", null, requestCode)) - .execute(); + selectMediaType(activity, "*/*", null, requestCode); } public static void selectGallery(Activity activity, int requestCode) { Permissions.with(activity) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) .ifNecessary() .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_storage_denied)) .onAllGranted(() -> selectMediaType(activity, "image/*", new String[] {"image/*", "video/*"}, requestCode)) @@ -402,7 +399,7 @@ public class AttachmentManager { public static void selectImage(Activity activity, int requestCode) { Permissions.with(activity) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) .ifNecessary() .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_storage_denied)) .onAllGranted(() -> selectMediaType(activity, "image/*", null, requestCode)) @@ -411,7 +408,7 @@ public class AttachmentManager { public static void selectAudio(Activity activity, int requestCode) { Permissions.with(activity) - .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .request(Manifest.permission.READ_EXTERNAL_STORAGE) .ifNecessary() .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_storage_denied)) .onAllGranted(() -> selectMediaType(activity, "audio/*", null, requestCode)) @@ -511,7 +508,11 @@ public class AttachmentManager { .execute(); } - private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { + public static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { + selectMediaType(activity, type, extraMimeType, requestCode, null); + } + + public static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode, @Nullable Uri initialUri) { final Intent intent = new Intent(); intent.setType(type); @@ -519,6 +520,10 @@ public class AttachmentManager { intent.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeType); } + if (initialUri != null && Build.VERSION.SDK_INT >= 26) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { intent.setAction(Intent.ACTION_OPEN_DOCUMENT); try { diff --git a/src/org/thoughtcrime/securesms/util/MediaUtil.java b/src/org/thoughtcrime/securesms/util/MediaUtil.java index 1342892c9..756eb82a3 100644 --- a/src/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/src/org/thoughtcrime/securesms/util/MediaUtil.java @@ -4,14 +4,15 @@ import android.content.Context; import android.graphics.Bitmap; import android.media.MediaMetadataRetriever; import android.net.Uri; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; import android.text.TextUtils; import android.util.Log; import android.util.Pair; import android.webkit.MimeTypeMap; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + import com.b44t.messenger.DcMsg; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.gif.GifDrawable; @@ -45,6 +46,7 @@ public class MediaUtil { public static final String AUDIO_AAC = "audio/aac"; public static final String AUDIO_UNSPECIFIED = "audio/*"; public static final String VIDEO_UNSPECIFIED = "video/*"; + public static final String OCTET = "application/octet-stream"; public static Slide getSlideForMsg(Context context, DcMsg dcMsg) { @@ -194,6 +196,18 @@ public class MediaUtil { return (null != contentType) && contentType.startsWith("video/"); } + public static boolean isOctetStream(@Nullable String contentType) { + return OCTET.equals(contentType); + } + + public static boolean isImageOrVideoType(String contentType) { + return isImageType(contentType) || isVideoType(contentType); + } + + public static boolean isImageVideoOrAudioType(String contentType) { + return isImageOrVideoType(contentType) || isAudioType(contentType); + } + public static class ThumbnailSize { public ThumbnailSize(int width, int height) { this.width = width; diff --git a/src/org/thoughtcrime/securesms/util/SaveAttachmentTask.java b/src/org/thoughtcrime/securesms/util/SaveAttachmentTask.java index d0eb9b1af..a457d438e 100644 --- a/src/org/thoughtcrime/securesms/util/SaveAttachmentTask.java +++ b/src/org/thoughtcrime/securesms/util/SaveAttachmentTask.java @@ -1,17 +1,25 @@ package org.thoughtcrime.securesms.util; +import android.content.ContentResolver; +import android.content.ContentValues; import android.content.Context; import android.content.DialogInterface.OnClickListener; +import android.database.Cursor; import android.media.MediaScannerConnection; import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; +import android.util.Log; +import android.webkit.MimeTypeMap; +import android.widget.Toast; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import android.util.Log; -import android.widget.Toast; +import androidx.loader.content.CursorLoader; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.NoExternalStorageException; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; @@ -22,8 +30,12 @@ import java.io.InputStream; import java.io.OutputStream; import java.lang.ref.WeakReference; import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.TimeUnit; -public class SaveAttachmentTask extends ProgressDialogAsyncTask> { +public class SaveAttachmentTask extends ProgressDialogAsyncTask> { private static final String TAG = SaveAttachmentTask.class.getSimpleName(); static final int SUCCESS = 0; @@ -40,16 +52,16 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask doInBackground(SaveAttachmentTask.Attachment... attachments) { + protected Pair doInBackground(SaveAttachmentTask.Attachment... attachments) { if (attachments == null || attachments.length == 0) { throw new AssertionError("must pass in at least one attachment"); } try { Context context = contextReference.get(); - String directory = null; + Uri uri = null; - if (!StorageUtil.canWriteInSignalStorageDir()) { + if (!StorageUtil.canWriteToMediaStore(context)) { return new Pair<>(WRITE_ACCESS_FAILURE, null); } @@ -59,61 +71,142 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask(FAILURE, null); + uri = saveAttachment(context, attachment); + if (uri == null) return new Pair<>(FAILURE, null); } } if (attachments.length > 1) return new Pair<>(SUCCESS, null); - else return new Pair<>(SUCCESS, directory); - } catch (NoExternalStorageException|IOException ioe) { + else return new Pair<>(SUCCESS, uri); + } catch (IOException ioe) { Log.w(TAG, ioe); return new Pair<>(FAILURE, null); } } - private @Nullable String saveAttachment(Context context, Attachment attachment) - throws NoExternalStorageException, IOException + private @Nullable Uri saveAttachment(Context context, Attachment attachment) throws IOException { - String contentType = MediaUtil.getCorrectedMimeType(attachment.contentType); + String contentType = Objects.requireNonNull(MediaUtil.getCorrectedMimeType(attachment.contentType)); String fileName = attachment.fileName; if (fileName == null) fileName = generateOutputFileName(contentType, attachment.date); fileName = sanitizeOutputFileName(fileName); - File outputDirectory = createOutputDirectoryFromContentType(contentType); - File mediaFile = createOutputFile(outputDirectory, fileName); - InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.uri); + Uri outputUri = getMediaStoreContentUriForType(contentType); + Uri mediaUri = createOutputUri(outputUri, contentType, fileName); + ContentValues updateValues = new ContentValues(); - if (inputStream == null) { + if (mediaUri == null) { + Log.w(TAG, "Failed to create mediaUri for " + contentType); return null; } - OutputStream outputStream = new FileOutputStream(mediaFile); - Util.copy(inputStream, outputStream); + try (InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.uri)) { - MediaScannerConnection.scanFile(context, new String[]{mediaFile.getAbsolutePath()}, - new String[]{contentType}, null); + if (inputStream == null) { + return null; + } - return outputDirectory.getName(); - } - - private File createOutputDirectoryFromContentType(@NonNull String contentType) - throws NoExternalStorageException - { - File outputDirectory; - - if (contentType.startsWith("video/")) { - outputDirectory = StorageUtil.getVideoDir(); - } else if (contentType.startsWith("audio/")) { - outputDirectory = StorageUtil.getAudioDir(); - } else if (contentType.startsWith("image/")) { - outputDirectory = StorageUtil.getImageDir(); - } else { - outputDirectory = StorageUtil.getDownloadDir(); + if (Util.equals(outputUri.getScheme(), ContentResolver.SCHEME_FILE)) { + try (OutputStream outputStream = new FileOutputStream(mediaUri.getPath())) { + StreamUtil.copy(inputStream, outputStream); + MediaScannerConnection.scanFile(context, new String[]{mediaUri.getPath()}, new String[]{contentType}, null); + } + } else { + try (OutputStream outputStream = context.getContentResolver().openOutputStream(mediaUri, "w")) { + long total = StreamUtil.copy(inputStream, outputStream); + if (total > 0) { + updateValues.put(MediaStore.MediaColumns.SIZE, total); + } + } + } } - if (!outputDirectory.mkdirs()) Log.w(TAG, "mkdirs() returned false, attempting to continue"); - return outputDirectory; + if (Build.VERSION.SDK_INT > 28) { + updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0); + } + + if (updateValues.size() > 0) { + getContext().getContentResolver().update(mediaUri, updateValues, null, null); + } + + return mediaUri; + } + + private @Nullable String getRealPathFromURI(Uri contentUri) { + String[] proj = {MediaStore.MediaColumns.DATA}; + CursorLoader loader = new CursorLoader(getContext(), contentUri, proj, null, null, null); + Cursor cursor = loader.loadInBackground(); + int column_index = 0; + String result = null; + if (cursor != null) { + column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); + cursor.moveToFirst(); + result = cursor.getString(column_index); + cursor.close(); + } + return result; + } + + private @NonNull Uri getMediaStoreContentUriForType(@NonNull String contentType) { + if (contentType.startsWith("video/")) { + return StorageUtil.getVideoUri(); + } else if (contentType.startsWith("audio/")) { + return StorageUtil.getAudioUri(); + } else if (contentType.startsWith("image/")) { + return StorageUtil.getImageUri(); + } else { + return StorageUtil.getDownloadUri(); + } + } + + private @Nullable File ensureExternalPath(@Nullable File path) { + if (path != null && path.exists()) { + return path; + } + + if (path == null) { + File documents = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + if (documents.exists() || documents.mkdirs()) { + return documents; + } else { + return null; + } + } + + if (path.mkdirs()) { + return path; + } else { + return null; + } + } + + /** + * Returns a path to a shared media (or documents) directory for the type of the file. + * + * Note that this method attempts to create a directory if the path returned from + * Environment object does not exist yet. The attempt may fail in which case it attempts + * to return the default "Document" path. It finally returns null if it also fails. + * Otherwise it returns the absolute path to the directory. + * + * @param contentType a MIME type of a file + * @return an absolute path to a directory or null + */ + private @Nullable String getExternalPathForType(String contentType) { + File storage = null; + if (contentType.startsWith("video/")) { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); + } else if (contentType.startsWith("audio/")) { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC); + } else if (contentType.startsWith("image/")) { + storage = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); + } + + storage = ensureExternalPath(storage); + if (storage == null) { + return null; + } + + return storage.getAbsolutePath(); } private String generateOutputFileName(@NonNull String contentType, long timestamp) { @@ -130,25 +223,73 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask 28) { + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1); + } else if (Util.equals(outputUri.getScheme(), ContentResolver.SCHEME_FILE)) { + File outputDirectory = new File(outputUri.getPath()); + File outputFile = new File(outputDirectory, base + "." + extension); + + int i = 0; + while (outputFile.exists()) { + outputFile = new File(outputDirectory, base + "-" + (++i) + "." + extension); + } + + if (outputFile.isHidden()) { + throw new IOException("Specified name would not be visible"); + } + + return Uri.fromFile(outputFile); + } else { + String dir = getExternalPathForType(contentType); + if (dir == null) { + throw new IOException(String.format(Locale.US, "Path for type: %s was not available", contentType)); + } + + String outputFileName = fileName; + String dataPath = String.format("%s/%s", dir, outputFileName); + int i = 0; + while (pathTaken(outputUri, dataPath)) { + Log.d(TAG, "The content exists. Rename and check again."); + outputFileName = base + "-" + (++i) + "." + extension; + dataPath = String.format("%s/%s", dir, outputFileName); + } + contentValues.put(MediaStore.MediaColumns.DATA, dataPath); } - return outputFile; + return getContext().getContentResolver().insert(outputUri, contentValues); + } + + private boolean pathTaken(@NonNull Uri outputUri, @NonNull String dataPath) throws IOException { + try (Cursor cursor = getContext().getContentResolver().query(outputUri, + new String[] { MediaStore.MediaColumns.DATA }, + MediaStore.MediaColumns.DATA + " = ?", + new String[] { dataPath }, + null)) + { + if (cursor == null) { + throw new IOException("Something is wrong with the filename to save"); + } + return cursor.moveToFirst(); + } } private String[] getFileNameParts(String fileName) { @@ -164,7 +305,7 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask result) { + protected void onPostExecute(final Pair result) { super.onPostExecute(result); final Context context = contextReference.get(); if (context == null) return; @@ -176,7 +317,19 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask segments = uri.getPathSegments(); + if (segments.size() >= 2) { + dir = segments.get(segments.size() - 2); + } else { + dir = uri.getPath(); + } + Toast.makeText(context, dir==null? context.getString(R.string.done) : context.getString(R.string.file_saved_to, dir), Toast.LENGTH_LONG).show(); diff --git a/src/org/thoughtcrime/securesms/util/StorageUtil.java b/src/org/thoughtcrime/securesms/util/StorageUtil.java index 9cb2e2848..559151b45 100644 --- a/src/org/thoughtcrime/securesms/util/StorageUtil.java +++ b/src/org/thoughtcrime/securesms/util/StorageUtil.java @@ -1,9 +1,17 @@ package org.thoughtcrime.securesms.util; +import android.Manifest; +import android.content.Context; +import android.net.Uri; +import android.os.Build; import android.os.Environment; +import android.provider.MediaStore; + +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.permissions.Permissions; import java.io.File; @@ -31,20 +39,46 @@ public class StorageUtil { return storage.canWrite(); } - public static File getVideoDir() throws NoExternalStorageException { - return new File(getStorageDir(), Environment.DIRECTORY_MOVIES); + public static boolean canWriteToMediaStore(Context context) { + return Build.VERSION.SDK_INT > 28 || + Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE); } - public static File getAudioDir() throws NoExternalStorageException { - return new File(getStorageDir(), Environment.DIRECTORY_MUSIC); + public static @NonNull Uri getVideoUri() { + if (Build.VERSION.SDK_INT < 21) { + return getLegacyUri(Environment.DIRECTORY_MOVIES); + } else { + return MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } } - public static File getImageDir() throws NoExternalStorageException { - return new File(getStorageDir(), Environment.DIRECTORY_PICTURES); + public static @NonNull + Uri getAudioUri() { + if (Build.VERSION.SDK_INT < 21) { + return getLegacyUri(Environment.DIRECTORY_MUSIC); + } else { + return MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + } } - public static File getDownloadDir() throws NoExternalStorageException { - return new File(getStorageDir(), Environment.DIRECTORY_DOWNLOADS); + public static @NonNull Uri getImageUri() { + if (Build.VERSION.SDK_INT < 21) { + return getLegacyUri(Environment.DIRECTORY_PICTURES); + } else { + return MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } + } + + public static @NonNull Uri getDownloadUri() { + if (Build.VERSION.SDK_INT < 29) { + return getLegacyUri(Environment.DIRECTORY_DOWNLOADS); + } else { + return MediaStore.Downloads.EXTERNAL_CONTENT_URI; + } + } + + public static @NonNull Uri getLegacyUri(@NonNull String directory) { + return Uri.fromFile(Environment.getExternalStoragePublicDirectory(directory)); } public static @Nullable String getCleanFileName(@Nullable String fileName) { diff --git a/src/org/thoughtcrime/securesms/util/StreamUtil.java b/src/org/thoughtcrime/securesms/util/StreamUtil.java new file mode 100644 index 000000000..884fdb620 --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/StreamUtil.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class StreamUtil { + + public static long copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[64 * 1024]; + int read; + long total = 0; + + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + total += read; + } + + in.close(); + out.close(); + + return total; + } + +}