mirror of
https://github.com/element-hq/element-android
synced 2024-11-28 05:31:21 +03:00
Merge pull request #1270 from vector-im/feature/misleading_url_target
Show a warning dialog if the text of the clicked link does not match
This commit is contained in:
commit
5f6969e2cc
8 changed files with 93 additions and 21 deletions
|
@ -23,6 +23,7 @@ Improvements 🙌:
|
||||||
- Emoji Verification | It's not the same butterfly! (#1220)
|
- Emoji Verification | It's not the same butterfly! (#1220)
|
||||||
- Cross-Signing | Composer decoration: shields (#1077)
|
- Cross-Signing | Composer decoration: shields (#1077)
|
||||||
- Cross-Signing | Migrate existing keybackup to cross signing with 4S from mobile (#1197)
|
- Cross-Signing | Migrate existing keybackup to cross signing with 4S from mobile (#1197)
|
||||||
|
- Show a warning dialog if the text of the clicked link does not match the link target (#922)
|
||||||
- Cross-Signing | Consider not using a spinner on the 'complete security' prompt (#1271)
|
- Cross-Signing | Consider not using a spinner on the 'complete security' prompt (#1271)
|
||||||
- Restart broken Olm sessions ([MSC1719](https://github.com/matrix-org/matrix-doc/pull/1719))
|
- Restart broken Olm sessions ([MSC1719](https://github.com/matrix-org/matrix-doc/pull/1719))
|
||||||
- Cross-Signing | Hide Use recovery key when 4S is not setup (#1007)
|
- Cross-Signing | Hide Use recovery key when 4S is not setup (#1007)
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2020 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package im.vector.riotx.core.utils
|
||||||
|
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.ClickableSpan
|
||||||
|
import android.text.style.URLSpan
|
||||||
|
import android.widget.TextView
|
||||||
|
import me.saket.bettermovementmethod.BetterLinkMovementMethod
|
||||||
|
|
||||||
|
class EvenBetterLinkMovementMethod(private val onLinkClickListener: OnLinkClickListener? = null) : BetterLinkMovementMethod() {
|
||||||
|
|
||||||
|
interface OnLinkClickListener {
|
||||||
|
/**
|
||||||
|
* @param textView The TextView on which a click was registered.
|
||||||
|
* @param span The ClickableSpan which is clicked on.
|
||||||
|
* @param url The clicked URL.
|
||||||
|
* @param actualText The original text which is spanned. Can be used to compare actualText and target url to prevent misleading urls.
|
||||||
|
* @return true if this click was handled, false to let Android handle the URL.
|
||||||
|
*/
|
||||||
|
fun onLinkClicked(textView: TextView, span: ClickableSpan, url: String, actualText: String): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispatchUrlClick(textView: TextView, clickableSpan: ClickableSpan) {
|
||||||
|
val spanned = textView.text as Spanned
|
||||||
|
val actualText = textView.text.subSequence(spanned.getSpanStart(clickableSpan), spanned.getSpanEnd(clickableSpan)).toString()
|
||||||
|
val url = (clickableSpan as? URLSpan)?.url ?: actualText
|
||||||
|
|
||||||
|
if (onLinkClickListener == null || !onLinkClickListener.onLinkClicked(textView, clickableSpan, url, actualText)) {
|
||||||
|
// Let Android handle this long click as a short-click.
|
||||||
|
clickableSpan.onClick(textView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -39,6 +39,7 @@ import androidx.core.app.ActivityOptionsCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
|
import androidx.core.text.toSpannable
|
||||||
import androidx.core.util.Pair
|
import androidx.core.util.Pair
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.forEach
|
import androidx.core.view.forEach
|
||||||
|
@ -110,9 +111,11 @@ import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_PICK_ATTACHMENT
|
||||||
import im.vector.riotx.core.utils.TextUtils
|
import im.vector.riotx.core.utils.TextUtils
|
||||||
import im.vector.riotx.core.utils.allGranted
|
import im.vector.riotx.core.utils.allGranted
|
||||||
import im.vector.riotx.core.utils.checkPermissions
|
import im.vector.riotx.core.utils.checkPermissions
|
||||||
|
import im.vector.riotx.core.utils.colorizeMatchingText
|
||||||
import im.vector.riotx.core.utils.copyToClipboard
|
import im.vector.riotx.core.utils.copyToClipboard
|
||||||
import im.vector.riotx.core.utils.createUIHandler
|
import im.vector.riotx.core.utils.createUIHandler
|
||||||
import im.vector.riotx.core.utils.getColorFromUserId
|
import im.vector.riotx.core.utils.getColorFromUserId
|
||||||
|
import im.vector.riotx.core.utils.isValidUrl
|
||||||
import im.vector.riotx.core.utils.jsonViewerStyler
|
import im.vector.riotx.core.utils.jsonViewerStyler
|
||||||
import im.vector.riotx.core.utils.openUrlInExternalBrowser
|
import im.vector.riotx.core.utils.openUrlInExternalBrowser
|
||||||
import im.vector.riotx.core.utils.saveMedia
|
import im.vector.riotx.core.utils.saveMedia
|
||||||
|
@ -167,6 +170,7 @@ import org.billcarsonfr.jsonviewer.JSonViewerDialog
|
||||||
import org.commonmark.parser.Parser
|
import org.commonmark.parser.Parser
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.URL
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -919,7 +923,7 @@ class RoomDetailFragment @Inject constructor(
|
||||||
|
|
||||||
// TimelineEventController.Callback ************************************************************
|
// TimelineEventController.Callback ************************************************************
|
||||||
|
|
||||||
override fun onUrlClicked(url: String): Boolean {
|
override fun onUrlClicked(url: String, title: String): Boolean {
|
||||||
permalinkHandler
|
permalinkHandler
|
||||||
.launch(requireActivity(), url, object : NavigationInterceptor {
|
.launch(requireActivity(), url, object : NavigationInterceptor {
|
||||||
override fun navToRoom(roomId: String?, eventId: String?): Boolean {
|
override fun navToRoom(roomId: String?, eventId: String?): Boolean {
|
||||||
|
@ -947,17 +951,34 @@ class RoomDetailFragment @Inject constructor(
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe { managed ->
|
.subscribe { managed ->
|
||||||
if (!managed) {
|
if (!managed) {
|
||||||
|
if (title.isValidUrl() && url.isValidUrl() && URL(title).host != URL(url).host) {
|
||||||
|
AlertDialog.Builder(requireActivity())
|
||||||
|
.setTitle(R.string.external_link_confirmation_title)
|
||||||
|
.setMessage(
|
||||||
|
getString(R.string.external_link_confirmation_message, title, url)
|
||||||
|
.toSpannable()
|
||||||
|
.colorizeMatchingText(url, colorProvider.getColorFromAttribute(android.R.attr.textColorLink))
|
||||||
|
.colorizeMatchingText(title, colorProvider.getColorFromAttribute(android.R.attr.textColorLink))
|
||||||
|
)
|
||||||
|
.setPositiveButton(R.string._continue) { _, _ ->
|
||||||
|
openUrlInExternalBrowser(requireContext(), url)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
.withColoredButton(DialogInterface.BUTTON_NEGATIVE)
|
||||||
|
} else {
|
||||||
// Open in external browser, in a new Tab
|
// Open in external browser, in a new Tab
|
||||||
openUrlInExternalBrowser(requireContext(), url)
|
openUrlInExternalBrowser(requireContext(), url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.disposeOnDestroyView()
|
.disposeOnDestroyView()
|
||||||
// In fact it is always managed
|
// In fact it is always managed
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUrlLongClicked(url: String): Boolean {
|
override fun onUrlLongClicked(url: String): Boolean {
|
||||||
if (url != getString(R.string.edited_suffix)) {
|
if (url != getString(R.string.edited_suffix) && url.isValidUrl()) {
|
||||||
// Copy the url to the clipboard
|
// Copy the url to the clipboard
|
||||||
copyToClipboard(requireContext(), url, true, R.string.link_copied_to_clipboard)
|
copyToClipboard(requireContext(), url, true, R.string.link_copied_to_clipboard)
|
||||||
}
|
}
|
||||||
|
@ -1263,7 +1284,7 @@ class RoomDetailFragment @Inject constructor(
|
||||||
roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(action.senderId))
|
roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(action.senderId))
|
||||||
}
|
}
|
||||||
is EventSharedAction.OnUrlClicked -> {
|
is EventSharedAction.OnUrlClicked -> {
|
||||||
onUrlClicked(action.url)
|
onUrlClicked(action.url, action.title)
|
||||||
}
|
}
|
||||||
is EventSharedAction.OnUrlLongClicked -> {
|
is EventSharedAction.OnUrlLongClicked -> {
|
||||||
onUrlLongClicked(action.url)
|
onUrlLongClicked(action.url)
|
||||||
|
|
|
@ -104,7 +104,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UrlClickCallback {
|
interface UrlClickCallback {
|
||||||
fun onUrlClicked(url: String): Boolean
|
fun onUrlClicked(url: String, title: String): Boolean
|
||||||
fun onUrlLongClicked(url: String): Boolean
|
fun onUrlLongClicked(url: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -99,7 +99,7 @@ sealed class EventSharedAction(@StringRes val titleRes: Int,
|
||||||
EventSharedAction(R.string.message_view_edit_history, R.drawable.ic_view_edit_history)
|
EventSharedAction(R.string.message_view_edit_history, R.drawable.ic_view_edit_history)
|
||||||
|
|
||||||
// An url in the event preview has been clicked
|
// An url in the event preview has been clicked
|
||||||
data class OnUrlClicked(val url: String) :
|
data class OnUrlClicked(val url: String, val title: String) :
|
||||||
EventSharedAction(0, 0)
|
EventSharedAction(0, 0)
|
||||||
|
|
||||||
// An url in the event preview has been long clicked
|
// An url in the event preview has been long clicked
|
||||||
|
|
|
@ -63,8 +63,8 @@ class MessageActionsBottomSheet : VectorBaseBottomSheetDialogFragment(), Message
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUrlClicked(url: String): Boolean {
|
override fun onUrlClicked(url: String, title: String): Boolean {
|
||||||
sharedActionViewModel.post(EventSharedAction.OnUrlClicked(url))
|
sharedActionViewModel.post(EventSharedAction.OnUrlClicked(url, title))
|
||||||
// Always consume
|
// Always consume
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,19 +17,20 @@
|
||||||
package im.vector.riotx.features.home.room.detail.timeline.tools
|
package im.vector.riotx.features.home.room.detail.timeline.tools
|
||||||
|
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.style.ClickableSpan
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.core.text.toSpannable
|
import androidx.core.text.toSpannable
|
||||||
import im.vector.matrix.android.api.permalinks.MatrixLinkify
|
import im.vector.matrix.android.api.permalinks.MatrixLinkify
|
||||||
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
|
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
|
||||||
import im.vector.riotx.core.linkify.VectorLinkify
|
import im.vector.riotx.core.linkify.VectorLinkify
|
||||||
import im.vector.riotx.core.utils.isValidUrl
|
import im.vector.riotx.core.utils.EvenBetterLinkMovementMethod
|
||||||
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
|
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
|
||||||
import im.vector.riotx.features.html.PillImageSpan
|
import im.vector.riotx.features.html.PillImageSpan
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.saket.bettermovementmethod.BetterLinkMovementMethod
|
|
||||||
|
|
||||||
fun CharSequence.findPillsAndProcess(scope: CoroutineScope, processBlock: (PillImageSpan) -> Unit) {
|
fun CharSequence.findPillsAndProcess(scope: CoroutineScope, processBlock: (PillImageSpan) -> Unit) {
|
||||||
scope.launch(Dispatchers.Main) {
|
scope.launch(Dispatchers.Main) {
|
||||||
|
@ -42,10 +43,11 @@ fun CharSequence.findPillsAndProcess(scope: CoroutineScope, processBlock: (PillI
|
||||||
}
|
}
|
||||||
|
|
||||||
fun CharSequence.linkify(callback: TimelineEventController.UrlClickCallback?): CharSequence {
|
fun CharSequence.linkify(callback: TimelineEventController.UrlClickCallback?): CharSequence {
|
||||||
|
val text = this.toString()
|
||||||
val spannable = SpannableStringBuilder(this)
|
val spannable = SpannableStringBuilder(this)
|
||||||
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
|
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
|
||||||
override fun onUrlClicked(url: String) {
|
override fun onUrlClicked(url: String) {
|
||||||
callback?.onUrlClicked(url)
|
callback?.onUrlClicked(url, text)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
VectorLinkify.addLinks(spannable, true)
|
VectorLinkify.addLinks(spannable, true)
|
||||||
|
@ -54,18 +56,17 @@ fun CharSequence.linkify(callback: TimelineEventController.UrlClickCallback?): C
|
||||||
|
|
||||||
// Better link movement methods fixes the issue when
|
// Better link movement methods fixes the issue when
|
||||||
// long pressing to open the context menu on a TextView also triggers an autoLink click.
|
// long pressing to open the context menu on a TextView also triggers an autoLink click.
|
||||||
fun createLinkMovementMethod(urlClickCallback: TimelineEventController.UrlClickCallback?): BetterLinkMovementMethod {
|
fun createLinkMovementMethod(urlClickCallback: TimelineEventController.UrlClickCallback?): EvenBetterLinkMovementMethod {
|
||||||
return BetterLinkMovementMethod.newInstance()
|
return EvenBetterLinkMovementMethod(object : EvenBetterLinkMovementMethod.OnLinkClickListener {
|
||||||
.apply {
|
override fun onLinkClicked(textView: TextView, span: ClickableSpan, url: String, actualText: String): Boolean {
|
||||||
setOnLinkClickListener { _, url ->
|
return urlClickCallback?.onUrlClicked(url, actualText) == true
|
||||||
// Return false to let android manage the click on the link, or true if the link is handled by the application
|
|
||||||
url.isValidUrl() && urlClickCallback?.onUrlClicked(url) == true
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.apply {
|
||||||
// We need also to fix the case when long click on link will trigger long click on cell
|
// We need also to fix the case when long click on link will trigger long click on cell
|
||||||
setOnLinkLongClickListener { tv, url ->
|
setOnLinkLongClickListener { tv, url ->
|
||||||
// Long clicks are handled by parent, return true to block android to do something with url
|
// Long clicks are handled by parent, return true to block android to do something with url
|
||||||
if (url.isValidUrl() && urlClickCallback?.onUrlLongClicked(url) == true) {
|
if (urlClickCallback?.onUrlLongClicked(url) == true) {
|
||||||
tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0))
|
tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0))
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -34,7 +34,8 @@
|
||||||
|
|
||||||
|
|
||||||
<!-- BEGIN Strings added by Onuray -->
|
<!-- BEGIN Strings added by Onuray -->
|
||||||
|
<string name="external_link_confirmation_title">Double-check this link</string>
|
||||||
|
<string name="external_link_confirmation_message">The link %1$s is taking you to another site: %2$s.\n\nAre you sure you want to continue?</string>
|
||||||
<!-- END Strings added by Onuray -->
|
<!-- END Strings added by Onuray -->
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue