diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index e356824da..a794f1bfa 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -664,6 +664,56 @@ public class FileBackend { } } + public void copyAttachmentToDownloadsFolder(Message m) throws FileCopyException { + File input = mXmppConnectionService.getFileBackend().getFile(m); + + File parentDirectory = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + File output = new File(parentDirectory, input.getName()); + int counter = 1; + while (output.exists()) { + output = new File(parentDirectory, "(" + counter + ") " + input.getName()); + counter++; + } + + try { + output.createNewFile(); + } catch (IOException e) { + throw new FileCopyException(R.string.error_unable_to_create_temporary_file); + } + try (final OutputStream os = new FileOutputStream(output); + final InputStream is = + mXmppConnectionService.getContentResolver().openInputStream(Uri.fromFile(input))) { + if (is == null) { + throw new FileCopyException(R.string.error_file_not_found); + } + try { + ByteStreams.copy(is, os); + } catch (IOException e) { + throw new FileWriterException(output); + } + try { + os.flush(); + } catch (IOException e) { + throw new FileWriterException(output); + } + + updateMediaScanner(output); + } catch (final FileNotFoundException e) { + cleanup(output); + throw new FileCopyException(R.string.error_file_not_found); + } catch (final FileWriterException e) { + cleanup(output); + throw new FileCopyException(R.string.error_unable_to_create_temporary_file); + } catch (final SecurityException | IllegalStateException e) { + cleanup(output); + throw new FileCopyException(R.string.error_security_exception); + } catch (final IOException e) { + cleanup(output); + throw new FileCopyException(R.string.error_io_exception); + } + } + private void copyFileToPrivateStorage(File file, Uri uri) throws FileCopyException { Log.d( Config.LOGTAG, diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 8484498e4..3aa11986f 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -212,7 +212,7 @@ public class XmppConnectionService extends Service { public final CountDownLatch restoredFromDatabaseLatch = new CountDownLatch(1); private final static Executor FILE_OBSERVER_EXECUTOR = Executors.newSingleThreadExecutor(); private final static Executor FILE_ATTACHMENT_EXECUTOR = Executors.newSingleThreadExecutor(); - + private final static Executor COPY_TO_DOWNLOAD_EXECUTOR = Executors.newSingleThreadExecutor(); private final ScheduledExecutorService internalPingExecutor = Executors.newSingleThreadScheduledExecutor(); private final static SerialSingleThreadExecutor VIDEO_COMPRESSION_EXECUTOR = new SerialSingleThreadExecutor("VideoCompression"); private final SerialSingleThreadExecutor mDatabaseWriterExecutor = new SerialSingleThreadExecutor("DatabaseWriter"); @@ -540,6 +540,17 @@ public class XmppConnectionService extends Service { return this.restoredFromDatabaseLatch.getCount() == 0; } + public void copyAttachmentToDownloadsFolder(Message m, final UiCallback callback) { + COPY_TO_DOWNLOAD_EXECUTOR.execute(() -> { + try { + fileBackend.copyAttachmentToDownloadsFolder(m); + callback.success(-1); + } catch (FileBackend.FileCopyException e) { + callback.error(-1, e.getResId()); + } + }); + } + public PgpEngine getPgpEngine() { if (!Config.supportOpenPgp()) { return null; diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 187735ae6..86121be70 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -18,6 +18,8 @@ import android.app.FragmentManager; import android.app.PendingIntent; import android.app.ProgressDialog; import android.content.ActivityNotFoundException; +import android.content.ContentResolver; +import android.content.ContentValues; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; @@ -28,6 +30,7 @@ import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.Environment; import android.os.Handler; import android.os.SystemClock; import android.os.VibrationEffect; @@ -40,7 +43,6 @@ import android.text.TextUtils; import android.text.format.DateUtils; import android.util.Log; import android.util.TypedValue; -import android.util.Range; import android.view.ActionMode; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; @@ -53,8 +55,6 @@ import android.view.MotionEvent; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; -import android.view.animation.CycleInterpolator; -import android.view.ViewParent; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; import android.widget.AbsListView; @@ -64,7 +64,6 @@ import android.widget.AdapterView.AdapterContextMenuInfo; import android.widget.CheckBox; import android.widget.ListView; import android.widget.PopupMenu; -import android.widget.TextView; import android.widget.TextView.OnEditorActionListener; import android.widget.Toast; @@ -77,8 +76,6 @@ import androidx.appcompat.content.res.AppCompatResources; import androidx.core.view.inputmethod.InputConnectionCompat; import androidx.core.view.inputmethod.InputContentInfoCompat; import androidx.databinding.DataBindingUtil; -import androidx.interpolator.view.animation.FastOutSlowInInterpolator; -import androidx.recyclerview.widget.LinearLayoutManager; import androidx.viewpager.widget.PagerAdapter; import com.google.common.base.Optional; @@ -86,19 +83,18 @@ import com.google.common.collect.ImmutableList; import org.jetbrains.annotations.NotNull; +import java.net.URL; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; @@ -150,7 +146,6 @@ import eu.siacs.conversations.ui.widget.HighlighterView; import eu.siacs.conversations.ui.widget.TabLayout; import eu.siacs.conversations.utils.AccountUtils; import eu.siacs.conversations.utils.Compatibility; -import eu.siacs.conversations.utils.Emoticons; import eu.siacs.conversations.utils.GeoHelper; import eu.siacs.conversations.utils.MessageUtils; import eu.siacs.conversations.utils.NickValidityChecker; @@ -172,19 +167,6 @@ import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession; import eu.siacs.conversations.xmpp.jingle.RtpCapability; import eu.siacs.conversations.xmpp.stanzas.IqPacket; -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicBoolean; - public class ConversationFragment extends XmppFragment implements EditMessage.KeyboardListener, MessageAdapter.OnContactPictureLongClicked, @@ -1729,6 +1711,7 @@ public class ConversationFragment extends XmppFragment MenuItem shareWith = menu.findItem(R.id.share_with); MenuItem sendAgain = menu.findItem(R.id.send_again); MenuItem copyUrl = menu.findItem(R.id.copy_url); + MenuItem saveToDownloads = menu.findItem(R.id.save_to_downloads); MenuItem downloadFile = menu.findItem(R.id.download_file); MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission); MenuItem deleteFile = menu.findItem(R.id.delete_file); @@ -1792,6 +1775,7 @@ public class ConversationFragment extends XmppFragment || t instanceof HttpDownloadConnection) { copyUrl.setVisible(true); } + if (m.isFileOrImage() && deleted && m.hasFileOnRemoteHost()) { downloadFile.setVisible(true); downloadFile.setTitle( @@ -1814,10 +1798,14 @@ public class ConversationFragment extends XmppFragment || !path.startsWith("/") || FileBackend.inConversationsDirectory(requireActivity(), path)) { deleteFile.setVisible(true); + + String fileDescriptorString = UIHelper.getFileDescriptionString(activity, m); deleteFile.setTitle( activity.getString( R.string.delete_x_file, - UIHelper.getFileDescriptionString(activity, m))); + fileDescriptorString)); + + saveToDownloads.setVisible(true); } } if (showError) { @@ -1869,6 +1857,9 @@ public class ConversationFragment extends XmppFragment case R.id.delete_file: deleteFile(selectedMessage); return true; + case R.id.save_to_downloads: + saveToDownloads(selectedMessage); + return true; case R.id.show_error_message: showErrorMessage(selectedMessage); return true; @@ -2635,6 +2626,24 @@ public class ConversationFragment extends XmppFragment builder.create().show(); } + private void saveToDownloads(final Message message) { + activity.xmppConnectionService.copyAttachmentToDownloadsFolder(message, new UiCallback<>() { + @Override + public void success(Integer object) { + runOnUiThread(() -> Toast.makeText(activity, R.string.save_to_downloads_success, Toast.LENGTH_LONG).show()); + } + + @Override + public void error(int errorCode, Integer object) { + runOnUiThread(() -> Toast.makeText(activity, object, Toast.LENGTH_LONG).show()); + } + + @Override + public void userInputRequired(PendingIntent pi, Integer object) { + } + }); + } + private void resendMessage(final Message message) { if (message.isFileOrImage()) { if (!(message.getConversation() instanceof Conversation)) { diff --git a/src/main/java/eu/siacs/conversations/ui/SearchActivity.java b/src/main/java/eu/siacs/conversations/ui/SearchActivity.java index c6ddeb03d..7967ba284 100644 --- a/src/main/java/eu/siacs/conversations/ui/SearchActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SearchActivity.java @@ -29,6 +29,7 @@ package eu.siacs.conversations.ui; +import android.app.PendingIntent; import android.content.Intent; import android.os.Bundle; import android.text.Editable; @@ -41,6 +42,7 @@ import android.view.MotionEvent; import android.view.View; import android.widget.AdapterView; import android.widget.EditText; +import android.widget.Toast; import androidx.databinding.DataBindingUtil; @@ -56,6 +58,7 @@ import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.persistance.FileBackend; import eu.siacs.conversations.services.MessageSearchTask; import eu.siacs.conversations.ui.adapter.MessageAdapter; import eu.siacs.conversations.ui.interfaces.OnSearchResultsAvailable; @@ -67,6 +70,7 @@ import eu.siacs.conversations.ui.util.ShareUtil; import eu.siacs.conversations.ui.util.StyledAttributes; import eu.siacs.conversations.utils.FtsUtils; import eu.siacs.conversations.utils.MessageUtils; +import eu.siacs.conversations.utils.UIHelper; import static eu.siacs.conversations.ui.util.SoftKeyboardUtils.hideSoftKeyboard; import static eu.siacs.conversations.ui.util.SoftKeyboardUtils.showKeyboard; @@ -140,6 +144,26 @@ public class SearchActivity extends XmppActivity implements TextWatcher, OnSearc MenuItem copy = menu.findItem(R.id.copy_message); MenuItem quote = menu.findItem(R.id.quote_message); MenuItem copyUrl = menu.findItem(R.id.copy_url); + MenuItem saveToDownloads = menu.findItem(R.id.save_to_downloads); + + + final boolean deleted = message.isDeleted(); + final boolean waitingOfferedSending = + message.getStatus() == Message.STATUS_WAITING + || message.getStatus() == Message.STATUS_UNSEND + || message.getStatus() == Message.STATUS_OFFERED; + final boolean cancelable = + (message.getTransferable() != null && !deleted) || waitingOfferedSending && message.needsUploading(); + + if (message.isFileOrImage() && !deleted && !cancelable) { + final String path = message.getRelativeFilePath(); + if (path == null + || !path.startsWith("/") + || FileBackend.inConversationsDirectory(this, path)) { + saveToDownloads.setVisible(true); + } + } + if (message.isGeoUri()) { copy.setVisible(false); quote.setVisible(false); @@ -171,6 +195,23 @@ public class SearchActivity extends XmppActivity implements TextWatcher, OnSearc case R.id.copy_message: ShareUtil.copyToClipboard(this, message); break; + case R.id.save_to_downloads: + xmppConnectionService.copyAttachmentToDownloadsFolder(message, new UiCallback<>() { + @Override + public void success(Integer object) { + runOnUiThread(() -> Toast.makeText(SearchActivity.this, R.string.save_to_downloads_success, Toast.LENGTH_LONG).show()); + } + + @Override + public void error(int errorCode, Integer object) { + runOnUiThread(() -> Toast.makeText(SearchActivity.this, object, Toast.LENGTH_LONG).show()); + } + + @Override + public void userInputRequired(PendingIntent pi, Integer object) { + } + }); + break; case R.id.copy_url: ShareUtil.copyUrlToClipboard(this, message); break; diff --git a/src/main/res/menu/message_context.xml b/src/main/res/menu/message_context.xml index 3787a08b1..9e3a508d2 100644 --- a/src/main/res/menu/message_context.xml +++ b/src/main/res/menu/message_context.xml @@ -42,6 +42,12 @@ android:id="@+id/copy_url" android:title="@string/copy_original_url" android:visible="false" /> + + + + + \ No newline at end of file diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index bbb19220c..7a5788124 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -315,6 +315,8 @@ Quote Paste as quote Copy original URL + Save to Downloads + File successfully saved to Downloads folder Send again File URL Copied URL to clipboard