diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/Binding.java b/app/src/main/java/pl/szczodrzynski/edziennik/Binding.java deleted file mode 100644 index d9c97a16..00000000 --- a/app/src/main/java/pl/szczodrzynski/edziennik/Binding.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) Kuba Szczodrzyński 2019-11-11. - */ - -package pl.szczodrzynski.edziennik; - -import android.graphics.Paint; -import android.widget.TextView; - -import androidx.databinding.BindingAdapter; - -public class Binding { - @BindingAdapter("strikeThrough") - public static void strikeThrough(TextView textView, Boolean strikeThrough) { - if (strikeThrough) { - textView.setPaintFlags(textView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); - } else { - textView.setPaintFlags(textView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG); - } - } -} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/Binding.kt b/app/src/main/java/pl/szczodrzynski/edziennik/Binding.kt new file mode 100644 index 00000000..3dc9d0a6 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/Binding.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2019-11-11. + */ +package pl.szczodrzynski.edziennik + +import android.graphics.Paint +import android.widget.TextView +import androidx.databinding.BindingAdapter + +object Binding { + @JvmStatic + @BindingAdapter("strikeThrough") + fun strikeThrough(textView: TextView, strikeThrough: Boolean) { + if (strikeThrough) { + textView.paintFlags = textView.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + } else { + textView.paintFlags = textView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + } + } +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt b/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt index cff0645e..0d7b1f56 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt @@ -2,6 +2,8 @@ package pl.szczodrzynski.edziennik import android.Manifest import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -10,6 +12,7 @@ import android.content.res.Resources import android.database.Cursor import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter +import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.Drawable import android.os.Build @@ -23,6 +26,7 @@ import android.util.Base64 import android.util.Base64.NO_WRAP import android.util.Base64.encodeToString import android.view.View +import android.view.WindowManager import android.widget.CheckBox import android.widget.CompoundButton import android.widget.RadioButton @@ -1094,3 +1098,71 @@ inline fun ifNotNull(a: A?, b: B?, code: (A, B) -> R): R? { fun Iterable.averageOrNull() = this.average().let { if (it.isNaN()) null else it } @kotlin.jvm.JvmName("averageOrNullOfFloat") fun Iterable.averageOrNull() = this.average().let { if (it.isNaN()) null else it } + +fun String.copyToClipboard(context: Context) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText("Tekst", this) + clipboard.primaryClip = clipData +} + +fun TextView.getTextPosition(range: IntRange): Rect { + val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + + // Initialize global value + var parentTextViewRect = Rect() + + // Initialize values for the computing of clickedText position + //val completeText = parentTextView.text as SpannableString + val textViewLayout = this.layout + + val startOffsetOfClickedText = range.first//completeText.getSpanStart(clickedText) + val endOffsetOfClickedText = range.last//completeText.getSpanEnd(clickedText) + var startXCoordinatesOfClickedText = textViewLayout.getPrimaryHorizontal(startOffsetOfClickedText) + var endXCoordinatesOfClickedText = textViewLayout.getPrimaryHorizontal(endOffsetOfClickedText) + + // Get the rectangle of the clicked text + val currentLineStartOffset = textViewLayout.getLineForOffset(startOffsetOfClickedText) + val currentLineEndOffset = textViewLayout.getLineForOffset(endOffsetOfClickedText) + val keywordIsInMultiLine = currentLineStartOffset != currentLineEndOffset + textViewLayout.getLineBounds(currentLineStartOffset, parentTextViewRect) + + // Update the rectangle position to his real position on screen + val parentTextViewLocation = intArrayOf(0, 0) + this.getLocationOnScreen(parentTextViewLocation) + + val parentTextViewTopAndBottomOffset = (parentTextViewLocation[1] - this.scrollY + this.compoundPaddingTop) + parentTextViewRect.top += parentTextViewTopAndBottomOffset + parentTextViewRect.bottom += parentTextViewTopAndBottomOffset + + // In the case of multi line text, we have to choose what rectangle take + if (keywordIsInMultiLine) { + val screenHeight = windowManager.defaultDisplay.height + val dyTop = parentTextViewRect.top + val dyBottom = screenHeight - parentTextViewRect.bottom + val onTop = dyTop > dyBottom + + if (onTop) { + endXCoordinatesOfClickedText = textViewLayout.getLineRight(currentLineStartOffset); + } else { + parentTextViewRect = Rect() + textViewLayout.getLineBounds(currentLineEndOffset, parentTextViewRect); + parentTextViewRect.top += parentTextViewTopAndBottomOffset; + parentTextViewRect.bottom += parentTextViewTopAndBottomOffset; + startXCoordinatesOfClickedText = textViewLayout.getLineLeft(currentLineEndOffset); + } + } + + parentTextViewRect.left += ( + parentTextViewLocation[0] + + startXCoordinatesOfClickedText + + this.compoundPaddingLeft - + this.scrollX + ).toInt() + parentTextViewRect.right = ( + parentTextViewRect.left + + endXCoordinatesOfClickedText - + startXCoordinatesOfClickedText + ).toInt() + + return parentTextViewRect +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/MainActivity.kt b/app/src/main/java/pl/szczodrzynski/edziennik/MainActivity.kt index 99a631af..40ab9215 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/MainActivity.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/MainActivity.kt @@ -49,6 +49,7 @@ import pl.szczodrzynski.edziennik.sync.SyncWorker import pl.szczodrzynski.edziennik.sync.UpdateWorker import pl.szczodrzynski.edziennik.ui.dialogs.ServerMessageDialog import pl.szczodrzynski.edziennik.ui.dialogs.changelog.ChangelogDialog +import pl.szczodrzynski.edziennik.ui.dialogs.event.EventManualDialog import pl.szczodrzynski.edziennik.ui.dialogs.settings.ProfileRemoveDialog import pl.szczodrzynski.edziennik.ui.dialogs.sync.SyncViewListDialog import pl.szczodrzynski.edziennik.ui.modules.agenda.AgendaFragment @@ -723,6 +724,15 @@ class MainActivity : AppCompatActivity(), CoroutineScope { ) true } + "createManualEvent" -> { + val date = extras.getString("eventDate")?.let { Date.fromY_m_d(it) } ?: Date.getToday() + EventManualDialog( + this, + App.profileId, + defaultDate = date + ) + true + } else -> false } if (handled && !navLoading) { diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Regexes.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Regexes.kt index 5a994156..ca88823d 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Regexes.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Regexes.kt @@ -5,6 +5,7 @@ package pl.szczodrzynski.edziennik.data.api import kotlin.text.RegexOption.DOT_MATCHES_ALL +import kotlin.text.RegexOption.IGNORE_CASE object Regexes { val STYLE_CSS_COLOR by lazy { @@ -199,4 +200,20 @@ object Regexes { val EDUDZIENNIK_TEACHERS by lazy { """
.*?

(.+?) (.+?)

""".toRegex(DOT_MATCHES_ALL) } + + + + + val LINKIFY_DATE_YMD by lazy { + """(1\d{3}|20\d{2})[\-./](1[0-2]|0?\d)[\-./]([1-2]\d|3[0-1]|0?\d)""".toRegex() + } + val LINKIFY_DATE_DMY by lazy { + """(? Unit)? = null) { + textView.autoLinkMask = Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES + BetterLinkMovementMethod.linkify(textView.autoLinkMask, textView).setOnLinkClickListener { v, span: BetterLinkMovementMethod.ClickableSpanWithText -> + val url = span.text() + val c = v.context + + val s = v.text as Spanned + val start = s.getSpanStart(span.span()) + val end = s.getSpanEnd(span.span()) + + val parent = v.rootView.findViewById(android.R.id.content) + val parentLocation = intArrayOf(0, 0) + parent.getLocationOnScreen(parentLocation) + + val rect = textView.getTextPosition(start..end) + + val view = View(c) + view.layoutParams = ViewGroup.LayoutParams(rect.width(), rect.height()) + view.setBackgroundColor(Color.TRANSPARENT) + + parent.addView(view) + + view.x = rect.left.toFloat() - parentLocation[0] + view.y = rect.top.toFloat() - parentLocation[1] + + val menu = MenuBuilder(c) + val helper = MenuPopupHelper(c, menu, view) + val popup = helper.popup + + var menuTitle = url.substringAfter(":") + var date: Date? = null + + var urlItem: MenuItem? = null + var createEventItem: MenuItem? = null + //var goToTimetableItem: MenuItem? = null // TODO 2020-03-19: implement this + var mailItem: MenuItem? = null + var copyItem: MenuItem? = null + + when { + url.startsWith("mailto:") -> { + mailItem = menu.add(1, 20, 2, "Napisz e-mail") + } + url.startsWith("dateYmd:") -> { + createEventItem = menu.add(1, 10, 2, "Utwórz wydarzenie") + //goToTimetableItem = menu.add(1, 11, 3, "Idź do planu lekcji") + date = parseDateYmd(menuTitle) + } + url.startsWith("dateDmy:") -> { + createEventItem = menu.add(1, 10, 2, "Utwórz wydarzenie") + //goToTimetableItem = menu.add(1, 11, 3, "Idź do planu lekcji") + date = parseDateDmy(menuTitle) + } + url.startsWith("dateAbs:") -> { + createEventItem = menu.add(1, 10, 2, "Utwórz wydarzenie") + //goToTimetableItem = menu.add(1, 11, 3, "Idź do planu lekcji") + date = parseDateAbs(menuTitle) + } + url.startsWith("dateRel:") -> { + createEventItem = menu.add(1, 10, 2, "Utwórz wydarzenie") + //goToTimetableItem = menu.add(1, 11, 3, "Idź do planu lekcji") + date = parseDateRel(menuTitle) + } + else -> { + urlItem = menu.add(1, 1, 2, "Otwórz w przeglądarce") + menuTitle = url + } + } + copyItem = menu.add(1, 1000, 1000, "Kopiuj tekst") + + helper.setOnDismissListener { parent.removeView(view) } + + urlItem?.setOnMenuItemClickListener { Utils.openUrl(c, url); true } + mailItem?.setOnMenuItemClickListener { Utils.openUrl(c, url); true } + copyItem?.setOnMenuItemClickListener { menuTitle.copyToClipboard(c); true } + createEventItem?.setOnMenuItemClickListener { + onActionSelected?.invoke() + val intent = Intent( + android.content.Intent.ACTION_MAIN, + "action" to "createManualEvent", + "eventDate" to date?.stringY_m_d + ) + c.sendBroadcast(intent) + true + } + + menu::class.java.getDeclaredMethod("setHeaderTitleInt", CharSequence::class.java).let { + it.isAccessible = true + it.invoke(menu, menuTitle) + } + popup::class.java.getDeclaredField("mShowTitle").let { + it.isAccessible = true + it.set(popup, true) + } + helper::class.java.getDeclaredMethod("showPopup", Int::class.java, Int::class.java, Boolean::class.java, Boolean::class.java).let { + it.isAccessible = true + it.invoke(helper, 0, 0, false, true) + } + true + } + + val spanned = textView.text as? Spannable ?: { + SpannableString(textView.text) + }() + + Regexes.LINKIFY_DATE_YMD.findAll(textView.text).forEach { match -> + val span = URLSpan("dateYmd:" + match.value) + spanned.setSpan(span, match.range.first, match.range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + Regexes.LINKIFY_DATE_DMY.findAll(textView.text).forEach { match -> + val span = URLSpan("dateDmy:" + match.value) + spanned.setSpan(span, match.range.first, match.range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + Regexes.LINKIFY_DATE_ABSOLUTE.findAll(textView.text).forEach { match -> + val span = URLSpan("dateAbs:" + match.value) + spanned.setSpan(span, match.range.first, match.range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + Regexes.LINKIFY_DATE_RELATIVE.findAll(textView.text).forEach { match -> + val span = URLSpan("dateRel:" + match.value) + spanned.setSpan(span, match.range.first, match.range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + //Linkify.addLinks(textView, LINKIFY_DATE_ABSOLUTE.toPattern(), "dateAbs:") + //Linkify.addLinks(textView, LINKIFY_DATE_RELATIVE.toPattern(), "dateRel:") + } + + private val monthNames = listOf("sty", "lut", "mar", "kwi", "maj", "cze", "lip", "sie", "wrz", "paź", "lis", "gru") + + private fun parseDateYmd(text: String): Date? { + return Regexes.LINKIFY_DATE_YMD.find(text)?.let { + val year = it[1].toIntOrNull() ?: Date.getToday().year + val month = it[2].toIntOrNull() ?: 1 + val day = it[3].toIntOrNull() ?: 1 + Date(year, month, day) + } + } + private fun parseDateDmy(text: String): Date? { + return Regexes.LINKIFY_DATE_DMY.find(text)?.let { + val day = it[1].toIntOrNull() ?: 1 + val month = it[2].toIntOrNull() ?: 1 + var year = it[3].toIntOrNull() ?: Date.getToday().year + if (year < 50) + year += 2000 + Date(year, month, day) + } + } + + private fun parseDateAbs(text: String): Date? { + return Regexes.LINKIFY_DATE_ABSOLUTE.find(text)?.let { + val year = it[3].toIntOrNull() ?: Date.getToday().year + val month = monthNames.indexOf(it[2]) + 1 + val day = it[1].toIntOrNull() ?: 1 + Date(year, month.coerceAtLeast(1), day) + } + } + + private fun parseDateRel(text: String): Date? { + return Regexes.LINKIFY_DATE_RELATIVE.find(text)?.let { + val date = Date.getToday() + + val amount = it[1].toIntOrNull() ?: 1 + val unitInDays = when (it[2]) { + "dni", "dzień" -> 1 + "tydzień", "tygodnie" -> 7 + else -> 1 + } + + date.stepForward(0, 0, amount*unitInDays) + } + } +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/BetterLinkMovementMethod.java b/app/src/main/java/pl/szczodrzynski/edziennik/utils/BetterLinkMovementMethod.java new file mode 100644 index 00000000..92c8ae27 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/BetterLinkMovementMethod.java @@ -0,0 +1,458 @@ +package pl.szczodrzynski.edziennik.utils; + +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; + +import pl.szczodrzynski.edziennik.R; + +/** + * Handles URL clicks on TextViews. Unlike the default implementation, this: + *

+ *

    + *
  • Reliably applies a highlight color on links when they're touched.
  • + *
  • Let's you handle single and long clicks on URLs
  • + *
  • 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.)
  • + *
+ */ +public class BetterLinkMovementMethod extends LinkMovementMethod { + + private static BetterLinkMovementMethod singleInstance; + private static final int LINKIFY_NONE = -2; + + private OnLinkClickListener onLinkClickListener; + private OnLinkLongClickListener onLinkLongClickListener; + private final RectF touchedLineBounds = new RectF(); + private boolean isUrlHighlighted; + private ClickableSpan clickableSpanUnderTouchOnActionDown; + private int activeTextViewHashcode; + private LongPressTimer ongoingLongPressTimer; + private boolean wasLongPressRegistered; + + public interface OnLinkClickListener { + /** + * @param textView The TextView on which a click was registered. + * @param span The clicked URL span. + * @return True if this click was handled. False to let Android handle the URL. + */ + boolean onClick(TextView textView, ClickableSpanWithText span); + } + + public interface OnLinkLongClickListener { + /** + * @param textView The TextView on which a long-click was registered. + * @param span The long-clicked URL span. + * @return True if this long-click was handled. False to let Android handle the URL (as a short-click). + */ + boolean onLongClick(TextView textView, ClickableSpanWithText span); + } + + /** + * 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. + */ + @SuppressWarnings("unused") + 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. + */ + @SuppressWarnings("unused") + 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. + */ + @SuppressWarnings("unused") + public static BetterLinkMovementMethod getInstance() { + if (singleInstance == null) { + singleInstance = new BetterLinkMovementMethod(); + } + return singleInstance; + } + + protected BetterLinkMovementMethod() { + } + + /** + * 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 ======== // + + 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); + } + } + + @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 clickableSpan's location. + */ + protected void highlightUrl(TextView textView, ClickableSpan clickableSpan, Spannable text) { + if (isUrlHighlighted) { + return; + } + isUrlHighlighted = true; + + int spanStart = text.getSpanStart(clickableSpan); + int spanEnd = text.getSpanEnd(clickableSpan); + BackgroundColorSpan highlightSpan = new BackgroundColorSpan(textView.getHighlightColor()); + text.setSpan(highlightSpan, spanStart, spanEnd, Spannable.SPAN_INCLUSIVE_INCLUSIVE); + + textView.setTag(R.id.bettermovementmethod_highlight_background_span, highlightSpan); + + Selection.setSelection(text, spanStart, spanEnd); + } + + /** + * Removes the highlight color under the Url. + */ + protected void removeUrlHighlightColor(TextView textView) { + if (!isUrlHighlighted) { + return; + } + isUrlHighlighted = false; + + Spannable text = (Spannable) textView.getText(); + BackgroundColorSpan highlightSpan = (BackgroundColorSpan) textView.getTag(R.id.bettermovementmethod_highlight_background_span); + 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); + + 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); + + if (!handled) { + // Let Android handle this long click as a short-click. + clickableSpanWithText.span().onClick(textView); + } + } + + protected static final class LongPressTimer implements Runnable { + private OnTimerReachedListener onTimerReachedListener; + + protected interface OnTimerReachedListener { + void onTimerReached(); + } + + @Override + public void run() { + onTimerReachedListener.onTimerReached(); + } + + public void setOnTimerReachedListener(OnTimerReachedListener listener) { + onTimerReachedListener = listener; + } + } + + /** + * 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; + + 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); + } + + protected ClickableSpanWithText(ClickableSpan span, String text) { + this.span = span; + this.text = text; + } + + protected ClickableSpan span() { + return span; + } + + protected String text() { + return text; + } + } +} diff --git a/app/src/main/res/layout/dialog_event_details.xml b/app/src/main/res/layout/dialog_event_details.xml index 8def11ec..f23e9093 100644 --- a/app/src/main/res/layout/dialog_event_details.xml +++ b/app/src/main/res/layout/dialog_event_details.xml @@ -141,12 +141,14 @@ android:layout_marginTop="4dp" android:textAppearance="@style/NavView.TextView.Helper" android:text="@string/dialog_event_details_topic"/> + - \ No newline at end of file + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 51e1215f..fd9da7a5 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -6,4 +6,5 @@ + \ No newline at end of file