diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 726f57a466c08a3ec788860fb502761008406205..df3ccc09f6f236d87125dba405d5919bd628512e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -175,7 +175,6 @@ dependencies { implementation("com.github.bumptech.glide", "recyclerview-integration", version) kapt("com.github.bumptech.glide", "compiler", version) } - implementation("me.saket", "better-link-movement-method", "2.1.0") implementation(project(":slidingpanel")) // Quality Assurance diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/info/channel/ChannelInfoFragment.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/info/channel/ChannelInfoFragment.kt index 29dec85550f6e6ec889efbdd65319298ea136c38..5c43a3e762b5f386c41dea566cba23eb44536954 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/info/channel/ChannelInfoFragment.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/info/channel/ChannelInfoFragment.kt @@ -42,8 +42,8 @@ import de.kuschku.quasseldroid.util.helper.setTooltip import de.kuschku.quasseldroid.util.helper.toLiveData import de.kuschku.quasseldroid.util.irc.format.ContentFormatter import de.kuschku.quasseldroid.util.service.ServiceBoundFragment +import de.kuschku.quasseldroid.util.ui.BetterLinkMovementMethod import de.kuschku.quasseldroid.util.ui.LinkLongClickMenuHelper -import me.saket.bettermovementmethod.BetterLinkMovementMethod import javax.inject.Inject class ChannelInfoFragment : ServiceBoundFragment() { diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/info/user/UserInfoFragment.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/info/user/UserInfoFragment.kt index 320b8051e2753cad45ac8d16e9b5216447884585..f5c0f9906cb27b3365d38479e99c66fdfc76030d 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/info/user/UserInfoFragment.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/info/user/UserInfoFragment.kt @@ -44,10 +44,10 @@ import de.kuschku.quasseldroid.util.AvatarHelper import de.kuschku.quasseldroid.util.helper.* import de.kuschku.quasseldroid.util.irc.format.ContentFormatter import de.kuschku.quasseldroid.util.service.ServiceBoundFragment +import de.kuschku.quasseldroid.util.ui.BetterLinkMovementMethod import de.kuschku.quasseldroid.util.ui.LinkLongClickMenuHelper import de.kuschku.quasseldroid.util.ui.TextDrawable import io.reactivex.Observable -import me.saket.bettermovementmethod.BetterLinkMovementMethod import javax.inject.Inject class UserInfoFragment : ServiceBoundFragment() { diff --git a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/messages/MessageAdapter.kt b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/messages/MessageAdapter.kt index a1f822d33968a407e2a6c74846a7ab37a4fed195..f8df83300409a5f6d93e6ca20fb633b3f253e21d 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/ui/chat/messages/MessageAdapter.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/ui/chat/messages/MessageAdapter.kt @@ -40,9 +40,9 @@ import de.kuschku.quasseldroid.R import de.kuschku.quasseldroid.persistence.QuasselDatabase import de.kuschku.quasseldroid.util.helper.getOrPut import de.kuschku.quasseldroid.util.helper.loadAvatars +import de.kuschku.quasseldroid.util.ui.BetterLinkMovementMethod import de.kuschku.quasseldroid.util.ui.DoubleClickHelper import de.kuschku.quasseldroid.viewmodel.data.FormattedMessage -import me.saket.bettermovementmethod.BetterLinkMovementMethod import javax.inject.Inject class MessageAdapter @Inject constructor( diff --git a/app/src/main/java/de/kuschku/quasseldroid/util/ui/BetterLinkMovementMethod.java b/app/src/main/java/de/kuschku/quasseldroid/util/ui/BetterLinkMovementMethod.java new file mode 100644 index 0000000000000000000000000000000000000000..89cde6d0d1a19edc2d061b36a5a357532f681ffa --- /dev/null +++ b/app/src/main/java/de/kuschku/quasseldroid/util/ui/BetterLinkMovementMethod.java @@ -0,0 +1,453 @@ +package de.kuschku.quasseldroid.util.ui; + +import android.app.Activity; +import android.graphics.RectF; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.BackgroundColorSpan; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.TextView; + +/** + * Handles URL clicks on TextViews. Unlike the default implementation, this: + * <p> + * <ul> + * <li>Reliably applies a highlight color on links when they're touched.</li> + * <li>Let's you handle single and long clicks on URLs</li> + * <li>Correctly identifies focused URLs (Unlike the default implementation where a click is registered even if it's + * made outside of the URL's bounds if there is no more text in that direction.)</li> + * </ul> + */ +public class BetterLinkMovementMethod extends LinkMovementMethod { + + private static final int LINKIFY_NONE = -2; + private static BetterLinkMovementMethod singleInstance; + private final RectF touchedLineBounds = new RectF(); + private OnLinkClickListener onLinkClickListener; + private OnLinkLongClickListener onLinkLongClickListener; + private boolean isUrlHighlighted; + private ClickableSpan clickableSpanUnderTouchOnActionDown; + private int activeTextViewHashcode; + private LongPressTimer ongoingLongPressTimer; + private boolean wasLongPressRegistered; + + protected BetterLinkMovementMethod() { + } + + /** + * Return a new instance of BetterLinkMovementMethod. + */ + public static BetterLinkMovementMethod newInstance() { + return new BetterLinkMovementMethod(); + } + + /** + * @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES}, + * {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}. + * @param textViews The TextViews on which a {@link BetterLinkMovementMethod} should be registered. + * @return The registered {@link BetterLinkMovementMethod} on the TextViews. + */ + public static BetterLinkMovementMethod linkify(int linkifyMask, TextView... textViews) { + BetterLinkMovementMethod movementMethod = newInstance(); + for (TextView textView : textViews) { + addLinks(linkifyMask, movementMethod, textView); + } + return movementMethod; + } + + /** + * Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links. + * + * @param textViews The TextViews on which a {@link BetterLinkMovementMethod} should be registered. + * @return The registered {@link BetterLinkMovementMethod} on the TextViews. + */ + public static BetterLinkMovementMethod linkifyHtml(TextView... textViews) { + return linkify(LINKIFY_NONE, textViews); + } + + /** + * Recursively register a {@link BetterLinkMovementMethod} on every TextView inside a layout. + * + * @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES}, + * {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}. + * @return The registered {@link BetterLinkMovementMethod} on the TextViews. + */ + public static BetterLinkMovementMethod linkify(int linkifyMask, ViewGroup viewGroup) { + BetterLinkMovementMethod movementMethod = newInstance(); + rAddLinks(linkifyMask, viewGroup, movementMethod); + return movementMethod; + } + + /** + * Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links. + * + * @return The registered {@link BetterLinkMovementMethod} on the TextViews. + */ + public static BetterLinkMovementMethod linkifyHtml(ViewGroup viewGroup) { + return linkify(LINKIFY_NONE, viewGroup); + } + + /** + * Recursively register a {@link BetterLinkMovementMethod} on every TextView inside a layout. + * + * @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES}, + * {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}. + * @return The registered {@link BetterLinkMovementMethod} on the TextViews. + */ + public static BetterLinkMovementMethod linkify(int linkifyMask, Activity activity) { + // Find the layout passed to setContentView(). + ViewGroup activityLayout = ((ViewGroup) ((ViewGroup) activity.findViewById(Window.ID_ANDROID_CONTENT)).getChildAt(0)); + + BetterLinkMovementMethod movementMethod = newInstance(); + rAddLinks(linkifyMask, activityLayout, movementMethod); + return movementMethod; + } + + /** + * Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links. + * + * @return The registered {@link BetterLinkMovementMethod} on the TextViews. + */ + public static BetterLinkMovementMethod linkifyHtml(Activity activity) { + return linkify(LINKIFY_NONE, activity); + } + + /** + * Get a static instance of BetterLinkMovementMethod. Do note that registering a click listener on the returned + * instance is not supported because it will potentially be shared on multiple TextViews. + */ + public static BetterLinkMovementMethod getInstance() { + if (singleInstance == null) { + singleInstance = new BetterLinkMovementMethod(); + } + return singleInstance; + } + + private static void rAddLinks(int linkifyMask, ViewGroup viewGroup, BetterLinkMovementMethod movementMethod) { + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + + if (child instanceof ViewGroup) { + // Recursively find child TextViews. + rAddLinks(linkifyMask, ((ViewGroup) child), movementMethod); + + } else if (child instanceof TextView) { + TextView textView = (TextView) child; + addLinks(linkifyMask, movementMethod, textView); + } + } + } + + private static void addLinks(int linkifyMask, BetterLinkMovementMethod movementMethod, TextView textView) { + textView.setMovementMethod(movementMethod); + if (linkifyMask != LINKIFY_NONE) { + Linkify.addLinks(textView, linkifyMask); + } + } + + /** + * Set a listener that will get called whenever any link is clicked on the TextView. + */ + public BetterLinkMovementMethod setOnLinkClickListener(OnLinkClickListener clickListener) { + if (this == singleInstance) { + throw new UnsupportedOperationException("Setting a click listener on the instance returned by getInstance() is not supported to avoid memory " + + "leaks. Please use newInstance() or any of the linkify() methods instead."); + } + + this.onLinkClickListener = clickListener; + return this; + } + + /** + * Set a listener that will get called whenever any link is clicked on the TextView. + */ + public BetterLinkMovementMethod setOnLinkLongClickListener(OnLinkLongClickListener longClickListener) { + if (this == singleInstance) { + throw new UnsupportedOperationException("Setting a long-click listener on the instance returned by getInstance() is not supported to avoid " + + "memory leaks. Please use newInstance() or any of the linkify() methods instead."); + } + + this.onLinkLongClickListener = longClickListener; + return this; + } + +// ======== PUBLIC APIs END ======== // + + @Override + public boolean onTouchEvent(final TextView textView, Spannable text, MotionEvent event) { + if (activeTextViewHashcode != textView.hashCode()) { + // Bug workaround: TextView stops calling onTouchEvent() once any URL is highlighted. + // A hacky solution is to reset any "autoLink" property set in XML. But we also want + // to do this once per TextView. + activeTextViewHashcode = textView.hashCode(); + textView.setAutoLinkMask(0); + } + + final ClickableSpan clickableSpanUnderTouch = findClickableSpanUnderTouch(textView, text, event); + if (event.getAction() == MotionEvent.ACTION_DOWN) { + clickableSpanUnderTouchOnActionDown = clickableSpanUnderTouch; + } + final boolean touchStartedOverAClickableSpan = clickableSpanUnderTouchOnActionDown != null; + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + if (clickableSpanUnderTouch != null) { + highlightUrl(textView, clickableSpanUnderTouch, text); + } + + if (touchStartedOverAClickableSpan && onLinkLongClickListener != null) { + LongPressTimer.OnTimerReachedListener longClickListener = new LongPressTimer.OnTimerReachedListener() { + @Override + public void onTimerReached() { + wasLongPressRegistered = true; + textView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + removeUrlHighlightColor(textView); + dispatchUrlLongClick(textView, clickableSpanUnderTouch); + } + }; + startTimerForRegisteringLongClick(textView, longClickListener); + } + return touchStartedOverAClickableSpan; + + case MotionEvent.ACTION_UP: + // Register a click only if the touch started and ended on the same URL. + if (!wasLongPressRegistered && touchStartedOverAClickableSpan && clickableSpanUnderTouch == clickableSpanUnderTouchOnActionDown) { + dispatchUrlClick(textView, clickableSpanUnderTouch); + } + cleanupOnTouchUp(textView); + + // Consume this event even if we could not find any spans to avoid letting Android handle this event. + // Android's TextView implementation has a bug where links get clicked even when there is no more text + // next to the link and the touch lies outside its bounds in the same direction. + return touchStartedOverAClickableSpan; + + case MotionEvent.ACTION_CANCEL: + cleanupOnTouchUp(textView); + return false; + + case MotionEvent.ACTION_MOVE: + // Stop listening for a long-press as soon as the user wanders off to unknown lands. + if (clickableSpanUnderTouch != clickableSpanUnderTouchOnActionDown) { + removeLongPressCallback(textView); + } + + if (!wasLongPressRegistered) { + // Toggle highlight. + if (clickableSpanUnderTouch != null) { + highlightUrl(textView, clickableSpanUnderTouch, text); + } else { + removeUrlHighlightColor(textView); + } + } + + return touchStartedOverAClickableSpan; + + default: + return false; + } + } + + private void cleanupOnTouchUp(TextView textView) { + wasLongPressRegistered = false; + clickableSpanUnderTouchOnActionDown = null; + removeUrlHighlightColor(textView); + removeLongPressCallback(textView); + } + + /** + * Determines the touched location inside the TextView's text and returns the ClickableSpan found under it (if any). + * + * @return The touched ClickableSpan or null. + */ + protected ClickableSpan findClickableSpanUnderTouch(TextView textView, Spannable text, MotionEvent event) { + // So we need to find the location in text where touch was made, regardless of whether the TextView + // has scrollable text. That is, not the entire text is currently visible. + int touchX = (int) event.getX(); + int touchY = (int) event.getY(); + + // Ignore padding. + touchX -= textView.getTotalPaddingLeft(); + touchY -= textView.getTotalPaddingTop(); + + // Account for scrollable text. + touchX += textView.getScrollX(); + touchY += textView.getScrollY(); + + final Layout layout = textView.getLayout(); + final int touchedLine = layout.getLineForVertical(touchY); + final int touchOffset = layout.getOffsetForHorizontal(touchedLine, touchX); + + touchedLineBounds.left = layout.getLineLeft(touchedLine); + touchedLineBounds.top = layout.getLineTop(touchedLine); + touchedLineBounds.right = layout.getLineWidth(touchedLine) + touchedLineBounds.left; + touchedLineBounds.bottom = layout.getLineBottom(touchedLine); + + if (touchedLineBounds.contains(touchX, touchY)) { + // Find a ClickableSpan that lies under the touched area. + final Object[] spans = text.getSpans(touchOffset, touchOffset, ClickableSpan.class); + for (final Object span : spans) { + if (span instanceof ClickableSpan) { + return (ClickableSpan) span; + } + } + // No ClickableSpan found under the touched location. + return null; + + } else { + // Touch lies outside the line's horizontal bounds where no spans should exist. + return null; + } + } + + /** + * Adds a background color span at <var>clickableSpan</var>'s location. + */ + protected void highlightUrl(TextView textView, ClickableSpan clickableSpan, Spannable text) { + if (isUrlHighlighted) { + return; + } + isUrlHighlighted = true; + + final int spanStart = text.getSpanStart(clickableSpan); + final int spanEnd = text.getSpanEnd(clickableSpan); + text.setSpan(new BackgroundColorSpan(textView.getHighlightColor()), spanStart, spanEnd, Spannable.SPAN_INCLUSIVE_INCLUSIVE | Spannable.SPAN_COMPOSING); + + Selection.setSelection(text, spanStart, spanEnd); + } + + /** + * Removes the highlight color under the Url. + */ + protected void removeUrlHighlightColor(TextView textView) { + if (!isUrlHighlighted) { + return; + } + isUrlHighlighted = false; + + final Spannable text = (Spannable) textView.getText(); + + BackgroundColorSpan[] highlightSpans = text.getSpans(0, text.length(), BackgroundColorSpan.class); + for (BackgroundColorSpan highlightSpan : highlightSpans) { + if ((text.getSpanFlags(highlightSpan) & Spannable.SPAN_COMPOSING) != 0) + text.removeSpan(highlightSpan); + } + + Selection.removeSelection(text); + } + + protected void startTimerForRegisteringLongClick(TextView textView, LongPressTimer.OnTimerReachedListener longClickListener) { + ongoingLongPressTimer = new LongPressTimer(); + ongoingLongPressTimer.setOnTimerReachedListener(longClickListener); + textView.postDelayed(ongoingLongPressTimer, ViewConfiguration.getLongPressTimeout()); + } + + /** + * Remove the long-press detection timer. + */ + protected void removeLongPressCallback(TextView textView) { + if (ongoingLongPressTimer != null) { + textView.removeCallbacks(ongoingLongPressTimer); + ongoingLongPressTimer = null; + } + } + + protected void dispatchUrlClick(TextView textView, ClickableSpan clickableSpan) { + ClickableSpanWithText clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan); + boolean handled = onLinkClickListener != null && onLinkClickListener.onClick(textView, clickableSpanWithText.text()); + + if (!handled) { + // Let Android handle this click. + clickableSpanWithText.span().onClick(textView); + } + } + + protected void dispatchUrlLongClick(TextView textView, ClickableSpan clickableSpan) { + ClickableSpanWithText clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan); + boolean handled = onLinkLongClickListener != null && onLinkLongClickListener.onLongClick(textView, clickableSpanWithText.text()); + + if (!handled) { + // Let Android handle this long click as a short-click. + clickableSpanWithText.span().onClick(textView); + } + } + + public interface OnLinkClickListener { + /** + * @param textView The TextView on which a click was registered. + * @param url The clicked URL. + * @return True if this click was handled. False to let Android handle the URL. + */ + boolean onClick(TextView textView, String url); + } + + public interface OnLinkLongClickListener { + /** + * @param textView The TextView on which a long-click was registered. + * @param url The long-clicked URL. + * @return True if this long-click was handled. False to let Android handle the URL (as a short-click). + */ + boolean onLongClick(TextView textView, String url); + } + + protected static final class LongPressTimer implements Runnable { + private OnTimerReachedListener onTimerReachedListener; + + @Override + public void run() { + onTimerReachedListener.onTimerReached(); + } + + public void setOnTimerReachedListener(OnTimerReachedListener listener) { + onTimerReachedListener = listener; + } + + interface OnTimerReachedListener { + void onTimerReached(); + } + } + + /** + * A wrapper to support all {@link ClickableSpan}s that may or may not provide URLs. + */ + protected static class ClickableSpanWithText { + private ClickableSpan span; + private String text; + + private ClickableSpanWithText(ClickableSpan span, String text) { + this.span = span; + this.text = text; + } + + protected static ClickableSpanWithText ofSpan(TextView textView, ClickableSpan span) { + Spanned s = (Spanned) textView.getText(); + String text; + if (span instanceof URLSpan) { + text = ((URLSpan) span).getURL(); + } else { + int start = s.getSpanStart(span); + int end = s.getSpanEnd(span); + text = s.subSequence(start, end).toString(); + } + return new ClickableSpanWithText(span, text); + } + + ClickableSpan span() { + return span; + } + + String text() { + return text; + } + } +} diff --git a/app/src/main/java/de/kuschku/quasseldroid/util/ui/LinkLongClickMenuHelper.kt b/app/src/main/java/de/kuschku/quasseldroid/util/ui/LinkLongClickMenuHelper.kt index b05d061ab488a78b40a54a2f1365c3f1c7cd2a0c..c2ea8a9004db40b716f1f6e79aeaab650946bf35 100644 --- a/app/src/main/java/de/kuschku/quasseldroid/util/ui/LinkLongClickMenuHelper.kt +++ b/app/src/main/java/de/kuschku/quasseldroid/util/ui/LinkLongClickMenuHelper.kt @@ -29,7 +29,6 @@ import android.content.Intent import android.support.v7.widget.PopupMenu import android.widget.TextView import de.kuschku.quasseldroid.R -import me.saket.bettermovementmethod.BetterLinkMovementMethod class LinkLongClickMenuHelper : BetterLinkMovementMethod.OnLinkLongClickListener, diff --git a/app/src/main/res/values/styles_widgets.xml b/app/src/main/res/values/styles_widgets.xml index abe9e6947e9c1b73951c05f1a7726406f095b31e..e1d677c6cb3e63abf84846b77b0cae154494433c 100644 --- a/app/src/main/res/values/styles_widgets.xml +++ b/app/src/main/res/values/styles_widgets.xml @@ -197,14 +197,14 @@ </style> <style name="Widget.Info.Header.Name" parent="Widget.RtlConformTextView"> - <item name="android:layout_width">wrap_content</item> + <item name="android:layout_width">match_parent</item> <item name="android:layout_height">wrap_content</item> <item name="android:textAppearance">@style/TextAppearance.AppCompat.Headline</item> <item name="android:animateLayoutChanges">true</item> </style> <style name="Widget.Info.Header.Description" parent="Widget.RtlConformTextView"> - <item name="android:layout_width">wrap_content</item> + <item name="android:layout_width">match_parent</item> <item name="android:layout_height">wrap_content</item> <item name="android:textAppearance">@style/TextAppearance.AppCompat.Medium</item> <item name="android:animateLayoutChanges">true</item>