diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index dbf99fac3c..c2a985df7d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -38,7 +38,7 @@
-
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
index 0c1f05c574..ce8853e391 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
@@ -1,12 +1,14 @@
package org.thoughtcrime.securesms.conversation.v2
import android.Manifest
+import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.animation.FloatEvaluator
import android.animation.ValueAnimator
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.content.res.Resources
import android.database.Cursor
import android.graphics.Rect
@@ -35,6 +37,7 @@ import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
+import androidx.core.content.ContextCompat
import androidx.core.view.drawToBitmap
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -50,19 +53,9 @@ import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.annimon.stream.Stream
+import com.bumptech.glide.Glide
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
-import java.lang.ref.WeakReference
-import java.util.Locale
-import java.util.concurrent.ExecutionException
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicLong
-import java.util.concurrent.atomic.AtomicReference
-import javax.inject.Inject
-import kotlin.math.abs
-import kotlin.math.min
-import kotlin.math.roundToInt
-import kotlin.math.sqrt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
@@ -169,7 +162,6 @@ import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivity
import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.mms.GifSlide
-import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.Slide
@@ -190,6 +182,18 @@ import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.toPx
+import java.lang.ref.WeakReference
+import java.util.Locale
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
+import java.util.concurrent.atomic.AtomicReference
+import javax.inject.Inject
+import kotlin.math.abs
+import kotlin.math.min
+import kotlin.math.roundToInt
+import kotlin.math.sqrt
+
private const val TAG = "ConversationActivityV2"
@@ -2148,6 +2152,26 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode()
}
+ private fun saveAttachments(message: MmsMessageRecord) {
+ val attachments: List = Stream.of(message.slideDeck.slides)
+ .filter { s: Slide -> s.uri != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument()) }
+ .map { s: Slide -> SaveAttachmentTask.Attachment(s.uri!!, s.contentType, message.dateReceived, s.fileName.orNull()) }
+ .toList()
+ if (attachments.isNotEmpty()) {
+ val saveTask = SaveAttachmentTask(this)
+ saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, *attachments.toTypedArray())
+ if (!message.isOutgoing) { sendMediaSavedNotification() }
+ return
+ }
+ // Implied else that there were no attachment(s)
+ Toast.makeText(this, resources.getString(R.string.attachmentsSaveError), Toast.LENGTH_LONG).show()
+ }
+
+ private fun hasPermission(permission: String): Boolean {
+ val result = ContextCompat.checkSelfPermission(this, permission)
+ return result == PackageManager.PERMISSION_GRANTED
+ }
+
override fun saveAttachment(messages: Set) {
val message = messages.first() as MmsMessageRecord
@@ -2159,37 +2183,64 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return
}
+ // On Android versions below 30 we require the WRITE_EXTERNAL_STORAGE permission to save attachments.
+ // However, we would like to on more recent Android API versions there is scoped storage
+ // If we already have permission to write to external storage then just get on with it & return..
+ //
+ // Android versions will j
+ if (Build.VERSION.SDK_INT < 30) {
+ // Save the attachment(s) then bail if we already have permission to do so
+ if (hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ saveAttachments(message)
+ return
+ }
+ } else {
+ // On more modern versions of Android on API 30+ WRITE_EXTERNAL_STORAGE is no longer used and we can just
+ // save files to the public directories like "Downloads", "Pictures" etc. - but... we would still like to
+ // inform the user just _once_ that saving attachments means that other apps can access them - so we'll
+ val haveWarned = TextSecurePreferences.getHaveWarnedUserAboutSavingAttachments(this)
+ if (haveWarned) {
+ saveAttachments(message)
+ return
+ }
+ }
+
+ // ..otherwise we must ask for it first.
SaveAttachmentTask.showWarningDialog(this) {
Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
- .maxSdkVersion(Build.VERSION_CODES.P)
+ .maxSdkVersion(Build.VERSION_CODES.P) // P is 28
.withPermanentDenialDialog(Phrase.from(applicationContext, R.string.permissionsStorageSaveDenied)
.put(APP_NAME_KEY, getString(R.string.app_name))
.format().toString())
.onAnyDenied {
endActionMode()
- val txt = Phrase.from(applicationContext, R.string.permissionsStorageSaveDenied)
- .put(APP_NAME_KEY, getString(R.string.app_name))
- .format().toString()
- Toast.makeText(this@ConversationActivityV2, txt, Toast.LENGTH_LONG).show()
+
+ showSessionDialog {
+ title(R.string.permissionsRequired)
+
+ val txt = Phrase.from(applicationContext, R.string.permissionsStorageSaveDenied)
+ .put(APP_NAME_KEY, getString(R.string.app_name))
+ .format().toString()
+ text(txt)
+
+ // Take the user directly to the settings app for Session to grant the permission if they
+ // initially denied it but then have a change of heart when they realise they can't
+ // proceed without it.
+ dangerButton(R.string.theContinue) {
+ val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ val uri = Uri.fromParts("package", packageName, null)
+ intent.setData(uri)
+ startActivity(intent)
+ }
+
+ button(R.string.cancel)
+ }
}
.onAllGranted {
endActionMode()
- val attachments: List = Stream.of(message.slideDeck.slides)
- .filter { s: Slide -> s.uri != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument()) }
- .map { s: Slide -> SaveAttachmentTask.Attachment(s.uri!!, s.contentType, message.dateReceived, s.fileName.orNull()) }
- .toList()
- if (attachments.isNotEmpty()) {
- val saveTask = SaveAttachmentTask(this)
- saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, *attachments.toTypedArray())
- if (!message.isOutgoing) {
- sendMediaSavedNotification()
- }
- return@onAllGranted
- }
- Toast.makeText(this,
- resources.getString(R.string.attachmentsSaveError),
- Toast.LENGTH_LONG).show()
+ saveAttachments(message)
}
.execute()
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt
index 922096c9f6..7d1dc625f6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/UntrustedAttachmentView.kt
@@ -39,7 +39,7 @@ class UntrustedAttachmentView: LinearLayout {
iconDrawable.mutate().setTint(textColor)
val text = Phrase.from(context, R.string.attachmentsTapToDownload)
- .put(FILE_TYPE_KEY, stringRes)
+ .put(FILE_TYPE_KEY, context.getString(stringRes))
.format()
binding.untrustedAttachmentTitle.text = text
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt
index 71f09314cc..5609ac1da9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt
@@ -12,6 +12,7 @@ import android.text.TextUtils
import android.webkit.MimeTypeMap
import android.widget.Toast
import network.loki.messenger.R
+import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.task.ProgressDialogAsyncTask
import org.session.libsignal.utilities.ExternalStorageUtil
import org.session.libsignal.utilities.Log
@@ -47,10 +48,20 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int
@JvmOverloads
fun showWarningDialog(context: Context, count: Int = 1, onAcceptListener: () -> Unit = {}) {
context.showSessionDialog {
- title(R.string.permissionsRequired)
+ title(R.string.warning)
iconAttribute(R.attr.dialog_alert_icon)
text(context.getString(R.string.attachmentsWarning))
- button(R.string.accept) { onAcceptListener() }
+ dangerButton(R.string.save) {
+ // On Android API 30+ there is no WRITE_EXTERNAL_STORAGE permission to save files so we can't
+ // check against that to show a one-time warning that saved attachments can be accessed by other
+ // apps - so on such devices we'll use a saved boolean preference.
+ val haveWarned = TextSecurePreferences.getHaveWarnedUserAboutSavingAttachments(context)
+ if (!haveWarned && Build.VERSION.SDK_INT >= 30) {
+ TextSecurePreferences.setHaveWarnedUserAboutSavingAttachments(context)
+ }
+
+ onAcceptListener()
+ }
button(R.string.cancel)
}
}
diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt
index 5bf109843d..d723058b61 100644
--- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt
+++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt
@@ -301,6 +301,12 @@ interface TextSecurePreferences {
const val ALLOW_MESSAGE_REQUESTS = "libsession.ALLOW_MESSAGE_REQUESTS"
+ // Key name for if we've warned the user that saving attachments will allow other apps to access them.
+ // Note: This is only a concern on Android API 30+ which does not have the WRITE_EXTERNAL_STORAGE permission
+ // for us to check against - and we only display this once, or until the user consents to this and continues
+ // to save the attachment(s).
+ const val HAVE_WARNED_USER_ABOUT_SAVING_ATTACHMENTS = "libsession.HAVE_WARNED_USER_ABOUT_SAVING_ATTACHMENTS"
+
@JvmStatic
fun getLastConfigurationSyncTime(context: Context): Long {
return getLongPreference(context, LAST_CONFIGURATION_SYNC_TIME, 0)
@@ -981,6 +987,19 @@ interface TextSecurePreferences {
setBooleanPreference(context, FINGERPRINT_KEY_GENERATED, true)
}
+
+ // ----- Get / set methods for if we have already warned the user that saving attachments will allow other apps to access them -----
+ @JvmStatic
+ fun getHaveWarnedUserAboutSavingAttachments(context: Context): Boolean {
+ return getBooleanPreference(context, HAVE_WARNED_USER_ABOUT_SAVING_ATTACHMENTS, false)
+ }
+
+ @JvmStatic
+ fun setHaveWarnedUserAboutSavingAttachments(context: Context) {
+ setBooleanPreference(context, HAVE_WARNED_USER_ABOUT_SAVING_ATTACHMENTS, true)
+ }
+ // ---------------------------------------------------------------------------------------------------------------------------------
+
@JvmStatic
fun clearAll(context: Context) {
getDefaultSharedPreferences(context).edit().clear().commit()