Merge pull request #335 from metaphore/store-permission-refactoring

File Storage Refactoring
This commit is contained in:
Niels Andriesse 2020-09-11 15:08:22 +10:00 committed by GitHub
commit 2991bd37c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 334 additions and 405 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 681 B

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
</vector>

View File

@ -33,7 +33,8 @@
android:layout_width="20dp" android:layout_width="20dp"
android:layout_height="20dp" android:layout_height="20dp"
android:layout_marginEnd="6dp" android:layout_marginEnd="6dp"
android:src="@drawable/ic_folder_white_48dp"/> android:tint="@android:color/white"
android:src="@drawable/ic_baseline_folder_24"/>
<TextView <TextView
android:id="@+id/mediapicker_folder_item_title" android:id="@+id/mediapicker_folder_item_title"

View File

@ -215,7 +215,6 @@ public class CreateProfileActivity extends BaseActionBarActivity implements Inje
this.avatar.setOnClickListener(view -> Permissions.with(this) this.avatar.setOnClickListener(view -> Permissions.with(this)
.request(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.onAnyResult(this::startAvatarSelection) .onAnyResult(this::startAvatarSelection)
.execute()); .execute());

View File

@ -98,7 +98,6 @@ public class DeviceActivity extends PassphraseRequiredActionBarActivity
public void onClick(View v) { public void onClick(View v) {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.DeviceActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code)) .withPermanentDenialDialog(getString(R.string.DeviceActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code))
.onAllGranted(() -> { .onAllGranted(() -> {
getSupportFragmentManager().beginTransaction() getSupportFragmentManager().beginTransaction()

View File

@ -16,7 +16,6 @@
*/ */
package org.thoughtcrime.securesms; package org.thoughtcrime.securesms;
import android.Manifest;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
@ -324,17 +323,19 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
} }
@SuppressWarnings("CodeBlock2Expr") @SuppressWarnings("CodeBlock2Expr")
@SuppressLint({"InlinedApi","StaticFieldLeak"}) @SuppressLint({"InlinedApi", "StaticFieldLeak"})
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) { private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
final Context context = getContext(); final Context context = getContext();
SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> { SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> {
Permissions.with(this) Permissions.with(this)
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary() .maxSdkVersion(Build.VERSION_CODES.P)
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
.onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) .onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
.onAllGranted(() -> { .onAllGranted(() -> {
new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(context, new ProgressDialogAsyncTask<Void, Void, List<SaveAttachmentTask.Attachment>>(
context,
R.string.MediaOverviewActivity_collecting_attachments, R.string.MediaOverviewActivity_collecting_attachments,
R.string.please_wait) { R.string.please_wait) {
@Override @Override
@ -356,8 +357,7 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
@Override @Override
protected void onPostExecute(List<SaveAttachmentTask.Attachment> attachments) { protected void onPostExecute(List<SaveAttachmentTask.Attachment> attachments) {
super.onPostExecute(attachments); super.onPostExecute(attachments);
SaveAttachmentTask saveTask = new SaveAttachmentTask(context, SaveAttachmentTask saveTask = new SaveAttachmentTask(context, attachments.size());
attachments.size());
saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR, saveTask.executeOnExecutor(THREAD_POOL_EXECUTOR,
attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()])); attachments.toArray(new SaveAttachmentTask.Attachment[attachments.size()]));
actionMode.finish(); actionMode.finish();

View File

@ -16,17 +16,16 @@
*/ */
package org.thoughtcrime.securesms; package org.thoughtcrime.securesms;
import android.Manifest;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.annotation.TargetApi; import android.annotation.TargetApi;
import androidx.appcompat.widget.Toolbar;
import androidx.lifecycle.ViewModelProviders; import androidx.lifecycle.ViewModelProviders;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build;
import android.os.Build.VERSION; import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES; import android.os.Build.VERSION_CODES;
import android.os.Bundle; import android.os.Bundle;
@ -341,23 +340,24 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private void saveToDisk() { private void saveToDisk() {
MediaItem mediaItem = getCurrentMediaItem(); MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem == null) return;
if (mediaItem != null) {
SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> { SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary() .maxSdkVersion(Build.VERSION_CODES.P)
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
.onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) .onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
.onAllGranted(() -> { .onAllGranted(() -> {
SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis(); long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis();
saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null)); saveTask.executeOnExecutor(
AsyncTask.THREAD_POOL_EXECUTOR,
new Attachment(mediaItem.uri, mediaItem.type, saveDate, null));
}) })
.execute(); .execute();
}); });
} }
}
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
private void deleteMedia() { private void deleteMedia() {

View File

@ -268,7 +268,7 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
private void initializeBackupDetection() { private void initializeBackupDetection() {
if (!Permissions.hasAll(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { if (!Permissions.hasAll(this, Manifest.permission.READ_EXTERNAL_STORAGE)) {
Log.i(TAG, "Skipping backup detection. We don't have the permission."); Log.i(TAG, "Skipping backup detection. We don't have the permission.");
return; return;
} }

View File

@ -172,7 +172,6 @@ public class VerifyIdentityActivity extends PassphraseRequiredActionBarActivity
public void onClick(View v) { public void onClick(View v) {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.VerifyIdentityActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code_but_it_has_been_permanently_denied)) .withPermanentDenialDialog(getString(R.string.VerifyIdentityActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code_but_it_has_been_permanently_denied))
.onAllGranted(() -> { .onAllGranted(() -> {
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();

View File

@ -164,7 +164,6 @@ public class WebRtcCallActivity extends Activity {
if (event != null) { if (event != null) {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, event.getRecipient().toShortString()), .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, event.getRecipient().toShortString()),
R.drawable.ic_mic_white_48dp, R.drawable.ic_videocam_white_48dp) R.drawable.ic_mic_white_48dp, R.drawable.ic_videocam_white_48dp)
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls)) .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))

View File

@ -110,7 +110,7 @@ public class AttachmentTypeSelector extends PopupWindow {
public void show(@NonNull Activity activity, final @NonNull View anchor) { public void show(@NonNull Activity activity, final @NonNull View anchor) {
updateHeight(); updateHeight();
if (Permissions.hasAll(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { if (Permissions.hasAll(activity, Manifest.permission.READ_EXTERNAL_STORAGE)) {
recentRail.setVisibility(View.VISIBLE); recentRail.setVisibility(View.VISIBLE);
loaderManager.restartLoader(1, null, recentRail); loaderManager.restartLoader(1, null, recentRail);
} else { } else {

View File

@ -2564,7 +2564,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
public void onRecorderPermissionRequired() { public void onRecorderPermissionRequired() {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO) .request(Manifest.permission.RECORD_AUDIO)
.ifNecessary()
.withRationaleDialog(getString(R.string.ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone), R.drawable.ic_baseline_mic_48) .withRationaleDialog(getString(R.string.ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone), R.drawable.ic_baseline_mic_48)
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages)) .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages))
.execute(); .execute();
@ -2765,7 +2764,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
public void onClick(View v) { public void onClick(View v) {
Permissions.with(ConversationActivity.this) Permissions.with(ConversationActivity.this)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_baseline_photo_camera_48) .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_baseline_photo_camera_48)
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video)) .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
.onAllGranted(() -> { .onAllGranted(() -> {

View File

@ -85,6 +85,7 @@ import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.UnknownSenderView; import org.thoughtcrime.securesms.profiles.UnknownSenderView;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.MessageSender;
@ -658,9 +659,15 @@ public class ConversationFragment extends Fragment
} }
private void handleSaveAttachment(final MediaMmsMessageRecord message) { private void handleSaveAttachment(final MediaMmsMessageRecord message) {
SaveAttachmentTask.showWarningDialog(getActivity(), new DialogInterface.OnClickListener() { SaveAttachmentTask.showWarningDialog(getActivity(), (dialog, which) -> {
public void onClick(DialogInterface dialog, int which) { Permissions.with(this)
List<SaveAttachmentTask.Attachment> attachments = Stream.of(message.getSlideDeck().getSlides()) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P)
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
.onAnyDenied(() -> Toast.makeText(getContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
.onAllGranted(() -> {
List<SaveAttachmentTask.Attachment> attachments =
Stream.of(message.getSlideDeck().getSlides())
.filter(s -> s.getUri() != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument())) .filter(s -> s.getUri() != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument()))
.map(s -> new SaveAttachmentTask.Attachment(s.getUri(), s.getContentType(), message.getDateReceived(), s.getFileName().orNull())) .map(s -> new SaveAttachmentTask.Attachment(s.getUri(), s.getContentType(), message.getDateReceived(), s.getFileName().orNull()))
.toList(); .toList();
@ -674,7 +681,8 @@ public class ConversationFragment extends Fragment
Toast.makeText(getActivity(), Toast.makeText(getActivity(),
getResources().getQuantityString(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, 1), getResources().getQuantityString(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, 1),
Toast.LENGTH_LONG).show(); Toast.LENGTH_LONG).show();
} })
.execute();
}); });
} }

View File

@ -35,7 +35,7 @@ public class RecentPhotosLoader extends CursorLoader {
@Override @Override
public Cursor loadInBackground() { public Cursor loadInBackground() {
if (Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { if (Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {
return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
PROJECTION, null, null, PROJECTION, null, null,
MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC"); MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC");

View File

@ -26,6 +26,7 @@ import java.util.Locale;
import network.loki.messenger.R; import network.loki.messenger.R;
//TODO AC: Needs to be refactored to use Storage Access Framework or Media Store API.
public class LocalBackupJob extends BaseJob { public class LocalBackupJob extends BaseJob {
public static final String KEY = "LocalBackupJob"; public static final String KEY = "LocalBackupJob";

View File

@ -251,7 +251,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
// Ask for an optional camera permission. // Ask for an optional camera permission.
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.ifNecessary()
.onAnyResult { .onAnyResult {
tempFile = AvatarSelection.startAvatarSelection(this, false, true) tempFile = AvatarSelection.startAvatarSelection(this, false, true)
} }

View File

@ -12,14 +12,12 @@ public class MediaFolder {
private final String title; private final String title;
private final int itemCount; private final int itemCount;
private final String bucketId; private final String bucketId;
private final FolderType folderType;
MediaFolder(@NonNull Uri thumbnailUri, @NonNull String title, int itemCount, @NonNull String bucketId, @NonNull FolderType folderType) { MediaFolder(@NonNull Uri thumbnailUri, @NonNull String title, int itemCount, @NonNull String bucketId) {
this.thumbnailUri = thumbnailUri; this.thumbnailUri = thumbnailUri;
this.title = title; this.title = title;
this.itemCount = itemCount; this.itemCount = itemCount;
this.bucketId = bucketId; this.bucketId = bucketId;
this.folderType = folderType;
} }
Uri getThumbnailUri() { Uri getThumbnailUri() {
@ -38,10 +36,6 @@ public class MediaFolder {
return bucketId; return bucketId;
} }
FolderType getFolderType() {
return folderType;
}
enum FolderType { enum FolderType {
NORMAL, CAMERA NORMAL, CAMERA
} }

View File

@ -75,7 +75,6 @@ class MediaPickerFolderAdapter extends RecyclerView.Adapter<MediaPickerFolderAda
void bind(@NonNull MediaFolder folder, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) { void bind(@NonNull MediaFolder folder, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
title.setText(folder.getTitle()); title.setText(folder.getTitle());
count.setText(String.valueOf(folder.getItemCount())); count.setText(String.valueOf(folder.getItemCount()));
icon.setImageResource(folder.getFolderType() == MediaFolder.FolderType.CAMERA ? R.drawable.ic_camera_alt_white_24dp : R.drawable.ic_folder_white_48dp);
glideRequests.load(folder.getThumbnailUri()) glideRequests.load(folder.getThumbnailUri())
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)

View File

@ -5,7 +5,6 @@ import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Environment;
import android.provider.MediaStore.Images; import android.provider.MediaStore.Images;
import android.provider.MediaStore.Video; import android.provider.MediaStore.Video;
import android.provider.OpenableColumns; import android.provider.OpenableColumns;
@ -33,11 +32,6 @@ import java.util.Map;
/** /**
* Handles the retrieval of media present on the user's device. * Handles the retrieval of media present on the user's device.
* @deprecated Usage of this class is unsafe on Android API 30 and up,
* the public external directory is no longer exposed to the apps.
* <p><b>
* The functionality of this class should be refactored to use
* <a href="https://developer.android.com/reference/android/provider/MediaStore">MediaStore</a>.
*/ */
class MediaRepository { class MediaRepository {
@ -82,30 +76,17 @@ class MediaRepository {
} }
} }
String cameraBucketId = imageFolders.getCameraBucketId() != null ? imageFolders.getCameraBucketId() : videoFolders.getCameraBucketId();
FolderData cameraFolder = cameraBucketId != null ? folders.remove(cameraBucketId) : null;
List<MediaFolder> mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(), List<MediaFolder> mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(),
folder.getTitle(), folder.getTitle(),
folder.getCount(), folder.getCount(),
folder.getBucketId(), folder.getBucketId()))
MediaFolder.FolderType.NORMAL))
.sorted((o1, o2) -> o1.getTitle().toLowerCase().compareTo(o2.getTitle().toLowerCase())) .sorted((o1, o2) -> o1.getTitle().toLowerCase().compareTo(o2.getTitle().toLowerCase()))
.toList(); .toList();
Uri allMediaThumbnail = imageFolders.getThumbnailTimestamp() > videoFolders.getThumbnailTimestamp() ? imageFolders.getThumbnail() : videoFolders.getThumbnail(); Uri allMediaThumbnail = imageFolders.getThumbnailTimestamp() > videoFolders.getThumbnailTimestamp() ? imageFolders.getThumbnail() : videoFolders.getThumbnail();
if (allMediaThumbnail != null) { if (allMediaThumbnail != null) {
int allMediaCount = Stream.of(mediaFolders).reduce(0, (count, folder) -> count + folder.getItemCount()); int allMediaCount = Stream.of(mediaFolders).reduce(0, (count, folder) -> count + folder.getItemCount());
mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.MediaRepository_all_media), allMediaCount, Media.ALL_MEDIA_BUCKET_ID));
if (cameraFolder != null) {
allMediaCount += cameraFolder.getCount();
}
mediaFolders.add(0, new MediaFolder(allMediaThumbnail, context.getString(R.string.MediaRepository_all_media), allMediaCount, Media.ALL_MEDIA_BUCKET_ID, MediaFolder.FolderType.NORMAL));
}
if (cameraFolder != null) {
mediaFolders.add(0, new MediaFolder(cameraFolder.getThumbnail(), cameraFolder.getTitle(), cameraFolder.getCount(), cameraFolder.getBucketId(), MediaFolder.FolderType.CAMERA));
} }
return mediaFolders; return mediaFolders;
@ -113,8 +94,6 @@ class MediaRepository {
@WorkerThread @WorkerThread
private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) { private @NonNull FolderResult getFolders(@NonNull Context context, @NonNull Uri contentUri) {
String cameraPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() + File.separator + "Camera";
String cameraBucketId = null;
Uri globalThumbnail = null; Uri globalThumbnail = null;
long thumbnailTimestamp = 0; long thumbnailTimestamp = 0;
Map<String, FolderData> folders = new HashMap<>(); Map<String, FolderData> folders = new HashMap<>();
@ -135,10 +114,6 @@ class MediaRepository {
folder.incrementCount(); folder.incrementCount();
folders.put(bucketId, folder); folders.put(bucketId, folder);
if (cameraBucketId == null && path.startsWith(cameraPath)) {
cameraBucketId = bucketId;
}
if (timestamp > thumbnailTimestamp) { if (timestamp > thumbnailTimestamp) {
globalThumbnail = thumbnail; globalThumbnail = thumbnail;
thumbnailTimestamp = timestamp; thumbnailTimestamp = timestamp;
@ -146,7 +121,7 @@ class MediaRepository {
} }
} }
return new FolderResult(cameraBucketId, globalThumbnail, thumbnailTimestamp, folders); return new FolderResult(globalThumbnail, thumbnailTimestamp, folders);
} }
@WorkerThread @WorkerThread
@ -163,7 +138,8 @@ class MediaRepository {
} }
@WorkerThread @WorkerThread
private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean hasOrienation) { private @NonNull List<Media> getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri, boolean hasOrientation) {
//TODO Constrain media file size to match the Loki protocol limit.
List<Media> media = new LinkedList<>(); List<Media> media = new LinkedList<>();
String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL"; String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL";
String[] selectionArgs = new String[] { bucketId }; String[] selectionArgs = new String[] { bucketId };
@ -171,7 +147,7 @@ class MediaRepository {
String[] projection; String[] projection;
if (hasOrienation) { if (hasOrientation) {
projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE}; projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE};
} else { } else {
projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE}; projection = new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE};
@ -187,7 +163,7 @@ class MediaRepository {
Uri uri = Uri.withAppendedPath(contentUri, cursor.getString(cursor.getColumnIndexOrThrow(Images.Media._ID))); Uri uri = Uri.withAppendedPath(contentUri, cursor.getString(cursor.getColumnIndexOrThrow(Images.Media._ID)));
String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE)); String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.MIME_TYPE));
long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_TAKEN)); long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_TAKEN));
int orientation = hasOrienation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0; int orientation = hasOrientation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0;
int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)));
int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)));
long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)); long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE));
@ -284,26 +260,19 @@ class MediaRepository {
} }
private static class FolderResult { private static class FolderResult {
private final String cameraBucketId;
private final Uri thumbnail; private final Uri thumbnail;
private final long thumbnailTimestamp; private final long thumbnailTimestamp;
private final Map<String, FolderData> folderData; private final Map<String, FolderData> folderData;
private FolderResult(@Nullable String cameraBucketId, private FolderResult(@Nullable Uri thumbnail,
@Nullable Uri thumbnail,
long thumbnailTimestamp, long thumbnailTimestamp,
@NonNull Map<String, FolderData> folderData) @NonNull Map<String, FolderData> folderData)
{ {
this.cameraBucketId = cameraBucketId;
this.thumbnail = thumbnail; this.thumbnail = thumbnail;
this.thumbnailTimestamp = thumbnailTimestamp; this.thumbnailTimestamp = thumbnailTimestamp;
this.folderData = folderData; this.folderData = folderData;
} }
@Nullable String getCameraBucketId() {
return cameraBucketId;
}
@Nullable Uri getThumbnail() { @Nullable Uri getThumbnail() {
return thumbnail; return thumbnail;
} }

View File

@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.mediasend; package org.thoughtcrime.securesms.mediasend;
import android.Manifest; import android.Manifest;
import androidx.lifecycle.ViewModelProvider;
import androidx.lifecycle.ViewModelProviders; import androidx.lifecycle.ViewModelProviders;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
@ -133,7 +135,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
countButtonText = findViewById(R.id.mediasend_count_button_text); countButtonText = findViewById(R.id.mediasend_count_button_text);
cameraButton = findViewById(R.id.mediasend_camera_button); cameraButton = findViewById(R.id.mediasend_camera_button);
viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class); viewModel = new ViewModelProvider(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
recipient = Recipient.from(this, Address.fromSerialized(getIntent().getStringExtra(KEY_ADDRESS)), true); recipient = Recipient.from(this, Address.fromSerialized(getIntent().getStringExtra(KEY_ADDRESS)), true);
transport = getIntent().getParcelableExtra(KEY_TRANSPORT); transport = getIntent().getParcelableExtra(KEY_TRANSPORT);
@ -375,7 +377,6 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
private void navigateToCamera() { private void navigateToCamera() {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_baseline_photo_camera_48) .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_baseline_photo_camera_48)
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video)) .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
.onAllGranted(() -> { .onAllGranted(() -> {

View File

@ -371,37 +371,25 @@ public class AttachmentManager {
} }
public static void selectDocument(Activity activity, int requestCode) { public static void selectDocument(Activity activity, int requestCode) {
Permissions.with(activity) selectMediaType(activity, "*/*", null, requestCode);
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.onAllGranted(() -> selectMediaType(activity, "*/*", null, requestCode))
.execute();
} }
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body, @NonNull TransportOption transport) { public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body, @NonNull TransportOption transport) {
Permissions.with(activity) Permissions.with(activity)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.READ_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.onAllGranted(() -> selectMediaType(activity, "image/*", new String[] {"image/*", "video/*"}, requestCode)) // .onAllGranted(() -> selectMediaType(activity, "image/*", new String[] {"image/*", "video/*"}, requestCode))
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body, transport), requestCode)) .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body, transport), requestCode))
.execute(); .execute();
} }
public static void selectAudio(Activity activity, int requestCode) { public static void selectAudio(Activity activity, int requestCode) {
Permissions.with(activity) selectMediaType(activity, "audio/*", null, requestCode);
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.onAllGranted(() -> selectMediaType(activity, "audio/*", null, requestCode))
.execute();
} }
public static void selectContactInfo(Activity activity, int requestCode) { public static void selectContactInfo(Activity activity, int requestCode) {
Permissions.with(activity) Permissions.with(activity)
.request(Manifest.permission.WRITE_CONTACTS) .request(Manifest.permission.WRITE_CONTACTS)
.ifNecessary()
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_contacts_permission_in_order_to_attach_contact_information)) .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_contacts_permission_in_order_to_attach_contact_information))
.onAllGranted(() -> { .onAllGranted(() -> {
Intent intent = new Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI); Intent intent = new Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI);
@ -414,7 +402,6 @@ public class AttachmentManager {
/* Loki - Enable again once we have location sharing /* Loki - Enable again once we have location sharing
Permissions.with(activity) Permissions.with(activity)
.request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) .request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
.ifNecessary()
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_location_information_in_order_to_attach_a_location)) .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_location_information_in_order_to_attach_a_location))
.onAllGranted(() -> { .onAllGranted(() -> {
try { try {
@ -444,7 +431,6 @@ public class AttachmentManager {
public void capturePhoto(Activity activity, int requestCode) { public void capturePhoto(Activity activity, int requestCode) {
Permissions.with(activity) Permissions.with(activity)
.request(Manifest.permission.CAMERA) .request(Manifest.permission.CAMERA)
.ifNecessary()
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied)) .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied))
.onAllGranted(() -> { .onAllGranted(() -> {
try { try {
@ -469,6 +455,7 @@ public class AttachmentManager {
} }
private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) {
//TODO Constrain media file size to match the Loki protocol limit.
final Intent intent = new Intent(); final Intent intent = new Intent();
intent.setType(type); intent.setType(type);

View File

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.permissions; package org.thoughtcrime.securesms.permissions;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.content.Context; import android.content.Context;
@ -63,9 +62,8 @@ public class Permissions {
private @DrawableRes int[] rationalDialogHeader; private @DrawableRes int[] rationalDialogHeader;
private String rationaleDialogMessage; private String rationaleDialogMessage;
private boolean ifNecesary; private int minSdkVersion = 0;
private int maxSdkVersion = Integer.MAX_VALUE;
private boolean condition = true;
PermissionsBuilder(PermissionObject permissionObject) { PermissionsBuilder(PermissionObject permissionObject) {
this.permissionObject = permissionObject; this.permissionObject = permissionObject;
@ -76,17 +74,6 @@ public class Permissions {
return this; return this;
} }
public PermissionsBuilder ifNecessary() {
this.ifNecesary = true;
return this;
}
public PermissionsBuilder ifNecessary(boolean condition) {
this.ifNecesary = true;
this.condition = condition;
return this;
}
public PermissionsBuilder withRationaleDialog(@NonNull String message, @NonNull @DrawableRes int... headers) { public PermissionsBuilder withRationaleDialog(@NonNull String message, @NonNull @DrawableRes int... headers) {
this.rationalDialogHeader = headers; this.rationalDialogHeader = headers;
this.rationaleDialogMessage = message; this.rationaleDialogMessage = message;
@ -133,11 +120,29 @@ public class Permissions {
return this; return this;
} }
/**
* Min Android SDK version to request the permissions for (inclusive).
*/
public PermissionsBuilder minSdkVersion(int minSdkVersion) {
this.minSdkVersion = minSdkVersion;
return this;
}
/**
* Max Android SDK version to request the permissions for (inclusive).
*/
public PermissionsBuilder maxSdkVersion(int maxSdkVersion) {
this.maxSdkVersion = maxSdkVersion;
return this;
}
public void execute() { public void execute() {
PermissionsRequest request = new PermissionsRequest(allGrantedListener, anyDeniedListener, anyPermanentlyDeniedListener, anyResultListener, PermissionsRequest request = new PermissionsRequest(allGrantedListener, anyDeniedListener, anyPermanentlyDeniedListener, anyResultListener,
someGrantedListener, someDeniedListener, somePermanentlyDeniedListener); someGrantedListener, someDeniedListener, somePermanentlyDeniedListener);
if (ifNecesary && (permissionObject.hasAll(requestedPermissions) || !condition)) { boolean targetSdk = Build.VERSION.SDK_INT >= minSdkVersion && Build.VERSION.SDK_INT <= maxSdkVersion;
if (!targetSdk || permissionObject.hasAll(requestedPermissions)) {
executePreGrantedPermissionsRequest(request); executePreGrantedPermissionsRequest(request);
} else if (rationaleDialogMessage != null && rationalDialogHeader != null) { } else if (rationaleDialogMessage != null && rationalDialogHeader != null) {
executePermissionsRequestWithRationale(request); executePermissionsRequestWithRationale(request);

View File

@ -139,7 +139,6 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
public boolean onPreferenceClick(Preference preference) { public boolean onPreferenceClick(Preference preference) {
Permissions.with(ChatsPreferenceFragment.this) Permissions.with(ChatsPreferenceFragment.this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.onAllGranted(() -> { .onAllGranted(() -> {
if (!((SwitchPreferenceCompat)preference).isChecked()) { if (!((SwitchPreferenceCompat)preference).isChecked()) {
BackupDialog.showEnableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference); BackupDialog.showEnableBackupDialog(getActivity(), (SwitchPreferenceCompat)preference);
@ -160,7 +159,6 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
public boolean onPreferenceClick(Preference preference) { public boolean onPreferenceClick(Preference preference) {
Permissions.with(ChatsPreferenceFragment.this) Permissions.with(ChatsPreferenceFragment.this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.onAllGranted(() -> { .onAllGranted(() -> {
Log.i(TAG, "Queing backup..."); Log.i(TAG, "Queing backup...");
ApplicationContext.getInstance(getContext()) ApplicationContext.getInstance(getContext())

View File

@ -55,7 +55,6 @@ public class WelcomeActivity extends BaseActionBarActivity {
private void onContinueClicked() { private void onContinueClicked() {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE)
.ifNecessary()
.withRationaleDialog(getString(R.string.activity_landing_permission_dialog_message), R.drawable.ic_baseline_folder_48) .withRationaleDialog(getString(R.string.activity_landing_permission_dialog_message), R.drawable.ic_baseline_folder_48)
.onAnyResult(() -> { .onAnyResult(() -> {
Intent nextIntent = getIntent().getParcelableExtra("next_intent"); Intent nextIntent = getIntent().getParcelableExtra("next_intent");

View File

@ -16,6 +16,7 @@ import java.util.Arrays;
import java.util.Calendar; import java.util.Calendar;
import java.util.Locale; import java.util.Locale;
//TODO AC: Needs to be refactored to use Storage Access Framework or Media Store API.
public class BackupUtil { public class BackupUtil {
private static final String TAG = BackupUtil.class.getSimpleName(); private static final String TAG = BackupUtil.class.getSimpleName();

View File

@ -35,8 +35,7 @@ public class CommunicationActions {
Permissions.with(activity) Permissions.with(activity)
.request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
.ifNecessary() .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_call_s_signal_needs_access_to_your_microphone_and_camera),
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_call_s_signal_needs_access_to_your_microphone_and_camera, recipient.toShortString()),
R.drawable.ic_mic_white_48dp, R.drawable.ic_mic_white_48dp,
R.drawable.ic_videocam_white_48dp) R.drawable.ic_videocam_white_48dp)
.withPermanentDenialDialog(activity.getString(R.string.ConversationActivity_signal_needs_the_microphone_and_camera_permissions_in_order_to_call_s, recipient.toShortString())) .withPermanentDenialDialog(activity.getString(R.string.ConversationActivity_signal_needs_the_microphone_and_camera_permissions_in_order_to_call_s, recipient.toShortString()))

View File

@ -1,234 +0,0 @@
package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.content.DialogInterface.OnClickListener;
import android.media.MediaScannerConnection;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import android.text.TextUtils;
import android.webkit.MimeTypeMap;
import android.widget.Toast;
import network.loki.messenger.R;
import org.thoughtcrime.securesms.database.NoExternalStorageException;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.whispersystems.libsignal.util.Pair;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.text.SimpleDateFormat;
public class SaveAttachmentTask extends ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Pair<Integer, String>> {
private static final String TAG = SaveAttachmentTask.class.getSimpleName();
static final int SUCCESS = 0;
private static final int FAILURE = 1;
private static final int WRITE_ACCESS_FAILURE = 2;
private final WeakReference<Context> contextReference;
private final int attachmentCount;
public SaveAttachmentTask(Context context) {
this(context, 1);
}
public SaveAttachmentTask(Context context, int count) {
super(context,
context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count),
context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count));
this.contextReference = new WeakReference<>(context);
this.attachmentCount = count;
}
@Override
protected Pair<Integer, String> 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;
if (context == null) {
return new Pair<>(FAILURE, null);
}
for (Attachment attachment : attachments) {
if (attachment != null) {
directory = saveAttachment(context, attachment);
if (directory == 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) {
Log.w(TAG, ioe);
return new Pair<>(FAILURE, null);
}
}
private @Nullable String saveAttachment(Context context, Attachment attachment)
throws NoExternalStorageException, IOException
{
String contentType = 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);
if (inputStream == null) {
return null;
}
OutputStream outputStream = new FileOutputStream(mediaFile);
Util.copy(inputStream, outputStream);
MediaScannerConnection.scanFile(context, new String[]{mediaFile.getAbsolutePath()},
new String[]{contentType}, null);
return outputDirectory.getName();
}
private File createOutputDirectoryFromContentType(@NonNull String contentType)
throws NoExternalStorageException
{
File outputDirectory;
if (contentType.startsWith("video/")) {
outputDirectory = ExternalStorageUtil.getVideoDir(getContext());
} else if (contentType.startsWith("audio/")) {
outputDirectory = ExternalStorageUtil.getAudioDir(getContext());
} else if (contentType.startsWith("image/")) {
outputDirectory = ExternalStorageUtil.getImageDir(getContext());
} else {
outputDirectory = ExternalStorageUtil.getDownloadDir(getContext());
}
if (!outputDirectory.mkdirs()) Log.w(TAG, "mkdirs() returned false, attempting to continue");
return outputDirectory;
}
private String generateOutputFileName(@NonNull String contentType, long timestamp) {
MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
String extension = mimeTypeMap.getExtensionFromMimeType(contentType);
SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd-HHmmss");
String base = "signal-" + dateFormatter.format(timestamp);
if (extension == null) extension = "attach";
return base + "." + extension;
}
private String sanitizeOutputFileName(@NonNull String fileName) {
return new File(fileName).getName();
}
private File createOutputFile(@NonNull File outputDirectory, @NonNull String fileName)
throws IOException
{
String[] fileParts = getFileNameParts(fileName);
String base = fileParts[0];
String extension = fileParts[1];
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 outputFile;
}
private String[] getFileNameParts(String fileName) {
String[] result = new String[2];
String[] tokens = fileName.split("\\.(?=[^\\.]+$)");
result[0] = tokens[0];
if (tokens.length > 1) result[1] = tokens[1];
else result[1] = "";
return result;
}
@Override
protected void onPostExecute(final Pair<Integer, String> result) {
super.onPostExecute(result);
final Context context = contextReference.get();
if (context == null) return;
switch (result.first()) {
case FAILURE:
Toast.makeText(context,
context.getResources().getQuantityText(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card,
attachmentCount),
Toast.LENGTH_LONG).show();
break;
case SUCCESS:
String message = !TextUtils.isEmpty(result.second()) ? context.getResources().getString(R.string.SaveAttachmentTask_saved_to, result.second())
: context.getResources().getString(R.string.SaveAttachmentTask_saved);
Toast.makeText(context, message, Toast.LENGTH_LONG).show();
break;
case WRITE_ACCESS_FAILURE:
Toast.makeText(context, R.string.ConversationFragment_unable_to_write_to_sd_card_exclamation,
Toast.LENGTH_LONG).show();
break;
}
}
public static class Attachment {
public Uri uri;
public String fileName;
public String contentType;
public long date;
public Attachment(@NonNull Uri uri, @NonNull String contentType,
long date, @Nullable String fileName)
{
if (uri == null || contentType == null || date < 0) {
throw new AssertionError("uri, content type, and date must all be specified");
}
this.uri = uri;
this.fileName = fileName;
this.contentType = contentType;
this.date = date;
}
}
public static void showWarningDialog(Context context, OnClickListener onAcceptListener) {
showWarningDialog(context, onAcceptListener, 1);
}
public static void showWarningDialog(Context context, OnClickListener onAcceptListener, int count) {
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(R.string.ConversationFragment_save_to_sd_card);
builder.setIconAttribute(R.attr.dialog_alert_icon);
builder.setCancelable(true);
builder.setMessage(context.getResources().getQuantityString(R.plurals.ConversationFragment_saving_n_media_to_storage_warning,
count, count));
builder.setPositiveButton(R.string.yes, onAcceptListener);
builder.setNegativeButton(R.string.no, null);
builder.show();
}
}

View File

@ -0,0 +1,196 @@
package org.thoughtcrime.securesms.util
import android.content.ContentValues
import android.content.Context
import android.content.DialogInterface.OnClickListener
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.text.TextUtils
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.R
import org.thoughtcrime.securesms.logging.Log
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask
import java.io.File
import java.io.IOException
import java.lang.ref.WeakReference
import java.text.SimpleDateFormat
/**
* Saves attachment files to an external storage using [MediaStore] API.
*/
class SaveAttachmentTask : ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Pair<Int, String?>> {
companion object {
@JvmStatic
private val TAG = SaveAttachmentTask::class.simpleName
private const val RESULT_SUCCESS = 0
private const val RESULT_FAILURE = 1
@JvmStatic
@JvmOverloads
fun showWarningDialog(context: Context, onAcceptListener: OnClickListener, count: Int = 1) {
val builder = AlertDialog.Builder(context)
builder.setTitle(R.string.ConversationFragment_save_to_sd_card)
builder.setIconAttribute(R.attr.dialog_alert_icon)
builder.setCancelable(true)
builder.setMessage(context.resources.getQuantityString(
R.plurals.ConversationFragment_saving_n_media_to_storage_warning,
count,
count))
builder.setPositiveButton(R.string.yes, onAcceptListener)
builder.setNegativeButton(R.string.no, null)
builder.show()
}
}
private val contextReference: WeakReference<Context>
private val attachmentCount: Int
@JvmOverloads
constructor(context: Context, count: Int = 1): super(context,
context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count),
context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count)) {
this.contextReference = WeakReference(context)
this.attachmentCount = count
}
override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> {
if (attachments.isEmpty()) {
throw IllegalArgumentException("Must pass in at least one attachment")
}
try {
val context = contextReference.get()
var directory: String? = null
if (context == null) {
return Pair(RESULT_FAILURE, null)
}
for (attachment in attachments) {
if (attachment != null) {
directory = saveAttachment(context, attachment)
if (directory == null) return Pair(RESULT_FAILURE, null)
}
}
return if (attachments.size > 1)
Pair(RESULT_SUCCESS, null)
else
Pair(RESULT_SUCCESS, directory)
} catch (e: IOException) {
Log.w(TAG, e)
return Pair(RESULT_FAILURE, null)
}
}
@Throws(IOException::class)
private fun saveAttachment(context: Context, attachment: Attachment): String? {
val resolver = context.contentResolver
val contentType = MediaUtil.getCorrectedMimeType(attachment.contentType)!!
val fileName = attachment.fileName
?: sanitizeOutputFileName(generateOutputFileName(contentType, attachment.date))
val mediaRecord = ContentValues()
val mediaVolume = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
MediaStore.VOLUME_EXTERNAL
} else {
MediaStore.VOLUME_EXTERNAL_PRIMARY
}
val collectionUri: Uri
when {
contentType.startsWith("video/") -> {
collectionUri = MediaStore.Video.Media.getContentUri(mediaVolume)
mediaRecord.put(MediaStore.Video.Media.DISPLAY_NAME, fileName)
mediaRecord.put(MediaStore.Video.Media.MIME_TYPE, contentType)
// Add the date meta data to ensure the image is added at the front of the gallery
mediaRecord.put(MediaStore.Video.Media.DATE_ADDED, System.currentTimeMillis())
mediaRecord.put(MediaStore.Video.Media.DATE_TAKEN, System.currentTimeMillis())
}
contentType.startsWith("audio/") -> {
collectionUri = MediaStore.Audio.Media.getContentUri(mediaVolume)
mediaRecord.put(MediaStore.Audio.Media.DISPLAY_NAME, fileName)
mediaRecord.put(MediaStore.Audio.Media.MIME_TYPE, contentType)
mediaRecord.put(MediaStore.Audio.Media.DATE_ADDED, System.currentTimeMillis())
mediaRecord.put(MediaStore.Audio.Media.DATE_TAKEN, System.currentTimeMillis())
}
contentType.startsWith("image/") -> {
collectionUri = MediaStore.Images.Media.getContentUri(mediaVolume)
mediaRecord.put(MediaStore.Images.Media.TITLE, fileName)
mediaRecord.put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
mediaRecord.put(MediaStore.Images.Media.MIME_TYPE, contentType)
mediaRecord.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis())
mediaRecord.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
}
else -> {
mediaRecord.put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName)
collectionUri = MediaStore.Files.getContentUri(mediaVolume)
}
}
val mediaFileUri = resolver.insert(collectionUri, mediaRecord)
if (mediaFileUri == null) return null
val inputStream = PartAuthority.getAttachmentStream(context, attachment.uri)
if (inputStream == null) return null
inputStream.use {
resolver.openOutputStream(mediaFileUri).use {
Util.copy(inputStream, it)
}
}
return mediaFileUri.toString()
}
private fun generateOutputFileName(contentType: String, timestamp: Long): String {
val mimeTypeMap = MimeTypeMap.getSingleton()
val extension = mimeTypeMap.getExtensionFromMimeType(contentType) ?: "attach"
val dateFormatter = SimpleDateFormat("yyyy-MM-dd-HHmmss")
val base = "signal-${dateFormatter.format(timestamp)}"
return "${base}.${extension}";
}
private fun sanitizeOutputFileName(fileName: String): String {
return File(fileName).name
}
override fun onPostExecute(result: Pair<Int, String?>) {
super.onPostExecute(result)
val context = contextReference.get()
if (context == null) return
when (result.first) {
RESULT_FAILURE -> {
val message = context.resources.getQuantityText(
R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card,
attachmentCount)
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
RESULT_SUCCESS -> {
val message = if (!TextUtils.isEmpty(result.second)) {
context.resources.getString(R.string.SaveAttachmentTask_saved_to, result.second)
} else {
context.resources.getString(R.string.SaveAttachmentTask_saved)
}
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
else -> throw IllegalStateException("Unexpected result value: " + result.first)
}
}
data class Attachment(val uri: Uri, val contentType: String, val date: Long, val fileName: String?)
}

View File

@ -4,6 +4,8 @@ import android.app.ProgressDialog;
import android.content.Context; import android.content.Context;
import android.os.AsyncTask; import android.os.AsyncTask;
import androidx.annotation.NonNull;
import java.lang.ref.WeakReference; import java.lang.ref.WeakReference;
public abstract class ProgressDialogAsyncTask<Params, Progress, Result> extends AsyncTask<Params, Progress, Result> { public abstract class ProgressDialogAsyncTask<Params, Progress, Result> extends AsyncTask<Params, Progress, Result> {
@ -13,14 +15,14 @@ public abstract class ProgressDialogAsyncTask<Params, Progress, Result> extends
private final String title; private final String title;
private final String message; private final String message;
public ProgressDialogAsyncTask(Context context, String title, String message) { public ProgressDialogAsyncTask(@NonNull Context context, @NonNull String title, @NonNull String message) {
super(); super();
this.contextReference = new WeakReference<>(context); this.contextReference = new WeakReference<>(context);
this.title = title; this.title = title;
this.message = message; this.message = message;
} }
public ProgressDialogAsyncTask(Context context, int title, int message) { public ProgressDialogAsyncTask(@NonNull Context context, int title, int message) {
this(context, context.getString(title), context.getString(message)); this(context, context.getString(title), context.getString(message));
} }
@ -35,7 +37,7 @@ public abstract class ProgressDialogAsyncTask<Params, Progress, Result> extends
if (progress != null) progress.dismiss(); if (progress != null) progress.dismiss();
} }
protected Context getContext() { protected @NonNull Context getContext() {
return contextReference.get(); return contextReference.get();
} }
} }