diff --git a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java b/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java index f84d986aab0..37ca0e400de 100644 --- a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java +++ b/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java @@ -129,13 +129,8 @@ private static void compareAppVersionAndShowNotification(@NonNull final Applicat if (BuildConfig.VERSION_CODE < versionCode) { // A pending intent to open the apk location url in the browser. - final Intent viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl)); - - final Intent intent = new Intent(Intent.ACTION_CHOOSER); - intent.putExtra(Intent.EXTRA_INTENT, viewIntent); - intent.putExtra(Intent.EXTRA_TITLE, R.string.open_with); + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl)); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - final PendingIntent pendingIntent = PendingIntent.getActivity(application, 0, intent, 0); diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 0d70a7181dd..55bb8424f33 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -69,7 +69,7 @@ import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.urlfinder.UrlFinder; import org.schabi.newpipe.views.FocusOverlayView; diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt index 2f015a049ec..0199f30d889 100644 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt @@ -17,8 +17,8 @@ import org.schabi.newpipe.R import org.schabi.newpipe.databinding.ActivityAboutBinding import org.schabi.newpipe.databinding.FragmentAboutBinding import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.ShareUtils import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.external_communication.ShareUtils class AboutActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt index 24995596882..ba0c04eb099 100644 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt @@ -19,7 +19,6 @@ import java.util.Objects */ class LicenseFragment : Fragment() { private lateinit var softwareComponents: Array - private var componentForContextMenu: SoftwareComponent? = null private var activeLicense: License? = null private val compositeDisposable = CompositeDisposable() diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt index f24116e23ec..7617ef451ea 100644 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt @@ -10,8 +10,8 @@ import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.R import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.ShareUtils import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.external_communication.ShareUtils import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java index 106a86cfad3..c0d88c8ec83 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java @@ -27,7 +27,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ActivityErrorBinding; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.time.LocalDateTime; @@ -195,7 +195,8 @@ public boolean onOptionsItemSelected(final MenuItem item) { onBackPressed(); return true; case R.id.menu_item_share_error: - ShareUtils.shareText(this, getString(R.string.error_report_title), buildJson()); + ShareUtils.shareText(getApplicationContext(), + getString(R.string.error_report_title), buildJson()); return true; default: return false; @@ -220,13 +221,10 @@ private void openPrivacyPolicyDialog(final Context context, final String action) + getString(R.string.app_name) + " " + BuildConfig.VERSION_NAME) .putExtra(Intent.EXTRA_TEXT, buildJson()); - if (i.resolveActivity(getPackageManager()) != null) { - ShareUtils.openIntentInApp(context, i); - } + ShareUtils.openIntentInApp(context, i, true); } else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub ShareUtils.openUrlInBrowser(this, ERROR_GITHUB_ISSUE_URL, false); } - }) .setNegativeButton(R.string.decline, (dialog, which) -> { // do nothing 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 5f1cbc36583..92a571f377d 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 @@ -23,15 +23,15 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; -import org.schabi.newpipe.util.TextLinkifier; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.external_communication.TextLinkifier; import java.util.ArrayList; import java.util.Collections; import java.util.List; import icepick.State; -import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.disposables.CompositeDisposable; import static android.text.TextUtils.isEmpty; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; @@ -41,8 +41,7 @@ public class DescriptionFragment extends BaseFragment { @State StreamInfo streamInfo = null; - @Nullable - Disposable descriptionDisposable = null; + final CompositeDisposable descriptionDisposables = new CompositeDisposable(); FragmentDescriptionBinding binding; public DescriptionFragment() { @@ -67,10 +66,8 @@ public View onCreateView(@NonNull final LayoutInflater inflater, @Override public void onDestroy() { + descriptionDisposables.clear(); super.onDestroy(); - if (descriptionDisposable != null) { - descriptionDisposable.dispose(); - } } @@ -133,17 +130,17 @@ private void loadDescriptionContent() { final Description description = streamInfo.getDescription(); switch (description.getType()) { case Description.HTML: - descriptionDisposable = TextLinkifier.createLinksFromHtmlBlock(requireContext(), - description.getContent(), binding.detailDescriptionView, - HtmlCompat.FROM_HTML_MODE_LEGACY); + TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView, + description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo, + descriptionDisposables); break; case Description.MARKDOWN: - descriptionDisposable = TextLinkifier.createLinksFromMarkdownText(requireContext(), - description.getContent(), binding.detailDescriptionView); + TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView, + description.getContent(), streamInfo, descriptionDisposables); break; case Description.PLAIN_TEXT: default: - descriptionDisposable = TextLinkifier.createLinksFromPlainText(requireContext(), - description.getContent(), binding.detailDescriptionView); + TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView, + description.getContent(), streamInfo, descriptionDisposables); break; } } @@ -198,8 +195,8 @@ private void addMetadataItem(final LayoutInflater inflater, }); if (linkifyContent) { - TextLinkifier.createLinksFromPlainText(requireContext(), - content, itemBinding.metadataContentView); + TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null, + descriptionDisposables); } else { itemBinding.metadataContentView.setText(content); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 784a1c3be7d..fbd11283fac 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -91,12 +91,12 @@ import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; @@ -454,8 +454,8 @@ public void onClick(final View v) { break; case R.id.detail_controls_share: if (currentInfo != null) { - ShareUtils.shareText(requireContext(), - currentInfo.getName(), currentInfo.getUrl()); + ShareUtils.shareText(requireContext(), currentInfo.getName(), + currentInfo.getUrl(), currentInfo.getThumbnailUrl()); } break; case R.id.detail_controls_open_in_browser: @@ -472,7 +472,7 @@ public void onClick(final View v) { if (DEBUG) { Log.i(TAG, "Failed to start kore", e); } - KoreUtil.showInstallKoreDialog(requireContext()); + KoreUtils.showInstallKoreDialog(requireContext()); } } break; @@ -631,7 +631,7 @@ protected void initListeners() { binding.detailControlsShare.setOnClickListener(this); binding.detailControlsOpenInBrowser.setOnClickListener(this); binding.detailControlsPlayWithKodi.setOnClickListener(this); - binding.detailControlsPlayWithKodi.setVisibility(KoreUtil.shouldShowPlayWithKodi( + binding.detailControlsPlayWithKodi.setVisibility(KoreUtils.shouldShowPlayWithKodi( requireContext(), serviceId) ? View.VISIBLE : View.GONE); binding.overlayThumbnail.setOnClickListener(this); @@ -1546,8 +1546,8 @@ public void handleResult(@NonNull final StreamInfo info) { .getDefaultResolutionIndex(activity, sortedVideoStreams); updateProgressInfo(info); initThumbnailViews(info); - disposables.add(showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, - binding.detailMetaInfoSeparator)); + showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, + binding.detailMetaInfoSeparator, disposables); if (player == null || player.isStopped()) { updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl()); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 72855868559..c28a41b7d47 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -33,7 +33,7 @@ import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; @@ -371,7 +371,7 @@ protected void showStreamDialog(final StreamInfoItem item) { )); } entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) { + if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } if (!isNullOrEmpty(item.getUploaderUrl())) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index e02e18a8636..fe07a8dc947 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -43,7 +43,7 @@ import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; @@ -203,7 +203,8 @@ public boolean onOptionsItemSelected(final MenuItem item) { break; case R.id.menu_item_share: if (currentInfo != null) { - ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl()); + ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(), + currentInfo.getAvatarUrl()); } break; default: diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index 0e36d18c702..938cf9d6619 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -42,10 +42,10 @@ import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.StreamDialogEntry; import java.util.ArrayList; @@ -162,7 +162,7 @@ protected void showStreamDialog(final StreamInfoItem item) { )); } entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) { + if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } @@ -251,7 +251,7 @@ public boolean onOptionsItemSelected(final MenuItem item) { ShareUtils.openUrlInBrowser(requireContext(), url); break; case R.id.menu_item_share: - ShareUtils.shareText(requireContext(), name, url); + ShareUtils.shareText(requireContext(), name, url, currentInfo.getThumbnailUrl()); break; case R.id.menu_item_bookmark: onBookmarkClicked(); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index 70fce1cb75b..2810b9b7677 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -278,8 +278,9 @@ public void onResume() { handleSearchSuggestion(); - disposables.add(showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo), - searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator)); + showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo), + searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator, + disposables); if (TextUtils.isEmpty(searchString) || wasSearchFocused) { showKeyboardSearch(); @@ -841,7 +842,7 @@ private void search(final String theSearchString, infoListAdapter.clearStreamItemList(); hideSuggestionsPanel(); showMetaInfoInTextView(null, searchBinding.searchMetaInfoTextView, - searchBinding.searchMetaInfoSeparator); + searchBinding.searchMetaInfoSeparator, disposables); hideKeyboardSearch(); disposables.add(historyRecordManager.onSearched(serviceId, theSearchString) @@ -986,8 +987,8 @@ public void handleResult(@NonNull final SearchInfo result) { // List cannot be bundled without creating some containers metaInfo = new MetaInfo[result.getMetaInfo().size()]; metaInfo = result.getMetaInfo().toArray(metaInfo); - disposables.add(showMetaInfoInTextView(result.getMetaInfo(), - searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator)); + showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView, + searchBinding.searchMetaInfoSeparator, disposables); handleSearchSuggestion(); 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 ae7ddfd6350..629240dc6bf 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 @@ -24,7 +24,7 @@ import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.regex.Matcher; import java.util.regex.Pattern; diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 10aa8aa68df..2fe757dea02 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -36,7 +36,7 @@ import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.settings.HistorySettingsFragment; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StreamDialogEntry; @@ -359,7 +359,7 @@ private void showStreamDialog(final StreamStatisticsEntry item) { )); } entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtil.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { + if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index f79282641cb..768b0b86215 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -44,7 +44,7 @@ import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; @@ -770,7 +770,7 @@ protected void showStreamItemDialog(final PlaylistStreamEntry item) { )); } entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtil.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { + if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 095c8dbc772..488ab68afb7 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -56,9 +56,9 @@ import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE import org.schabi.newpipe.streams.io.StoredFileHelper +import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture -import org.schabi.newpipe.util.ShareUtils import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -293,7 +293,8 @@ class SubscriptionFragment : BaseStateFragment() { val actions = DialogInterface.OnClickListener { _, i -> when (i) { - 0 -> ShareUtils.shareText(requireContext(), selectedItem.name, selectedItem.url) + 0 -> ShareUtils.shareText(requireContext(), selectedItem.name, selectedItem.url, + selectedItem.thumbnailUrl) 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url) 2 -> deleteChannel(selectedItem) } diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index 29c9ac77b60..13b66af8004 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -47,7 +47,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; -import static org.schabi.newpipe.util.ShareUtils.shareText; +import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText; public final class PlayQueueActivity extends AppCompatActivity implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, @@ -313,7 +313,8 @@ private void buildItemPopupMenu(final PlayQueueItem item, final View view) { final MenuItem share = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 3, Menu.NONE, R.string.share); share.setOnMenuItemClickListener(menuItem -> { - shareText(getApplicationContext(), item.getTitle(), item.getUrl()); + shareText(getApplicationContext(), item.getTitle(), item.getUrl(), + item.getThumbnailUrl()); return true; }); diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 0c5dbbb6fe7..af04695c070 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -123,11 +123,11 @@ import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.SerializedCache; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.views.ExpandableSurfaceView; import java.io.IOException; @@ -1033,7 +1033,7 @@ private void showHideKodiButton() { // show kodi button if it supports the current service and it is enabled in settings binding.playWithKodi.setVisibility(videoPlayerSelected() && playQueue != null && playQueue.getItem() != null - && KoreUtil.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) + && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) ? View.VISIBLE : View.GONE); } //endregion @@ -3593,7 +3593,8 @@ public void onClick(final View v) { } else if (v.getId() == binding.moreOptionsButton.getId()) { onMoreOptionsClicked(); } else if (v.getId() == binding.share.getId()) { - ShareUtils.shareText(context, getVideoTitle(), getVideoUrlAtCurrentTime()); + ShareUtils.shareText(context, getVideoTitle(), getVideoUrlAtCurrentTime(), + currentItem.getThumbnailUrl()); } else if (v.getId() == binding.playWithKodi.getId()) { onPlayWithKodiClicked(); } else if (v.getId() == binding.openInBrowser.getId()) { @@ -3724,7 +3725,7 @@ private void onPlayWithKodiClicked() { if (DEBUG) { Log.i(TAG, "Failed to start kore", e); } - KoreUtil.showInstallKoreDialog(getParentActivity()); + KoreUtils.showInstallKoreDialog(getParentActivity()); } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java index d26116139f0..7c87e664ba8 100644 --- a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java +++ b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.util; -import android.content.Context; import android.text.Layout; import android.text.Selection; import android.text.Spannable; @@ -11,27 +10,14 @@ import android.view.View; import android.widget.TextView; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.exceptions.ParsingException; -import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.external_communication.InternalUrlsHandler; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; +import io.reactivex.rxjava3.disposables.CompositeDisposable; public class CommentTextOnTouchListener implements View.OnTouchListener { public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); - private static final Pattern TIMESTAMP_PATTERN = Pattern.compile("(.*)#timestamp=(\\d+)"); - @Override public boolean onTouch(final View v, final MotionEvent event) { if (!(v instanceof TextView)) { @@ -64,13 +50,12 @@ public boolean onTouch(final View v, final MotionEvent event) { if (link.length != 0) { if (action == MotionEvent.ACTION_UP) { - boolean handled = false; if (link[0] instanceof URLSpan) { - handled = handleUrl(v.getContext(), (URLSpan) link[0]); - } - if (!handled) { - ShareUtils.openUrlInBrowser(v.getContext(), - ((URLSpan) link[0]).getURL(), false); + final String url = ((URLSpan) link[0]).getURL(); + if (!InternalUrlsHandler.handleUrlCommentsTimestamp( + new CompositeDisposable(), v.getContext(), url)) { + ShareUtils.openUrlInBrowser(v.getContext(), url, false); + } } } else if (action == MotionEvent.ACTION_DOWN) { Selection.setSelection(buffer, @@ -83,52 +68,4 @@ public boolean onTouch(final View v, final MotionEvent event) { } return false; } - - private boolean handleUrl(final Context context, final URLSpan urlSpan) { - String url = urlSpan.getURL(); - int seconds = -1; - final Matcher matcher = TIMESTAMP_PATTERN.matcher(url); - if (matcher.matches()) { - url = matcher.group(1); - seconds = Integer.parseInt(matcher.group(2)); - } - final StreamingService service; - final StreamingService.LinkType linkType; - try { - service = NewPipe.getServiceByUrl(url); - linkType = service.getLinkTypeByUrl(url); - } catch (final ExtractionException e) { - return false; - } - if (linkType == StreamingService.LinkType.NONE) { - return false; - } - if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { - return playOnPopup(context, url, service, seconds); - } else { - NavigationHelper.openRouterActivity(context, url); - return true; - } - } - - private boolean playOnPopup(final Context context, final String url, - final StreamingService service, final int seconds) { - final LinkHandlerFactory factory = service.getStreamLHFactory(); - final String cleanUrl; - try { - cleanUrl = factory.getUrl(factory.getId(url)); - } catch (final ParsingException e) { - return false; - } - final Single single - = ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); - single.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(info -> { - final PlayQueue playQueue - = new SinglePlayQueue((StreamInfo) info, seconds * 1000); - NavigationHelper.playOnPopupPlayer(context, playQueue, false); - }); - return true; - } } 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 af7cafc1518..af94e3366a9 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -30,6 +30,7 @@ import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.external_communication.TextLinkifier; import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; @@ -54,7 +55,7 @@ import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.disposables.CompositeDisposable; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; @@ -268,18 +269,19 @@ public static boolean isCached(final int serviceId, final String url, * @param metaInfos a list of meta information, can be null or empty * @param metaInfoTextView the text view in which to show the formatted HTML * @param metaInfoSeparator another view to be shown or hidden accordingly to the text view - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class */ - public static Disposable showMetaInfoInTextView(@Nullable final List metaInfos, - final TextView metaInfoTextView, - final View metaInfoSeparator) { + public static void showMetaInfoInTextView(@Nullable final List metaInfos, + final TextView metaInfoTextView, + final View metaInfoSeparator, + final CompositeDisposable disposables) { final Context context = metaInfoTextView.getContext(); if (metaInfos == null || metaInfos.isEmpty() || !PreferenceManager.getDefaultSharedPreferences(context).getBoolean( context.getString(R.string.show_meta_info_key), true)) { metaInfoTextView.setVisibility(View.GONE); metaInfoSeparator.setVisibility(View.GONE); - return Disposable.empty(); } else { final StringBuilder stringBuilder = new StringBuilder(); @@ -310,8 +312,8 @@ public static Disposable showMetaInfoInTextView(@Nullable final List m } metaInfoSeparator.setVisibility(View.VISIBLE); - return TextLinkifier.createLinksFromHtmlBlock(context, stringBuilder.toString(), - metaInfoTextView, HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING); + TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(), + HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 106399735ab..d4d158eedd3 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -53,10 +53,11 @@ import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.settings.SettingsActivity; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.ArrayList; -import static org.schabi.newpipe.util.ShareUtils.installApp; +import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -252,7 +253,7 @@ public static void playOnExternalPlayer(final Context context, final String name public static void resolveActivityOrAskToInstall(final Context context, final Intent intent) { if (intent.resolveActivity(context.getPackageManager()) != null) { - ShareUtils.openIntentInApp(context, intent); + ShareUtils.openIntentInApp(context, intent, false); } else { if (context instanceof Activity) { new AlertDialog.Builder(context) diff --git a/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java deleted file mode 100644 index 45ec1d01557..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java +++ /dev/null @@ -1,229 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.ActivityNotFoundException; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.widget.Toast; - -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.R; - -public final class ShareUtils { - private ShareUtils() { - } - - /** - * Open an Intent to install an app. - *

- * This method tries to open the default app market with the package id passed as the - * second param (a system chooser will be opened if there are multiple markets and no default) - * and falls back to Google Play Store web URL if no app to handle the market scheme was found. - *

- * It uses {@link ShareUtils#openIntentInApp(Context, Intent)} to open market scheme and - * {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} to open Google Play Store web - * URL with false for the boolean param. - * - * @param context the context to use - * @param packageId the package id of the app to be installed - */ - public static void installApp(final Context context, final String packageId) { - // Try market:// scheme - final boolean marketSchemeResult = openIntentInApp(context, new Intent(Intent.ACTION_VIEW, - Uri.parse("market://details?id=" + packageId)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - if (!marketSchemeResult) { - // Fall back to Google Play Store Web URL (F-Droid can handle it) - openUrlInBrowser(context, - "https://play.google.com/store/apps/details?id=" + packageId, false); - } - } - - /** - * Open the url with the system default browser. - *

- * If no browser is set as default, fallbacks to - * {@link ShareUtils#openAppChooser(Context, Intent, String)} - * - * @param context the context to use - * @param url the url to browse - * @param httpDefaultBrowserTest the boolean to set if the test for the default browser will be - * for HTTP protocol or for the created intent - * @return true if the URL can be opened or false if it cannot - */ - public static boolean openUrlInBrowser(final Context context, final String url, - final boolean httpDefaultBrowserTest) { - final String defaultPackageName; - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - if (httpDefaultBrowserTest) { - defaultPackageName = getDefaultAppPackageName(context, new Intent(Intent.ACTION_VIEW, - Uri.parse("http://")).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - } else { - defaultPackageName = getDefaultAppPackageName(context, intent); - } - - if (defaultPackageName.equals("android")) { - // No browser set as default (doesn't work on some devices) - openAppChooser(context, intent, context.getString(R.string.open_with)); - } else { - if (defaultPackageName.isEmpty()) { - // No app installed to open a web url - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); - return false; - } else { - try { - intent.setPackage(defaultPackageName); - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // Not a browser but an app chooser because of OEMs changes - intent.setPackage(null); - openAppChooser(context, intent, context.getString(R.string.open_with)); - } - } - } - - return true; - } - - /** - * Open the url with the system default browser. - *

- * If no browser is set as default, fallbacks to - * {@link ShareUtils#openAppChooser(Context, Intent, String)} - *

- * This calls {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} with true - * for the boolean parameter - * - * @param context the context to use - * @param url the url to browse - * @return true if the URL can be opened or false if it cannot be - **/ - public static boolean openUrlInBrowser(final Context context, final String url) { - return openUrlInBrowser(context, url, true); - } - - /** - * Open an intent with the system default app. - *

- * The intent can be of every type, excepted a web intent for which - * {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} should be used. - *

- * If no app is set as default, fallbacks to - * {@link ShareUtils#openAppChooser(Context, Intent, String)} - * - * @param context the context to use - * @param intent the intent to open - * @return true if the intent can be opened or false if it cannot be - */ - public static boolean openIntentInApp(final Context context, final Intent intent) { - final String defaultPackageName = getDefaultAppPackageName(context, intent); - - if (defaultPackageName.equals("android")) { - // No app set as default (doesn't work on some devices) - openAppChooser(context, intent, context.getString(R.string.open_with)); - } else { - if (defaultPackageName.isEmpty()) { - // No app installed to open the intent - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); - return false; - } else { - try { - intent.setPackage(defaultPackageName); - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // Not an app to open the intent but an app chooser because of OEMs changes - intent.setPackage(null); - openAppChooser(context, intent, context.getString(R.string.open_with)); - } - } - } - - return true; - } - - /** - * Open the system chooser to launch an intent. - *

- * This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted - * as the viewIntent param. A string for the chooser's title must be passed as the last param. - * - * @param context the context to use - * @param intent the intent to open - * @param chooserStringTitle the string of chooser's title - */ - private static void openAppChooser(final Context context, final Intent intent, - final String chooserStringTitle) { - final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); - chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); - chooserIntent.putExtra(Intent.EXTRA_TITLE, chooserStringTitle); - chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(chooserIntent); - } - - /** - * Get the default app package name. - *

- * If no app is set as default, it will return "android" (not on some devices because some - * OEMs changed the app chooser). - *

- * If no app is installed on user's device to handle the intent, it will return an empty string. - * - * @param context the context to use - * @param intent the intent to get default app - * @return the package name of the default app, an empty string if there's no app installed to - * handle the intent or the app chooser if there's no default - */ - private static String getDefaultAppPackageName(final Context context, final Intent intent) { - final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, - PackageManager.MATCH_DEFAULT_ONLY); - - if (resolveInfo == null) { - return ""; - } else { - return resolveInfo.activityInfo.packageName; - } - } - - /** - * Open the android share menu to share the current url. - * - * @param context the context to use - * @param subject the url subject, typically the title - * @param url the url to share - */ - public static void shareText(final Context context, final String subject, final String url) { - final Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject); - shareIntent.putExtra(Intent.EXTRA_TEXT, url); - - openAppChooser(context, shareIntent, context.getString(R.string.share_dialog_title)); - } - - /** - * Copy the text to clipboard, and indicate to the user whether the operation was completed - * successfully using a Toast. - * - * @param context the context to use - * @param text the text to copy - */ - public static void copyToClipboard(final Context context, final String text) { - final ClipboardManager clipboardManager = - ContextCompat.getSystemService(context, ClipboardManager.class); - - if (clipboardManager == null) { - Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); - return; - } - - clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); - Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java index 610f9f85237..ad646066497 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java @@ -12,6 +12,8 @@ import org.schabi.newpipe.player.MainPlayer; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.external_communication.KoreUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.Collections; import java.util.List; @@ -83,12 +85,13 @@ public enum StreamDialogEntry { try { NavigationHelper.playWithKore(fragment.requireContext(), videoUrl); } catch (final Exception e) { - KoreUtil.showInstallKoreDialog(fragment.getActivity()); + KoreUtils.showInstallKoreDialog(fragment.getActivity()); } }), share(R.string.share, (fragment, item) -> - ShareUtils.shareText(fragment.getContext(), item.getName(), item.getUrl())), + ShareUtils.shareText(fragment.getContext(), item.getName(), item.getUrl(), + item.getThumbnailUrl())), open_in_browser(R.string.open_in_browser, (fragment, item) -> ShareUtils.openUrlInBrowser(fragment.getContext(), item.getUrl())); diff --git a/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java deleted file mode 100644 index 08767733339..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java +++ /dev/null @@ -1,145 +0,0 @@ -package org.schabi.newpipe.util; - -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; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.core.text.HtmlCompat; - -import io.noties.markwon.Markwon; -import io.noties.markwon.linkify.LinkifyPlugin; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class TextLinkifier { - public static final String TAG = TextLinkifier.class.getSimpleName(); - - private TextLinkifier() { - } - - /** - * Create web links for contents with an HTML description. - *

- * This will call - * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} - * after having linked the URLs with {@link HtmlCompat#fromHtml(String, int)}. - * - * @param context the context to use - * @param htmlBlock the htmlBlock to be linked - * @param textView the TextView to set the htmlBlock linked - * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} - * will be called - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - public static Disposable createLinksFromHtmlBlock(final Context context, - final String htmlBlock, - final TextView textView, - final int htmlCompatFlag) { - return changeIntentsOfDescriptionLinks(context, - HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), textView); - } - - /** - * Create web links for contents with a plain text description. - *

- * This will call - * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} - * after having linked the URLs with {@link TextView#setAutoLinkMask(int)} and - * {@link TextView#setText(CharSequence, TextView.BufferType)}. - * - * @param context the context to use - * @param plainTextBlock the block of plain text to be linked - * @param textView the TextView to set the plain text block linked - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - public static Disposable createLinksFromPlainText(final Context context, - final String plainTextBlock, - final TextView textView) { - textView.setAutoLinkMask(Linkify.WEB_URLS); - textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); - return changeIntentsOfDescriptionLinks(context, textView.getText(), textView); - } - - /** - * Create web links for contents with a markdown description. - *

- * This will call - * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} - * after creating an {@link Markwon} object and using - * {@link Markwon#setMarkdown(TextView, String)}. - * - * @param context the context to use - * @param markdownBlock the block of markdown text to be linked - * @param textView the TextView to set the plain text block linked - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - public static Disposable createLinksFromMarkdownText(final Context context, - final String markdownBlock, - final TextView textView) { - final Markwon markwon = Markwon.builder(context).usePlugin(LinkifyPlugin.create()).build(); - markwon.setMarkdown(textView, markdownBlock); - return changeIntentsOfDescriptionLinks(context, textView.getText(), textView); - } - - /** - * Change links generated by libraries in the description of a content to a custom link action. - *

- * 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 is required in order to intercept links and e.g. show a confirmation dialog - * before opening a web link. - * - * @param context the context to use - * @param chars the CharSequence to be parsed - * @param textView the TextView in which the converted CharSequence will be applied - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - private static Disposable changeIntentsOfDescriptionLinks(final Context context, - final CharSequence chars, - final TextView textView) { - return Single.fromCallable(() -> { - final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars); - final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class); - - for (final URLSpan span : urls) { - final ClickableSpan clickableSpan = new ClickableSpan() { - public void onClick(@NonNull final View view) { - ShareUtils.openUrlInBrowser(context, span.getURL(), false); - } - }; - - textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span), - textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span)); - textBlockLinked.removeSpan(span); - } - - return textBlockLinked; - }).subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), - throwable -> { - Log.e(TAG, "Unable to linkify text", throwable); - // this should never happen, but if it does, just fallback to it - setTextViewCharSequence(textView, chars); - }); - } - - private static void setTextViewCharSequence(final TextView textView, - 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/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java new file mode 100644 index 00000000000..39ec51ce41b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java @@ -0,0 +1,154 @@ +package org.schabi.newpipe.util.external_communication; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.NavigationHelper; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public final class InternalUrlsHandler { + private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)"); + private static final Pattern HASHTAG_TIMESTAMP_PATTERN = + Pattern.compile("(.*)#timestamp=(\\d+)"); + + private InternalUrlsHandler() { + } + + /** + * Handle a YouTube timestamp comment URL in NewPipe. + *

+ * This method will check if the provided url is a YouTube comment description URL ({@code + * https://www.youtube.com/watch?v=}video_id{@code #timestamp=}time_in_seconds). If yes, the + * popup player will be opened when the user will click on the timestamp in the comment, + * at the time and for the video indicated in the timestamp. + * + * @param disposables a field of the Activity/Fragment class that calls this method + * @param context the context to use + * @param url the URL to check if it can be handled + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + public static boolean handleUrlCommentsTimestamp(@NonNull final CompositeDisposable + disposables, + final Context context, + @NonNull final String url) { + return handleUrl(context, url, HASHTAG_TIMESTAMP_PATTERN, disposables); + } + + /** + * Handle a YouTube timestamp description URL in NewPipe. + *

+ * This method will check if the provided url is a YouTube timestamp description URL ({@code + * https://www.youtube.com/watch?v=}video_id{@code &t=}time_in_seconds). If yes, the popup + * player will be opened when the user will click on the timestamp in the video description, + * at the time and for the video indicated in the timestamp. + * + * @param disposables a field of the Activity/Fragment class that calls this method + * @param context the context to use + * @param url the URL to check if it can be handled + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + public static boolean handleUrlDescriptionTimestamp(@NonNull final CompositeDisposable + disposables, + final Context context, + @NonNull final String url) { + return handleUrl(context, url, AMPERSAND_TIMESTAMP_PATTERN, disposables); + } + + /** + * Handle an URL in NewPipe. + *

+ * This method will check if the provided url can be handled in NewPipe or not. If this is a + * service URL with a timestamp, the popup player will be opened and true will be returned; + * else, false will be returned. + * + * @param context the context to use + * @param url the URL to check if it can be handled + * @param pattern the pattern to use + * @param disposables a field of the Activity/Fragment class that calls this method + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + private static boolean handleUrl(final Context context, + @NonNull final String url, + @NonNull final Pattern pattern, + @NonNull final CompositeDisposable disposables) { + final Matcher matcher = pattern.matcher(url); + if (!matcher.matches()) { + return false; + } + final String matchedUrl = matcher.group(1); + final int seconds = Integer.parseInt(matcher.group(2)); + + final StreamingService service; + final StreamingService.LinkType linkType; + try { + service = NewPipe.getServiceByUrl(matchedUrl); + linkType = service.getLinkTypeByUrl(matchedUrl); + if (linkType == StreamingService.LinkType.NONE) { + return false; + } + } catch (final ExtractionException e) { + return false; + } + + if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { + return playOnPopup(context, matchedUrl, service, seconds, disposables); + } else { + NavigationHelper.openRouterActivity(context, matchedUrl); + return true; + } + } + + /** + * Play a content in the floating player. + * + * @param context the context to be used + * @param url the URL of the content + * @param service the service of the content + * @param seconds the position in seconds at which the floating player will start + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class + * @return true if the playback of the content has successfully started or false if not + */ + public static boolean playOnPopup(final Context context, + final String url, + @NonNull final StreamingService service, + final int seconds, + @NonNull final CompositeDisposable disposables) { + final LinkHandlerFactory factory = service.getStreamLHFactory(); + final String cleanUrl; + + try { + cleanUrl = factory.getUrl(factory.getId(url)); + } catch (final ParsingException e) { + return false; + } + + final Single single + = ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); + disposables.add(single.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(info -> { + final PlayQueue playQueue + = new SinglePlayQueue(info, seconds * 1000); + NavigationHelper.playOnPopupPlayer(context, playQueue, false); + })); + return true; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/KoreUtil.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java similarity index 70% rename from app/src/main/java/org/schabi/newpipe/util/KoreUtil.java rename to app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java index de6f3fa9a8d..6801f24ef2d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KoreUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java @@ -1,28 +1,31 @@ -package org.schabi.newpipe.util; +package org.schabi.newpipe.util.external_communication; import android.content.Context; +import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.util.NavigationHelper; -public final class KoreUtil { - private KoreUtil() { } +public final class KoreUtils { + private KoreUtils() { } public static boolean isServiceSupportedByKore(final int serviceId) { return (serviceId == ServiceList.YouTube.getServiceId() || serviceId == ServiceList.SoundCloud.getServiceId()); } - public static boolean shouldShowPlayWithKodi(final Context context, final int serviceId) { + public static boolean shouldShowPlayWithKodi(@NonNull final Context context, + final int serviceId) { return isServiceSupportedByKore(serviceId) && PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.show_play_with_kodi_key), false); } - public static void showInstallKoreDialog(final Context context) { + public static void showInstallKoreDialog(@NonNull final Context context) { final AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setMessage(R.string.kore_not_found) .setPositiveButton(R.string.install, (dialog, which) -> 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 new file mode 100644 index 00000000000..e49cd6ea2d4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java @@ -0,0 +1,302 @@ +package org.schabi.newpipe.util.external_communication; + +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Build; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.schabi.newpipe.R; + +public final class ShareUtils { + private ShareUtils() { + } + + /** + * Open an Intent to install an app. + *

+ * This method tries to open the default app market with the package id passed as the + * second param (a system chooser will be opened if there are multiple markets and no default) + * and falls back to Google Play Store web URL if no app to handle the market scheme was found. + *

+ * It uses {@link #openIntentInApp(Context, Intent, boolean)} to open market scheme + * and {@link #openUrlInBrowser(Context, String, boolean)} to open Google Play Store + * web URL with false for the boolean param. + * + * @param context the context to use + * @param packageId the package id of the app to be installed + */ + public static void installApp(@NonNull final Context context, final String packageId) { + // Try market scheme + final boolean marketSchemeResult = openIntentInApp(context, new Intent(Intent.ACTION_VIEW, + Uri.parse("market://details?id=" + packageId)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), false); + if (!marketSchemeResult) { + // Fall back to Google Play Store Web URL (F-Droid can handle it) + openUrlInBrowser(context, + "https://play.google.com/store/apps/details?id=" + packageId, false); + } + } + + /** + * Open the url with the system default browser. + *

+ * If no browser is set as default, fallbacks to + * {@link #openAppChooser(Context, Intent, boolean)} + * + * @param context the context to use + * @param url the url to browse + * @param httpDefaultBrowserTest the boolean to set if the test for the default browser will be + * for HTTP protocol or for the created intent + * @return true if the URL can be opened or false if it cannot + */ + public static boolean openUrlInBrowser(@NonNull final Context context, + final String url, + final boolean httpDefaultBrowserTest) { + final String defaultPackageName; + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (httpDefaultBrowserTest) { + defaultPackageName = getDefaultAppPackageName(context, new Intent(Intent.ACTION_VIEW, + Uri.parse("http://")).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } else { + defaultPackageName = getDefaultAppPackageName(context, intent); + } + + if (defaultPackageName.equals("android")) { + // No browser set as default (doesn't work on some devices) + openAppChooser(context, intent, true); + } else { + if (defaultPackageName.isEmpty()) { + // No app installed to open a web url + Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); + return false; + } else { + try { + intent.setPackage(defaultPackageName); + context.startActivity(intent); + } catch (final ActivityNotFoundException e) { + // Not a browser but an app chooser because of OEMs changes + intent.setPackage(null); + openAppChooser(context, intent, true); + } + } + } + + return true; + } + + /** + * Open the url with the system default browser. + *

+ * If no browser is set as default, fallbacks to + * {@link #openAppChooser(Context, Intent, boolean)} + *

+ * This calls {@link #openUrlInBrowser(Context, String, boolean)} with true + * for the boolean parameter + * + * @param context the context to use + * @param url the url to browse + * @return true if the URL can be opened or false if it cannot be + **/ + public static boolean openUrlInBrowser(@NonNull final Context context, final String url) { + return openUrlInBrowser(context, url, true); + } + + /** + * Open an intent with the system default app. + *

+ * The intent can be of every type, excepted a web intent for which + * {@link #openUrlInBrowser(Context, String, boolean)} should be used. + *

+ * If no app can open the intent, a toast with the message {@code No app on your device can + * open this} is shown. + * + * @param context the context to use + * @param intent the intent to open + * @param showToast a boolean to set if a toast is displayed to user when no app is installed + * to open the intent (true) or not (false) + * @return true if the intent can be opened or false if it cannot be + */ + public static boolean openIntentInApp(@NonNull final Context context, + @NonNull final Intent intent, + final boolean showToast) { + final String defaultPackageName = getDefaultAppPackageName(context, intent); + + if (defaultPackageName.isEmpty()) { + // No app installed to open the intent + if (showToast) { + Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG) + .show(); + } + return false; + } else { + context.startActivity(intent); + } + + return true; + } + + /** + * Open the system chooser to launch an intent. + *

+ * This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted + * as the intent param. If the setTitleChooser boolean is true, the string "Open with" will be + * set as the title of the system chooser. + * For Android P and higher, title for {@link android.content.Intent#ACTION_SEND} system + * choosers must be set on this intent, not on the + * {@link android.content.Intent#ACTION_CHOOSER} intent. + * + * @param context the context to use + * @param intent the intent to open + * @param setTitleChooser set the title "Open with" to the chooser if true, else not + */ + private static void openAppChooser(@NonNull final Context context, + @NonNull final Intent intent, + final boolean setTitleChooser) { + final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); + chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (setTitleChooser) { + chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with)); + } + + // Migrate any clip data and flags from the original intent. + final int permFlags; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); + } else { + permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } + if (permFlags != 0) { + ClipData targetClipData = intent.getClipData(); + if (targetClipData == null && intent.getData() != null) { + final ClipData.Item item = new ClipData.Item(intent.getData()); + final String[] mimeTypes; + if (intent.getType() != null) { + mimeTypes = new String[] {intent.getType()}; + } else { + mimeTypes = new String[] {}; + } + targetClipData = new ClipData(null, mimeTypes, item); + } + if (targetClipData != null) { + chooserIntent.setClipData(targetClipData); + chooserIntent.addFlags(permFlags); + } + } + context.startActivity(chooserIntent); + } + + /** + * Get the default app package name. + *

+ * If no app is set as default, it will return "android" (not on some devices because some + * OEMs changed the app chooser). + *

+ * If no app is installed on user's device to handle the intent, it will return an empty string. + * + * @param context the context to use + * @param intent the intent to get default app + * @return the package name of the default app, an empty string if there's no app installed to + * handle the intent or the app chooser if there's no default + */ + private static String getDefaultAppPackageName(@NonNull final Context context, + @NonNull final Intent intent) { + final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, + PackageManager.MATCH_DEFAULT_ONLY); + + if (resolveInfo == null) { + return ""; + } else { + return resolveInfo.activityInfo.packageName; + } + } + + /** + * Open the android share sheet to share a content. + * + * For Android 10+ users, a content preview is shown, which includes the title of the shared + * content. + * Support sharing the image of the content needs to done, if possible. + * + * @param context the context to use + * @param title the title of the content + * @param content the content to share + * @param imagePreviewUrl the image of the subject + */ + public static void shareText(@NonNull final Context context, + @NonNull final String title, + final String content, + final String imagePreviewUrl) { + final Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_TEXT, content); + if (!title.isEmpty()) { + shareIntent.putExtra(Intent.EXTRA_TITLE, title); + } + + /* TODO: add the image of the content to Android share sheet with setClipData after + generating a content URI of this image, then use ClipData.newUri(the content resolver, + null, the content URI) and set the ClipData to the share intent with + shareIntent.setClipData(generated ClipData). + if (!imagePreviewUrl.isEmpty()) { + //shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + }*/ + + openAppChooser(context, shareIntent, false); + } + + /** + * Open the android share sheet to share a content. + * + * For Android 10+ users, a content preview is shown, which includes the title of the shared + * content. + *

+ * This calls {@link #shareText(Context, String, String, String)} with an empty string for the + * imagePreviewUrl parameter. + * + * @param context the context to use + * @param title the title of the content + * @param content the content to share + */ + public static void shareText(@NonNull final Context context, + @NonNull final String title, + final String content) { + shareText(context, title, content, ""); + } + + /** + * Copy the text to clipboard, and indicate to the user whether the operation was completed + * successfully using a Toast. + * + * @param context the context to use + * @param text the text to copy + */ + public static void copyToClipboard(@NonNull final Context context, final String text) { + final ClipboardManager clipboardManager = + ContextCompat.getSystemService(context, ClipboardManager.class); + + if (clipboardManager == null) { + Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); + return; + } + + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); + Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java new file mode 100644 index 00000000000..76da096094b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java @@ -0,0 +1,287 @@ +package org.schabi.newpipe.util.external_communication; + +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; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +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 java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.markwon.Markwon; +import io.noties.markwon.linkify.LinkifyPlugin; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +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(); + private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[A-Za-z0-9_]+)"); + private static final Pattern TIMESTAMPS_PATTERN = Pattern.compile( + "(?:^|(?!:)\\W)(?:([0-5]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])(?=$|(?!:)\\W)"); + + 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)}. + * + * @param textView the TextView to set the htmlBlock linked + * @param htmlBlock the htmlBlock 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 + * 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 + */ + public static void createLinksFromHtmlBlock(@NonNull final TextView textView, + final String htmlBlock, + final int htmlCompatFlag, + @Nullable final Info relatedInfo, + 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)}. + * + * @param textView the 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 + * 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, + @Nullable final Info relatedInfo, + 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)}. + * + * @param textView the 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 + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class + */ + public static void createLinksFromMarkdownText(@NonNull final TextView textView, + final String markdownBlock, + @Nullable final Info relatedInfo, + final CompositeDisposable disposables) { + final Markwon markwon = Markwon.builder(textView.getContext()) + .usePlugin(LinkifyPlugin.create()).build(); + changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo, + 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 = TIMESTAMPS_PATTERN.matcher(descriptionText); + + while (timestampsMatches.find()) { + final int timestampStart = timestampsMatches.start(2); + final int timestampEnd = timestampsMatches.end(3); + final String parsedTimestamp = descriptionText.substring(timestampStart, timestampEnd); + final String[] timestampParts = parsedTimestamp.split(":"); + + final int seconds; + if (timestampParts.length == 3) { // timestamp format: XX:XX:XX + seconds = Integer.parseInt(timestampParts[0]) * 3600 // hours + + Integer.parseInt(timestampParts[1]) * 60 // minutes + + Integer.parseInt(timestampParts[2]); // seconds + } else if (timestampParts.length == 2) { // timestamp format: XX:XX + seconds = Integer.parseInt(timestampParts[0]) * 60 // minutes + + Integer.parseInt(timestampParts[1]); // seconds + } else { + continue; + } + + spannableDescription.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull final View view) { + playOnPopup(context, relatedInfo.getUrl(), relatedInfo.getService(), seconds, + disposables); + } + }, timestampStart, 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)}, + * 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 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) { + disposables.add(Single.fromCallable(() -> { + 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); + + 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); + } + } + }; + + textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span), + textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span)); + textBlockLinked.removeSpan(span); + } + + // 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); + } + + return textBlockLinked; + }).subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), + throwable -> { + Log.e(TAG, "Unable to linkify text", throwable); + // this should never happen, but if it does, just fallback to it + setTextViewCharSequence(textView, chars); + })); + } + + private static void setTextViewCharSequence(@NonNull final TextView textView, + final CharSequence charSequence) { + textView.setText(charSequence); + textView.setMovementMethod(LinkMovementMethod.getInstance()); + textView.setVisibility(View.VISIBLE); + } +} diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java index 45ee290f6f9..e06485fdf9f 100644 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -2,7 +2,6 @@ import android.annotation.SuppressLint; import android.app.NotificationManager; -import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.graphics.Color; @@ -45,7 +44,7 @@ import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.io.File; import java.net.URI; @@ -348,10 +347,8 @@ private void viewWithFileProvider(Mission mission) { if (BuildConfig.DEBUG) Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); - final Uri uri = resolveShareableUri(mission); - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(uri, mimeType); + intent.setDataAndType(resolveShareableUri(mission), mimeType); intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { @@ -361,10 +358,8 @@ private void viewWithFileProvider(Mission mission) { intent.addFlags(FLAG_ACTIVITY_NEW_TASK); } - //mContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (intent.resolveActivity(mContext.getPackageManager()) != null) { - ShareUtils.openIntentInApp(mContext, intent); + ShareUtils.openIntentInApp(mContext, intent, false); } else { Toast.makeText(mContext, R.string.toast_no_player, Toast.LENGTH_LONG).show(); } @@ -377,19 +372,18 @@ private void shareFile(Mission mission) { shareIntent.setType(resolveMimeType(mission)); shareIntent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission)); shareIntent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + final Intent intent = new Intent(Intent.ACTION_CHOOSER); intent.putExtra(Intent.EXTRA_INTENT, shareIntent); - intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); + // unneeded to set a title to the chooser on Android P and higher because the system + // ignores this title on these versions + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { + intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); + } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); - try { - intent.setPackage("android"); - mContext.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // falling back to OEM chooser if Android's system chooser was removed by the OEM - intent.setPackage(null); - mContext.startActivity(intent); - } + mContext.startActivity(intent); } /**