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()