diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java index bf7f8fa5d..ea89424ec 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.detail; import static android.text.TextUtils.isEmpty; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; +import static org.schabi.newpipe.util.Localization.getAppLocale; import android.os.Bundle; import android.view.LayoutInflater; @@ -28,7 +29,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.external_communication.TextLinkifier; +import org.schabi.newpipe.util.text.TextLinkifier; import icepick.State; import io.reactivex.rxjava3.disposables.CompositeDisposable; @@ -134,7 +135,8 @@ public class DescriptionFragment extends BaseFragment { TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView, description.getContent(), streamInfo, descriptionDisposables); break; - case Description.PLAIN_TEXT: default: + case Description.PLAIN_TEXT: + default: TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView, description.getContent(), streamInfo, descriptionDisposables); break; @@ -144,30 +146,30 @@ public class DescriptionFragment extends BaseFragment { private void setupMetadata(final LayoutInflater inflater, final LinearLayout layout) { - addMetadataItem(inflater, layout, false, - R.string.metadata_category, streamInfo.getCategory()); + addMetadataItem(inflater, layout, false, R.string.metadata_category, + streamInfo.getCategory()); - addMetadataItem(inflater, layout, false, - R.string.metadata_licence, streamInfo.getLicence()); + addMetadataItem(inflater, layout, false, R.string.metadata_licence, + streamInfo.getLicence()); addPrivacyMetadataItem(inflater, layout); if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) { - addMetadataItem(inflater, layout, false, - R.string.metadata_age_limit, String.valueOf(streamInfo.getAgeLimit())); + addMetadataItem(inflater, layout, false, R.string.metadata_age_limit, + String.valueOf(streamInfo.getAgeLimit())); } if (streamInfo.getLanguageInfo() != null) { - addMetadataItem(inflater, layout, false, - R.string.metadata_language, streamInfo.getLanguageInfo().getDisplayLanguage()); + addMetadataItem(inflater, layout, false, R.string.metadata_language, + streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext()))); } - addMetadataItem(inflater, layout, true, - R.string.metadata_support, streamInfo.getSupportInfo()); - addMetadataItem(inflater, layout, true, - R.string.metadata_host, streamInfo.getHost()); - addMetadataItem(inflater, layout, true, - R.string.metadata_thumbnail_url, streamInfo.getThumbnailUrl()); + addMetadataItem(inflater, layout, true, R.string.metadata_support, + streamInfo.getSupportInfo()); + addMetadataItem(inflater, layout, true, R.string.metadata_host, + streamInfo.getHost()); + addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url, + streamInfo.getThumbnailUrl()); addTagsMetadataItem(inflater, layout); } @@ -191,12 +193,14 @@ public class DescriptionFragment extends BaseFragment { }); if (linkifyContent) { - TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null, - descriptionDisposables); + TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, + null, descriptionDisposables); } else { itemBinding.metadataContentView.setText(content); } + itemBinding.metadataContentView.setClickable(true); + layout.addView(itemBinding.getRoot()); } @@ -245,14 +249,15 @@ public class DescriptionFragment extends BaseFragment { case INTERNAL: contentRes = R.string.metadata_privacy_internal; break; - case OTHER: default: + case OTHER: + default: contentRes = 0; break; } if (contentRes != 0) { - addMetadataItem(inflater, layout, false, - R.string.metadata_privacy, getString(contentRes)); + addMetadataItem(inflater, layout, false, R.string.metadata_privacy, + getString(contentRes)); } } } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java index 92e37afd8..69aba8c4f 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java @@ -20,13 +20,13 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.CommentTextOnTouchListener; +import org.schabi.newpipe.util.text.CommentTextOnTouchListener; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.external_communication.TimestampExtractor; +import org.schabi.newpipe.util.text.TimestampExtractor; import java.util.Objects; diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 27009efd1..2123010aa 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -51,7 +51,7 @@ import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; -import org.schabi.newpipe.util.external_communication.TextLinkifier; +import org.schabi.newpipe.util.text.TextLinkifier; import java.util.Collections; import java.util.List; diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java index 9829ddd2e..332298b22 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java @@ -313,10 +313,15 @@ public final class ShareUtils { return; } - clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); - if (Build.VERSION.SDK_INT < 33) { - // Android 13 has its own "copied to clipboard" dialog - Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); + try { + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); + if (Build.VERSION.SDK_INT < 33) { + // Android 13 has its own "copied to clipboard" dialog + Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); + } + } catch (final Exception e) { + Log.e(TAG, "Error when trying to copy text to clipboard", e); + Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show(); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java similarity index 67% rename from app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java rename to app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java index 7c87e664b..4ced4be77 100644 --- a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java @@ -1,6 +1,7 @@ -package org.schabi.newpipe.util; +package org.schabi.newpipe.util.text; + +import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; -import android.text.Layout; import android.text.Selection; import android.text.Spannable; import android.text.Spanned; @@ -11,7 +12,6 @@ import android.view.View; import android.widget.TextView; import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.external_communication.InternalUrlsHandler; import io.reactivex.rxjava3.disposables.CompositeDisposable; @@ -30,23 +30,9 @@ public class CommentTextOnTouchListener implements View.OnTouchListener { final int action = event.getAction(); - if (action == MotionEvent.ACTION_UP - || action == MotionEvent.ACTION_DOWN) { - int x = (int) event.getX(); - int y = (int) event.getY(); - - x -= widget.getTotalPaddingLeft(); - y -= widget.getTotalPaddingTop(); - - x += widget.getScrollX(); - y += widget.getScrollY(); - - final Layout layout = widget.getLayout(); - final int line = layout.getLineForVertical(y); - final int off = layout.getOffsetForHorizontal(line, x); - - final ClickableSpan[] link = buffer.getSpans(off, off, - ClickableSpan.class); + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + final int offset = getOffsetForHorizontalLine(widget, event); + final ClickableSpan[] link = buffer.getSpans(offset, offset, ClickableSpan.class); if (link.length != 0) { if (action == MotionEvent.ACTION_UP) { @@ -58,8 +44,7 @@ public class CommentTextOnTouchListener implements View.OnTouchListener { } } } else if (action == MotionEvent.ACTION_DOWN) { - Selection.setSelection(buffer, - buffer.getSpanStart(link[0]), + Selection.setSelection(buffer, buffer.getSpanStart(link[0]), buffer.getSpanEnd(link[0])); } return true; diff --git a/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java new file mode 100644 index 000000000..4ca6c326e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java @@ -0,0 +1,38 @@ +package org.schabi.newpipe.util.text; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.Info; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +final class HashtagLongPressClickableSpan extends LongPressClickableSpan { + + @NonNull + private final Context context; + @NonNull + private final String parsedHashtag; + @NonNull + private final Info relatedInfo; + + HashtagLongPressClickableSpan(@NonNull final Context context, + @NonNull final String parsedHashtag, + @NonNull final Info relatedInfo) { + this.context = context; + this.parsedHashtag = parsedHashtag; + this.relatedInfo = relatedInfo; + } + + @Override + public void onClick(@NonNull final View view) { + NavigationHelper.openSearch(context, relatedInfo.getServiceId(), parsedHashtag); + } + + @Override + public void onLongClick(@NonNull final View view) { + ShareUtils.copyToClipboard(context, parsedHashtag); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java similarity index 99% rename from app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java rename to app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java index c46e6636d..b87618922 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java @@ -1,4 +1,4 @@ -package org.schabi.newpipe.util.external_communication; +package org.schabi.newpipe.util.text; import android.content.Context; import android.util.Log; diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java new file mode 100644 index 000000000..5c94a5850 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java @@ -0,0 +1,12 @@ +package org.schabi.newpipe.util.text; + +import android.text.style.ClickableSpan; +import android.view.View; + +import androidx.annotation.NonNull; + +public abstract class LongPressClickableSpan extends ClickableSpan { + + public abstract void onLongClick(@NonNull View view); + +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java b/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java new file mode 100644 index 000000000..bd57621cb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java @@ -0,0 +1,77 @@ +package org.schabi.newpipe.util.text; + +import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; + +import android.os.Handler; +import android.os.Looper; +import android.text.Selection; +import android.text.Spannable; +import android.text.method.LinkMovementMethod; +import android.text.method.MovementMethod; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +// Class adapted from https://stackoverflow.com/a/31786969 + +public class LongPressLinkMovementMethod extends LinkMovementMethod { + + private static final int LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout(); + + private static LongPressLinkMovementMethod instance; + + private Handler longClickHandler; + private boolean isLongPressed = false; + + @Override + public boolean onTouchEvent(@NonNull final TextView widget, + @NonNull final Spannable buffer, + @NonNull final MotionEvent event) { + final int action = event.getAction(); + + if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) { + longClickHandler.removeCallbacksAndMessages(null); + } + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + final int offset = getOffsetForHorizontalLine(widget, event); + final LongPressClickableSpan[] link = buffer.getSpans(offset, offset, + LongPressClickableSpan.class); + + if (link.length != 0) { + if (action == MotionEvent.ACTION_UP) { + if (longClickHandler != null) { + longClickHandler.removeCallbacksAndMessages(null); + } + if (!isLongPressed) { + link[0].onClick(widget); + } + isLongPressed = false; + } else { + Selection.setSelection(buffer, buffer.getSpanStart(link[0]), + buffer.getSpanEnd(link[0])); + if (longClickHandler != null) { + longClickHandler.postDelayed(() -> { + link[0].onLongClick(widget); + isLongPressed = true; + }, LONG_PRESS_TIME); + } + } + return true; + } + } + + return super.onTouchEvent(widget, buffer, event); + } + + public static MovementMethod getInstance() { + if (instance == null) { + instance = new LongPressLinkMovementMethod(); + instance.longClickHandler = new Handler(Looper.myLooper()); + } + + return instance; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java similarity index 51% rename from app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java rename to app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java index 8b8eb265b..b7220d22f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java @@ -1,9 +1,7 @@ -package org.schabi.newpipe.util.external_communication; +package org.schabi.newpipe.util.text; import android.content.Context; import android.text.SpannableStringBuilder; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.Log; @@ -17,6 +15,7 @@ import androidx.core.text.HtmlCompat; import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -28,27 +27,26 @@ import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.schedulers.Schedulers; -import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler.playOnPopup; - public final class TextLinkifier { public static final String TAG = TextLinkifier.class.getSimpleName(); // Looks for hashtags with characters from any language (\p{L}), numbers, or underscores - private static final Pattern HASHTAGS_PATTERN = - Pattern.compile("(#[\\p{L}0-9_]+)"); + private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)"); private TextLinkifier() { } /** - * Create web links for contents with an HTML description. - *

- * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, - * Info, CompositeDisposable)} after having linked the URLs with - * {@link HtmlCompat#fromHtml(String, int)}. + * Create links for contents with an HTML description. * - * @param textView the TextView to set the htmlBlock linked - * @param htmlBlock the htmlBlock to be linked + *

+ * This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info, + * CompositeDisposable)} after having linked the URLs with + * {@link HtmlCompat#fromHtml(String, int)}. + *

+ * + * @param textView the {@link TextView} to set the the HTML string block linked + * @param htmlBlock the HTML string block to be linked * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} * will be called * @param relatedInfo if given, handle timestamps to open the stream in the popup player at @@ -58,50 +56,54 @@ public final class TextLinkifier { * should be handled by the calling class */ public static void createLinksFromHtmlBlock(@NonNull final TextView textView, - final String htmlBlock, + @NonNull final String htmlBlock, final int htmlCompatFlag, @Nullable final Info relatedInfo, - final CompositeDisposable disposables) { - changeIntentsOfDescriptionLinks( - textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfo, disposables); + @NonNull final CompositeDisposable disposables) { + changeIntentsOfDescriptionLinks(textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), + relatedInfo, disposables); } /** - * Create web links for contents with a plain text description. - *

- * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, - * Info, CompositeDisposable)} after having linked the URLs with - * {@link TextView#setAutoLinkMask(int)} and - * {@link TextView#setText(CharSequence, TextView.BufferType)}. + * Create links for contents with a plain text description. * - * @param textView the TextView to set the plain text block linked + *

+ * This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info, + * CompositeDisposable)} after having linked the URLs with {@link TextView#setAutoLinkMask(int)} + * and {@link TextView#setText(CharSequence, TextView.BufferType)}. + *

+ * + * @param textView the {@link TextView} to set the plain text block linked * @param plainTextBlock the block of plain text to be linked - * @param relatedInfo if given, handle timestamps to open the stream in the popup player at - * the specific time, and hashtags to search for the term in the correct + * @param relatedInfo if given, handle timestamps to open the stream in the popup player, at + * the specified time, and hashtags to search for the term in the correct * service * @param disposables disposables created by the method are added here and their lifecycle * should be handled by the calling class */ public static void createLinksFromPlainText(@NonNull final TextView textView, - final String plainTextBlock, + @NonNull final String plainTextBlock, @Nullable final Info relatedInfo, - final CompositeDisposable disposables) { + @NonNull final CompositeDisposable disposables) { textView.setAutoLinkMask(Linkify.WEB_URLS); textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables); } /** - * Create web links for contents with a markdown description. - *

- * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, - * Info, CompositeDisposable)} after creating an {@link Markwon} object and using - * {@link Markwon#setMarkdown(TextView, String)}. + * Create links for contents with a markdown description. * - * @param textView the TextView to set the plain text block linked + *

+ * This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info, + * CompositeDisposable)} after creating a {@link Markwon} object and using + * {@link Markwon#setMarkdown(TextView, String)}. + *

+ * + * @param textView the {@link TextView} to set the plain text block linked * @param markdownBlock the block of markdown text to be linked * @param relatedInfo if given, handle timestamps to open the stream in the popup player at * the specific time, and hashtags to search for the term in the correct + * service * @param disposables disposables created by the method are added here and their lifecycle * should be handled by the calling class */ @@ -115,161 +117,78 @@ public final class TextLinkifier { disposables); } - /** - * Add click listeners which opens a search on hashtags in a plain text. - *

- * This method finds all timestamps in the {@link SpannableStringBuilder} of the description - * using a regular expression, adds for each a {@link ClickableSpan} which opens - * {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag, - * in the service of the content. - * - * @param context the context to use - * @param spannableDescription the SpannableStringBuilder with the text of the - * content description - * @param relatedInfo used to search for the term in the correct service - */ - private static void addClickListenersOnHashtags(final Context context, - @NonNull final SpannableStringBuilder - spannableDescription, - final Info relatedInfo) { - final String descriptionText = spannableDescription.toString(); - final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); - - while (hashtagsMatches.find()) { - final int hashtagStart = hashtagsMatches.start(1); - final int hashtagEnd = hashtagsMatches.end(1); - final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd); - - // don't add a ClickableSpan if there is already one, which should be a part of an URL, - // already parsed before - if (spannableDescription.getSpans(hashtagStart, hashtagEnd, - ClickableSpan.class).length == 0) { - spannableDescription.setSpan(new ClickableSpan() { - @Override - public void onClick(@NonNull final View view) { - NavigationHelper.openSearch(context, relatedInfo.getServiceId(), - parsedHashtag); - } - }, hashtagStart, hashtagEnd, 0); - } - } - } - - /** - * Add click listeners which opens the popup player on timestamps in a plain text. - *

- * This method finds all timestamps in the {@link SpannableStringBuilder} of the description - * using a regular expression, adds for each a {@link ClickableSpan} which opens the popup - * player at the time indicated in the timestamps. - * - * @param context the context to use - * @param spannableDescription the SpannableStringBuilder with the text of the - * content description - * @param relatedInfo what to open in the popup player when timestamps are clicked - * @param disposables disposables created by the method are added here and their - * lifecycle should be handled by the calling class - */ - private static void addClickListenersOnTimestamps(final Context context, - @NonNull final SpannableStringBuilder - spannableDescription, - final Info relatedInfo, - final CompositeDisposable disposables) { - final String descriptionText = spannableDescription.toString(); - final Matcher timestampsMatches = - TimestampExtractor.TIMESTAMPS_PATTERN.matcher(descriptionText); - - while (timestampsMatches.find()) { - final TimestampExtractor.TimestampMatchDTO timestampMatchDTO = - TimestampExtractor.getTimestampFromMatcher( - timestampsMatches, - descriptionText); - - if (timestampMatchDTO == null) { - continue; - } - - spannableDescription.setSpan( - new ClickableSpan() { - @Override - public void onClick(@NonNull final View view) { - playOnPopup( - context, - relatedInfo.getUrl(), - relatedInfo.getService(), - timestampMatchDTO.seconds(), - disposables); - } - }, - timestampMatchDTO.timestampStart(), - timestampMatchDTO.timestampEnd(), - 0); - } - } - /** * Change links generated by libraries in the description of a content to a custom link action * and add click listeners on timestamps in this description. + * *

* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of * a content, this method will parse the {@link CharSequence} and replace all current web links * with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. + *

+ * + *

* This method will also add click listeners on timestamps in this description, which will play * the content in the popup player at the time indicated in the timestamp, by using - * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, Info, - * CompositeDisposable)} method and click listeners on hashtags, by using - * {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)}, + * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, + * StreamInfo, CompositeDisposable)} method and click listeners on hashtags, by using + * {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)})}, * which will open a search on the current service with the hashtag. + *

+ * *

* This method is required in order to intercept links and e.g. show a confirmation dialog * before opening a web link. + *

* - * @param textView the TextView in which the converted CharSequence will be applied - * @param chars the CharSequence to be parsed - * @param relatedInfo if given, handle timestamps to open the stream in the popup player at - * the specific time, and hashtags to search for the term in the correct - * service + * @param textView the {@link TextView} in which the converted {@link CharSequence} will be + * applied + * @param chars the {@link CharSequence} to be parsed + * @param relatedInfo if given, handle timestamps to open the stream in the popup player at the + * specific time, and hashtags to search for the term in the correct service * @param disposables disposables created by the method are added here and their lifecycle * should be handled by the calling class */ - private static void changeIntentsOfDescriptionLinks(final TextView textView, - final CharSequence chars, - @Nullable final Info relatedInfo, - final CompositeDisposable disposables) { + private static void changeIntentsOfDescriptionLinks( + @NonNull final TextView textView, + @NonNull final CharSequence chars, + @Nullable final Info relatedInfo, + @NonNull final CompositeDisposable disposables) { + textView.setMovementMethod(LongPressLinkMovementMethod.getInstance()); disposables.add(Single.fromCallable(() -> { - final Context context = textView.getContext(); + final Context context = textView.getContext(); - // add custom click actions on web links - final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars); - final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class); + // add custom click actions on web links + final SpannableStringBuilder textBlockLinked = + new SpannableStringBuilder(chars); + final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), + URLSpan.class); - for (final URLSpan span : urls) { - final String url = span.getURL(); - final ClickableSpan clickableSpan = new ClickableSpan() { - public void onClick(@NonNull final View view) { - if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( - new CompositeDisposable(), context, url)) { - ShareUtils.openUrlInBrowser(context, url, false); - } + for (final URLSpan span : urls) { + final String url = span.getURL(); + final LongPressClickableSpan longPressClickableSpan = + new UrlLongPressClickableSpan(context, disposables, url); + + textBlockLinked.setSpan(longPressClickableSpan, + textBlockLinked.getSpanStart(span), + textBlockLinked.getSpanEnd(span), + textBlockLinked.getSpanFlags(span)); + textBlockLinked.removeSpan(span); } - }; - textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span), - textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span)); - textBlockLinked.removeSpan(span); - } + if (relatedInfo != null) { + // add click actions on plain text timestamps only for description of + // contents, unneeded for meta-info or other TextViews + if (relatedInfo instanceof StreamInfo) { + addClickListenersOnTimestamps(context, textBlockLinked, + (StreamInfo) relatedInfo, disposables); + } - // add click actions on plain text timestamps only for description of contents, - // unneeded for meta-info or other TextViews - if (relatedInfo != null) { - if (relatedInfo instanceof StreamInfo) { - addClickListenersOnTimestamps(context, textBlockLinked, relatedInfo, - disposables); - } - addClickListenersOnHashtags(context, textBlockLinked, relatedInfo); - } + addClickListenersOnHashtags(context, textBlockLinked, relatedInfo); + } - return textBlockLinked; - }).subscribeOn(Schedulers.computation()) + return textBlockLinked; + }).subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), @@ -280,10 +199,90 @@ public final class TextLinkifier { })); } + /** + * Add click listeners which opens a search on hashtags in a plain text. + * + *

+ * This method finds all timestamps in the {@link SpannableStringBuilder} of the description + * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens + * {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag, + * in the service of the content when pressed, and copy the hashtag to clipboard when + * long-pressed, if allowed by the caller method (parameter {@code addLongClickCopyListener}). + *

+ * + * @param context the {@link Context} to use + * @param spannableDescription the {@link SpannableStringBuilder} with the text of the + * content description + * @param relatedInfo used to search for the term in the correct service + */ + private static void addClickListenersOnHashtags( + @NonNull final Context context, + @NonNull final SpannableStringBuilder spannableDescription, + @NonNull final Info relatedInfo) { + final String descriptionText = spannableDescription.toString(); + final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); + + while (hashtagsMatches.find()) { + final int hashtagStart = hashtagsMatches.start(1); + final int hashtagEnd = hashtagsMatches.end(1); + final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd); + + // Don't add a LongPressClickableSpan if there is already one, which should be a part + // of an URL, already parsed before + if (spannableDescription.getSpans(hashtagStart, hashtagEnd, + LongPressClickableSpan.class).length == 0) { + spannableDescription.setSpan( + new HashtagLongPressClickableSpan(context, parsedHashtag, relatedInfo), + hashtagStart, hashtagEnd, 0); + } + } + } + + /** + * Add click listeners which opens the popup player on timestamps in a plain text. + * + *

+ * This method finds all timestamps in the {@link SpannableStringBuilder} of the description + * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens the + * popup player at the time indicated in the timestamps and copy the timestamp in clipboard + * when long-pressed. + *

+ * + * @param context the {@link Context} to use + * @param spannableDescription the {@link SpannableStringBuilder} with the text of the + * content description + * @param streamInfo what to open in the popup player when timestamps are clicked + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + */ + private static void addClickListenersOnTimestamps( + @NonNull final Context context, + @NonNull final SpannableStringBuilder spannableDescription, + @NonNull final StreamInfo streamInfo, + @NonNull final CompositeDisposable disposables) { + final String descriptionText = spannableDescription.toString(); + final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher( + descriptionText); + + while (timestampsMatches.find()) { + final TimestampExtractor.TimestampMatchDTO timestampMatchDTO = + TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText); + + if (timestampMatchDTO == null) { + continue; + } + + spannableDescription.setSpan(new TimestampLongPressClickableSpan( + context, descriptionText, disposables, streamInfo, timestampMatchDTO), + timestampMatchDTO.timestampStart(), + timestampMatchDTO.timestampEnd(), + 0); + } + } + private static void setTextViewCharSequence(@NonNull final TextView textView, - final CharSequence charSequence) { + @Nullable final CharSequence charSequence) { textView.setText(charSequence); - textView.setMovementMethod(LinkMovementMethod.getInstance()); textView.setVisibility(View.VISIBLE); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/TimestampExtractor.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java similarity index 78% rename from app/src/main/java/org/schabi/newpipe/util/external_communication/TimestampExtractor.java rename to app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java index a13c66402..be603f41a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/TimestampExtractor.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java @@ -1,4 +1,7 @@ -package org.schabi.newpipe.util.external_communication; +package org.schabi.newpipe.util.text; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,17 +18,18 @@ public final class TimestampExtractor { } /** - * Get's a single timestamp from a matcher. + * Gets a single timestamp from a matcher. * - * @param timestampMatches The matcher which was created using {@link #TIMESTAMPS_PATTERN} - * @param baseText The text where the pattern was applied to / - * where the matcher is based upon - * @return If a match occurred: a {@link TimestampMatchDTO} filled with information.
- * If not null. + * @param timestampMatches the matcher which was created using {@link #TIMESTAMPS_PATTERN} + * @param baseText the text where the pattern was applied to / where the matcher is + * based upon + * @return if a match occurred, a {@link TimestampMatchDTO} filled with information, otherwise + * {@code null}. */ + @Nullable public static TimestampMatchDTO getTimestampFromMatcher( - final Matcher timestampMatches, - final String baseText) { + @NonNull final Matcher timestampMatches, + @NonNull final String baseText) { int timestampStart = timestampMatches.start(1); if (timestampStart == -1) { timestampStart = timestampMatches.start(2); diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java new file mode 100644 index 000000000..48110312d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java @@ -0,0 +1,75 @@ +package org.schabi.newpipe.util.text; + +import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +final class TimestampLongPressClickableSpan extends LongPressClickableSpan { + + @NonNull + private final Context context; + @NonNull + private final String descriptionText; + @NonNull + private final CompositeDisposable disposables; + @NonNull + private final StreamInfo streamInfo; + @NonNull + private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO; + + TimestampLongPressClickableSpan( + @NonNull final Context context, + @NonNull final String descriptionText, + @NonNull final CompositeDisposable disposables, + @NonNull final StreamInfo streamInfo, + @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { + this.context = context; + this.descriptionText = descriptionText; + this.disposables = disposables; + this.streamInfo = streamInfo; + this.timestampMatchDTO = timestampMatchDTO; + } + + @Override + public void onClick(@NonNull final View view) { + playOnPopup(context, streamInfo.getUrl(), streamInfo.getService(), + timestampMatchDTO.seconds(), disposables); + } + + @Override + public void onLongClick(@NonNull final View view) { + ShareUtils.copyToClipboard(context, + getTimestampTextToCopy(streamInfo, descriptionText, timestampMatchDTO)); + } + + @NonNull + private static String getTimestampTextToCopy( + @NonNull final StreamInfo relatedInfo, + @NonNull final String descriptionText, + @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { + // TODO: use extractor methods to get timestamps when this feature will be implemented in it + final StreamingService streamingService = relatedInfo.getService(); + if (streamingService == ServiceList.YouTube) { + return relatedInfo.getUrl() + "&t=" + timestampMatchDTO.seconds(); + } else if (streamingService == ServiceList.SoundCloud + || streamingService == ServiceList.MediaCCC) { + return relatedInfo.getUrl() + "#t=" + timestampMatchDTO.seconds(); + } else if (streamingService == ServiceList.PeerTube) { + return relatedInfo.getUrl() + "?start=" + timestampMatchDTO.seconds(); + } + + // Return timestamp text for other services + return descriptionText.subSequence(timestampMatchDTO.timestampStart(), + timestampMatchDTO.timestampEnd()).toString(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java b/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java new file mode 100644 index 000000000..5c0db20a3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java @@ -0,0 +1,38 @@ +package org.schabi.newpipe.util.text; + +import android.text.Layout; +import android.view.MotionEvent; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +public final class TouchUtils { + + private TouchUtils() { + } + + /** + * Get the character offset on the closest line to the position pressed by the user of a + * {@link TextView} from a {@link MotionEvent} which was fired on this {@link TextView}. + * + * @param textView the {@link TextView} on which the {@link MotionEvent} was fired + * @param event the {@link MotionEvent} which was fired + * @return the character offset on the closest line to the position pressed by the user + */ + public static int getOffsetForHorizontalLine(@NonNull final TextView textView, + @NonNull final MotionEvent event) { + + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= textView.getTotalPaddingLeft(); + y -= textView.getTotalPaddingTop(); + + x += textView.getScrollX(); + y += textView.getScrollY(); + + final Layout layout = textView.getLayout(); + final int line = layout.getLineForVertical(y); + return layout.getOffsetForHorizontal(line, x); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java new file mode 100644 index 000000000..eb0d7425e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java @@ -0,0 +1,41 @@ +package org.schabi.newpipe.util.text; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +final class UrlLongPressClickableSpan extends LongPressClickableSpan { + + @NonNull + private final Context context; + @NonNull + private final CompositeDisposable disposables; + @NonNull + private final String url; + + UrlLongPressClickableSpan(@NonNull final Context context, + @NonNull final CompositeDisposable disposables, + @NonNull final String url) { + this.context = context; + this.disposables = disposables; + this.url = url; + } + + @Override + public void onClick(@NonNull final View view) { + if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( + disposables, context, url)) { + ShareUtils.openUrlInBrowser(context, url, false); + } + } + + @Override + public void onLongClick(@NonNull final View view) { + ShareUtils.copyToClipboard(context, url); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java index 2adc28d0e..f0993055e 100644 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java +++ b/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java @@ -13,9 +13,10 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; /** * An {@link AppCompatEditText} which uses {@link ShareUtils#shareText(Context, String, String)} * when sharing selected text by using the {@code Share} command of the floating actions. + * *

- * This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text - * from {@link AppCompatEditText} on EMUI devices. + * This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing + * text from {@link AppCompatEditText} on EMUI devices. *

*/ public class NewPipeEditText extends AppCompatEditText { diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java index 8fdac32db..dd3f20f40 100644 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java +++ b/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.views; import android.content.Context; +import android.text.method.MovementMethod; import android.util.AttributeSet; import androidx.annotation.NonNull; @@ -13,9 +14,11 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; /** * An {@link AppCompatTextView} which uses {@link ShareUtils#shareText(Context, String, String)} * when sharing selected text by using the {@code Share} command of the floating actions. + * *

- * This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text - * from {@link AppCompatTextView} on EMUI devices. + * This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing + * text from {@link AppCompatTextView} on EMUI devices and also to keep movement method set when a + * text change occurs, if the text cannot be selected and text links are clickable. *

*/ public class NewPipeTextView extends AppCompatTextView { @@ -34,6 +37,16 @@ public class NewPipeTextView extends AppCompatTextView { super(context, attrs, defStyleAttr); } + @Override + public void setText(final CharSequence text, final BufferType type) { + // We need to set again the movement method after a text change because Android resets the + // movement method to the default one in the case where the text cannot be selected and + // text links are clickable (which is the default case in NewPipe). + final MovementMethod movementMethod = this.getMovementMethod(); + super.setText(text, type); + setMovementMethod(movementMethod); + } + @Override public boolean onTextContextMenuItem(final int id) { if (id == android.R.id.shareText) { diff --git a/app/src/main/res/layout/item_metadata.xml b/app/src/main/res/layout/item_metadata.xml index 31dedd880..251b9e832 100644 --- a/app/src/main/res/layout/item_metadata.xml +++ b/app/src/main/res/layout/item_metadata.xml @@ -6,7 +6,7 @@ android:layout_height="wrap_content" android:paddingVertical="6dp"> - - Importer ou exporter des abonnements à partir du menu Vous utilisez la dernière version de NewPipe Appuyez pour télécharger %s + Échec de la copie dans le presse-papiers \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9e0bb492..2deb14d8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -327,6 +327,7 @@ Calculating hash Please wait… Copied to clipboard + Failed to copy to clipboard Please define a download folder later in settings No download folder set yet, choose the default download folder now This permission is needed to\nopen in popup mode diff --git a/app/src/test/java/org/schabi/newpipe/util/external_communication/TimestampExtractorTest.java b/app/src/test/java/org/schabi/newpipe/util/external_communication/TimestampExtractorTest.java index 10e23883f..47853bd7f 100644 --- a/app/src/test/java/org/schabi/newpipe/util/external_communication/TimestampExtractorTest.java +++ b/app/src/test/java/org/schabi/newpipe/util/external_communication/TimestampExtractorTest.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.util.external_communication; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; +import org.schabi.newpipe.util.text.TimestampExtractor; import java.time.Duration; import java.util.Arrays;