Merge branch 'dev' into pr8221

This commit is contained in:
Stypox 2024-03-29 16:09:13 +01:00
commit e1ce3fef1b
No known key found for this signature in database
GPG key ID: 4BDF1B40A49FDD23
1741 changed files with 56947 additions and 21110 deletions

View file

@ -0,0 +1,94 @@
package org.schabi.newpipe.util;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
import java.io.Serializable;
import java.util.List;
import java.util.stream.Collectors;
/**
* A list adapter for groups of {@link AudioStream}s (audio tracks).
*/
public class AudioTrackAdapter extends BaseAdapter {
private final AudioTracksWrapper tracksWrapper;
public AudioTrackAdapter(final AudioTracksWrapper tracksWrapper) {
this.tracksWrapper = tracksWrapper;
}
@Override
public int getCount() {
return tracksWrapper.size();
}
@Override
public List<AudioStream> getItem(final int position) {
return tracksWrapper.getTracksList().get(position).getStreamsList();
}
@Override
public long getItemId(final int position) {
return position;
}
@Override
public View getView(final int position, final View convertView, final ViewGroup parent) {
final var context = parent.getContext();
final View view;
if (convertView == null) {
view = LayoutInflater.from(context).inflate(
R.layout.stream_quality_item, parent, false);
} else {
view = convertView;
}
final ImageView woSoundIconView = view.findViewById(R.id.wo_sound_icon);
final TextView formatNameView = view.findViewById(R.id.stream_format_name);
final TextView qualityView = view.findViewById(R.id.stream_quality);
final TextView sizeView = view.findViewById(R.id.stream_size);
final List<AudioStream> streams = getItem(position);
final AudioStream stream = streams.get(0);
woSoundIconView.setVisibility(View.GONE);
sizeView.setVisibility(View.VISIBLE);
if (stream.getAudioTrackId() != null) {
formatNameView.setText(stream.getAudioTrackId());
}
qualityView.setText(Localization.audioTrackName(context, stream));
return view;
}
public static class AudioTracksWrapper implements Serializable {
private final List<StreamInfoWrapper<AudioStream>> tracksList;
public AudioTracksWrapper(@NonNull final List<List<AudioStream>> groupedAudioStreams,
@Nullable final Context context) {
this.tracksList = groupedAudioStreams.stream().map(streams ->
new StreamInfoWrapper<>(streams, context)).collect(Collectors.toList());
}
public List<StreamInfoWrapper<AudioStream>> getTracksList() {
return tracksList;
}
public int size() {
return tracksList.size();
}
}
}

View file

@ -0,0 +1,151 @@
package org.schabi.newpipe.util;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.StringRes;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import java.util.List;
import java.util.Set;
public final class ChannelTabHelper {
private ChannelTabHelper() {
}
/**
* @param tab the channel tab to check
* @return whether the tab should contain (playable) streams or not
*/
public static boolean isStreamsTab(final String tab) {
switch (tab) {
case ChannelTabs.VIDEOS:
case ChannelTabs.TRACKS:
case ChannelTabs.SHORTS:
case ChannelTabs.LIVESTREAMS:
return true;
default:
return false;
}
}
/**
* @param tab the channel tab link handler to check
* @return whether the tab should contain (playable) streams or not
*/
public static boolean isStreamsTab(final ListLinkHandler tab) {
final List<String> contentFilters = tab.getContentFilters();
if (contentFilters.isEmpty()) {
return false; // this should never happen, but check just to be sure
} else {
return isStreamsTab(contentFilters.get(0));
}
}
@StringRes
private static int getShowTabKey(final String tab) {
switch (tab) {
case ChannelTabs.VIDEOS:
return R.string.show_channel_tabs_videos;
case ChannelTabs.TRACKS:
return R.string.show_channel_tabs_tracks;
case ChannelTabs.SHORTS:
return R.string.show_channel_tabs_shorts;
case ChannelTabs.LIVESTREAMS:
return R.string.show_channel_tabs_livestreams;
case ChannelTabs.CHANNELS:
return R.string.show_channel_tabs_channels;
case ChannelTabs.PLAYLISTS:
return R.string.show_channel_tabs_playlists;
case ChannelTabs.ALBUMS:
return R.string.show_channel_tabs_albums;
default:
return -1;
}
}
@StringRes
private static int getFetchFeedTabKey(final String tab) {
switch (tab) {
case ChannelTabs.VIDEOS:
return R.string.fetch_channel_tabs_videos;
case ChannelTabs.TRACKS:
return R.string.fetch_channel_tabs_tracks;
case ChannelTabs.SHORTS:
return R.string.fetch_channel_tabs_shorts;
case ChannelTabs.LIVESTREAMS:
return R.string.fetch_channel_tabs_livestreams;
default:
return -1;
}
}
@StringRes
public static int getTranslationKey(final String tab) {
switch (tab) {
case ChannelTabs.VIDEOS:
return R.string.channel_tab_videos;
case ChannelTabs.TRACKS:
return R.string.channel_tab_tracks;
case ChannelTabs.SHORTS:
return R.string.channel_tab_shorts;
case ChannelTabs.LIVESTREAMS:
return R.string.channel_tab_livestreams;
case ChannelTabs.CHANNELS:
return R.string.channel_tab_channels;
case ChannelTabs.PLAYLISTS:
return R.string.channel_tab_playlists;
case ChannelTabs.ALBUMS:
return R.string.channel_tab_albums;
default:
return R.string.unknown_content;
}
}
public static boolean showChannelTab(final Context context,
final SharedPreferences sharedPreferences,
@StringRes final int key) {
final Set<String> enabledTabs = sharedPreferences.getStringSet(
context.getString(R.string.show_channel_tabs_key), null);
if (enabledTabs == null) {
return true; // default to true
} else {
return enabledTabs.contains(context.getString(key));
}
}
public static boolean showChannelTab(final Context context,
final SharedPreferences sharedPreferences,
final String tab) {
final int key = ChannelTabHelper.getShowTabKey(tab);
if (key == -1) {
return false;
}
return showChannelTab(context, sharedPreferences, key);
}
public static boolean fetchFeedChannelTab(final Context context,
final SharedPreferences sharedPreferences,
final ListLinkHandler tab) {
final List<String> contentFilters = tab.getContentFilters();
if (contentFilters.isEmpty()) {
return false; // this should never happen, but check just to be sure
}
final int key = ChannelTabHelper.getFetchFeedTabKey(contentFilters.get(0));
if (key == -1) {
return false;
}
final Set<String> enabledTabs = sharedPreferences.getStringSet(
context.getString(R.string.feed_fetch_channel_tabs_key), null);
if (enabledTabs == null) {
return true; // default to true
} else {
return enabledTabs.contains(context.getString(key));
}
}
}

View file

@ -1,71 +0,0 @@
package org.schabi.newpipe.util;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.view.MotionEvent;
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;
public class CommentTextOnTouchListener implements View.OnTouchListener {
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
@Override
public boolean onTouch(final View v, final MotionEvent event) {
if (!(v instanceof TextView)) {
return false;
}
final TextView widget = (TextView) v;
final Object text = widget.getText();
if (text instanceof Spanned) {
final Spannable buffer = (Spannable) text;
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 (link.length != 0) {
if (action == MotionEvent.ACTION_UP) {
if (link[0] instanceof URLSpan) {
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,
buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0]));
}
return true;
}
}
}
return false;
}
}

View file

@ -1,25 +0,0 @@
package org.schabi.newpipe.util;
import android.text.TextUtils;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
public final class CookieUtils {
private CookieUtils() {
}
public static String concatCookies(final Collection<String> cookieStrings) {
final Set<String> cookieSet = new HashSet<>();
for (final String cookies : cookieStrings) {
cookieSet.addAll(splitCookies(cookies));
}
return TextUtils.join("; ", cookieSet).trim();
}
public static Set<String> splitCookies(final String cookies) {
return new HashSet<>(Arrays.asList(cookies.split("; *")));
}
}

View file

@ -0,0 +1,51 @@
package org.schabi.newpipe.util;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
/**
* For preferences with dependencies and multiple use case,
* this class can be used to reduce the lines of code.
*/
public final class DependentPreferenceHelper {
private DependentPreferenceHelper() {
// no instance
}
/**
* Option `Resume playback` depends on `Watch history`, this method can be used to retrieve if
* `Resume playback` and its dependencies are all enabled.
*
* @param context the Android context
* @return returns true if `Resume playback` and `Watch history` are both enabled
*/
public static boolean getResumePlaybackEnabled(final Context context) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
return prefs.getBoolean(context.getString(
R.string.enable_watch_history_key), true)
&& prefs.getBoolean(context.getString(
R.string.enable_playback_resume_key), true);
}
/**
* Option `Position in lists` depends on `Watch history`, this method can be used to retrieve if
* `Position in lists` and its dependencies are all enabled.
*
* @param context the Android context
* @return returns true if `Positions in lists` and `Watch history` are both enabled
*/
public static boolean getPositionsInListsEnabled(final Context context) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
return prefs.getBoolean(context.getString(
R.string.enable_watch_history_key), true)
&& prefs.getBoolean(context.getString(
R.string.enable_playback_state_lists_key), true);
}
}

View file

@ -1,14 +1,22 @@
package org.schabi.newpipe.util;
import static android.content.Context.INPUT_SERVICE;
import android.annotation.SuppressLint;
import android.app.UiModeManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Point;
import android.hardware.input.InputManager;
import android.os.BatteryManager;
import android.os.Build;
import android.provider.Settings;
import android.util.TypedValue;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.WindowInsets;
import android.view.WindowManager;
import androidx.annotation.Dimension;
import androidx.annotation.NonNull;
@ -19,27 +27,99 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import java.lang.reflect.Method;
public final class DeviceUtils {
private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv";
private static final boolean SAMSUNG = Build.MANUFACTURER.equals("samsung");
private static Boolean isTV = null;
private static Boolean isFireTV = null;
/*
* Devices that do not support media tunneling
/**
* <p>The app version code that corresponds to the last update
* of the media tunneling device blacklist.</p>
* <p>The value of this variable needs to be updated everytime a new device that does not
* support media tunneling to match the <strong>upcoming</strong> version code.</p>
* @see #shouldSupportMediaTunneling()
*/
public static final int MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION = 994;
// region: devices not supporting media tunneling / media tunneling blacklist
/**
* <p>Formuler Z8 Pro, Z8, CC, Z Alpha, Z+ Neo.</p>
* <p>Blacklist reason: black screen</p>
* <p>Board: HiSilicon Hi3798MV200</p>
*/
// Formuler Z8 Pro, Z8, CC, Z Alpha, Z+ Neo
private static final boolean HI3798MV200 = Build.VERSION.SDK_INT == 24
&& Build.DEVICE.equals("Hi3798MV200");
// Zephir TS43UHD-2
/**
* <p>Zephir TS43UHD-2.</p>
* <p>Blacklist reason: black screen</p>
*/
private static final boolean CVT_MT5886_EU_1G = Build.VERSION.SDK_INT == 24
&& Build.DEVICE.equals("cvt_mt5886_eu_1g");
// Hilife TV
/**
* Hilife TV.
* <p>Blacklist reason: black screen</p>
*/
private static final boolean REALTEKATV = Build.VERSION.SDK_INT == 25
&& Build.DEVICE.equals("RealtekATV");
// Philips QM16XE
/**
* <p>Phillips 4K (O)LED TV.</p>
* Supports custom ROMs with different API levels
*/
private static final boolean PH7M_EU_5596 = Build.VERSION.SDK_INT >= 26
&& Build.DEVICE.equals("PH7M_EU_5596");
/**
* <p>Philips QM16XE.</p>
* <p>Blacklist reason: black screen</p>
*/
private static final boolean QM16XE_U = Build.VERSION.SDK_INT == 23
&& Build.DEVICE.equals("QM16XE_U");
/**
* <p>Sony Bravia VH1.</p>
* <p>Processor: MT5895</p>
* <p>Blacklist reason: fullscreen crash / stuttering</p>
*/
private static final boolean BRAVIA_VH1 = Build.VERSION.SDK_INT == 29
&& Build.DEVICE.equals("BRAVIA_VH1");
/**
* <p>Sony Bravia VH2.</p>
* <p>Blacklist reason: fullscreen crash; this includes model A90J as reported in
* <a href="https://github.com/TeamNewPipe/NewPipe/issues/9023#issuecomment-1387106242">
* #9023</a></p>
*/
private static final boolean BRAVIA_VH2 = Build.VERSION.SDK_INT == 29
&& Build.DEVICE.equals("BRAVIA_VH2");
/**
* <p>Sony Bravia Android TV platform 2.</p>
* Uses a MediaTek MT5891 (MT5596) SoC.
* @see <a href="https://github.com/CiNcH83/bravia_atv2">
* https://github.com/CiNcH83/bravia_atv2</a>
*/
private static final boolean BRAVIA_ATV2 = Build.DEVICE.equals("BRAVIA_ATV2");
/**
* <p>Sony Bravia Android TV platform 3 4K.</p>
* <p>Uses ARM MT5891 and a {@link #BRAVIA_ATV2} motherboard.</p>
*
* @see <a href="https://browser.geekbench.com/v4/cpu/9101105">
* https://browser.geekbench.com/v4/cpu/9101105</a>
*/
private static final boolean BRAVIA_ATV3_4K = Build.DEVICE.equals("BRAVIA_ATV3_4K");
/**
* <p>Panasonic 4KTV-JUP.</p>
* <p>Blacklist reason: fullscreen crash</p>
*/
private static final boolean TX_50JXW834 = Build.DEVICE.equals("TX_50JXW834");
/**
* <p>Bouygtel4K / Bouygues Telecom Bbox 4K.</p>
* <p>Blacklist reason: black screen; reported at
* <a href="https://github.com/TeamNewPipe/NewPipe/pull/10122#issuecomment-1638475769">
* #10122</a></p>
*/
private static final boolean HMB9213NW = Build.DEVICE.equals("HMB9213NW");
// endregion
private DeviceUtils() {
}
@ -65,7 +145,7 @@ public final class DeviceUtils {
boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class)
.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION
|| isFireTv()
|| pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION);
|| pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK);
// from https://stackoverflow.com/a/58932366
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@ -77,14 +157,86 @@ public final class DeviceUtils {
&& pm.hasSystemFeature(PackageManager.FEATURE_ETHERNET));
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
isTv = isTv || pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK);
}
DeviceUtils.isTV = isTv;
return DeviceUtils.isTV;
}
/**
* Checks if the device is in desktop or DeX mode. This function should only
* be invoked once on view load as it is using reflection for the DeX checks.
* @param context the context to use for services and config.
* @return true if the Android device is in desktop mode or using DeX.
*/
@SuppressWarnings("JavaReflectionMemberAccess")
public static boolean isDesktopMode(@NonNull final Context context) {
// Adapted from https://stackoverflow.com/a/64615568
// to check for all input devices that have an active cursor
final InputManager im = (InputManager) context.getSystemService(INPUT_SERVICE);
for (final int id : im.getInputDeviceIds()) {
final InputDevice inputDevice = im.getInputDevice(id);
if (inputDevice.supportsSource(InputDevice.SOURCE_BLUETOOTH_STYLUS)
|| inputDevice.supportsSource(InputDevice.SOURCE_MOUSE)
|| inputDevice.supportsSource(InputDevice.SOURCE_STYLUS)
|| inputDevice.supportsSource(InputDevice.SOURCE_TOUCHPAD)
|| inputDevice.supportsSource(InputDevice.SOURCE_TRACKBALL)) {
return true;
}
}
final UiModeManager uiModeManager =
ContextCompat.getSystemService(context, UiModeManager.class);
if (uiModeManager != null
&& uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_DESK) {
return true;
}
if (!SAMSUNG) {
return false;
// DeX is Samsung-specific, skip the checks below on non-Samsung devices
}
// DeX check for standalone and multi-window mode, from:
// https://developer.samsung.com/samsung-dex/modify-optimizing.html
try {
final Configuration config = context.getResources().getConfiguration();
final Class<?> configClass = config.getClass();
final int semDesktopModeEnabledConst =
configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass);
final int currentMode =
configClass.getField("semDesktopModeEnabled").getInt(config);
if (semDesktopModeEnabledConst == currentMode) {
return true;
}
} catch (final NoSuchFieldException | IllegalAccessException ignored) {
// Device doesn't seem to support DeX
}
@SuppressLint("WrongConstant") final Object desktopModeManager = context
.getApplicationContext()
.getSystemService("desktopmode");
if (desktopModeManager != null) {
try {
final Method getDesktopModeStateMethod = desktopModeManager.getClass()
.getDeclaredMethod("getDesktopModeState");
final Object desktopModeState = getDesktopModeStateMethod
.invoke(desktopModeManager);
final Class<?> desktopModeStateClass = desktopModeState.getClass();
final Method getEnabledMethod = desktopModeStateClass
.getDeclaredMethod("getEnabled");
final int enabledStatus = (int) getEnabledMethod.invoke(desktopModeState);
if (enabledStatus == desktopModeStateClass
.getDeclaredField("ENABLED").getInt(desktopModeStateClass)) {
return true;
}
} catch (final Exception ignored) {
// Device does not support DeX 3.0 or something went wrong when trying to determine
// if it supports this feature
}
}
return false;
}
public static boolean isTablet(@NonNull final Context context) {
final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.tablet_mode_key), "");
@ -128,19 +280,6 @@ public final class DeviceUtils {
context.getResources().getDisplayMetrics());
}
/**
* Some devices have broken tunneled video playback but claim to support it.
* See https://github.com/TeamNewPipe/NewPipe/issues/5911
* @return false if Kitkat (does not support tunneling) or affected device
*/
public static boolean shouldSupportMediaTunneling() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& !HI3798MV200
&& !CVT_MT5886_EU_1G
&& !REALTEKATV
&& !QM16XE_U;
}
public static boolean isLandscape(final Context context) {
return context.getResources().getDisplayMetrics().heightPixels < context.getResources()
.getDisplayMetrics().widthPixels;
@ -156,4 +295,44 @@ public final class DeviceUtils {
Settings.Global.ANIMATOR_DURATION_SCALE,
1F) != 0F;
}
public static int getWindowHeight(@NonNull final WindowManager windowManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
final var windowMetrics = windowManager.getCurrentWindowMetrics();
final var windowInsets = windowMetrics.getWindowInsets();
final var insets = windowInsets.getInsetsIgnoringVisibility(
WindowInsets.Type.navigationBars() | WindowInsets.Type.displayCutout());
return windowMetrics.getBounds().height() - (insets.top + insets.bottom);
} else {
final Point point = new Point();
windowManager.getDefaultDisplay().getSize(point);
return point.y;
}
}
/**
* <p>Some devices have broken tunneled video playback but claim to support it.</p>
* <p>This can cause a black video player surface while attempting to play a video or
* crashes while entering or exiting the full screen player.
* The issue effects Android TVs most commonly.
* See <a href="https://github.com/TeamNewPipe/NewPipe/issues/5911">#5911</a> and
* <a href="https://github.com/TeamNewPipe/NewPipe/issues/9023">#9023</a> for more info.</p>
* @Note Update {@link #MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION}
* when adding a new device to the method.
* @return {@code false} if affected device; {@code true} otherwise
*/
public static boolean shouldSupportMediaTunneling() {
// Maintainers note: update MEDIA_TUNNELING_DEVICES_UPDATE_APP_VERSION_CODE
return !HI3798MV200
&& !CVT_MT5886_EU_1G
&& !REALTEKATV
&& !QM16XE_U
&& !BRAVIA_VH1
&& !BRAVIA_VH2
&& !BRAVIA_ATV2
&& !BRAVIA_ATV3_4K
&& !PH7M_EU_5596
&& !TX_50JXW834
&& !HMB9213NW;
}
}

View file

@ -20,12 +20,14 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.content.Context;
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 androidx.preference.PreferenceManager;
@ -35,23 +37,21 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.MetaInfo;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.feed.FeedExtractor;
import org.schabi.newpipe.extractor.feed.FeedInfo;
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
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;
@ -114,46 +114,43 @@ public final class ExtractorHelper {
public static Single<StreamInfo> getStreamInfo(final int serviceId, final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.STREAM,
return checkCache(forceLoad, serviceId, url, InfoCache.Type.STREAM,
Single.fromCallable(() -> StreamInfo.getInfo(NewPipe.getService(serviceId), url)));
}
public static Single<ChannelInfo> getChannelInfo(final int serviceId, final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.CHANNEL,
return checkCache(forceLoad, serviceId, url, InfoCache.Type.CHANNEL,
Single.fromCallable(() ->
ChannelInfo.getInfo(NewPipe.getService(serviceId), url)));
}
public static Single<InfoItemsPage<StreamInfoItem>> getMoreChannelItems(final int serviceId,
final String url,
final Page nextPage) {
checkServiceId(serviceId);
return Single.fromCallable(() ->
ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage));
}
public static Single<ListInfo<StreamInfoItem>> getFeedInfoFallbackToChannelInfo(
final int serviceId, final String url) {
final Maybe<ListInfo<StreamInfoItem>> maybeFeedInfo = Maybe.fromCallable(() -> {
final StreamingService service = NewPipe.getService(serviceId);
final FeedExtractor feedExtractor = service.getFeedExtractor(url);
if (feedExtractor == null) {
return null;
}
return FeedInfo.getInfo(feedExtractor);
});
return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true));
}
public static Single<CommentsInfo> getCommentsInfo(final int serviceId, final String url,
public static Single<ChannelTabInfo> getChannelTab(final int serviceId,
final ListLinkHandler listLinkHandler,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.COMMENT,
return checkCache(forceLoad, serviceId,
listLinkHandler.getUrl(), InfoCache.Type.CHANNEL_TAB,
Single.fromCallable(() ->
ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler)));
}
public static Single<InfoItemsPage<InfoItem>> getMoreChannelTabItems(
final int serviceId,
final ListLinkHandler listLinkHandler,
final Page nextPage) {
checkServiceId(serviceId);
return Single.fromCallable(() ->
ChannelTabInfo.getMoreItems(NewPipe.getService(serviceId),
listLinkHandler, nextPage));
}
public static Single<CommentsInfo> getCommentsInfo(final int serviceId,
final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId, url, InfoCache.Type.COMMENTS,
Single.fromCallable(() ->
CommentsInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@ -167,11 +164,20 @@ public final class ExtractorHelper {
CommentsInfo.getMoreItems(NewPipe.getService(serviceId), info, nextPage));
}
public static Single<InfoItemsPage<CommentsInfoItem>> getMoreCommentItems(
final int serviceId,
final String url,
final Page nextPage) {
checkServiceId(serviceId);
return Single.fromCallable(() ->
CommentsInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage));
}
public static Single<PlaylistInfo> getPlaylistInfo(final int serviceId,
final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST,
return checkCache(forceLoad, serviceId, url, InfoCache.Type.PLAYLIST,
Single.fromCallable(() ->
PlaylistInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@ -184,9 +190,10 @@ public final class ExtractorHelper {
PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage));
}
public static Single<KioskInfo> getKioskInfo(final int serviceId, final String url,
public static Single<KioskInfo> getKioskInfo(final int serviceId,
final String url,
final boolean forceLoad) {
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST,
return checkCache(forceLoad, serviceId, url, InfoCache.Type.KIOSK,
Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@ -198,7 +205,7 @@ public final class ExtractorHelper {
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
// Cache
//////////////////////////////////////////////////////////////////////////*/
/**
@ -210,25 +217,26 @@ public final class ExtractorHelper {
* @param forceLoad whether to force loading from the network instead of from the cache
* @param serviceId the service to load from
* @param url the URL to load
* @param infoType the {@link InfoItem.InfoType} of the item
* @param cacheType the {@link InfoCache.Type} of the item
* @param loadFromNetwork the {@link Single} to load the item from the network
* @return a {@link Single} that loads the item
*/
private static <I extends Info> Single<I> checkCache(final boolean forceLoad,
final int serviceId, final String url,
final InfoItem.InfoType infoType,
final Single<I> loadFromNetwork) {
final int serviceId,
@NonNull final String url,
@NonNull final InfoCache.Type cacheType,
@NonNull final Single<I> loadFromNetwork) {
checkServiceId(serviceId);
final Single<I> actualLoadFromNetwork = loadFromNetwork
.doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, infoType));
.doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, cacheType));
final Single<I> load;
if (forceLoad) {
CACHE.removeInfo(serviceId, url, infoType);
CACHE.removeInfo(serviceId, url, cacheType);
load = actualLoadFromNetwork;
} else {
load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType),
actualLoadFromNetwork.toMaybe())
load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, cacheType),
actualLoadFromNetwork.toMaybe())
.firstElement() // Take the first valid
.toSingle();
}
@ -239,18 +247,20 @@ public final class ExtractorHelper {
/**
* Default implementation uses the {@link InfoCache} to get cached results.
*
* @param <I> the item type's class that extends {@link Info}
* @param serviceId the service to load from
* @param url the URL to load
* @param infoType the {@link InfoItem.InfoType} of the item
* @param <I> the item type's class that extends {@link Info}
* @param serviceId the service to load from
* @param url the URL to load
* @param cacheType the {@link InfoCache.Type} of the item
* @return a {@link Single} that loads the item
*/
private static <I extends Info> Maybe<I> loadFromCache(final int serviceId, final String url,
final InfoItem.InfoType infoType) {
private static <I extends Info> Maybe<I> loadFromCache(
final int serviceId,
@NonNull final String url,
@NonNull final InfoCache.Type cacheType) {
checkServiceId(serviceId);
return Maybe.defer(() -> {
//noinspection unchecked
final I info = (I) CACHE.getFromKey(serviceId, url, infoType);
final I info = (I) CACHE.getFromKey(serviceId, url, cacheType);
if (MainActivity.DEBUG) {
Log.d(TAG, "loadFromCache() called, info > " + info);
}
@ -264,20 +274,27 @@ public final class ExtractorHelper {
});
}
public static boolean isCached(final int serviceId, final String url,
final InfoItem.InfoType infoType) {
return null != loadFromCache(serviceId, url, infoType).blockingGet();
public static boolean isCached(final int serviceId,
@NonNull final String url,
@NonNull final InfoCache.Type cacheType) {
return null != loadFromCache(serviceId, url, cacheType).blockingGet();
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
/**
* Formats the text contained in the meta info list as HTML and puts it into the text view,
* while also making the separator visible. If the list is null or empty, or the user chose not
* to see meta information, both the text view and the separator are hidden
* @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 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
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void showMetaInfoInTextView(@Nullable final List<MetaInfo> metaInfos,
final TextView metaInfoTextView,
@ -286,7 +303,7 @@ public final class ExtractorHelper {
final Context context = metaInfoTextView.getContext();
if (metaInfos == null || metaInfos.isEmpty()
|| !PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
context.getString(R.string.show_meta_info_key), true)) {
context.getString(R.string.show_meta_info_key), true)) {
metaInfoTextView.setVisibility(View.GONE);
metaInfoSeparator.setVisibility(View.GONE);
@ -319,8 +336,9 @@ public final class ExtractorHelper {
}
metaInfoSeparator.setVisibility(View.VISIBLE);
TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(),
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables);
TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(),
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables,
SET_LINK_MOVEMENT_METHOD);
}
}

View file

@ -76,7 +76,7 @@ public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.File
public static class CustomFilePickerFragment extends FilePickerFragment {
@Override
public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) {
return super.onCreateView(inflater, container, savedInstanceState);
}
@ -138,7 +138,7 @@ public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.File
}
@Override
public void onLoadFinished(final Loader<SortedList<File>> loader,
public void onLoadFinished(@NonNull final Loader<SortedList<File>> loader,
final SortedList<File> data) {
super.onLoadFinished(loader, data);
layoutManager.scrollToPosition(0);

View file

@ -7,6 +7,7 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class FilenameUtils {
@ -51,7 +52,7 @@ public final class FilenameUtils {
final Pattern pattern = Pattern.compile(charset);
return createFilename(title, pattern, replacementChar);
return createFilename(title, pattern, Matcher.quoteReplacement(replacementChar));
}
/**

View file

@ -27,7 +27,6 @@ import androidx.collection.LruCache;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.InfoItem;
import java.util.Map;
@ -48,14 +47,27 @@ public final class InfoCache {
// no instance
}
/**
* Identifies the type of {@link Info} to put into the cache.
*/
public enum Type {
STREAM,
CHANNEL,
CHANNEL_TAB,
COMMENTS,
PLAYLIST,
KIOSK,
}
public static InfoCache getInstance() {
return INSTANCE;
}
@NonNull
private static String keyOf(final int serviceId, @NonNull final String url,
@NonNull final InfoItem.InfoType infoType) {
return serviceId + url + infoType.toString();
private static String keyOf(final int serviceId,
@NonNull final String url,
@NonNull final Type cacheType) {
return serviceId + ":" + cacheType.ordinal() + ":" + url;
}
private static void removeStaleCache() {
@ -83,19 +95,22 @@ public final class InfoCache {
}
@Nullable
public Info getFromKey(final int serviceId, @NonNull final String url,
@NonNull final InfoItem.InfoType infoType) {
public Info getFromKey(final int serviceId,
@NonNull final String url,
@NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "getFromKey() called with: "
+ "serviceId = [" + serviceId + "], url = [" + url + "]");
}
synchronized (LRU_CACHE) {
return getInfo(keyOf(serviceId, url, infoType));
return getInfo(keyOf(serviceId, url, cacheType));
}
}
public void putInfo(final int serviceId, @NonNull final String url, @NonNull final Info info,
@NonNull final InfoItem.InfoType infoType) {
public void putInfo(final int serviceId,
@NonNull final String url,
@NonNull final Info info,
@NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "putInfo() called with: info = [" + info + "]");
}
@ -103,18 +118,19 @@ public final class InfoCache {
final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId());
synchronized (LRU_CACHE) {
final CacheData data = new CacheData(info, expirationMillis);
LRU_CACHE.put(keyOf(serviceId, url, infoType), data);
LRU_CACHE.put(keyOf(serviceId, url, cacheType), data);
}
}
public void removeInfo(final int serviceId, @NonNull final String url,
@NonNull final InfoItem.InfoType infoType) {
public void removeInfo(final int serviceId,
@NonNull final String url,
@NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "removeInfo() called with: "
+ "serviceId = [" + serviceId + "], url = [" + url + "]");
}
synchronized (LRU_CACHE) {
LRU_CACHE.remove(keyOf(serviceId, url, infoType));
LRU_CACHE.remove(keyOf(serviceId, url, cacheType));
}
}

View file

@ -24,7 +24,19 @@ public final class KeyboardUtil {
if (editText.requestFocus()) {
final InputMethodManager imm = ContextCompat.getSystemService(activity,
InputMethodManager.class);
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED);
if (!imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)) {
/*
* Sometimes the keyboard can't be shown because Android's ImeFocusController is in
* a incorrect state e.g. when animations are disabled or the unfocus event of the
* previous view arrives in the wrong moment (see #7647 for details).
* The invalid state can be fixed by to re-focusing the editText.
*/
editText.clearFocus();
editText.requestFocus();
// Try again
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED);
}
}
}

View file

@ -1,7 +1,10 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.net.ConnectivityManager;
import androidx.annotation.NonNull;
@ -13,6 +16,9 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.AudioTrackType;
import org.schabi.newpipe.extractor.stream.DeliveryMethod;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.VideoStream;
import java.util.ArrayList;
@ -20,35 +26,55 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public final class ListHelper {
// Video format in order of quality. 0=lowest quality, n=highest quality
private static final List<MediaFormat> VIDEO_FORMAT_QUALITY_RANKING =
Arrays.asList(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4);
List.of(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4);
// Audio format in order of quality. 0=lowest quality, n=highest quality
private static final List<MediaFormat> AUDIO_FORMAT_QUALITY_RANKING =
Arrays.asList(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A);
// Audio format in order of efficiency. 0=most efficient, n=least efficient
List.of(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A);
// Audio format in order of efficiency. 0=least efficient, n=most efficient
private static final List<MediaFormat> AUDIO_FORMAT_EFFICIENCY_RANKING =
Arrays.asList(MediaFormat.WEBMA, MediaFormat.M4A, MediaFormat.MP3);
List.of(MediaFormat.MP3, MediaFormat.M4A, MediaFormat.WEBMA);
// Use a Set for better performance
private static final Set<String> HIGH_RESOLUTION_LIST = Set.of("1440p", "2160p");
// Audio track types in order of priority. 0=lowest, n=highest
private static final List<AudioTrackType> AUDIO_TRACK_TYPE_RANKING =
List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL);
// Audio track types in order of priority when descriptive audio is preferred.
private static final List<AudioTrackType> AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE =
List.of(AudioTrackType.ORIGINAL, AudioTrackType.DUBBED, AudioTrackType.DESCRIPTIVE);
private static final Set<String> HIGH_RESOLUTION_LIST
// Uses a HashSet for better performance
= new HashSet<>(Arrays.asList("1440p", "2160p", "1440p60", "2160p60"));
/**
* List of supported YouTube Itag ids.
* The original order is kept.
* @see {@link org.schabi.newpipe.extractor.services.youtube.ItagItem#ITAG_LIST}
*/
private static final List<Integer> SUPPORTED_ITAG_IDS =
List.of(
17, 36, // video v3GPP
18, 34, 35, 59, 78, 22, 37, 38, // video MPEG4
43, 44, 45, 46, // video webm
171, 172, 139, 140, 141, 249, 250, 251, // audio
160, 133, 134, 135, 212, 136, 298, 137, 299, 266, // video only
278, 242, 243, 244, 245, 246, 247, 248, 271, 272, 302, 303, 308, 313, 315
);
private ListHelper() { }
/**
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
* @param context Android app context
* @param videoStreams list of the video streams to check
* @return index of the video stream with the default index
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
*/
public static int getDefaultResolutionIndex(final Context context,
final List<VideoStream> videoStreams) {
@ -58,11 +84,11 @@ public final class ListHelper {
}
/**
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
* @param context Android app context
* @param videoStreams list of the video streams to check
* @param defaultResolution the default resolution to look for
* @return index of the video stream with the default index
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
*/
public static int getResolutionIndex(final Context context,
final List<VideoStream> videoStreams,
@ -71,10 +97,10 @@ public final class ListHelper {
}
/**
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
* @param context Android app context
* @param videoStreams list of the video streams to check
* @param context Android app context
* @param videoStreams list of the video streams to check
* @return index of the video stream with the default index
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
*/
public static int getPopupDefaultResolutionIndex(final Context context,
final List<VideoStream> videoStreams) {
@ -84,11 +110,11 @@ public final class ListHelper {
}
/**
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
* @param context Android app context
* @param videoStreams list of the video streams to check
* @param defaultResolution the default resolution to look for
* @return index of the video stream with the default index
* @see #getDefaultResolutionIndex(String, String, MediaFormat, List)
*/
public static int getPopupResolutionIndex(final Context context,
final List<VideoStream> videoStreams,
@ -98,16 +124,94 @@ public final class ListHelper {
public static int getDefaultAudioFormat(final Context context,
final List<AudioStream> audioStreams) {
final MediaFormat defaultFormat = getDefaultFormat(context,
R.string.default_audio_format_key, R.string.default_audio_format_value);
return getAudioIndexByHighestRank(audioStreams,
getAudioTrackComparator(context).thenComparing(getAudioFormatComparator(context)));
}
// If the user has chosen to limit resolution to conserve mobile data
// usage then we should also limit our audio usage.
if (isLimitingDataUsage(context)) {
return getMostCompactAudioIndex(defaultFormat, audioStreams);
} else {
return getHighestQualityAudioIndex(defaultFormat, audioStreams);
public static int getDefaultAudioTrackGroup(final Context context,
final List<List<AudioStream>> groupedAudioStreams) {
if (groupedAudioStreams == null || groupedAudioStreams.isEmpty()) {
return -1;
}
final Comparator<AudioStream> cmp = getAudioTrackComparator(context);
final List<AudioStream> highestRanked = groupedAudioStreams.stream()
.max((o1, o2) -> cmp.compare(o1.get(0), o2.get(0)))
.orElse(null);
return groupedAudioStreams.indexOf(highestRanked);
}
public static int getAudioFormatIndex(final Context context,
final List<AudioStream> audioStreams,
@Nullable final String trackId) {
if (trackId != null) {
for (int i = 0; i < audioStreams.size(); i++) {
final AudioStream s = audioStreams.get(i);
if (s.getAudioTrackId() != null
&& s.getAudioTrackId().equals(trackId)) {
return i;
}
}
}
return getDefaultAudioFormat(context, audioStreams);
}
/**
* Return a {@link Stream} list which uses the given delivery method from a {@link Stream}
* list.
*
* @param streamList the original {@link Stream stream} list
* @param deliveryMethod the {@link DeliveryMethod delivery method}
* @param <S> the item type's class that extends {@link Stream}
* @return a {@link Stream stream} list which uses the given delivery method
*/
@NonNull
public static <S extends Stream> List<S> getStreamsOfSpecifiedDelivery(
@Nullable final List<S> streamList,
final DeliveryMethod deliveryMethod) {
return getFilteredStreamList(streamList,
stream -> stream.getDeliveryMethod() == deliveryMethod);
}
/**
* Return a {@link Stream} list which only contains URL streams and non-torrent streams.
*
* @param streamList the original stream list
* @param <S> the item type's class that extends {@link Stream}
* @return a stream list which only contains URL streams and non-torrent streams
*/
@NonNull
public static <S extends Stream> List<S> getUrlAndNonTorrentStreams(
@Nullable final List<S> streamList) {
return getFilteredStreamList(streamList,
stream -> stream.isUrl() && stream.getDeliveryMethod() != DeliveryMethod.TORRENT);
}
/**
* Return a {@link Stream} list which only contains streams which can be played by the player.
*
* <p>
* Some formats are not supported, see {@link #SUPPORTED_ITAG_IDS} for more details.
* Torrent streams are also removed, because they cannot be retrieved, like OPUS streams using
* HLS as their delivery method, since they are not supported by ExoPlayer.
* </p>
*
* @param <S> the item type's class that extends {@link Stream}
* @param streamList the original stream list
* @param serviceId the service ID from which the streams' list comes from
* @return a stream list which only contains streams that can be played the player
*/
@NonNull
public static <S extends Stream> List<S> getPlayableStreams(
@Nullable final List<S> streamList, final int serviceId) {
final int youtubeServiceId = YouTube.getServiceId();
return getFilteredStreamList(streamList,
stream -> stream.getDeliveryMethod() != DeliveryMethod.TORRENT
&& (stream.getDeliveryMethod() != DeliveryMethod.HLS
|| stream.getFormat() != MediaFormat.OPUS)
&& (serviceId != youtubeServiceId
|| stream.getItagItem() == null
|| SUPPORTED_ITAG_IDS.contains(stream.getItagItem().id)));
}
/**
@ -129,8 +233,8 @@ public final class ListHelper {
@Nullable final List<VideoStream> videoOnlyStreams,
final boolean ascendingOrder,
final boolean preferVideoOnlyStreams) {
final SharedPreferences preferences
= PreferenceManager.getDefaultSharedPreferences(context);
final SharedPreferences preferences =
PreferenceManager.getDefaultSharedPreferences(context);
final boolean showHigherResolutions = preferences.getBoolean(
context.getString(R.string.show_higher_resolutions_key), false);
@ -141,14 +245,155 @@ public final class ListHelper {
videoOnlyStreams, ascendingOrder, preferVideoOnlyStreams);
}
/**
* Get a sorted list containing a set of default resolution info
* and additional resolution info if showHigherResolutions is true.
*
* @param resources the resources to get the resolutions from
* @param defaultResolutionKey the settings key of the default resolution
* @param additionalResolutionKey the settings key of the additional resolutions
* @param showHigherResolutions if higher resolutions should be included in the sorted list
* @return a sorted list containing the default and maybe additional resolutions
*/
public static List<String> getSortedResolutionList(
final Resources resources,
final int defaultResolutionKey,
final int additionalResolutionKey,
final boolean showHigherResolutions) {
final List<String> resolutions = new ArrayList<>(Arrays.asList(
resources.getStringArray(defaultResolutionKey)));
if (!showHigherResolutions) {
return resolutions;
}
final List<String> additionalResolutions = Arrays.asList(
resources.getStringArray(additionalResolutionKey));
// keep "best resolution" at the top
resolutions.addAll(1, additionalResolutions);
return resolutions;
}
public static boolean isHighResolutionSelected(final String selectedResolution,
final int additionalResolutionKey,
final Resources resources) {
return Arrays.asList(resources.getStringArray(
additionalResolutionKey))
.contains(selectedResolution);
}
/**
* Filter the list of audio streams and return a list with the preferred stream for
* each audio track. Streams are sorted with the preferred language in the first position.
*
* @param context the context to search for the track to give preference
* @param audioStreams the list of audio streams
* @return the sorted, filtered list
*/
public static List<AudioStream> getFilteredAudioStreams(
@NonNull final Context context,
@Nullable final List<AudioStream> audioStreams) {
if (audioStreams == null) {
return Collections.emptyList();
}
final HashMap<String, AudioStream> collectedStreams = new HashMap<>();
final Comparator<AudioStream> cmp = getAudioFormatComparator(context);
for (final AudioStream stream : audioStreams) {
if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT
|| (stream.getDeliveryMethod() == DeliveryMethod.HLS
&& stream.getFormat() == MediaFormat.OPUS)) {
continue;
}
final String trackId = Objects.toString(stream.getAudioTrackId(), "");
final AudioStream presentStream = collectedStreams.get(trackId);
if (presentStream == null || cmp.compare(stream, presentStream) > 0) {
collectedStreams.put(trackId, stream);
}
}
// Filter unknown audio tracks if there are multiple tracks
if (collectedStreams.size() > 1) {
collectedStreams.remove("");
}
// Sort collected streams by name
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator(context))
.collect(Collectors.toList());
}
/**
* Group the list of audioStreams by their track ID and sort the resulting list by track name.
*
* @param context app context to get track names for sorting
* @param audioStreams list of audio streams
* @return list of audio streams lists representing individual tracks
*/
public static List<List<AudioStream>> getGroupedAudioStreams(
@NonNull final Context context,
@Nullable final List<AudioStream> audioStreams) {
if (audioStreams == null) {
return Collections.emptyList();
}
final HashMap<String, List<AudioStream>> collectedStreams = new HashMap<>();
for (final AudioStream stream : audioStreams) {
final String trackId = Objects.toString(stream.getAudioTrackId(), "");
if (collectedStreams.containsKey(trackId)) {
collectedStreams.get(trackId).add(stream);
} else {
final List<AudioStream> list = new ArrayList<>();
list.add(stream);
collectedStreams.put(trackId, list);
}
}
// Filter unknown audio tracks if there are multiple tracks
if (collectedStreams.size() > 1) {
collectedStreams.remove("");
}
// Sort tracks alphabetically, sort track streams by quality
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator(context);
final Comparator<AudioStream> formatCmp = getAudioFormatComparator(context);
return collectedStreams.values().stream()
.sorted((o1, o2) -> nameCmp.compare(o1.get(0), o2.get(0)))
.map(streams -> streams.stream().sorted(formatCmp).collect(Collectors.toList()))
.collect(Collectors.toList());
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
private static String computeDefaultResolution(final Context context, final int key,
/**
* Get a filtered stream list, by using Java 8 Stream's API and the given predicate.
*
* @param streamList the stream list to filter
* @param streamListPredicate the predicate which will be used to filter streams
* @param <S> the item type's class that extends {@link Stream}
* @return a new stream list filtered using the given predicate
*/
private static <S extends Stream> List<S> getFilteredStreamList(
@Nullable final List<S> streamList,
final Predicate<S> streamListPredicate) {
if (streamList == null) {
return Collections.emptyList();
}
return streamList.stream()
.filter(streamListPredicate)
.collect(Collectors.toList());
}
private static String computeDefaultResolution(@NonNull final Context context, final int key,
final int value) {
final SharedPreferences preferences
= PreferenceManager.getDefaultSharedPreferences(context);
final SharedPreferences preferences =
PreferenceManager.getDefaultSharedPreferences(context);
// Load the preferred resolution otherwise the best available
String resolution = preferences != null
@ -165,19 +410,20 @@ public final class ListHelper {
}
/**
* Return the index of the default stream in the list, based on the parameters
* defaultResolution and defaultFormat.
* Return the index of the default stream in the list, that will be sorted in the process, based
* on the parameters defaultResolution and defaultFormat.
*
* @param defaultResolution the default resolution to look for
* @param bestResolutionKey key of the best resolution
* @param defaultFormat the default format to look for
* @param videoStreams list of the video streams to check
* @return index of the default resolution&format
* @param videoStreams a mutable list of the video streams to check (it will be sorted in
* place)
* @return index of the default resolution&format in the sorted videoStreams
*/
static int getDefaultResolutionIndex(final String defaultResolution,
final String bestResolutionKey,
final MediaFormat defaultFormat,
final List<VideoStream> videoStreams) {
@Nullable final List<VideoStream> videoStreams) {
if (videoStreams == null || videoStreams.isEmpty()) {
return -1;
}
@ -187,8 +433,8 @@ public final class ListHelper {
return 0;
}
final int defaultStreamIndex
= getVideoStreamIndex(defaultResolution, defaultFormat, videoStreams);
final int defaultStreamIndex =
getVideoStreamIndex(defaultResolution, defaultFormat, videoStreams);
// this is actually an error,
// but maybe there is really no stream fitting to the default value.
@ -233,7 +479,9 @@ public final class ListHelper {
.flatMap(List::stream)
// Filter out higher resolutions (or not if high resolutions should always be shown)
.filter(stream -> showHigherResolutions
|| !HIGH_RESOLUTION_LIST.contains(stream.getResolution()))
|| !HIGH_RESOLUTION_LIST.contains(stream.getResolution()
// Replace any frame rate with nothing
.replaceAll("p\\d+$", "p")))
.collect(Collectors.toList());
final HashMap<String, VideoStream> hashMap = new HashMap<>();
@ -275,74 +523,30 @@ public final class ListHelper {
*/
private static List<VideoStream> sortStreamList(final List<VideoStream> videoStreams,
final boolean ascendingOrder) {
final Comparator<VideoStream> comparator = ListHelper::compareVideoStreamResolution;
// Compares the quality of two video streams.
final Comparator<VideoStream> comparator = Comparator.nullsLast(Comparator
.comparing(VideoStream::getResolution, ListHelper::compareVideoStreamResolution)
.thenComparingInt(s -> VIDEO_FORMAT_QUALITY_RANKING.indexOf(s.getFormat())));
Collections.sort(videoStreams, ascendingOrder ? comparator : comparator.reversed());
return videoStreams;
}
/**
* Get the audio from the list with the highest quality.
* Format will be ignored if it yields no results.
*
* @param format The target format type or null if it doesn't matter
* @param audioStreams List of audio streams
* @return Index of audio stream that produces the most compact results or -1 if not found
*/
static int getHighestQualityAudioIndex(@Nullable final MediaFormat format,
@Nullable final List<AudioStream> audioStreams) {
return getAudioIndexByHighestRank(format, audioStreams,
// Compares descending (last = highest rank)
(s1, s2) -> compareAudioStreamBitrate(s1, s2, AUDIO_FORMAT_QUALITY_RANKING)
);
}
/**
* Get the audio from the list with the lowest bitrate and most efficient format.
* Format will be ignored if it yields no results.
*
* @param format The target format type or null if it doesn't matter
* @param audioStreams List of audio streams
* @return Index of audio stream that produces the most compact results or -1 if not found
*/
static int getMostCompactAudioIndex(@Nullable final MediaFormat format,
@Nullable final List<AudioStream> audioStreams) {
return getAudioIndexByHighestRank(format, audioStreams,
// The "-" is important -> Compares ascending (first = highest rank)
(s1, s2) -> -compareAudioStreamBitrate(s1, s2, AUDIO_FORMAT_EFFICIENCY_RANKING)
);
}
/**
* Get the audio-stream from the list with the highest rank, depending on the comparator.
* Format will be ignored if it yields no results.
*
* @param targetedFormat The target format type or null if it doesn't matter
* @param audioStreams List of audio streams
* @param comparator The comparator used for determining the max/best/highest ranked value
* @param audioStreams List of audio streams
* @param comparator The comparator used for determining the max/best/highest ranked value
* @return Index of audio stream that produces the highest ranked result or -1 if not found
*/
private static int getAudioIndexByHighestRank(@Nullable final MediaFormat targetedFormat,
@Nullable final List<AudioStream> audioStreams,
final Comparator<AudioStream> comparator) {
static int getAudioIndexByHighestRank(@Nullable final List<AudioStream> audioStreams,
final Comparator<AudioStream> comparator) {
if (audioStreams == null || audioStreams.isEmpty()) {
return -1;
}
final AudioStream highestRankedAudioStream = audioStreams.stream()
.filter(audioStream -> targetedFormat == null
|| audioStream.getFormat() == targetedFormat)
.max(comparator)
.orElse(null);
if (highestRankedAudioStream == null) {
// Fallback: Ignore targetedFormat if not null
if (targetedFormat != null) {
return getAudioIndexByHighestRank(null, audioStreams, comparator);
}
// targetedFormat is already null -> return -1
return -1;
}
.max(comparator).orElse(null);
return audioStreams.indexOf(highestRankedAudioStream);
}
@ -366,8 +570,9 @@ public final class ListHelper {
* @param videoStreams the available video streams
* @return the index of the preferred video stream
*/
static int getVideoStreamIndex(final String targetResolution, final MediaFormat targetFormat,
final List<VideoStream> videoStreams) {
static int getVideoStreamIndex(@NonNull final String targetResolution,
final MediaFormat targetFormat,
@NonNull final List<VideoStream> videoStreams) {
int fullMatchIndex = -1;
int fullMatchNoRefreshIndex = -1;
int resMatchOnlyIndex = -1;
@ -376,8 +581,9 @@ public final class ListHelper {
final String targetResolutionNoRefresh = targetResolution.replaceAll("p\\d+$", "p");
for (int idx = 0; idx < videoStreams.size(); idx++) {
final MediaFormat format
= targetFormat == null ? null : videoStreams.get(idx).getFormat();
final MediaFormat format = targetFormat == null
? null
: videoStreams.get(idx).getFormat();
final String resolution = videoStreams.get(idx).getResolution();
final String resolutionNoRefresh = resolution.replaceAll("p\\d+$", "p");
@ -428,7 +634,7 @@ public final class ListHelper {
* @param videoStreams the list of video streams to check
* @return the index of the preferred video stream
*/
private static int getDefaultResolutionWithDefaultFormat(final Context context,
private static int getDefaultResolutionWithDefaultFormat(@NonNull final Context context,
final String defaultResolution,
final List<VideoStream> videoStreams) {
final MediaFormat defaultFormat = getDefaultFormat(context,
@ -437,28 +643,25 @@ public final class ListHelper {
context.getString(R.string.best_resolution_key), defaultFormat, videoStreams);
}
private static MediaFormat getDefaultFormat(final Context context,
@Nullable
private static MediaFormat getDefaultFormat(@NonNull final Context context,
@StringRes final int defaultFormatKey,
@StringRes final int defaultFormatValueKey) {
final SharedPreferences preferences
= PreferenceManager.getDefaultSharedPreferences(context);
final SharedPreferences preferences =
PreferenceManager.getDefaultSharedPreferences(context);
final String defaultFormat = context.getString(defaultFormatValueKey);
final String defaultFormatString = preferences.getString(
context.getString(defaultFormatKey), defaultFormat);
context.getString(defaultFormatKey),
defaultFormat
);
MediaFormat defaultMediaFormat = getMediaFormatFromKey(context, defaultFormatString);
if (defaultMediaFormat == null) {
preferences.edit().putString(context.getString(defaultFormatKey), defaultFormat)
.apply();
defaultMediaFormat = getMediaFormatFromKey(context, defaultFormat);
}
return defaultMediaFormat;
return getMediaFormatFromKey(context, defaultFormatString);
}
private static MediaFormat getMediaFormatFromKey(final Context context,
final String formatKey) {
@Nullable
private static MediaFormat getMediaFormatFromKey(@NonNull final Context context,
@NonNull final String formatKey) {
MediaFormat format = null;
if (formatKey.equals(context.getString(R.string.video_webm_key))) {
format = MediaFormat.WEBM;
@ -474,59 +677,23 @@ public final class ListHelper {
return format;
}
// Compares the quality of two audio streams
private static int compareAudioStreamBitrate(final AudioStream streamA,
final AudioStream streamB,
final List<MediaFormat> formatRanking) {
if (streamA == null) {
return -1;
}
if (streamB == null) {
private static int compareVideoStreamResolution(@NonNull final String r1,
@NonNull final String r2) {
try {
final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1")
.replaceAll("[^\\d.]", ""));
final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1")
.replaceAll("[^\\d.]", ""));
return res1 - res2;
} catch (final NumberFormatException e) {
// Consider the first one greater because we don't know if the two streams are
// different or not (a NumberFormatException was thrown so we don't know the resolution
// of one stream or of all streams)
return 1;
}
if (streamA.getAverageBitrate() < streamB.getAverageBitrate()) {
return -1;
}
if (streamA.getAverageBitrate() > streamB.getAverageBitrate()) {
return 1;
}
// Same bitrate and format
return formatRanking.indexOf(streamA.getFormat())
- formatRanking.indexOf(streamB.getFormat());
}
private static int compareVideoStreamResolution(final String r1, final String r2) {
final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1")
.replaceAll("[^\\d.]", ""));
final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1")
.replaceAll("[^\\d.]", ""));
return res1 - res2;
}
// Compares the quality of two video streams.
private static int compareVideoStreamResolution(final VideoStream streamA,
final VideoStream streamB) {
if (streamA == null) {
return -1;
}
if (streamB == null) {
return 1;
}
final int resComp = compareVideoStreamResolution(streamA.getResolution(),
streamB.getResolution());
if (resComp != 0) {
return resComp;
}
// Same bitrate and format
return ListHelper.VIDEO_FORMAT_QUALITY_RANKING.indexOf(streamA.getFormat())
- ListHelper.VIDEO_FORMAT_QUALITY_RANKING.indexOf(streamB.getFormat());
}
private static boolean isLimitingDataUsage(final Context context) {
static boolean isLimitingDataUsage(@NonNull final Context context) {
return getResolutionLimit(context) != null;
}
@ -536,11 +703,11 @@ public final class ListHelper {
* @param context App context
* @return maximum resolution allowed or null if there is no maximum
*/
private static String getResolutionLimit(final Context context) {
private static String getResolutionLimit(@NonNull final Context context) {
String resolutionLimit = null;
if (isMeteredNetwork(context)) {
final SharedPreferences preferences
= PreferenceManager.getDefaultSharedPreferences(context);
final SharedPreferences preferences =
PreferenceManager.getDefaultSharedPreferences(context);
final String defValue = context.getString(R.string.limit_data_usage_none_key);
final String value = preferences.getString(
context.getString(R.string.limit_mobile_data_usage_key), defValue);
@ -555,13 +722,159 @@ public final class ListHelper {
* @param context App context
* @return {@code true} if connected to a metered network
*/
public static boolean isMeteredNetwork(final Context context) {
final ConnectivityManager manager
= ContextCompat.getSystemService(context, ConnectivityManager.class);
public static boolean isMeteredNetwork(@NonNull final Context context) {
final ConnectivityManager manager =
ContextCompat.getSystemService(context, ConnectivityManager.class);
if (manager == null || manager.getActiveNetworkInfo() == null) {
return false;
}
return manager.isActiveNetworkMetered();
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate.
*
* <p>The preferred stream will be ordered last.</p>
*
* @param context app context
* @return Comparator
*/
private static Comparator<AudioStream> getAudioFormatComparator(
final @NonNull Context context) {
final MediaFormat defaultFormat = getDefaultFormat(context,
R.string.default_audio_format_key, R.string.default_audio_format_value);
return getAudioFormatComparator(defaultFormat, isLimitingDataUsage(context));
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate.
*
* <p>The preferred stream will be ordered last.</p>
*
* @param defaultFormat the default format to look for
* @param limitDataUsage choose low bitrate audio stream
* @return Comparator
*/
static Comparator<AudioStream> getAudioFormatComparator(
@Nullable final MediaFormat defaultFormat, final boolean limitDataUsage) {
final List<MediaFormat> formatRanking = limitDataUsage
? AUDIO_FORMAT_EFFICIENCY_RANKING : AUDIO_FORMAT_QUALITY_RANKING;
Comparator<AudioStream> bitrateComparator =
Comparator.comparingInt(AudioStream::getAverageBitrate);
if (limitDataUsage) {
bitrateComparator = bitrateComparator.reversed();
}
return Comparator.comparing(AudioStream::getFormat, (o1, o2) -> {
if (defaultFormat != null) {
return Boolean.compare(o1 == defaultFormat, o2 == defaultFormat);
}
return 0;
}).thenComparing(bitrateComparator).thenComparingInt(
stream -> formatRanking.indexOf(stream.getFormat()));
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their tracks.
*
* <p>Tracks will be compared this order:</p>
* <ol>
* <li>If {@code preferOriginalAudio}: use original audio</li>
* <li>Language matches {@code preferredLanguage}</li>
* <li>
* Track type ranks highest in this order:
* <i>Original</i> > <i>Dubbed</i> > <i>Descriptive</i>
* <p>If {@code preferDescriptiveAudio}:
* <i>Descriptive</i> > <i>Dubbed</i> > <i>Original</i></p>
* </li>
* <li>Language is English</li>
* </ol>
*
* <p>The preferred track will be ordered last.</p>
*
* @param context App context
* @return Comparator
*/
private static Comparator<AudioStream> getAudioTrackComparator(
@NonNull final Context context) {
final SharedPreferences preferences =
PreferenceManager.getDefaultSharedPreferences(context);
final Locale preferredLanguage = Localization.getPreferredLocale(context);
final boolean preferOriginalAudio =
preferences.getBoolean(context.getString(R.string.prefer_original_audio_key),
false);
final boolean preferDescriptiveAudio =
preferences.getBoolean(context.getString(R.string.prefer_descriptive_audio_key),
false);
return getAudioTrackComparator(preferredLanguage, preferOriginalAudio,
preferDescriptiveAudio);
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their tracks.
*
* <p>Tracks will be compared this order:</p>
* <ol>
* <li>If {@code preferOriginalAudio}: use original audio</li>
* <li>Language matches {@code preferredLanguage}</li>
* <li>
* Track type ranks highest in this order:
* <i>Original</i> > <i>Dubbed</i> > <i>Descriptive</i>
* <p>If {@code preferDescriptiveAudio}:
* <i>Descriptive</i> > <i>Dubbed</i> > <i>Original</i></p>
* </li>
* <li>Language is English</li>
* </ol>
*
* <p>The preferred track will be ordered last.</p>
*
* @param preferredLanguage Preferred audio stream language
* @param preferOriginalAudio Get the original audio track regardless of its language
* @param preferDescriptiveAudio Prefer the descriptive audio track if available
* @return Comparator
*/
static Comparator<AudioStream> getAudioTrackComparator(
final Locale preferredLanguage,
final boolean preferOriginalAudio,
final boolean preferDescriptiveAudio) {
final String langCode = preferredLanguage.getISO3Language();
final List<AudioTrackType> trackTypeRanking = preferDescriptiveAudio
? AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE : AUDIO_TRACK_TYPE_RANKING;
return Comparator.comparing(AudioStream::getAudioTrackType, (o1, o2) -> {
if (preferOriginalAudio) {
return Boolean.compare(
o1 == AudioTrackType.ORIGINAL, o2 == AudioTrackType.ORIGINAL);
}
return 0;
}).thenComparing(AudioStream::getAudioLocale,
Comparator.nullsFirst(Comparator.comparing(
locale -> locale.getISO3Language().equals(langCode))))
.thenComparing(AudioStream::getAudioTrackType,
Comparator.nullsFirst(Comparator.comparingInt(trackTypeRanking::indexOf)))
.thenComparing(AudioStream::getAudioLocale,
Comparator.nullsFirst(Comparator.comparing(
locale -> locale.getISO3Language().equals(
Locale.ENGLISH.getISO3Language()))));
}
/**
* Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types
* for alphabetical sorting.
*
* @param context app context for localization
* @return Comparator
*/
private static Comparator<AudioStream> getAudioTrackNameComparator(
@NonNull final Context context) {
final Locale appLoc = Localization.getAppLocale(context);
return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast(
Comparator.comparing(locale -> locale.getDisplayName(appLoc))))
.thenComparing(AudioStream::getAudioTrackType, Comparator.nullsLast(
Comparator.naturalOrder()));
}
}

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.MainActivity.DEBUG;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
@ -11,8 +13,10 @@ import android.text.TextUtils;
import android.util.DisplayMetrics;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.PluralsRes;
import androidx.annotation.StringRes;
import androidx.core.math.MathUtils;
import androidx.preference.PreferenceManager;
import org.ocpsoft.prettytime.PrettyTime;
@ -20,6 +24,9 @@ import org.ocpsoft.prettytime.units.Decade;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.AudioTrackType;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ -31,6 +38,7 @@ import java.time.format.FormatStyle;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
/*
@ -54,7 +62,6 @@ import java.util.Locale;
*/
public final class Localization {
public static final String DOT_SEPARATOR = "";
private static PrettyTime prettyTime;
@ -62,43 +69,23 @@ public final class Localization {
@NonNull
public static String concatenateStrings(final String... strings) {
return concatenateStrings(Arrays.asList(strings));
return concatenateStrings(DOT_SEPARATOR, Arrays.asList(strings));
}
@NonNull
public static String concatenateStrings(final List<String> strings) {
if (strings.isEmpty()) {
return "";
}
final StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(strings.get(0));
for (int i = 1; i < strings.size(); i++) {
final String string = strings.get(i);
if (!TextUtils.isEmpty(string)) {
stringBuilder.append(DOT_SEPARATOR).append(strings.get(i));
}
}
return stringBuilder.toString();
public static String concatenateStrings(final String delimiter, final List<String> strings) {
return strings.stream()
.filter(string -> !TextUtils.isEmpty(string))
.collect(Collectors.joining(delimiter));
}
public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization(
final Context context) {
final String contentLanguage = PreferenceManager
.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.content_language_key),
context.getString(R.string.default_localization_key));
if (contentLanguage.equals(context.getString(R.string.default_localization_key))) {
return org.schabi.newpipe.extractor.localization.Localization
.fromLocale(Locale.getDefault());
}
return org.schabi.newpipe.extractor.localization.Localization
.fromLocalizationCode(contentLanguage);
.fromLocale(getPreferredLocale(context));
}
public static ContentCountry getPreferredContentCountry(final Context context) {
public static ContentCountry getPreferredContentCountry(@NonNull final Context context) {
final String contentCountry = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.content_country_key),
context.getString(R.string.default_localization_key));
@ -108,52 +95,43 @@ public final class Localization {
return new ContentCountry(contentCountry);
}
public static Locale getPreferredLocale(final Context context) {
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
final String languageCode = sp.getString(context.getString(R.string.content_language_key),
context.getString(R.string.default_localization_key));
try {
if (languageCode.length() == 2) {
return new Locale(languageCode);
} else if (languageCode.contains("_")) {
final String country = languageCode.substring(languageCode.indexOf("_"));
return new Locale(languageCode.substring(0, 2), country);
}
} catch (final Exception ignored) {
}
return Locale.getDefault();
public static Locale getPreferredLocale(@NonNull final Context context) {
return getLocaleFromPrefs(context, R.string.content_language_key);
}
public static String localizeNumber(final Context context, final long number) {
public static Locale getAppLocale(@NonNull final Context context) {
return getLocaleFromPrefs(context, R.string.app_language_key);
}
public static String localizeNumber(@NonNull final Context context, final long number) {
return localizeNumber(context, (double) number);
}
public static String localizeNumber(final Context context, final double number) {
public static String localizeNumber(@NonNull final Context context, final double number) {
final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context));
return nf.format(number);
}
public static String formatDate(final OffsetDateTime offsetDateTime, final Context context) {
public static String formatDate(@NonNull final Context context,
@NonNull final OffsetDateTime offsetDateTime) {
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(getAppLocale(context)).format(offsetDateTime
.atZoneSameInstant(ZoneId.systemDefault()));
}
@SuppressLint("StringFormatInvalid")
public static String localizeUploadDate(final Context context,
final OffsetDateTime offsetDateTime) {
return context.getString(R.string.upload_date_text, formatDate(offsetDateTime, context));
public static String localizeUploadDate(@NonNull final Context context,
@NonNull final OffsetDateTime offsetDateTime) {
return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime));
}
public static String localizeViewCount(final Context context, final long viewCount) {
public static String localizeViewCount(@NonNull final Context context, final long viewCount) {
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
localizeNumber(context, viewCount));
}
public static String localizeStreamCount(final Context context, final long streamCount) {
public static String localizeStreamCount(@NonNull final Context context,
final long streamCount) {
switch ((int) streamCount) {
case (int) ListExtractor.ITEM_COUNT_UNKNOWN:
return "";
@ -167,7 +145,8 @@ public final class Localization {
}
}
public static String localizeStreamCountMini(final Context context, final long streamCount) {
public static String localizeStreamCountMini(@NonNull final Context context,
final long streamCount) {
switch ((int) streamCount) {
case (int) ListExtractor.ITEM_COUNT_UNKNOWN:
return "";
@ -180,12 +159,13 @@ public final class Localization {
}
}
public static String localizeWatchingCount(final Context context, final long watchingCount) {
public static String localizeWatchingCount(@NonNull final Context context,
final long watchingCount) {
return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount,
localizeNumber(context, watchingCount));
}
public static String shortCount(final Context context, final long count) {
public static String shortCount(@NonNull final Context context, final long count) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return CompactDecimalFormat.getInstance(getAppLocale(context),
CompactDecimalFormat.CompactStyle.SHORT).format(count);
@ -193,66 +173,90 @@ public final class Localization {
final double value = (double) count;
if (count >= 1000000000) {
return localizeNumber(context, round(value / 1000000000, 1))
return localizeNumber(context, round(value / 1000000000))
+ context.getString(R.string.short_billion);
} else if (count >= 1000000) {
return localizeNumber(context, round(value / 1000000, 1))
return localizeNumber(context, round(value / 1000000))
+ context.getString(R.string.short_million);
} else if (count >= 1000) {
return localizeNumber(context, round(value / 1000, 1))
return localizeNumber(context, round(value / 1000))
+ context.getString(R.string.short_thousand);
} else {
return localizeNumber(context, value);
}
}
public static String listeningCount(final Context context, final long listeningCount) {
public static String listeningCount(@NonNull final Context context, final long listeningCount) {
return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount,
shortCount(context, listeningCount));
}
public static String shortWatchingCount(final Context context, final long watchingCount) {
public static String shortWatchingCount(@NonNull final Context context,
final long watchingCount) {
return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount,
shortCount(context, watchingCount));
}
public static String shortViewCount(final Context context, final long viewCount) {
public static String shortViewCount(@NonNull final Context context, final long viewCount) {
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
shortCount(context, viewCount));
}
public static String shortSubscriberCount(final Context context, final long subscriberCount) {
public static String shortSubscriberCount(@NonNull final Context context,
final long subscriberCount) {
return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount,
shortCount(context, subscriberCount));
}
public static String downloadCount(final Context context, final int downloadCount) {
public static String downloadCount(@NonNull final Context context, final int downloadCount) {
return getQuantity(context, R.plurals.download_finished_notification, 0,
downloadCount, shortCount(context, downloadCount));
}
public static String deletedDownloadCount(final Context context, final int deletedCount) {
public static String deletedDownloadCount(@NonNull final Context context,
final int deletedCount) {
return getQuantity(context, R.plurals.deleted_downloads_toast, 0,
deletedCount, shortCount(context, deletedCount));
}
private static String getQuantity(final Context context, @PluralsRes final int pluralId,
@StringRes final int zeroCaseStringId, final long count,
final String formattedCount) {
if (count == 0) {
return context.getString(zeroCaseStringId);
}
// As we use the already formatted count
// is not the responsibility of this method handle long numbers
// (it probably will fall in the "other" category,
// or some language have some specific rule... then we have to change it)
final int safeCount = count > Integer.MAX_VALUE ? Integer.MAX_VALUE
: count < Integer.MIN_VALUE ? Integer.MIN_VALUE : (int) count;
return context.getResources().getQuantityString(pluralId, safeCount, formattedCount);
public static String replyCount(@NonNull final Context context, final int replyCount) {
return getQuantity(context, R.plurals.replies, 0, replyCount,
String.valueOf(replyCount));
}
/**
* @param context the Android context
* @param likeCount the like count, possibly negative if unknown
* @return if {@code likeCount} is smaller than {@code 0}, the string {@code "-"}, otherwise
* the result of calling {@link #shortCount(Context, long)} on the like count
*/
public static String likeCount(@NonNull final Context context, final int likeCount) {
if (likeCount < 0) {
return "-";
} else {
return shortCount(context, likeCount);
}
}
/**
* Get a readable text for a duration in the format {@code days:hours:minutes:seconds}.
* Prepended zeros are removed.
* @param duration the duration in seconds
* @return a formatted duration String or {@code 0:00} if the duration is zero.
*/
public static String getDurationString(final long duration) {
return getDurationString(duration, true);
}
/**
* Get a readable text for a duration in the format {@code days:hours:minutes:seconds+}.
* Prepended zeros are removed. If the given duration is incomplete, a plus is appended to the
* duration string.
* @param duration the duration in seconds
* @param isDurationComplete whether the given duration is complete or whether info is missing
* @return a formatted duration String or {@code 0:00} if the duration is zero.
*/
public static String getDurationString(final long duration, final boolean isDurationComplete) {
final String output;
final long days = duration / (24 * 60 * 60L); /* greater than a day */
@ -270,7 +274,8 @@ public final class Localization {
} else {
output = String.format(Locale.US, "%d:%02d", minutes, seconds);
}
return output;
final String durationPostfix = isDurationComplete ? "" : "+";
return output + durationPostfix;
}
/**
@ -284,7 +289,8 @@ public final class Localization {
* @return duration in a human readable string.
*/
@NonNull
public static String localizeDuration(final Context context, final int durationInSecs) {
public static String localizeDuration(@NonNull final Context context,
final int durationInSecs) {
if (durationInSecs < 0) {
throw new IllegalArgumentException("duration can not be negative");
}
@ -307,70 +313,135 @@ public final class Localization {
}
}
/**
* Get the localized name of an audio track.
*
* <p>Examples of results returned by this method:</p>
* <ul>
* <li>English (original)</li>
* <li>English (descriptive)</li>
* <li>Spanish (dubbed)</li>
* </ul>
*
* @param context the context used to get the app language
* @param track an {@link AudioStream} of the track
* @return the localized name of the audio track
*/
public static String audioTrackName(@NonNull final Context context, final AudioStream track) {
final String name;
if (track.getAudioLocale() != null) {
name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context));
} else if (track.getAudioTrackName() != null) {
name = track.getAudioTrackName();
} else {
name = context.getString(R.string.unknown_audio_track);
}
if (track.getAudioTrackType() != null) {
final String trackType = audioTrackType(context, track.getAudioTrackType());
if (trackType != null) {
return context.getString(R.string.audio_track_name, name, trackType);
}
}
return name;
}
@Nullable
private static String audioTrackType(@NonNull final Context context,
final AudioTrackType trackType) {
switch (trackType) {
case ORIGINAL:
return context.getString(R.string.audio_track_type_original);
case DUBBED:
return context.getString(R.string.audio_track_type_dubbed);
case DESCRIPTIVE:
return context.getString(R.string.audio_track_type_descriptive);
}
return null;
}
/*//////////////////////////////////////////////////////////////////////////
// Pretty Time
//////////////////////////////////////////////////////////////////////////*/
public static void initPrettyTime(final PrettyTime time) {
public static void initPrettyTime(@NonNull final PrettyTime time) {
prettyTime = time;
// Do not use decades as YouTube doesn't either.
prettyTime.removeUnit(Decade.class);
}
public static PrettyTime resolvePrettyTime(final Context context) {
public static PrettyTime resolvePrettyTime(@NonNull final Context context) {
return new PrettyTime(getAppLocale(context));
}
public static String relativeTime(final OffsetDateTime offsetDateTime) {
public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) {
return prettyTime.formatUnrounded(offsetDateTime);
}
private static void changeAppLanguage(final Locale loc, final Resources res) {
final DisplayMetrics dm = res.getDisplayMetrics();
final Configuration conf = res.getConfiguration();
conf.setLocale(loc);
res.updateConfiguration(conf, dm);
}
public static Locale getAppLocale(final Context context) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String lang = prefs.getString(context.getString(R.string.app_language_key), "en");
final Locale loc;
if (lang.equals(context.getString(R.string.default_localization_key))) {
loc = Locale.getDefault();
} else if (lang.matches(".*-.*")) {
//to differentiate different versions of the language
//for example, pt (portuguese in Portugal) and pt-br (portuguese in Brazil)
final String[] localisation = lang.split("-");
lang = localisation[0];
final String country = localisation[1];
loc = new Locale(lang, country);
/**
* @param context the Android context; if {@code null} then even if in debug mode and the
* setting is enabled, {@code textual} will not be shown next to {@code parsed}
* @param parsed the textual date or time ago parsed by NewPipeExtractor, or {@code null} if
* the extractor could not parse it
* @param textual the original textual date or time ago string as provided by services
* @return {@link #relativeTime(OffsetDateTime)} is used if {@code parsed != null}, otherwise
* {@code textual} is returned. If in debug mode, {@code context != null},
* {@code parsed != null} and the relevant setting is enabled, {@code textual} will
* be appended to the returned string for debugging purposes.
*/
public static String relativeTimeOrTextual(@Nullable final Context context,
@Nullable final DateWrapper parsed,
final String textual) {
if (parsed == null) {
return textual;
} else if (DEBUG && context != null && PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.show_original_time_ago_key), false)) {
return relativeTime(parsed.offsetDateTime()) + " (" + textual + ")";
} else {
loc = new Locale(lang);
return relativeTime(parsed.offsetDateTime());
}
return loc;
}
public static void assureCorrectAppLanguage(final Context c) {
changeAppLanguage(getAppLocale(c), c.getResources());
final Resources res = c.getResources();
final DisplayMetrics dm = res.getDisplayMetrics();
final Configuration conf = res.getConfiguration();
conf.setLocale(getAppLocale(c));
res.updateConfiguration(conf, dm);
}
private static double round(final double value, final int places) {
return new BigDecimal(value).setScale(places, RoundingMode.HALF_UP).doubleValue();
}
private static Locale getLocaleFromPrefs(@NonNull final Context context,
@StringRes final int prefKey) {
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
final String defaultKey = context.getString(R.string.default_localization_key);
final String languageCode = sp.getString(context.getString(prefKey), defaultKey);
/**
* Workaround to match normalized captions like english to English or deutsch to Deutsch.
* @param list the list to search into
* @param toFind the string to look for
* @return whether the string was found or not
*/
public static boolean containsCaseInsensitive(final List<String> list, final String toFind) {
for (final String i : list) {
if (i.equalsIgnoreCase(toFind)) {
return true;
}
if (languageCode.equals(defaultKey)) {
return Locale.getDefault();
} else {
return Locale.forLanguageTag(languageCode);
}
return false;
}
private static double round(final double value) {
return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue();
}
private static String getQuantity(@NonNull final Context context,
@PluralsRes final int pluralId,
@StringRes final int zeroCaseStringId,
final long count,
final String formattedCount) {
if (count == 0) {
return context.getString(zeroCaseStringId);
}
// As we use the already formatted count
// is not the responsibility of this method handle long numbers
// (it probably will fall in the "other" category,
// or some language have some specific rule... then we have to change it)
final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE);
return context.getResources().getQuantityString(pluralId, safeCount, formattedCount);
}
}

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
import android.annotation.SuppressLint;
import android.app.Activity;
@ -17,6 +18,7 @@ import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
@ -29,10 +31,13 @@ import org.schabi.newpipe.RouterActivity;
import org.schabi.newpipe.about.AboutActivity;
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.download.DownloadActivity;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.DeliveryMethod;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
@ -40,6 +45,7 @@ import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.channel.ChannelFragment;
import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
import org.schabi.newpipe.fragments.list.kiosk.KioskFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment;
@ -49,10 +55,10 @@ import org.schabi.newpipe.local.history.StatisticsPlaylistFragment;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.local.subscription.SubscriptionFragment;
import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.PlayQueueActivity;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -60,7 +66,7 @@ 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 java.util.List;
public final class NavigationHelper {
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
@ -88,7 +94,7 @@ public final class NavigationHelper {
intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey);
}
}
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.VIDEO.ordinal());
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
return intent;
@ -153,15 +159,14 @@ public final class NavigationHelper {
public static void playOnPopupPlayer(final Context context,
final PlayQueue queue,
final boolean resumePlayback) {
if (!PermissionHelper.isPopupEnabled(context)) {
PermissionHelper.showPopupEnablementToast(context);
if (!PermissionHelper.isPopupEnabledElseAsk(context)) {
return;
}
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerIntent(context, MainPlayer.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.POPUP.ordinal());
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -171,8 +176,8 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
.show();
final Intent intent = getPlayerIntent(context, MainPlayer.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.AUDIO.ordinal());
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -180,18 +185,22 @@ public final class NavigationHelper {
public static void enqueueOnPlayer(final Context context,
final PlayQueue queue,
final PlayerType playerType) {
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueIntent(context, MainPlayer.class, queue);
if (playerType == PlayerType.POPUP && !PermissionHelper.isPopupEnabledElseAsk(context)) {
return;
}
intent.putExtra(Player.PLAYER_TYPE, playerType.ordinal());
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
public static void enqueueOnPlayer(final Context context, final PlayQueue queue) {
PlayerType playerType = PlayerHolder.getInstance().getType();
if (!PlayerHolder.getInstance().isPlayerOpen()) {
if (playerType == null) {
Log.e(TAG, "Enqueueing but no player is open; defaulting to background player");
playerType = MainPlayer.PlayerType.AUDIO;
playerType = PlayerType.AUDIO;
}
enqueueOnPlayer(context, queue, playerType);
@ -200,14 +209,14 @@ public final class NavigationHelper {
/* ENQUEUE NEXT */
public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) {
PlayerType playerType = PlayerHolder.getInstance().getType();
if (!PlayerHolder.getInstance().isPlayerOpen()) {
if (playerType == null) {
Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player");
playerType = MainPlayer.PlayerType.AUDIO;
playerType = PlayerType.AUDIO;
}
Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueNextIntent(context, MainPlayer.class, queue);
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue);
intent.putExtra(Player.PLAYER_TYPE, playerType.ordinal());
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -217,30 +226,47 @@ public final class NavigationHelper {
public static void playOnExternalAudioPlayer(@NonNull final Context context,
@NonNull final StreamInfo info) {
final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams());
if (index == -1) {
final List<AudioStream> audioStreams = info.getAudioStreams();
if (audioStreams == null || audioStreams.isEmpty()) {
Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show();
return;
}
final AudioStream audioStream = info.getAudioStreams().get(index);
final List<AudioStream> audioStreamsForExternalPlayers =
getUrlAndNonTorrentStreams(audioStreams);
if (audioStreamsForExternalPlayers.isEmpty()) {
Toast.makeText(context, R.string.no_audio_streams_available_for_external_players,
Toast.LENGTH_SHORT).show();
return;
}
final int index = ListHelper.getDefaultAudioFormat(context, audioStreamsForExternalPlayers);
final AudioStream audioStream = audioStreamsForExternalPlayers.get(index);
playOnExternalPlayer(context, info.getName(), info.getUploaderName(), audioStream);
}
public static void playOnExternalVideoPlayer(@NonNull final Context context,
public static void playOnExternalVideoPlayer(final Context context,
@NonNull final StreamInfo info) {
final ArrayList<VideoStream> videoStreamsList = new ArrayList<>(
ListHelper.getSortedStreamVideosList(context, info.getVideoStreams(), null, false,
false));
final int index = ListHelper.getDefaultResolutionIndex(context, videoStreamsList);
if (index == -1) {
final List<VideoStream> videoStreams = info.getVideoStreams();
if (videoStreams == null || videoStreams.isEmpty()) {
Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show();
return;
}
final VideoStream videoStream = videoStreamsList.get(index);
final List<VideoStream> videoStreamsForExternalPlayers =
ListHelper.getSortedStreamVideosList(context,
getUrlAndNonTorrentStreams(videoStreams), null, false, false);
if (videoStreamsForExternalPlayers.isEmpty()) {
Toast.makeText(context, R.string.no_video_streams_available_for_external_players,
Toast.LENGTH_SHORT).show();
return;
}
final int index = ListHelper.getDefaultResolutionIndex(context,
videoStreamsForExternalPlayers);
final VideoStream videoStream = videoStreamsForExternalPlayers.get(index);
playOnExternalPlayer(context, info.getName(), info.getUploaderName(), videoStream);
}
@ -248,9 +274,48 @@ public final class NavigationHelper {
@Nullable final String name,
@Nullable final String artist,
@NonNull final Stream stream) {
final DeliveryMethod deliveryMethod = stream.getDeliveryMethod();
final String mimeType;
if (!stream.isUrl() || deliveryMethod == DeliveryMethod.TORRENT) {
Toast.makeText(context, R.string.selected_stream_external_player_not_supported,
Toast.LENGTH_SHORT).show();
return;
}
switch (deliveryMethod) {
case PROGRESSIVE_HTTP:
if (stream.getFormat() == null) {
if (stream instanceof AudioStream) {
mimeType = "audio/*";
} else if (stream instanceof VideoStream) {
mimeType = "video/*";
} else {
// This should never be reached, because subtitles are not opened in
// external players
return;
}
} else {
mimeType = stream.getFormat().getMimeType();
}
break;
case HLS:
mimeType = "application/x-mpegURL";
break;
case DASH:
mimeType = "application/dash+xml";
break;
case SS:
mimeType = "application/vnd.ms-sstr+xml";
break;
default:
// Torrent streams are not exposed to external players
mimeType = "";
}
final Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.parse(stream.getUrl()), stream.getFormat().getMimeType());
intent.setDataAndType(Uri.parse(stream.getContent()), mimeType);
intent.putExtra(Intent.EXTRA_TITLE, name);
intent.putExtra("title", name);
intent.putExtra("artist", artist);
@ -261,17 +326,15 @@ public final class NavigationHelper {
public static void resolveActivityOrAskToInstall(@NonNull final Context context,
@NonNull final Intent intent) {
if (intent.resolveActivity(context.getPackageManager()) != null) {
ShareUtils.openIntentInApp(context, intent, false);
} else {
if (!ShareUtils.tryOpenIntentInApp(context, intent)) {
if (context instanceof Activity) {
new AlertDialog.Builder(context)
.setMessage(R.string.no_player_found)
.setPositiveButton(R.string.install,
(dialog, which) -> ShareUtils.openUrlInBrowser(context,
context.getString(R.string.fdroid_vlc_url), false))
.setNegativeButton(R.string.cancel, (dialog, which)
-> Log.i("NavigationHelper", "You unlocked a secret unicorn."))
.setPositiveButton(R.string.install, (dialog, which) ->
ShareUtils.installApp(context,
context.getString(R.string.vlc_package)))
.setNegativeButton(R.string.cancel, (dialog, which) ->
Log.i("NavigationHelper", "You unlocked a secret unicorn."))
.show();
} else {
Toast.makeText(context, R.string.no_player_found_toast, Toast.LENGTH_LONG).show();
@ -355,14 +418,14 @@ public final class NavigationHelper {
final boolean switchingPlayers) {
final boolean autoPlay;
@Nullable final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType();
if (!PlayerHolder.getInstance().isPlayerOpen()) {
@Nullable final PlayerType playerType = PlayerHolder.getInstance().getType();
if (playerType == null) {
// no player open
autoPlay = PlayerHelper.isAutoplayAllowedByUser(context);
} else if (switchingPlayers) {
// switching player to main player
autoPlay = PlayerHolder.getInstance().isPlaying(); // keep play/pause state
} else if (playerType == MainPlayer.PlayerType.VIDEO) {
} else if (playerType == PlayerType.MAIN) {
// opening new stream while already playing in main player
autoPlay = PlayerHelper.isAutoplayAllowedByUser(context);
} else {
@ -377,7 +440,7 @@ public final class NavigationHelper {
// Situation when user switches from players to main player. All needed data is
// here, we can start watching (assuming newQueue equals playQueue).
// Starting directly in fullscreen if the previous player type was popup.
detailFragment.openVideoPlayer(playerType == MainPlayer.PlayerType.POPUP
detailFragment.openVideoPlayer(playerType == PlayerType.POPUP
|| PlayerHelper.isStartMainPlayerFullscreenEnabled(context));
} else {
detailFragment.selectAndLoadVideo(serviceId, url, title, playQueue);
@ -418,6 +481,35 @@ public final class NavigationHelper {
item.getServiceId(), uploaderUrl, item.getUploaderName());
}
/**
* Opens the comment author channel fragment, if the {@link CommentsInfoItem#getUploaderUrl()}
* of {@code comment} is non-null. Shows a UI-error snackbar if something goes wrong.
*
* @param activity the activity with the fragment manager and in which to show the snackbar
* @param comment the comment whose uploader/author will be opened
*/
public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity activity,
@NonNull final CommentsInfoItem comment) {
if (isEmpty(comment.getUploaderUrl())) {
return;
}
try {
openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(),
comment.getUploaderUrl(), comment.getUploaderName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e);
}
}
public static void openCommentRepliesFragment(@NonNull final FragmentActivity activity,
@NonNull final CommentsInfoItem comment) {
defaultTransaction(activity.getSupportFragmentManager())
.replace(R.id.fragment_holder, new CommentRepliesFragment(comment),
CommentRepliesFragment.TAG)
.addToBackStack(CommentRepliesFragment.TAG)
.commit();
}
public static void openPlaylistFragment(final FragmentManager fragmentManager,
final int serviceId, final String url,
@NonNull final String name) {
@ -505,11 +597,8 @@ public final class NavigationHelper {
@Nullable final PlayQueue playQueue,
final boolean switchingPlayers) {
final Intent intent = getOpenIntent(context, url, serviceId,
StreamingService.LinkType.STREAM);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra(Constants.KEY_TITLE, title);
intent.putExtra(VideoDetailFragment.KEY_SWITCHING_PLAYERS, switchingPlayers);
final Intent intent = getStreamIntent(context, serviceId, url, title)
.putExtra(VideoDetailFragment.KEY_SWITCHING_PLAYERS, switchingPlayers);
if (playQueue != null) {
final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class);
@ -580,6 +669,11 @@ public final class NavigationHelper {
return intent;
}
public static void openPlayQueue(final Context context) {
final Intent intent = new Intent(context, PlayQueueActivity.class);
context.startActivity(intent);
}
/*//////////////////////////////////////////////////////////////////////////
// Link handling
//////////////////////////////////////////////////////////////////////////*/
@ -617,32 +711,13 @@ public final class NavigationHelper {
return getOpenIntent(context, url, serviceId, StreamingService.LinkType.CHANNEL);
}
/**
* Start an activity to install Kore.
*
* @param context the context
*/
public static void installKore(final Context context) {
installApp(context, context.getString(R.string.kore_package));
}
/**
* Start Kore app to show a video on Kodi.
* <p>
* For a list of supported urls see the
* <a href="https://github.com/xbmc/Kore/blob/master/app/src/main/AndroidManifest.xml">
* Kore source code
* </a>.
*
* @param context the context to use
* @param videoURL the url to the video
*/
public static void playWithKore(final Context context, final Uri videoURL) {
final Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setPackage(context.getString(R.string.kore_package));
intent.setData(videoURL);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
public static Intent getStreamIntent(final Context context,
final int serviceId,
final String url,
@Nullable final String title) {
return getOpenIntent(context, url, serviceId, StreamingService.LinkType.STREAM)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(Constants.KEY_TITLE, title);
}
/**

View file

@ -2,15 +2,14 @@ package org.schabi.newpipe.util;
import androidx.recyclerview.widget.RecyclerView;
public abstract class OnClickGesture<T> {
public interface OnClickGesture<T> {
void selected(T selectedItem);
public abstract void selected(T selectedItem);
public void held(final T selectedItem) {
default void held(final T selectedItem) {
// Optional gesture
}
public void drag(final T selectedItem, final RecyclerView.ViewHolder viewHolder) {
default void drag(final T selectedItem, final RecyclerView.ViewHolder viewHolder) {
// Optional gesture
}
}

View file

@ -17,7 +17,6 @@ import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class PeertubeHelper {
@ -29,7 +28,7 @@ public final class PeertubeHelper {
final String savedInstanceListKey = context.getString(R.string.peertube_instance_list_key);
final String savedJson = sharedPreferences.getString(savedInstanceListKey, null);
if (null == savedJson) {
return Collections.singletonList(getCurrentInstance());
return List.of(getCurrentInstance());
}
try {
@ -45,17 +44,16 @@ public final class PeertubeHelper {
}
return result;
} catch (final JsonParserException e) {
return Collections.singletonList(getCurrentInstance());
return List.of(getCurrentInstance());
}
}
public static PeertubeInstance selectInstance(final PeertubeInstance instance,
final Context context) {
final SharedPreferences sharedPreferences = PreferenceManager
.getDefaultSharedPreferences(context);
final String selectedInstanceKey
= context.getString(R.string.peertube_selected_instance_key);
final String selectedInstanceKey =
context.getString(R.string.peertube_selected_instance_key);
final JsonStringWriter jsonWriter = JsonWriter.string().object();
jsonWriter.value("name", instance.getName());
jsonWriter.value("url", instance.getUrl());

View file

@ -9,8 +9,6 @@ import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.view.Gravity;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.RequiresApi;
@ -21,6 +19,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.settings.NewPipeSettings;
public final class PermissionHelper {
public static final int POST_NOTIFICATIONS_REQUEST_CODE = 779;
public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778;
public static final int DOWNLOADS_REQUEST_CODE = 777;
@ -37,7 +36,6 @@ public final class PermissionHelper {
return checkWriteStoragePermissions(activity, requestCode);
}
@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
public static boolean checkReadStoragePermissions(final Activity activity,
final int requestCode) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE)
@ -72,8 +70,7 @@ public final class PermissionHelper {
// No explanation needed, we can request the permission.
ActivityCompat.requestPermissions(activity,
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
requestCode);
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode);
// PERMISSION_WRITE_STORAGE is an
// app-defined int constant. The callback method gets the
@ -84,6 +81,18 @@ public final class PermissionHelper {
return true;
}
public static boolean checkPostNotificationsPermission(final Activity activity,
final int requestCode) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& ContextCompat.checkSelfPermission(activity,
Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activity,
new String[] {Manifest.permission.POST_NOTIFICATIONS}, requestCode);
return false;
}
return true;
}
/**
* In order to be able to draw over other apps,
@ -117,18 +126,21 @@ public final class PermissionHelper {
}
}
public static boolean isPopupEnabled(final Context context) {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|| checkSystemAlertWindowPermission(context);
}
public static void showPopupEnablementToast(final Context context) {
final Toast toast
= Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG);
final TextView messageView = toast.getView().findViewById(android.R.id.message);
if (messageView != null) {
messageView.setGravity(Gravity.CENTER);
/**
* Determines whether the popup is enabled, and if it is not, starts the system activity to
* request the permission with {@link #checkSystemAlertWindowPermission(Context)} and shows a
* toast to the user explaining why the permission is needed.
*
* @param context the Android context
* @return whether the popup is enabled
*/
public static boolean isPopupEnabledElseAsk(final Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|| checkSystemAlertWindowPermission(context)) {
return true;
} else {
Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG).show();
return false;
}
toast.show();
}
}

View file

@ -0,0 +1,90 @@
package org.schabi.newpipe.util;
import android.content.Context;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.player.PlayerType;
/**
* Utility class for play buttons and their respective click listeners.
*/
public final class PlayButtonHelper {
private PlayButtonHelper() {
// utility class
}
/**
* Initialize {@link android.view.View.OnClickListener OnClickListener}
* and {@link android.view.View.OnLongClickListener OnLongClickListener} for playlist control
* buttons defined in {@link R.layout#playlist_control}.
*
* @param activity The activity to use for the {@link android.widget.Toast Toast}.
* @param playlistControlBinding The binding of the
* {@link R.layout#playlist_control playlist control layout}.
* @param fragment The fragment to get the play queue from.
*/
public static void initPlaylistControlClickListener(
@NonNull final AppCompatActivity activity,
@NonNull final PlaylistControlBinding playlistControlBinding,
@NonNull final PlaylistControlViewHolder fragment) {
// click listener
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> {
NavigationHelper.playOnMainPlayer(activity, fragment.getPlayQueue());
showHoldToAppendToastIfNeeded(activity);
});
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> {
NavigationHelper.playOnPopupPlayer(activity, fragment.getPlayQueue(), false);
showHoldToAppendToastIfNeeded(activity);
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> {
NavigationHelper.playOnBackgroundPlayer(activity, fragment.getPlayQueue(), false);
showHoldToAppendToastIfNeeded(activity);
});
// long click listener
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.AUDIO);
return true;
});
}
/**
* Show the "hold to append" toast if the corresponding preference is enabled.
*
* @param context The context to show the toast.
*/
private static void showHoldToAppendToastIfNeeded(@NonNull final Context context) {
if (shouldShowHoldToAppendTip(context)) {
Toast.makeText(context, R.string.hold_to_append, Toast.LENGTH_SHORT).show();
}
}
/**
* Check if the "hold to append" toast should be shown.
*
* <p>
* The tip is shown if the corresponding preference is enabled.
* This is the default behaviour.
* </p>
*
* @param context The context to get the preference.
* @return {@code true} if the tip should be shown, {@code false} otherwise.
*/
public static boolean shouldShowHoldToAppendTip(@NonNull final Context context) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.show_hold_to_append_key), true);
}
}

View file

@ -1,27 +0,0 @@
package org.schabi.newpipe.util;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class RelatedItemInfo extends ListInfo<InfoItem> {
public RelatedItemInfo(final int serviceId, final ListLinkHandler listUrlIdHandler,
final String name) {
super(serviceId, listUrlIdHandler, name);
}
public static RelatedItemInfo getInfo(final StreamInfo info) {
final ListLinkHandler handler = new ListLinkHandler(
info.getOriginalUrl(), info.getUrl(), info.getId(), Collections.emptyList(), null);
final RelatedItemInfo relatedItemInfo = new RelatedItemInfo(
info.getServiceId(), handler, info.getName());
final List<InfoItem> relatedItems = new ArrayList<>(info.getRelatedItems());
relatedItemInfo.setRelatedItems(relatedItems);
return relatedItemInfo;
}
}

View file

@ -1,101 +1,39 @@
package org.schabi.newpipe.util
import android.content.pm.PackageManager
import android.content.pm.Signature
import androidx.core.content.pm.PackageInfoCompat
import org.schabi.newpipe.App
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification
import org.schabi.newpipe.error.UserAction
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.cert.CertificateEncodingException
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.time.Instant
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
object ReleaseVersionUtil {
// Public key of the certificate that is used in NewPipe release versions
private const val RELEASE_CERT_PUBLIC_KEY_SHA1 =
"B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15"
private const val RELEASE_CERT_PUBLIC_KEY_SHA256 =
"cb84069bd68116bafae5ee4ee5b08a567aa6d898404e7cb12f9e756df5cf5cab"
@JvmStatic
fun isReleaseApk(): Boolean {
return certificateSHA1Fingerprint == RELEASE_CERT_PUBLIC_KEY_SHA1
}
/**
* Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133.
*
* @return String with the APK's SHA1 fingerprint in hexadecimal
*/
private val certificateSHA1Fingerprint: String
get() {
val app = App.getApp()
val signatures: List<Signature> = try {
PackageInfoCompat.getSignatures(app.packageManager, app.packageName)
} catch (e: PackageManager.NameNotFoundException) {
showRequestError(app, e, "Could not find package info")
return ""
}
if (signatures.isEmpty()) {
return ""
}
val x509cert = try {
val cert = signatures[0].toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
cf.generateCertificate(input) as X509Certificate
} catch (e: CertificateException) {
showRequestError(app, e, "Certificate error")
return ""
}
return try {
val md = MessageDigest.getInstance("SHA1")
val publicKey = md.digest(x509cert.encoded)
byte2HexFormatted(publicKey)
} catch (e: NoSuchAlgorithmException) {
showRequestError(app, e, "Could not retrieve SHA1 key")
""
} catch (e: CertificateEncodingException) {
showRequestError(app, e, "Could not retrieve SHA1 key")
""
}
}
private fun byte2HexFormatted(arr: ByteArray): String {
val str = StringBuilder(arr.size * 2)
for (i in arr.indices) {
var h = Integer.toHexString(arr[i].toInt())
val l = h.length
if (l == 1) {
h = "0$h"
}
if (l > 2) {
h = h.substring(l - 2, l)
}
str.append(h.uppercase())
if (i < arr.size - 1) {
str.append(':')
}
}
return str.toString()
}
private fun showRequestError(app: App, e: Exception, request: String) {
createNotification(
app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, request)
@OptIn(ExperimentalStdlibApi::class)
val isReleaseApk by lazy {
@Suppress("NewApi")
val certificates = mapOf(
RELEASE_CERT_PUBLIC_KEY_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256
)
val app = App.getApp()
try {
PackageInfoCompat.hasSignatures(app.packageManager, app.packageName, certificates, false)
} catch (e: PackageManager.NameNotFoundException) {
createNotification(
app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info")
)
false
}
}
fun isLastUpdateCheckExpired(expiry: Long): Boolean {
return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
return Instant.ofEpochSecond(expiry) < Instant.now()
}
/**
@ -104,13 +42,11 @@ object ReleaseVersionUtil {
* @return Epoch second of expiry date time
*/
fun coerceUpdateCheckExpiry(expiryString: String?): Long {
val now = ZonedDateTime.now()
return expiryString?.let {
var expiry =
ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString))
expiry = maxOf(expiry, now.plusHours(6))
expiry = minOf(expiry, now.plusHours(72))
expiry.toEpochSecond()
} ?: now.plusHours(6).toEpochSecond()
val nowPlus6Hours = ZonedDateTime.now().plusHours(6)
val expiry = expiryString?.let {
ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(it))
.coerceIn(nowPlus6Hours, nowPlus6Hours.plusHours(66))
} ?: nowPlus6Hours
return expiry.toEpochSecond()
}
}

View file

@ -1,20 +1,24 @@
package org.schabi.newpipe.util;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
import java.util.List;
public class SecondaryStreamHelper<T extends Stream> {
private final int position;
private final StreamSizeWrapper<T> streams;
private final StreamInfoWrapper<T> streams;
public SecondaryStreamHelper(final StreamSizeWrapper<T> streams, final T selectedStream) {
public SecondaryStreamHelper(@NonNull final StreamInfoWrapper<T> streams,
final T selectedStream) {
this.streams = streams;
this.position = streams.getStreamsList().indexOf(selectedStream);
if (this.position < 0) {
@ -23,43 +27,42 @@ public class SecondaryStreamHelper<T extends Stream> {
}
/**
* Find the correct audio stream for the desired video stream.
* Finds an audio stream compatible with the provided video-only stream, so that the two streams
* can be combined in a single file by the downloader. If there are multiple available audio
* streams, chooses either the highest or the lowest quality one based on
* {@link ListHelper#isLimitingDataUsage(Context)}.
*
* @param context Android context
* @param audioStreams list of audio streams
* @param videoStream desired video ONLY stream
* @return selected audio stream or null if a candidate was not found
* @param videoStream desired video-ONLY stream
* @return the selected audio stream or null if a candidate was not found
*/
public static AudioStream getAudioStreamFor(@NonNull final List<AudioStream> audioStreams,
@Nullable
public static AudioStream getAudioStreamFor(@NonNull final Context context,
@NonNull final List<AudioStream> audioStreams,
@NonNull final VideoStream videoStream) {
switch (videoStream.getFormat()) {
case WEBM:
case MPEG_4:// ¿is mpeg-4 DASH?
break;
default:
return null;
}
final MediaFormat mediaFormat = videoStream.getFormat();
final boolean m4v = videoStream.getFormat() == MediaFormat.MPEG_4;
if (mediaFormat == MediaFormat.WEBM) {
return audioStreams
.stream()
.filter(audioStream -> audioStream.getFormat() == MediaFormat.WEBMA
|| audioStream.getFormat() == MediaFormat.WEBMA_OPUS)
.max(ListHelper.getAudioFormatComparator(MediaFormat.WEBMA,
ListHelper.isLimitingDataUsage(context)))
.orElse(null);
for (final AudioStream audio : audioStreams) {
if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) {
return audio;
}
}
} else if (mediaFormat == MediaFormat.MPEG_4) {
return audioStreams
.stream()
.filter(audioStream -> audioStream.getFormat() == MediaFormat.M4A)
.max(ListHelper.getAudioFormatComparator(MediaFormat.M4A,
ListHelper.isLimitingDataUsage(context)))
.orElse(null);
if (m4v) {
} else {
return null;
}
// retry, but this time in reverse order
for (int i = audioStreams.size() - 1; i >= 0; i--) {
final AudioStream audio = audioStreams.get(i);
if (audio.getFormat() == MediaFormat.WEBMA_OPUS) {
return audio;
}
}
return null;
}
public T getStream() {

View file

@ -1,9 +1,13 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.preference.PreferenceManager;
@ -18,10 +22,9 @@ import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
public final class ServiceHelper {
private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube;
@ -31,17 +34,17 @@ public final class ServiceHelper {
public static int getIcon(final int serviceId) {
switch (serviceId) {
case 0:
return R.drawable.place_holder_youtube;
return R.drawable.ic_smart_display;
case 1:
return R.drawable.place_holder_cloud;
return R.drawable.ic_cloud;
case 2:
return R.drawable.place_holder_gadse;
return R.drawable.ic_placeholder_media_ccc;
case 3:
return R.drawable.place_holder_peertube;
return R.drawable.ic_placeholder_peertube;
case 4:
return R.drawable.place_holder_bandcamp;
return R.drawable.ic_placeholder_bandcamp;
default:
return R.drawable.place_holder_circle;
return R.drawable.ic_circle;
}
}
@ -113,18 +116,45 @@ public final class ServiceHelper {
}
public static int getSelectedServiceId(final Context context) {
return Optional.ofNullable(getSelectedService(context))
.orElse(DEFAULT_FALLBACK_SERVICE)
.getServiceId();
}
@Nullable
public static StreamingService getSelectedService(final Context context) {
final String serviceName = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.current_service_key),
context.getString(R.string.default_service_value));
int serviceId;
try {
serviceId = NewPipe.getService(serviceName).getServiceId();
return NewPipe.getService(serviceName);
} catch (final ExtractionException e) {
serviceId = DEFAULT_FALLBACK_SERVICE.getServiceId();
return null;
}
}
return serviceId;
@NonNull
public static String getNameOfServiceById(final int serviceId) {
return ServiceList.all().stream()
.filter(s -> s.getServiceId() == serviceId)
.findFirst()
.map(StreamingService::getServiceInfo)
.map(StreamingService.ServiceInfo::getName)
.orElse("<unknown>");
}
/**
* @param serviceId the id of the service
* @return the service corresponding to the provided id
* @throws java.util.NoSuchElementException if there is no service with the provided id
*/
@NonNull
public static StreamingService getServiceById(final int serviceId) {
return ServiceList.all().stream()
.filter(s -> s.getServiceId() == serviceId)
.findFirst()
.orElseThrow();
}
public static void setSelectedServiceId(final Context context, final int serviceId) {
@ -138,16 +168,6 @@ public final class ServiceHelper {
setSelectedServicePreferences(context, serviceName);
}
public static void setSelectedServiceId(final Context context, final String serviceName) {
final int serviceId = NewPipe.getIdOfService(serviceName);
if (serviceId == -1) {
setSelectedServicePreferences(context,
DEFAULT_FALLBACK_SERVICE.getServiceInfo().getName());
} else {
setSelectedServicePreferences(context, serviceName);
}
}
private static void setSelectedServicePreferences(final Context context,
final String serviceName) {
PreferenceManager.getDefaultSharedPreferences(context).edit().
@ -162,15 +182,6 @@ public final class ServiceHelper {
}
}
public static boolean isBeta(final StreamingService s) {
switch (s.getServiceInfo().getName()) {
case "YouTube":
return false;
default:
return true;
}
}
public static void initService(final Context context, final int serviceId) {
if (serviceId == ServiceList.PeerTube.getServiceId()) {
final SharedPreferences sharedPreferences = PreferenceManager

View file

@ -1,7 +1,5 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM;
import static org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import android.content.Context;
@ -49,8 +47,8 @@ public final class SparseItemUtil {
public static void fetchItemInfoIfSparse(@NonNull final Context context,
@NonNull final StreamInfoItem item,
@NonNull final Consumer<SinglePlayQueue> callback) {
if (((item.getStreamType() == LIVE_STREAM || item.getStreamType() == AUDIO_LIVE_STREAM)
|| item.getDuration() >= 0) && !isNullOrEmpty(item.getUploaderUrl())) {
if ((StreamTypeUtil.isLiveStream(item.getStreamType()) || item.getDuration() >= 0)
&& !isNullOrEmpty(item.getUploaderUrl())) {
// if the duration is >= 0 (provided that the item is not a livestream) and there is an
// uploader url, probably all info is already there, so there is no need to fetch it
callback.accept(new SinglePlayQueue(item));
@ -99,10 +97,10 @@ public final class SparseItemUtil {
* @param url url of the stream to load
* @param callback callback to be called with the result
*/
private static void fetchStreamInfoAndSaveToDatabase(@NonNull final Context context,
final int serviceId,
@NonNull final String url,
final Consumer<StreamInfo> callback) {
public static void fetchStreamInfoAndSaveToDatabase(@NonNull final Context context,
final int serviceId,
@NonNull final String url,
final Consumer<StreamInfo> callback) {
Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show();
ExtractorHelper.getStreamInfo(serviceId, url, false)
.subscribeOn(Schedulers.io())

View file

@ -27,6 +27,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.BundleCompat;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.MainActivity;
@ -46,8 +47,8 @@ import java.util.concurrent.ConcurrentHashMap;
*/
public final class StateSaver {
public static final String KEY_SAVED_STATE = "key_saved_state";
private static final ConcurrentHashMap<String, Queue<Object>> STATE_OBJECTS_HOLDER
= new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, Queue<Object>> STATE_OBJECTS_HOLDER =
new ConcurrentHashMap<>();
private static final String TAG = "StateSaver";
private static final String CACHE_DIR_NAME = "state_cache";
private static String cacheDirPath;
@ -82,7 +83,8 @@ public final class StateSaver {
return null;
}
final SavedState savedState = outState.getParcelable(KEY_SAVED_STATE);
final SavedState savedState = BundleCompat.getParcelable(
outState, KEY_SAVED_STATE, SavedState.class);
if (savedState == null) {
return null;
}
@ -107,8 +109,8 @@ public final class StateSaver {
}
try {
Queue<Object> savedObjects
= STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved());
Queue<Object> savedObjects =
STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved());
if (savedObjects != null) {
writeRead.readFrom(savedObjects);
if (MainActivity.DEBUG) {
@ -309,7 +311,7 @@ public final class StateSaver {
}
/**
* Used for describe how to save/read the objects.
* Used for describing how to save/read the objects.
* <p>
* Queue was chosen by its FIFO property.
*/

View file

@ -1,7 +1,8 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import android.content.Context;
import android.util.SparseArray;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -10,19 +11,27 @@ import android.widget.ImageView;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.collection.SparseArrayCompat;
import org.schabi.newpipe.DownloaderImpl;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.downloader.Response;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.extractor.utils.Utils;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
@ -37,10 +46,10 @@ import us.shandian.giga.util.Utility;
* @param <U> the secondary stream type's class extending {@link Stream}
*/
public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseAdapter {
private final Context context;
private final StreamSizeWrapper<T> streamsWrapper;
private final SparseArray<SecondaryStreamHelper<U>> secondaryStreams;
@NonNull
private final StreamInfoWrapper<T> streamsWrapper;
@NonNull
private final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams;
/**
* Indicates that at least one of the primary streams is an instance of {@link VideoStream},
@ -49,9 +58,10 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
*/
private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream;
public StreamItemAdapter(final Context context, final StreamSizeWrapper<T> streamsWrapper,
final SparseArray<SecondaryStreamHelper<U>> secondaryStreams) {
this.context = context;
public StreamItemAdapter(
@NonNull final StreamInfoWrapper<T> streamsWrapper,
@NonNull final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams
) {
this.streamsWrapper = streamsWrapper;
this.secondaryStreams = secondaryStreams;
@ -59,15 +69,15 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
checkHasAnyVideoOnlyStreamWithNoSecondaryStream();
}
public StreamItemAdapter(final Context context, final StreamSizeWrapper<T> streamsWrapper) {
this(context, streamsWrapper, null);
public StreamItemAdapter(final StreamInfoWrapper<T> streamsWrapper) {
this(streamsWrapper, new SparseArrayCompat<>(0));
}
public List<T> getAll() {
return streamsWrapper.getStreamsList();
}
public SparseArray<SecondaryStreamHelper<U>> getAllSecondary() {
public SparseArrayCompat<SecondaryStreamHelper<U>> getAllSecondary() {
return secondaryStreams;
}
@ -87,7 +97,8 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
}
@Override
public View getDropDownView(final int position, final View convertView,
public View getDropDownView(final int position,
final View convertView,
final ViewGroup parent) {
return getCustomView(position, convertView, parent, true);
}
@ -98,8 +109,12 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
convertView, parent, false);
}
private View getCustomView(final int position, final View view, final ViewGroup parent,
@NonNull
private View getCustomView(final int position,
final View view,
final ViewGroup parent,
final boolean isDropdownItem) {
final var context = parent.getContext();
View convertView = view;
if (convertView == null) {
convertView = LayoutInflater.from(context).inflate(
@ -112,6 +127,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
final TextView sizeView = convertView.findViewById(R.id.stream_size);
final T stream = getItem(position);
final MediaFormat mediaFormat = streamsWrapper.getFormat(position);
int woSoundIconVisibility = View.GONE;
String qualityString;
@ -122,7 +138,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
if (hasAnyVideoOnlyStreamWithNoSecondaryStream) {
if (videoStream.isVideoOnly()) {
woSoundIconVisibility = hasSecondaryStream(position)
woSoundIconVisibility = secondaryStreams.get(position) != null
// It has a secondary stream associated with it, so check if it's a
// dropdown view so it doesn't look out of place (missing margin)
// compared to those that don't.
@ -135,24 +151,29 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
}
} else if (stream instanceof AudioStream) {
final AudioStream audioStream = ((AudioStream) stream);
qualityString = audioStream.getAverageBitrate() > 0
? audioStream.getAverageBitrate() + "kbps"
: audioStream.getFormat().getName();
if (audioStream.getAverageBitrate() > 0) {
qualityString = audioStream.getAverageBitrate() + "kbps";
} else {
qualityString = context.getString(R.string.unknown_quality);
}
} else if (stream instanceof SubtitlesStream) {
qualityString = ((SubtitlesStream) stream).getDisplayLanguageName();
if (((SubtitlesStream) stream).isAutoGenerated()) {
qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")";
}
} else {
qualityString = stream.getFormat().getSuffix();
if (mediaFormat == null) {
qualityString = context.getString(R.string.unknown_quality);
} else {
qualityString = mediaFormat.getSuffix();
}
}
if (streamsWrapper.getSizeInBytes(position) > 0) {
final SecondaryStreamHelper<U> secondary = secondaryStreams == null ? null
: secondaryStreams.get(position);
final var secondary = secondaryStreams.get(position);
if (secondary != null) {
final long size
= secondary.getSizeInBytes() + streamsWrapper.getSizeInBytes(position);
final long size = secondary.getSizeInBytes()
+ streamsWrapper.getSizeInBytes(position);
sizeView.setText(Utility.formatBytes(size));
} else {
sizeView.setText(streamsWrapper.getFormattedSize(position));
@ -164,11 +185,15 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
if (stream instanceof SubtitlesStream) {
formatNameView.setText(((SubtitlesStream) stream).getLanguageTag());
} else if (stream.getFormat() == MediaFormat.WEBMA_OPUS) {
// noinspection AndroidLintSetTextI18n
formatNameView.setText("opus");
} else {
formatNameView.setText(stream.getFormat().getName());
if (mediaFormat == null) {
formatNameView.setText(context.getString(R.string.unknown_format));
} else if (mediaFormat == MediaFormat.WEBMA_OPUS) {
// noinspection AndroidLintSetTextI18n
formatNameView.setText("opus");
} else {
formatNameView.setText(mediaFormat.getName());
}
}
qualityView.setText(qualityString);
@ -177,14 +202,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
return convertView;
}
/**
* @param position which primary stream to check.
* @return whether the primary stream at position has a secondary stream associated with it.
*/
private boolean hasSecondaryStream(final int position) {
return secondaryStreams != null && secondaryStreams.get(position) != null;
}
/**
* @return if there are any video-only streams with no secondary stream associated with them.
* @see #hasAnyVideoOnlyStreamWithNoSecondaryStream
@ -194,7 +211,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
final T stream = streamsWrapper.getStreamsList().get(i);
if (stream instanceof VideoStream) {
final boolean videoOnly = ((VideoStream) stream).isVideoOnly();
if (videoOnly && !hasSecondaryStream(i)) {
if (videoOnly && secondaryStreams.get(i) == null) {
return true;
}
}
@ -208,44 +225,58 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
*
* @param <T> the stream type's class extending {@link Stream}
*/
public static class StreamSizeWrapper<T extends Stream> implements Serializable {
private static final StreamSizeWrapper<Stream> EMPTY = new StreamSizeWrapper<>(
Collections.emptyList(), null);
public static class StreamInfoWrapper<T extends Stream> implements Serializable {
private static final StreamInfoWrapper<Stream> EMPTY =
new StreamInfoWrapper<>(Collections.emptyList(), null);
private static final int SIZE_UNSET = -2;
private final List<T> streamsList;
private final long[] streamSizes;
private final MediaFormat[] streamFormats;
private final String unknownSize;
public StreamSizeWrapper(final List<T> sL, final Context context) {
this.streamsList = sL != null
? sL
: Collections.emptyList();
public StreamInfoWrapper(@NonNull final List<T> streamList,
@Nullable final Context context) {
this.streamsList = streamList;
this.streamSizes = new long[streamsList.size()];
this.unknownSize = context == null
? "--.-" : context.getString(R.string.unknown_content);
Arrays.fill(streamSizes, -2);
this.streamFormats = new MediaFormat[streamsList.size()];
resetInfo();
}
/**
* Helper method to fetch the sizes of all the streams in a wrapper.
* Helper method to fetch the sizes and missing media formats
* of all the streams in a wrapper.
*
* @param <X> the stream type's class extending {@link Stream}
* @param streamsWrapper the wrapper
* @return a {@link Single} that returns a boolean indicating if any elements were changed
*/
public static <X extends Stream> Single<Boolean> fetchSizeForWrapper(
final StreamSizeWrapper<X> streamsWrapper) {
@NonNull
public static <X extends Stream> Single<Boolean> fetchMoreInfoForWrapper(
final StreamInfoWrapper<X> streamsWrapper) {
final Callable<Boolean> fetchAndSet = () -> {
boolean hasChanged = false;
for (final X stream : streamsWrapper.getStreamsList()) {
if (streamsWrapper.getSizeInBytes(stream) > -2) {
final boolean changeSize = streamsWrapper.getSizeInBytes(stream) <= SIZE_UNSET;
final boolean changeFormat = stream.getFormat() == null;
if (!changeSize && !changeFormat) {
continue;
}
final long contentLength = DownloaderImpl.getInstance().getContentLength(
stream.getUrl());
streamsWrapper.setSize(stream, contentLength);
hasChanged = true;
final Response response = DownloaderImpl.getInstance()
.head(stream.getContent());
if (changeSize) {
final String contentLength = response.getHeader("Content-Length");
if (!isNullOrEmpty(contentLength)) {
streamsWrapper.setSize(stream, Long.parseLong(contentLength));
hasChanged = true;
}
}
if (changeFormat) {
hasChanged = retrieveMediaFormat(stream, streamsWrapper, response)
|| hasChanged;
}
}
return hasChanged;
};
@ -256,9 +287,149 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
.onErrorReturnItem(true);
}
public static <X extends Stream> StreamSizeWrapper<X> empty() {
/**
* Try to retrieve the {@link MediaFormat} for a stream from the request headers.
*
* @param <X> the stream type to get the {@link MediaFormat} for
* @param stream the stream to find the {@link MediaFormat} for
* @param streamsWrapper the wrapper to store the found {@link MediaFormat} in
* @param response the response of the head request for the given stream
* @return {@code true} if the media format could be retrieved; {@code false} otherwise
*/
@VisibleForTesting
public static <X extends Stream> boolean retrieveMediaFormat(
@NonNull final X stream,
@NonNull final StreamInfoWrapper<X> streamsWrapper,
@NonNull final Response response) {
return retrieveMediaFormatFromFileTypeHeaders(stream, streamsWrapper, response)
|| retrieveMediaFormatFromContentDispositionHeader(
stream, streamsWrapper, response)
|| retrieveMediaFormatFromContentTypeHeader(stream, streamsWrapper, response);
}
@VisibleForTesting
public static <X extends Stream> boolean retrieveMediaFormatFromFileTypeHeaders(
@NonNull final X stream,
@NonNull final StreamInfoWrapper<X> streamsWrapper,
@NonNull final Response response) {
// try to use additional headers from CDNs or servers,
// e.g. x-amz-meta-file-type (e.g. for SoundCloud)
final List<String> keys = response.responseHeaders().keySet().stream()
.filter(k -> k.endsWith("file-type")).collect(Collectors.toList());
if (!keys.isEmpty()) {
for (final String key : keys) {
final String suffix = response.getHeader(key);
final MediaFormat format = MediaFormat.getFromSuffix(suffix);
if (format != null) {
streamsWrapper.setFormat(stream, format);
return true;
}
}
}
return false;
}
/**
* <p>Retrieve a {@link MediaFormat} from a HTTP Content-Disposition header
* for a stream and store the info in a wrapper.</p>
* @see
* <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition">
* mdn Web Docs for the HTTP Content-Disposition Header</a>
* @param stream the stream to get the {@link MediaFormat} for
* @param streamsWrapper the wrapper to store the {@link MediaFormat} in
* @param response the response to get the Content-Disposition header from
* @return {@code true} if the {@link MediaFormat} could be retrieved from the response;
* otherwise {@code false}
* @param <X>
*/
@VisibleForTesting
public static <X extends Stream> boolean retrieveMediaFormatFromContentDispositionHeader(
@NonNull final X stream,
@NonNull final StreamInfoWrapper<X> streamsWrapper,
@NonNull final Response response) {
// parse the Content-Disposition header,
// see
// there can be two filename directives
String contentDisposition = response.getHeader("Content-Disposition");
if (contentDisposition == null) {
return false;
}
try {
contentDisposition = Utils.decodeUrlUtf8(contentDisposition);
final String[] parts = contentDisposition.split(";");
for (String part : parts) {
final String fileName;
part = part.trim();
// extract the filename
if (part.startsWith("filename=")) {
// remove directive and decode
fileName = Utils.decodeUrlUtf8(part.substring(9));
} else if (part.startsWith("filename*=")) {
fileName = Utils.decodeUrlUtf8(part.substring(10));
} else {
continue;
}
// extract the file extension / suffix
final String[] p = fileName.split("\\.");
String suffix = p[p.length - 1];
if (suffix.endsWith("\"") || suffix.endsWith("'")) {
// remove trailing quotes if present, end index is exclusive
suffix = suffix.substring(0, suffix.length() - 1);
}
// get the corresponding media format
final MediaFormat format = MediaFormat.getFromSuffix(suffix);
if (format != null) {
streamsWrapper.setFormat(stream, format);
return true;
}
}
} catch (final Exception ignored) {
// fail silently
}
return false;
}
@VisibleForTesting
public static <X extends Stream> boolean retrieveMediaFormatFromContentTypeHeader(
@NonNull final X stream,
@NonNull final StreamInfoWrapper<X> streamsWrapper,
@NonNull final Response response) {
// try to get the format by content type
// some mime types are not unique for every format, those are omitted
final String contentTypeHeader = response.getHeader("Content-Type");
if (contentTypeHeader == null) {
return false;
}
@Nullable MediaFormat foundFormat = null;
for (final MediaFormat format : MediaFormat.getAllFromMimeType(contentTypeHeader)) {
if (foundFormat == null) {
foundFormat = format;
} else if (foundFormat.id != format.id) {
return false;
}
}
if (foundFormat != null) {
streamsWrapper.setFormat(stream, foundFormat);
return true;
}
return false;
}
public void resetInfo() {
Arrays.fill(streamSizes, SIZE_UNSET);
for (int i = 0; i < streamsList.size(); i++) {
streamFormats[i] = streamsList.get(i) == null // test for invalid streams
? null : streamsList.get(i).getFormat();
}
}
public static <X extends Stream> StreamInfoWrapper<X> empty() {
//noinspection unchecked
return (StreamSizeWrapper<X>) EMPTY;
return (StreamInfoWrapper<X>) EMPTY;
}
public List<T> getStreamsList() {
@ -277,10 +448,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
return formatSize(getSizeInBytes(streamIndex));
}
public String getFormattedSize(final T stream) {
return formatSize(getSizeInBytes(stream));
}
private String formatSize(final long size) {
if (size > -1) {
return Utility.formatBytes(size);
@ -288,12 +455,16 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
return unknownSize;
}
public void setSize(final int streamIndex, final long sizeInBytes) {
streamSizes[streamIndex] = sizeInBytes;
}
public void setSize(final T stream, final long sizeInBytes) {
streamSizes[streamsList.indexOf(stream)] = sizeInBytes;
}
public MediaFormat getFormat(final int streamIndex) {
return streamFormats[streamIndex];
}
public void setFormat(final T stream, final MediaFormat format) {
streamFormats[streamsList.indexOf(stream)] = format;
}
}
}

View file

@ -3,7 +3,7 @@ package org.schabi.newpipe.util;
import org.schabi.newpipe.extractor.stream.StreamType;
/**
* Utility class for {@link org.schabi.newpipe.extractor.stream.StreamType}.
* Utility class for {@link StreamType}.
*/
public final class StreamTypeUtil {
private StreamTypeUtil() {
@ -11,11 +11,37 @@ public final class StreamTypeUtil {
}
/**
* Checks if the streamType is a livestream.
* Check if the {@link StreamType} of a stream is a livestream.
*
* @param streamType
* @return <code>true</code> when the streamType is a
* {@link StreamType#LIVE_STREAM} or {@link StreamType#AUDIO_LIVE_STREAM}
* @param streamType the stream type of the stream
* @return whether the stream type is {@link StreamType#AUDIO_STREAM},
* {@link StreamType#AUDIO_LIVE_STREAM} or {@link StreamType#POST_LIVE_AUDIO_STREAM}
*/
public static boolean isAudio(final StreamType streamType) {
return streamType == StreamType.AUDIO_STREAM
|| streamType == StreamType.AUDIO_LIVE_STREAM
|| streamType == StreamType.POST_LIVE_AUDIO_STREAM;
}
/**
* Check if the {@link StreamType} of a stream is a livestream.
*
* @param streamType the stream type of the stream
* @return whether the stream type is {@link StreamType#VIDEO_STREAM},
* {@link StreamType#LIVE_STREAM} or {@link StreamType#POST_LIVE_STREAM}
*/
public static boolean isVideo(final StreamType streamType) {
return streamType == StreamType.VIDEO_STREAM
|| streamType == StreamType.LIVE_STREAM
|| streamType == StreamType.POST_LIVE_STREAM;
}
/**
* Check if the {@link StreamType} of a stream is a livestream.
*
* @param streamType the stream type of the stream
* @return whether the stream type is {@link StreamType#LIVE_STREAM} or
* {@link StreamType#AUDIO_LIVE_STREAM}
*/
public static boolean isLiveStream(final StreamType streamType) {
return streamType == StreamType.LIVE_STREAM

View file

@ -1,104 +0,0 @@
package org.schabi.newpipe.util;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import android.util.Log;
/**
* This is an extension of the SSLSocketFactory which enables TLS 1.2 and 1.1.
* Created for usage on Android 4.1-4.4 devices, which haven't enabled those by default.
*/
public class TLSSocketFactoryCompat extends SSLSocketFactory {
private static final String TAG = "TLSSocketFactoryCom";
private static TLSSocketFactoryCompat instance = null;
private final SSLSocketFactory internalSSLSocketFactory;
public TLSSocketFactoryCompat() throws KeyManagementException, NoSuchAlgorithmException {
final SSLContext context = SSLContext.getInstance("TLS");
context.init(null, null, null);
internalSSLSocketFactory = context.getSocketFactory();
}
public static TLSSocketFactoryCompat getInstance()
throws NoSuchAlgorithmException, KeyManagementException {
if (instance != null) {
return instance;
}
instance = new TLSSocketFactoryCompat();
return instance;
}
public static void setAsDefault() {
try {
HttpsURLConnection.setDefaultSSLSocketFactory(getInstance());
} catch (NoSuchAlgorithmException | KeyManagementException e) {
Log.e(TAG, "Unable to setAsDefault", e);
}
}
@Override
public String[] getDefaultCipherSuites() {
return internalSSLSocketFactory.getDefaultCipherSuites();
}
@Override
public String[] getSupportedCipherSuites() {
return internalSSLSocketFactory.getSupportedCipherSuites();
}
@Override
public Socket createSocket() throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket());
}
@Override
public Socket createSocket(final Socket s, final String host, final int port,
final boolean autoClose) throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose));
}
@Override
public Socket createSocket(final String host, final int port) throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port));
}
@Override
public Socket createSocket(final String host, final int port, final InetAddress localHost,
final int localPort) throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(
host, port, localHost, localPort));
}
@Override
public Socket createSocket(final InetAddress host, final int port) throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port));
}
@Override
public Socket createSocket(final InetAddress address, final int port,
final InetAddress localAddress, final int localPort)
throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(
address, port, localAddress, localPort));
}
private Socket enableTLSOnSocket(final Socket socket) {
if (socket instanceof SSLSocket) {
((SSLSocket) socket).setEnabledProtocols(new String[]{"TLSv1.1", "TLSv1.2"});
}
return socket;
}
}

View file

@ -23,14 +23,17 @@ import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.util.TypedValue;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
@ -38,6 +41,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.info_list.ItemViewMode;
public final class ThemeHelper {
private ThemeHelper() {
@ -227,6 +231,36 @@ public final class ThemeHelper {
return value.data;
}
/**
* Resolves a {@link Drawable} by it's id.
*
* @param context Context
* @param attrResId Resource id
* @return the {@link Drawable}
*/
public static Drawable resolveDrawable(@NonNull final Context context,
@AttrRes final int attrResId) {
final TypedValue typedValue = new TypedValue();
context.getTheme().resolveAttribute(attrResId, typedValue, true);
return AppCompatResources.getDrawable(context, typedValue.resourceId);
}
/**
* Gets a runtime dimen from the {@code android} package. Should be used for dimens for which
* normal accessing with {@code R.dimen.} is not available.
*
* @param context context
* @param name dimen resource name (e.g. navigation_bar_height)
* @return the obtained dimension, in pixels, or 0 if the resource could not be resolved
*/
public static int getAndroidDimenPx(@NonNull final Context context, final String name) {
final int resId = context.getResources().getIdentifier(name, "dimen", "android");
if (resId <= 0) {
return 0;
}
return context.getResources().getDimensionPixelSize(resId);
}
private static String getSelectedThemeKey(final Context context) {
final String themeKey = context.getString(R.string.theme_key);
final String defaultTheme = context.getResources().getString(R.string.default_theme_value);
@ -299,7 +333,6 @@ public final class ThemeHelper {
}
}
/**
* Returns whether the grid layout or the list layout should be used. If the user set "auto"
* mode in settings, decides based on screen orientation (landscape) and size.
@ -308,19 +341,8 @@ public final class ThemeHelper {
* @return true:use grid layout, false:use list layout
*/
public static boolean shouldUseGridLayout(final Context context) {
final String listMode = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.list_view_mode_key),
context.getString(R.string.list_view_mode_value));
if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) {
return false;
} else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) {
return true;
} else {
final Configuration configuration = context.getResources().getConfiguration();
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
}
final ItemViewMode mode = getItemViewMode(context);
return mode == ItemViewMode.GRID;
}
/**
@ -334,6 +356,36 @@ public final class ThemeHelper {
context.getResources().getDimensionPixelSize(R.dimen.channel_item_grid_min_width));
}
/**
* Returns item view mode.
* @param context to read preference and parse string
* @return Returns one of ItemViewMode
*/
public static ItemViewMode getItemViewMode(final Context context) {
final String listMode = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.list_view_mode_key),
context.getString(R.string.list_view_mode_value));
final ItemViewMode result;
if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) {
result = ItemViewMode.LIST;
} else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) {
result = ItemViewMode.GRID;
} else if (listMode.equals(context.getString(R.string.list_view_mode_card_key))) {
result = ItemViewMode.CARD;
} else {
// Auto mode - evaluate whether to use Grid based on screen real estate.
final Configuration configuration = context.getResources().getConfiguration();
final boolean useGrid = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
if (useGrid) {
result = ItemViewMode.GRID;
} else {
result = ItemViewMode.LIST;
}
}
return result;
}
/**
* Calculates the number of grid stream info items that can fit horizontally on the screen. The
* width of a grid stream info item is obtained from the thumbnail width plus the right and left

View file

@ -1,6 +1,11 @@
package org.schabi.newpipe.util.external_communication;
import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp;
import static org.schabi.newpipe.util.external_communication.ShareUtils.tryOpenIntentInApp;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
@ -8,7 +13,6 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.util.NavigationHelper;
/**
* Util class that provides methods which are related to the Kodi Media Center and its Kore app.
@ -29,13 +33,40 @@ public final class KoreUtils {
.getBoolean(context.getString(R.string.show_play_with_kodi_key), false);
}
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) ->
NavigationHelper.installKore(context))
.setNegativeButton(R.string.cancel, (dialog, which) -> {
});
builder.create().show();
/**
* Start an activity to install Kore.
*
* @param context the context to use
*/
public static void installKore(final Context context) {
installApp(context, context.getString(R.string.kore_package));
}
/**
* Start Kore app to show a video on Kodi, and if the app is not installed ask the user to
* install it.
* <p>
* For a list of supported urls see the
* <a href="https://github.com/xbmc/Kore/blob/master/app/src/main/AndroidManifest.xml">
* Kore source code
* </a>.
*
* @param context the context to use
* @param streamUrl the url to the stream to play
*/
public static void playWithKore(final Context context, final Uri streamUrl) {
final Intent intent = new Intent(Intent.ACTION_VIEW)
.setPackage(context.getString(R.string.kore_package))
.setData(streamUrl)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (!tryOpenIntentInApp(context, intent)) {
new AlertDialog.Builder(context)
.setMessage(R.string.kore_not_found)
.setPositiveButton(R.string.install, (dialog, which) ->
installKore(context))
.setNegativeButton(R.string.cancel, null)
.show();
}
}
}

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe.util.external_communication;
import static org.schabi.newpipe.MainActivity.DEBUG;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
@ -7,17 +9,31 @@ import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import java.io.File;
import java.io.FileOutputStream;
import java.util.List;
public final class ShareUtils {
private static final String TAG = ShareUtils.class.getSimpleName();
private ShareUtils() {
}
@ -28,125 +44,128 @@ public final class ShareUtils {
* 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.
* <p>
* 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.
* It uses {@link #openIntentInApp(Context, Intent)} to open market scheme and {@link
* #openUrlInBrowser(Context, String)} to open Google Play Store web URL.
*
* @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,
final Intent marketSchemeIntent = 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.
* <p>
* 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 (!tryOpenIntentInApp(context, marketSchemeIntent)) {
// Fall back to Google Play Store Web URL (F-Droid can handle it)
openUrlInApp(context, "https://play.google.com/store/apps/details?id=" + packageId);
}
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.
* Open the url with the system default browser. If no browser is set as default, falls back to
* {@link #openAppChooser(Context, Intent, boolean)}.
* <p>
* If no browser is set as default, fallbacks to
* {@link #openAppChooser(Context, Intent, boolean)}
* This function selects the package to open based on which apps respond to the {@code http://}
* schema alone, which should exclude special non-browser apps that are can handle the url (e.g.
* the official YouTube app).
* <p>
* This calls {@link #openUrlInBrowser(Context, String, boolean)} with true
* for the boolean parameter
* Therefore <b>please prefer {@link #openUrlInApp(Context, String)}</b>, that handles package
* resolution in a standard way, unless this is the action of an explicit "Open in browser"
* button.
*
* @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);
public static void openUrlInBrowser(@NonNull final Context context, final String url) {
// Resolve using a generic http://, so we are sure to get a browser and not e.g. the yt app.
// Note that this requires the `http` schema to be added to `<queries>` in the manifest.
final ResolveInfo defaultBrowserInfo;
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://"));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY));
} else {
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
PackageManager.MATCH_DEFAULT_ONLY);
}
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (defaultBrowserInfo == null) {
// No app installed to open a web URL, but it may be handled by other apps so try
// opening a system chooser for the link in this case (it could be bypassed by the
// system if there is only one app which can open the link or a default app associated
// with the link domain on Android 12 and higher)
openAppChooser(context, intent, true);
return;
}
final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName;
if (defaultBrowserPackage.equals("android")) {
// No browser set as default (doesn't work on some devices)
openAppChooser(context, intent, true);
} else {
try {
intent.setPackage(defaultBrowserPackage);
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);
}
}
}
/**
* Open a url with the system default app using {@link Intent#ACTION_VIEW}, showing a toast in
* case of failure.
*
* @param context the context to use
* @param url the url to open
*/
public static void openUrlInApp(@NonNull final Context context, final String url) {
openIntentInApp(context, new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
}
/**
* Open an intent with the system default app.
* <p>
* The intent can be of every type, excepted a web intent for which
* {@link #openUrlInBrowser(Context, String, boolean)} should be used.
* <p>
* If no app can open the intent, a toast with the message {@code No app on your device can
* open this} is shown.
* Use {@link #openIntentInApp(Context, Intent)} to show a toast in case of failure.
*
* @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
* @param context the context to use
* @param intent the intent to open
* @return true if the intent could be opened successfully, false otherwise
*/
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 {
public static boolean tryOpenIntentInApp(@NonNull final Context context,
@NonNull final Intent intent) {
try {
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
return false;
}
return true;
}
/**
* Open an intent with the system default app, showing a toast in case of failure.
* <p>
* Use {@link #tryOpenIntentInApp(Context, Intent)} if you don't want the toast. Use {@link
* #openUrlInApp(Context, String)} as a shorthand for {@link Intent#ACTION_VIEW} with urls.
*
* @param context the context to use
* @param intent the intent to
*/
public static void openIntentInApp(@NonNull final Context context,
@NonNull final Intent intent) {
if (!tryOpenIntentInApp(context, intent)) {
Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG)
.show();
}
}
/**
* Open the system chooser to launch an intent.
* <p>
@ -172,17 +191,10 @@ public final class ShareUtils {
}
// 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);
}
final int 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);
if (permFlags != 0) {
ClipData targetClipData = intent.getClipData();
if (targetClipData == null && intent.getData() != null) {
@ -200,40 +212,22 @@ public final class ShareUtils {
chooserIntent.addFlags(permFlags);
}
}
context.startActivity(chooserIntent);
}
/**
* Get the default app package name.
* <p>
* If no app is set as default, it will return "android" (not on some devices because some
* OEMs changed the app chooser).
* <p>
* 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;
try {
context.startActivity(chooserIntent);
} catch (final ActivityNotFoundException e) {
Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show();
}
}
/**
* Open the android share sheet to share a content.
*
* <p>
* 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.
* content and an image preview the content, if its URL is not null or empty and its
* corresponding image is in the image cache.
* </p>
*
* @param context the context to use
* @param title the title of the content
@ -252,13 +246,20 @@ public final class ShareUtils {
shareIntent.putExtra(Intent.EXTRA_SUBJECT, 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);
}*/
// Content preview in the share sheet has been added in Android 10, so it's not needed to
// set a content preview which will be never displayed
// See https://developer.android.com/training/sharing/send#adding-rich-content-previews
// If loading of images has been disabled, don't try to generate a content preview
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
&& !TextUtils.isEmpty(imagePreviewUrl)
&& ImageStrategy.shouldLoadImages()) {
final ClipData clipData = generateClipDataForImagePreview(context, imagePreviewUrl);
if (clipData != null) {
shareIntent.setClipData(clipData);
shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
}
}
openAppChooser(context, shareIntent, false);
}
@ -266,11 +267,34 @@ public final class ShareUtils {
/**
* Open the android share sheet to share a content.
*
* <p>
* For Android 10+ users, a content preview is shown, which includes the title of the shared
* content.
* content and an image preview the content, if the preferred image chosen by {@link
* ImageStrategy#choosePreferredImage(List)} is in the image cache.
* </p>
*
* @param context the context to use
* @param title the title of the content
* @param content the content to share
* @param images a set of possible {@link Image}s of the subject, among which to choose with
* {@link ImageStrategy#choosePreferredImage(List)} since that's likely to
* provide an image that is in Picasso's cache
*/
public static void shareText(@NonNull final Context context,
@NonNull final String title,
final String content,
final List<Image> images) {
shareText(context, title, content, ImageStrategy.choosePreferredImage(images));
}
/**
* Open the android share sheet to share a content.
*
* <p>
* This calls {@link #shareText(Context, String, String, String)} with an empty string for the
* imagePreviewUrl parameter.
* {@code imagePreviewUrl} parameter. This method should be used when the shared content has no
* preview thumbnail.
* </p>
*
* @param context the context to use
* @param title the title of the content
@ -298,7 +322,92 @@ public final class ShareUtils {
return;
}
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
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();
}
}
/**
* Generate a {@link ClipData} with the image of the content shared, if it's in the app cache.
*
* <p>
* In order not to worry about network issues (timeouts, DNS issues, low connection speed, ...)
* when sharing a content, only images in the {@link com.squareup.picasso.LruCache LruCache}
* used by the Picasso library inside {@link PicassoHelper} are used as preview images. If the
* thumbnail image is not in the cache, no {@link ClipData} will be generated and {@code null}
* will be returned.
* </p>
*
* <p>
* In order to display the image in the content preview of the Android share sheet, an URI of
* the content, accessible and readable by other apps has to be generated, so a new file inside
* the application cache will be generated, named {@code android_share_sheet_image_preview.jpg}
* (if a file under this name already exists, it will be overwritten). The thumbnail will be
* compressed in JPEG format, with a {@code 90} compression level.
* </p>
*
* <p>
* Note that if an exception occurs when generating the {@link ClipData}, {@code null} is
* returned.
* </p>
*
* <p>
* This method will call {@link PicassoHelper#getImageFromCacheIfPresent(String)} to get the
* thumbnail of the content in the {@link com.squareup.picasso.LruCache LruCache} used by
* the Picasso library inside {@link PicassoHelper}.
* </p>
*
* <p>
* Using the result of this method when sharing has only an effect on the system share sheet (if
* OEMs didn't change Android system standard behavior) on Android API 29 and higher.
* </p>
*
* @param context the context to use
* @param thumbnailUrl the URL of the content thumbnail
* @return a {@link ClipData} of the content thumbnail, or {@code null}
*/
@Nullable
private static ClipData generateClipDataForImagePreview(
@NonNull final Context context,
@NonNull final String thumbnailUrl) {
try {
final Bitmap bitmap = PicassoHelper.getImageFromCacheIfPresent(thumbnailUrl);
if (bitmap == null) {
return null;
}
// Save the image in memory to the application's cache because we need a URI to the
// image to generate a ClipData which will show the share sheet, and so an image file
final Context applicationContext = context.getApplicationContext();
final String appFolder = applicationContext.getCacheDir().getAbsolutePath();
final File thumbnailPreviewFile = new File(appFolder
+ "/android_share_sheet_image_preview.jpg");
// Any existing file will be overwritten with FileOutputStream
final FileOutputStream fileOutputStream = new FileOutputStream(thumbnailPreviewFile);
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, fileOutputStream);
fileOutputStream.close();
final ClipData clipData = ClipData.newUri(applicationContext.getContentResolver(), "",
FileProvider.getUriForFile(applicationContext,
BuildConfig.APPLICATION_ID + ".provider",
thumbnailPreviewFile));
if (DEBUG) {
Log.d(TAG, "ClipData successfully generated for Android share sheet: " + clipData);
}
return clipData;
} catch (final Exception e) {
Log.w(TAG, "Error when setting preview image for share sheet", e);
return null;
}
}
}

View file

@ -1,289 +0,0 @@
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();
// 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 TextLinkifier() {
}
/**
* Create web links for contents with an HTML description.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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);
}
}

View file

@ -0,0 +1,195 @@
package org.schabi.newpipe.util.image;
import static org.schabi.newpipe.extractor.Image.HEIGHT_UNKNOWN;
import static org.schabi.newpipe.extractor.Image.WIDTH_UNKNOWN;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.extractor.Image;
import java.util.Comparator;
import java.util.List;
public final class ImageStrategy {
// when preferredImageQuality is LOW or MEDIUM, images are sorted by how close their preferred
// image quality is to these values (H stands for "Height")
private static final int BEST_LOW_H = 75;
private static final int BEST_MEDIUM_H = 250;
private static PreferredImageQuality preferredImageQuality = PreferredImageQuality.MEDIUM;
private ImageStrategy() {
}
public static void setPreferredImageQuality(final PreferredImageQuality preferredImageQuality) {
ImageStrategy.preferredImageQuality = preferredImageQuality;
}
public static boolean shouldLoadImages() {
return preferredImageQuality != PreferredImageQuality.NONE;
}
static double estimatePixelCount(final Image image, final double widthOverHeight) {
if (image.getHeight() == HEIGHT_UNKNOWN) {
if (image.getWidth() == WIDTH_UNKNOWN) {
// images whose size is completely unknown will be in their own subgroups, so
// any one of them will do, hence returning the same value for all of them
return 0;
} else {
return image.getWidth() * image.getWidth() / widthOverHeight;
}
} else if (image.getWidth() == WIDTH_UNKNOWN) {
return image.getHeight() * image.getHeight() * widthOverHeight;
} else {
return image.getHeight() * image.getWidth();
}
}
/**
* {@link #choosePreferredImage(List)} contains the description for this function's logic.
*
* @param images the images from which to choose
* @param nonNoneQuality the preferred quality (must NOT be {@link PreferredImageQuality#NONE})
* @return the chosen preferred image, or {@link null} if the list is empty
* @see #choosePreferredImage(List)
*/
@Nullable
static String choosePreferredImage(@NonNull final List<Image> images,
final PreferredImageQuality nonNoneQuality) {
// this will be used to estimate the pixel count for images where only one of height or
// width are known
final double widthOverHeight = images.stream()
.filter(image -> image.getHeight() != HEIGHT_UNKNOWN
&& image.getWidth() != WIDTH_UNKNOWN)
.mapToDouble(image -> ((double) image.getWidth()) / image.getHeight())
.findFirst()
.orElse(1.0);
final Image.ResolutionLevel preferredLevel = nonNoneQuality.toResolutionLevel();
final Comparator<Image> initialComparator = Comparator
// the first step splits the images into groups of resolution levels
.<Image>comparingInt(i -> {
if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
return 3; // avoid unknowns as much as possible
} else if (i.getEstimatedResolutionLevel() == preferredLevel) {
return 0; // prefer a matching resolution level
} else if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.MEDIUM) {
return 1; // the preferredLevel is only 1 "step" away (either HIGH or LOW)
} else {
return 2; // the preferredLevel is the furthest away possible (2 "steps")
}
})
// then each level's group is further split into two subgroups, one with known image
// size (which is also the preferred subgroup) and the other without
.thenComparing(image ->
image.getHeight() == HEIGHT_UNKNOWN && image.getWidth() == WIDTH_UNKNOWN);
// The third step chooses, within each subgroup with known image size, the best image based
// on how close its size is to BEST_LOW_H or BEST_MEDIUM_H (with proper units). Subgroups
// without known image size will be left untouched since estimatePixelCount always returns
// the same number for those.
final Comparator<Image> finalComparator = switch (nonNoneQuality) {
case NONE -> initialComparator; // unreachable
case LOW -> initialComparator.thenComparingDouble(image -> {
final double pixelCount = estimatePixelCount(image, widthOverHeight);
return Math.abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight);
});
case MEDIUM -> initialComparator.thenComparingDouble(image -> {
final double pixelCount = estimatePixelCount(image, widthOverHeight);
return Math.abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight);
});
case HIGH -> initialComparator.thenComparingDouble(
// this is reversed with a - so that the highest resolution is chosen
i -> -estimatePixelCount(i, widthOverHeight));
};
return images.stream()
// using "min" basically means "take the first group, then take the first subgroup,
// then choose the best image, while ignoring all other groups and subgroups"
.min(finalComparator)
.map(Image::getUrl)
.orElse(null);
}
/**
* Chooses an image amongst the provided list based on the user preference previously set with
* {@link #setPreferredImageQuality(PreferredImageQuality)}. {@code null} will be returned in
* case the list is empty or the user preference is to not show images.
* <br>
* These properties will be preferred, from most to least important:
* <ol>
* <li>The image's {@link Image#getEstimatedResolutionLevel()} is not unknown and is close
* to {@link #preferredImageQuality}</li>
* <li>At least one of the image's width or height are known</li>
* <li>The highest resolution image is finally chosen if the user's preference is {@link
* PreferredImageQuality#HIGH}, otherwise the chosen image is the one that has the height
* closest to {@link #BEST_LOW_H} or {@link #BEST_MEDIUM_H}</li>
* </ol>
* <br>
* Use {@link #imageListToDbUrl(List)} if the URL is going to be saved to the database, to avoid
* saving nothing in case at the moment of saving the user preference is to not show images.
*
* @param images the images from which to choose
* @return the chosen preferred image, or {@link null} if the list is empty or the user disabled
* images
* @see #imageListToDbUrl(List)
*/
@Nullable
public static String choosePreferredImage(@NonNull final List<Image> images) {
if (preferredImageQuality == PreferredImageQuality.NONE) {
return null; // do not load images
}
return choosePreferredImage(images, preferredImageQuality);
}
/**
* Like {@link #choosePreferredImage(List)}, except that if {@link #preferredImageQuality} is
* {@link PreferredImageQuality#NONE} an image will be chosen anyway (with preferred quality
* {@link PreferredImageQuality#MEDIUM}.
* <br>
* To go back to a list of images (obviously with just the one chosen image) from a URL saved in
* the database use {@link #dbUrlToImageList(String)}.
*
* @param images the images from which to choose
* @return the chosen preferred image, or {@link null} if the list is empty
* @see #choosePreferredImage(List)
* @see #dbUrlToImageList(String)
*/
@Nullable
public static String imageListToDbUrl(@NonNull final List<Image> images) {
final PreferredImageQuality quality;
if (preferredImageQuality == PreferredImageQuality.NONE) {
quality = PreferredImageQuality.MEDIUM;
} else {
quality = preferredImageQuality;
}
return choosePreferredImage(images, quality);
}
/**
* Wraps the URL (coming from the database) in a {@code List<Image>} so that it is usable
* seamlessly in all of the places where the extractor would return a list of images, including
* allowing to build info objects based on database objects.
* <br>
* To obtain a url to save to the database from a list of images use {@link
* #imageListToDbUrl(List)}.
*
* @param url the URL to wrap coming from the database, or {@code null} to get an empty list
* @return a list containing just one {@link Image} wrapping the provided URL, with unknown
* image size fields, or an empty list if the URL is {@code null}
* @see #imageListToDbUrl(List)
*/
@NonNull
public static List<Image> dbUrlToImageList(@Nullable final String url) {
if (url == null) {
return List.of();
} else {
return List.of(new Image(url, -1, -1, Image.ResolutionLevel.UNKNOWN));
}
}
}

View file

@ -1,33 +1,40 @@
package org.schabi.newpipe.util;
package org.schabi.newpipe.util.image;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.MainActivity.DEBUG;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.util.image.ImageStrategy.choosePreferredImage;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.util.Log;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.BitmapCompat;
import com.squareup.picasso.Cache;
import com.squareup.picasso.LruCache;
import com.squareup.picasso.OkHttp3Downloader;
import com.squareup.picasso.Picasso;
import com.squareup.picasso.RequestCreator;
import com.squareup.picasso.Target;
import com.squareup.picasso.Transformation;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.Image;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import okhttp3.OkHttpClient;
public final class PicassoHelper {
public static final String PLAYER_THUMBNAIL_TAG = "PICASSO_PLAYER_THUMBNAIL_TAG";
private static final String PLAYER_THUMBNAIL_TRANSFORMATION_KEY
= "PICASSO_PLAYER_THUMBNAIL_TRANSFORMATION_KEY";
private static final String TAG = PicassoHelper.class.getSimpleName();
private static final String PLAYER_THUMBNAIL_TRANSFORMATION_KEY =
"PICASSO_PLAYER_THUMBNAIL_TRANSFORMATION_KEY";
private PicassoHelper() {
}
@ -39,13 +46,12 @@ public final class PicassoHelper {
@SuppressLint("StaticFieldLeak")
private static Picasso picassoInstance;
private static boolean shouldLoadImages;
public static void init(final Context context) {
picassoCache = new LruCache(10 * 1024 * 1024);
picassoDownloaderClient = new OkHttpClient.Builder()
.cache(new okhttp3.Cache(new File(context.getExternalCacheDir(), "picasso"),
50 * 1024 * 1024))
50L * 1024L * 1024L))
// this should already be the default timeout in OkHttp3, but just to be sure...
.callTimeout(15, TimeUnit.SECONDS)
.build();
@ -85,63 +91,82 @@ public final class PicassoHelper {
picassoInstance.setIndicatorsEnabled(enabled); // useful for debugging
}
public static void setShouldLoadImages(final boolean shouldLoadImages) {
PicassoHelper.shouldLoadImages = shouldLoadImages;
public static RequestCreator loadAvatar(@NonNull final List<Image> images) {
return loadImageDefault(images, R.drawable.placeholder_person);
}
public static boolean getShouldLoadImages() {
return shouldLoadImages;
public static RequestCreator loadAvatar(@Nullable final String url) {
return loadImageDefault(url, R.drawable.placeholder_person);
}
public static RequestCreator loadAvatar(final String url) {
return loadImageDefault(url, R.drawable.buddy);
public static RequestCreator loadThumbnail(@NonNull final List<Image> images) {
return loadImageDefault(images, R.drawable.placeholder_thumbnail_video);
}
public static RequestCreator loadThumbnail(final String url) {
return loadImageDefault(url, R.drawable.dummy_thumbnail);
public static RequestCreator loadThumbnail(@Nullable final String url) {
return loadImageDefault(url, R.drawable.placeholder_thumbnail_video);
}
public static RequestCreator loadBanner(final String url) {
return loadImageDefault(url, R.drawable.channel_banner);
public static RequestCreator loadDetailsThumbnail(@NonNull final List<Image> images) {
return loadImageDefault(choosePreferredImage(images),
R.drawable.placeholder_thumbnail_video, false);
}
public static RequestCreator loadPlaylistThumbnail(final String url) {
return loadImageDefault(url, R.drawable.dummy_thumbnail_playlist);
public static RequestCreator loadBanner(@NonNull final List<Image> images) {
return loadImageDefault(images, R.drawable.placeholder_channel_banner);
}
public static RequestCreator loadSeekbarThumbnailPreview(final String url) {
public static RequestCreator loadPlaylistThumbnail(@NonNull final List<Image> images) {
return loadImageDefault(images, R.drawable.placeholder_thumbnail_playlist);
}
public static RequestCreator loadPlaylistThumbnail(@Nullable final String url) {
return loadImageDefault(url, R.drawable.placeholder_thumbnail_playlist);
}
public static RequestCreator loadSeekbarThumbnailPreview(@Nullable final String url) {
return picassoInstance.load(url);
}
public static RequestCreator loadNotificationIcon(@Nullable final String url) {
return loadImageDefault(url, R.drawable.ic_newpipe_triangle_white);
}
public static RequestCreator loadScaledDownThumbnail(final Context context, final String url) {
public static RequestCreator loadScaledDownThumbnail(final Context context,
@NonNull final List<Image> images) {
// scale down the notification thumbnail for performance
return PicassoHelper.loadThumbnail(url)
.tag(PLAYER_THUMBNAIL_TAG)
return PicassoHelper.loadThumbnail(images)
.transform(new Transformation() {
@Override
public Bitmap transform(final Bitmap source) {
if (DEBUG) {
Log.d(TAG, "Thumbnail - transform() called");
}
final float notificationThumbnailWidth = Math.min(
context.getResources()
.getDimension(R.dimen.player_notification_thumbnail_width),
source.getWidth());
final Bitmap result = Bitmap.createScaledBitmap(
final Bitmap result = BitmapCompat.createScaledBitmap(
source,
(int) notificationThumbnailWidth,
(int) (source.getHeight()
/ (source.getWidth() / notificationThumbnailWidth)),
null,
true);
if (result == source) {
if (result == source || !result.isMutable()) {
// create a new mutable bitmap to prevent strange crashes on some
// devices (see #4638)
final Bitmap copied = Bitmap.createScaledBitmap(
final Bitmap copied = BitmapCompat.createScaledBitmap(
source,
(int) notificationThumbnailWidth - 1,
(int) (source.getHeight() / (source.getWidth()
/ (notificationThumbnailWidth - 1))),
null,
true);
source.recycle();
return copied;
@ -158,39 +183,42 @@ public final class PicassoHelper {
});
}
public static void loadNotificationIcon(final String url,
final Consumer<Bitmap> bitmapConsumer) {
loadImageDefault(url, R.drawable.ic_newpipe_triangle_white)
.into(new Target() {
@Override
public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) {
bitmapConsumer.accept(bitmap);
}
@Override
public void onBitmapFailed(final Exception e, final Drawable errorDrawable) {
bitmapConsumer.accept(null);
}
@Override
public void onPrepareLoad(final Drawable placeHolderDrawable) {
// Nothing to do
}
});
@Nullable
public static Bitmap getImageFromCacheIfPresent(@NonNull final String imageUrl) {
// URLs in the internal cache finish with \n so we need to add \n to image URLs
return picassoCache.get(imageUrl + "\n");
}
private static RequestCreator loadImageDefault(final String url, final int placeholderResId) {
if (!shouldLoadImages || isBlank(url)) {
private static RequestCreator loadImageDefault(@NonNull final List<Image> images,
@DrawableRes final int placeholderResId) {
return loadImageDefault(choosePreferredImage(images), placeholderResId);
}
private static RequestCreator loadImageDefault(@Nullable final String url,
@DrawableRes final int placeholderResId) {
return loadImageDefault(url, placeholderResId, true);
}
private static RequestCreator loadImageDefault(@Nullable final String url,
@DrawableRes final int placeholderResId,
final boolean showPlaceholderWhileLoading) {
// if the URL was chosen with `choosePreferredImage` it will be null, but check again
// `shouldLoadImages` in case the URL was chosen with `imageListToDbUrl` (which is the case
// for URLs stored in the database)
if (isNullOrEmpty(url) || !ImageStrategy.shouldLoadImages()) {
return picassoInstance
.load((String) null)
.placeholder(placeholderResId) // show placeholder when no image should load
.error(placeholderResId);
} else {
return picassoInstance
final RequestCreator requestCreator = picassoInstance
.load(url)
.error(placeholderResId); // don't show placeholder while loading, only on error
.error(placeholderResId);
if (showPlaceholderWhileLoading) {
requestCreator.placeholder(placeholderResId);
}
return requestCreator;
}
}
}

View file

@ -0,0 +1,39 @@
package org.schabi.newpipe.util.image;
import android.content.Context;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.Image;
public enum PreferredImageQuality {
NONE,
LOW,
MEDIUM,
HIGH;
public static PreferredImageQuality fromPreferenceKey(final Context context, final String key) {
if (context.getString(R.string.image_quality_none_key).equals(key)) {
return NONE;
} else if (context.getString(R.string.image_quality_low_key).equals(key)) {
return LOW;
} else if (context.getString(R.string.image_quality_high_key).equals(key)) {
return HIGH;
} else {
return MEDIUM; // default to medium
}
}
public Image.ResolutionLevel toResolutionLevel() {
switch (this) {
case LOW:
return Image.ResolutionLevel.LOW;
case MEDIUM:
return Image.ResolutionLevel.MEDIUM;
case HIGH:
return Image.ResolutionLevel.HIGH;
default:
case NONE:
return Image.ResolutionLevel.UNKNOWN;
}
}
}

View file

@ -0,0 +1,42 @@
package org.schabi.newpipe.util.text;
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
import android.annotation.SuppressLint;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.view.MotionEvent;
import android.view.View;
import android.widget.TextView;
public class CommentTextOnTouchListener implements View.OnTouchListener {
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouch(final View v, final MotionEvent event) {
if (!(v instanceof TextView)) {
return false;
}
final TextView widget = (TextView) v;
final CharSequence text = widget.getText();
if (text instanceof Spanned) {
final Spanned buffer = (Spanned) text;
final int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
final int offset = getOffsetForHorizontalLine(widget, event);
final ClickableSpan[] links = buffer.getSpans(offset, offset, ClickableSpan.class);
if (links.length != 0) {
if (action == MotionEvent.ACTION_UP) {
links[0].onClick(widget);
}
// we handle events that intersect links, so return true
return true;
}
}
}
return false;
}
}

View file

@ -0,0 +1,36 @@
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
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;
private final int relatedInfoServiceId;
HashtagLongPressClickableSpan(@NonNull final Context context,
@NonNull final String parsedHashtag,
final int relatedInfoServiceId) {
this.context = context;
this.parsedHashtag = parsedHashtag;
this.relatedInfoServiceId = relatedInfoServiceId;
}
@Override
public void onClick(@NonNull final View view) {
NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag);
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, parsedHashtag);
}
}

View file

@ -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;
@ -153,13 +153,13 @@ public final class InternalUrlsHandler {
return false;
}
final Single<StreamInfo> single
= ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false);
final Single<StreamInfo> 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 * 1000L);
final PlayQueue playQueue =
new SinglePlayQueue(info, seconds * 1000L);
NavigationHelper.playOnPopupPlayer(context, playQueue, false);
}, throwable -> {
if (DEBUG) {
@ -169,7 +169,7 @@ public final class InternalUrlsHandler {
.setTitle(R.string.player_stream_failure)
.setMessage(
ErrorPanelHelper.Companion.getExceptionDescription(throwable))
.setPositiveButton(R.string.ok, (v, b) -> { })
.setPositiveButton(R.string.ok, null)
.show();
}));
return true;

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -0,0 +1,193 @@
package org.schabi.newpipe.util.text;
import android.graphics.Paint;
import android.text.Layout;
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.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import java.util.function.Consumer;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
/**
* <p>Class to ellipsize text inside a {@link TextView}.</p>
* This class provides all utils to automatically ellipsize and expand a text
*/
public final class TextEllipsizer {
private static final int EXPANDED_LINES = Integer.MAX_VALUE;
private static final String ELLIPSIS = "";
@NonNull private final CompositeDisposable disposable = new CompositeDisposable();
@NonNull private final TextView view;
private final int maxLines;
@NonNull private Description content;
@Nullable private StreamingService streamingService;
@Nullable private String streamUrl;
private boolean isEllipsized = false;
@Nullable private Boolean canBeEllipsized = null;
@NonNull private final Paint paintAtContentSize = new Paint();
private final float ellipsisWidthPx;
@Nullable private Consumer<Boolean> stateChangeListener = null;
@Nullable private Consumer<Boolean> onContentChanged;
public TextEllipsizer(@NonNull final TextView view,
final int maxLines,
@Nullable final StreamingService streamingService) {
this.view = view;
this.maxLines = maxLines;
this.content = Description.EMPTY_DESCRIPTION;
this.streamingService = streamingService;
paintAtContentSize.setTextSize(view.getTextSize());
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
}
public void setOnContentChanged(@Nullable final Consumer<Boolean> onContentChanged) {
this.onContentChanged = onContentChanged;
}
public void setContent(@NonNull final Description content) {
this.content = content;
canBeEllipsized = null;
linkifyContentView(v -> {
final int currentMaxLines = view.getMaxLines();
view.setMaxLines(EXPANDED_LINES);
canBeEllipsized = view.getLineCount() > maxLines;
view.setMaxLines(currentMaxLines);
if (onContentChanged != null) {
onContentChanged.accept(canBeEllipsized);
}
});
}
public void setStreamUrl(@Nullable final String streamUrl) {
this.streamUrl = streamUrl;
}
public void setStreamingService(@NonNull final StreamingService streamingService) {
this.streamingService = streamingService;
}
/**
* Expand the {@link TextEllipsizer#content} to its full length.
*/
public void expand() {
view.setMaxLines(EXPANDED_LINES);
linkifyContentView(v -> isEllipsized = false);
}
/**
* Shorten the {@link TextEllipsizer#content} to the given number of
* {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code }'
* if the text was shorted.
*/
public void ellipsize() {
// expand text to see whether it is necessary to ellipsize the text
view.setMaxLines(EXPANDED_LINES);
linkifyContentView(v -> {
final CharSequence charSeqText = view.getText();
if (charSeqText != null && view.getLineCount() > maxLines) {
// Note that converting to String removes spans (i.e. links), but that's something
// we actually want since when the text is ellipsized we want all clicks on the
// comment to expand the comment, not to open links.
final String text = charSeqText.toString();
final Layout layout = view.getLayout();
final float lineWidth = layout.getLineWidth(maxLines - 1);
final float layoutWidth = layout.getWidth();
final int lineStart = layout.getLineStart(maxLines - 1);
final int lineEnd = layout.getLineEnd(maxLines - 1);
// remove characters up until there is enough space for the ellipsis
// (also summing 2 more pixels, just to be sure to avoid float rounding errors)
int end = lineEnd;
float removedCharactersWidth = 0.0f;
while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth
&& end >= lineStart) {
end -= 1;
// recalculate each time to account for ligatures or other similar things
removedCharactersWidth = paintAtContentSize.measureText(
text.substring(end, lineEnd));
}
// remove trailing spaces and newlines
while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) {
end -= 1;
}
final String newVal = text.substring(0, end) + ELLIPSIS;
view.setText(newVal);
isEllipsized = true;
} else {
isEllipsized = false;
}
view.setMaxLines(maxLines);
});
}
/**
* Toggle the view between the ellipsized and expanded state.
*/
public void toggle() {
if (isEllipsized) {
expand();
} else {
ellipsize();
}
}
/**
* Whether the {@link #view} can be ellipsized.
* This is only the case when the {@link #content} has more lines
* than allowed via {@link #maxLines}.
* @return {@code true} if the {@link #content} has more lines than allowed via
* {@link #maxLines} and thus can be shortened, {@code false} if the {@code content} fits into
* the {@link #view} without being shortened and {@code null} if the initialization is not
* completed yet.
*/
@Nullable
public Boolean canBeEllipsized() {
return canBeEllipsized;
}
private void linkifyContentView(final Consumer<View> consumer) {
final boolean oldState = isEllipsized;
disposable.clear();
TextLinkifier.fromDescription(view, content,
HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable,
v -> {
consumer.accept(v);
notifyStateChangeListener(oldState);
});
}
/**
* Add a listener which is called when the given content is changed,
* either from <em>ellipsized</em> to <em>full</em> or vice versa.
* @param listener The listener to be called, or {@code null} to remove it.
* The Boolean parameter is the new state.
* <em>Ellipsized</em> content is represented as {@code true},
* normal or <em>full</em> content by {@code false}.
*/
public void setStateChangeListener(@Nullable final Consumer<Boolean> listener) {
this.stateChangeListener = listener;
}
private void notifyStateChangeListener(final boolean oldState) {
if (oldState != isEllipsized && stateChangeListener != null) {
stateChangeListener.accept(isEllipsized);
}
}
}

View file

@ -0,0 +1,369 @@
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.text.SpannableStringBuilder;
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.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.function.Consumer;
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;
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_]+)");
public static final Consumer<TextView> SET_LINK_MOVEMENT_METHOD =
v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance());
private TextLinkifier() {
}
/**
* Create links for contents with an {@link Description} in the various possible formats.
* <p>
* This will call one of these three functions based on the format: {@link #fromHtml},
* {@link #fromMarkdown} or {@link #fromPlainText}.
*
* @param textView the TextView to set the htmlBlock linked
* @param description the htmlBlock to be linked
* @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)}
* will be called (not used for formats different than HTML)
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromDescription(@NonNull final TextView textView,
@NonNull final Description description,
final int htmlCompatFlag,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
switch (description.getType()) {
case Description.HTML:
TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag,
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
break;
case Description.MARKDOWN:
TextLinkifier.fromMarkdown(textView, description.getContent(),
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
break;
case Description.PLAIN_TEXT: default:
TextLinkifier.fromPlainText(textView, description.getContent(),
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
break;
}
}
/**
* Create links for contents with an HTML description.
*
* <p>
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* String, CompositeDisposable, Consumer)} after having linked the URLs with
* {@link HtmlCompat#fromHtml(String, int)}.
* </p>
*
* @param textView the {@link TextView} to set 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 relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromHtml(@NonNull final TextView textView,
@NonNull final String htmlBlock,
final int htmlCompatFlag,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
changeLinkIntents(
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService,
relatedStreamUrl, disposables, onCompletion);
}
/**
* Create links for contents with a plain text description.
*
* <p>
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* String, CompositeDisposable, Consumer)} after having linked the URLs with
* {@link TextView#setAutoLinkMask(int)} and
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
* </p>
*
* @param textView the {@link TextView} to set the plain text block linked
* @param plainTextBlock the block of plain text to be linked
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromPlainText(@NonNull final TextView textView,
@NonNull final String plainTextBlock,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
textView.setAutoLinkMask(Linkify.WEB_URLS);
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
changeLinkIntents(textView, textView.getText(), relatedInfoService,
relatedStreamUrl, disposables, onCompletion);
}
/**
* Create links for contents with a markdown description.
*
* <p>
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using
* {@link Markwon#setMarkdown(TextView, String)}.
* </p>
*
* @param textView the {@link TextView} to set the plain text block linked
* @param markdownBlock the block of markdown text to be linked
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromMarkdown(@NonNull final TextView textView,
@NonNull final String markdownBlock,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
final Markwon markwon = Markwon.builder(textView.getContext())
.usePlugin(LinkifyPlugin.create()).build();
changeLinkIntents(textView, markwon.toMarkdown(markdownBlock),
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
}
/**
* 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.
*
* <p>
* 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)}.
* </p>
*
* <p>
* 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,
* StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by
* using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder,
* StreamingService)}, which will open a search on the current service with the hashtag.
* </p>
*
* <p>
* This method is required in order to intercept links and e.g. show a confirmation dialog
* before opening a web link.
* </p>
*
* @param textView the {@link TextView} to which the converted {@link CharSequence}
* will be applied
* @param chars the {@link CharSequence} to be parsed
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
private static void changeLinkIntents(@NonNull final TextView textView,
@NonNull final CharSequence chars,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
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 LongPressClickableSpan longPressClickableSpan =
new UrlLongPressClickableSpan(context, disposables, url);
textBlockLinked.setSpan(longPressClickableSpan,
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 (relatedInfoService != null) {
if (relatedStreamUrl != null) {
addClickListenersOnTimestamps(context, textBlockLinked,
relatedInfoService, relatedStreamUrl, disposables);
}
addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService);
}
return textBlockLinked;
}).subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
textBlockLinked ->
setTextViewCharSequence(textView, textBlockLinked, onCompletion),
throwable -> {
Log.e(TAG, "Unable to linkify text", throwable);
// this should never happen, but if it does, just fallback to it
setTextViewCharSequence(textView, chars, onCompletion);
}));
}
/**
* Add click listeners which opens a search on hashtags in a plain text.
*
* <p>
* 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}).
* </p>
*
* @param context the {@link Context} to use
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the
* content description
* @param relatedInfoService used to search for the term in the correct service
*/
private static void addClickListenersOnHashtags(
@NonNull final Context context,
@NonNull final SpannableStringBuilder spannableDescription,
@NonNull final StreamingService relatedInfoService) {
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) {
final int serviceId = relatedInfoService.getServiceId();
spannableDescription.setSpan(
new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId),
hashtagStart, hashtagEnd, 0);
}
}
}
/**
* Add click listeners which opens the popup player on timestamps in a plain text.
*
* <p>
* 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.
* </p>
*
* @param context the {@link Context} to use
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the
* content description
* @param relatedInfoService the service of the {@code relatedStreamUrl}
* @param relatedStreamUrl 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 StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@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,
relatedInfoService, relatedStreamUrl, timestampMatchDTO),
timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd(),
0);
}
}
private static void setTextViewCharSequence(@NonNull final TextView textView,
@Nullable final CharSequence charSequence,
@Nullable final Consumer<TextView> onCompletion) {
textView.setText(charSequence);
textView.setVisibility(View.VISIBLE);
if (onCompletion != null) {
onCompletion.accept(textView);
}
}
}

View file

@ -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.<br/>
* If not <code>null</code>.
* @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);

View file

@ -0,0 +1,78 @@
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.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 StreamingService relatedInfoService;
@NonNull
private final String relatedStreamUrl;
@NonNull
private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO;
TimestampLongPressClickableSpan(
@NonNull final Context context,
@NonNull final String descriptionText,
@NonNull final CompositeDisposable disposables,
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
this.context = context;
this.descriptionText = descriptionText;
this.disposables = disposables;
this.relatedInfoService = relatedInfoService;
this.relatedStreamUrl = relatedStreamUrl;
this.timestampMatchDTO = timestampMatchDTO;
}
@Override
public void onClick(@NonNull final View view) {
playOnPopup(context, relatedStreamUrl, relatedInfoService,
timestampMatchDTO.seconds(), disposables);
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, getTimestampTextToCopy(
relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO));
}
@NonNull
private static String getTimestampTextToCopy(
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final String descriptionText,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
// TODO: use extractor methods to get timestamps when this feature will be implemented in it
if (relatedInfoService == ServiceList.YouTube) {
return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds();
} else if (relatedInfoService == ServiceList.SoundCloud
|| relatedInfoService == ServiceList.MediaCCC) {
return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds();
} else if (relatedInfoService == ServiceList.PeerTube) {
return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds();
}
// Return timestamp text for other services
return descriptionText.subSequence(timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd()).toString();
}
}

View file

@ -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);
}
}

View file

@ -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.openUrlInApp(context, url);
}
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, url);
}
}

View file

@ -18,159 +18,17 @@
package org.schabi.newpipe.util.urlfinder;
import androidx.annotation.RestrictTo;
import java.util.regex.Pattern;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
/**
* Commonly used regular expression patterns.
*/
public final class PatternsCompat {
/**
* Regular expression to match all IANA top-level domains.
*
* List accurate as of 2015/11/24. List taken from:
* http://data.iana.org/TLD/tlds-alpha-by-domain.txt
* This pattern is auto-generated by frameworks/ex/common/tools/make-iana-tld-pattern.py
*/
static final String IANA_TOP_LEVEL_DOMAINS = "(?:"
+ "(?:aaa|aarp|abb|abbott|abogado|academy|accenture|accountant|accountants|aco|active"
+ "|actor|ads|adult|aeg|aero|afl|agency|aig|airforce|airtel|allfinanz|alsace|amica"
+ "|amsterdam|android|apartments|app|apple|aquarelle|aramco|archi|army|arpa|arte|asia"
+ "|associates|attorney|auction|audio|auto|autos|axa|azure|a[cdefgilmoqrstuwxz])"
+ "|(?:band|bank|bar|barcelona|barclaycard|barclays|bargains|bauhaus|bayern|bbc|bbva"
+ "|bcn|beats|beer|bentley|berlin|best|bet|bharti|bible|bid|bike|bing|bingo|bio|biz"
+ "|black|blackfriday|bloomberg|blue|bms|bmw|bnl|bnpparibas|boats|bom|bond|boo|boots"
+ "|boutique|bradesco|bridgestone|broadway|broker|brother|brussels|budapest|build"
+ "|builders|business|buzz|bzh|b[abdefghijmnorstvwyz])"
+ "|(?:cab|cafe|cal|camera|camp|cancerresearch|canon|capetown|capital|car|caravan|cards"
+ "|care|career|careers|cars|cartier|casa|cash|casino|cat|catering|cba|cbn|ceb|center"
+ "|ceo|cern|cfa|cfd|chanel|channel|chat|cheap|chloe|christmas|chrome|church|cipriani"
+ "|cisco|citic|city|cityeats|claims|cleaning|click|clinic|clothing|cloud|club|clubmed"
+ "|coach|codes|coffee|college|cologne|com|commbank|community|company|computer|comsec"
+ "|condos|construction|consulting|contractors|cooking|cool|coop|corsica|country"
+ "|coupons|courses|credit|creditcard|creditunion|cricket|crown|crs|cruises|csc"
+ "|cuisinella|cymru|cyou|c[acdfghiklmnoruvwxyz])"
+ "|(?:dabur|dad|dance|date|dating|datsun|day|dclk|deals|degree|delivery|dell|delta"
+ "|democrat|dental|dentist|desi|design|dev|diamonds|diet|digital|direct|directory"
+ "|discount|dnp|docs|dog|doha|domains|doosan|download|drive|durban|dvag|d[ejkmoz])"
+ "|(?:earth|eat|edu|education|email|emerck|energy|engineer|engineering|enterprises"
+ "|epson|equipment|erni|esq|estate|eurovision|eus|events|everbank|exchange|expert"
+ "|exposed|express|e[cegrstu])"
+ "|(?:fage|fail|fairwinds|faith|family|fan|fans|farm"
+ "|fashion|feedback|ferrero|film|final|finance|financial|firmdale|fish|fishing|fit"
+ "|fitness|flights|florist|flowers|flsmidth|fly|foo|football|forex|forsale|forum"
+ "|foundation|frl|frogans|fund|furniture|futbol|fyi|f[ijkmor])"
+ "|(?:gal|gallery|game|garden|gbiz|gdn|gea|gent|genting|ggee|gift|gifts|gives|giving"
+ "|glass|gle|global|globo|gmail|gmo|gmx|gold|goldpoint|golf|goo|goog|google|gop|gov"
+ "|grainger|graphics|gratis|green|gripe|group|gucci|guge|guide|guitars|guru"
+ "|g[abdefghilmnpqrstuwy])"
+ "|(?:hamburg|hangout|haus|healthcare|help|here|hermes|hiphop|hitachi|hiv|hockey"
+ "|holdings|holiday|homedepot|homes|honda|horse|host|hosting|hoteles|hotmail|house"
+ "|how|hsbc|hyundai|h[kmnrtu])"
+ "|(?:ibm|icbc|ice|icu|ifm|iinet|immo|immobilien|industries|infiniti|info|ing|ink"
+ "|institute|insure|int|international|investments|ipiranga|irish|ist|istanbul|itau"
+ "|iwc|i[delmnoqrst])"
+ "|(?:jaguar|java|jcb|jetzt|jewelry|jlc|jll|jobs|joburg|jprs|juegos|j[emop])"
+ "|(?:kaufen|kddi|kia|kim|kinder|kitchen|kiwi|koeln|komatsu|krd|kred|kyoto"
+ "|k[eghimnprwyz])"
+ "|(?:lacaixa|lancaster|land|landrover|lasalle|lat|latrobe|law|lawyer|lds|lease"
+ "|leclerc|legal|lexus|lgbt|liaison|lidl|life|lifestyle|lighting|limited|limo|linde"
+ "|link|live|lixil|loan|loans|lol|london|lotte|lotto|love|ltd|ltda|lupin|luxe|luxury"
+ "|l[abcikrstuvy])"
+ "|(?:madrid|maif|maison|man|management|mango|market|marketing|markets|marriott|mba"
+ "|media|meet|melbourne|meme|memorial|men|menu|meo|miami|microsoft|mil|mini|mma|mobi"
+ "|moda|moe|moi|mom|monash|money|montblanc|mormon|mortgage|moscow|motorcycles|mov"
+ "|movie|movistar|mtn|mtpc|mtr|museum|mutuelle|m[acdeghklmnopqrstuvwxyz])"
+ "|(?:nadex|nagoya|name|navy|nec|net|netbank|network|neustar|new|news|nexus|ngo|nhk"
+ "|nico|ninja|nissan|nokia|nra|nrw|ntt|nyc|n[acefgilopruz])"
+ "|(?:obi|office|okinawa|omega|one|ong|onl|online|ooo|oracle|orange|org|organic|osaka"
+ "|otsuka|ovh|om)"
+ "|(?:page|panerai|paris|partners|parts|party|pet|pharmacy|philips|photo|photography"
+ "|photos|physio|piaget|pics|pictet|pictures|ping|pink|pizza|place|play|playstation"
+ "|plumbing|plus|pohl|poker|porn|post|praxi|press|pro|prod|productions|prof|properties"
+ "|property|protection|pub|p[aefghklmnrstwy])"
+ "|(?:qpon|quebec|qa)"
+ "|(?:racing|realtor|realty|recipes|red|redstone|rehab|reise|reisen|reit|ren|rent"
+ "|rentals|repair|report|republican|rest|restaurant|review|reviews|rich|ricoh|rio|rip"
+ "|rocher|rocks|rodeo|rsvp|ruhr|run|rwe|ryukyu|r[eosuw])"
+ "|(?:saarland|sakura|sale|samsung|sandvik|sandvikcoromant|sanofi|sap|sapo|sarl|saxo"
+ "|sbs|sca|scb|schmidt|scholarships|school|schule|schwarz|science|scor|scot|seat"
+ "|security|seek|sener|services|seven|sew|sex|sexy|shiksha|shoes|show|shriram|singles"
+ "|site|ski|sky|skype|sncf|soccer|social|software|sohu|solar|solutions|sony|soy|space"
+ "|spiegel|spreadbetting|srl|stada|starhub|statoil|stc|stcgroup|stockholm|studio|study"
+ "|style|sucks|supplies|supply|support|surf|surgery|suzuki|swatch|swiss|sydney|systems"
+ "|s[abcdeghijklmnortuvxyz])"
+ "|(?:tab|taipei|tatamotors|tatar|tattoo|tax|taxi|team|tech|technology|tel|telefonica"
+ "|temasek|tennis|thd|theater|theatre|tickets|tienda|tips|tires|tirol|today|tokyo"
+ "|tools|top|toray|toshiba|tours|town|toyota|toys|trade|trading|training|travel|trust"
+ "|tui|t[cdfghjklmnortvwz])"
+ "|(?:ubs|university|uno|uol|u[agksyz])"
+ "|(?:vacations|vana|vegas|ventures|versicherung|vet|viajes|video|villas|vin|virgin"
+ "|vision|vista|vistaprint|viva|vlaanderen|vodka|vote|voting|voto|voyage|v[aceginu])"
+ "|(?:wales|walter|wang|watch|webcam|website|wed|wedding|weir|whoswho|wien|wiki"
+ "|williamhill|win|windows|wine|wme|work|works|world|wtc|wtf|w[fs])"
+ "|(?:\u03b5\u03bb|\u0431\u0435\u043b|\u0434\u0435\u0442\u0438|\u043a\u043e\u043c"
+ "|\u043c\u043a\u0434|\u043c\u043e\u043d|\u043c\u043e\u0441\u043a\u0432\u0430"
+ "|\u043e\u043d\u043b\u0430\u0439\u043d|\u043e\u0440\u0433|\u0440\u0443\u0441"
+ "|\u0440\u0444|\u0441\u0430\u0439\u0442|\u0441\u0440\u0431|\u0443\u043a\u0440"
+ "|\u049b\u0430\u0437|\u0570\u0561\u0575|\u05e7\u05d5\u05dd"
+ "|\u0627\u0631\u0627\u0645\u0643\u0648|\u0627\u0644\u0627\u0631\u062f\u0646"
+ "|\u0627\u0644\u062c\u0632\u0627\u0626\u0631"
+ "|\u0627\u0644\u0633\u0639\u0648\u062f\u064a\u0629"
+ "|\u0627\u0644\u0645\u063a\u0631\u0628|\u0627\u0645\u0627\u0631\u0627\u062a"
+ "|\u0627\u06cc\u0631\u0627\u0646|\u0628\u0627\u0632\u0627\u0631"
+ "|\u0628\u06be\u0627\u0631\u062a|\u062a\u0648\u0646\u0633"
+ "|\u0633\u0648\u062f\u0627\u0646|\u0633\u0648\u0631\u064a\u0629"
+ "|\u0634\u0628\u0643\u0629|\u0639\u0631\u0627\u0642|\u0639\u0645\u0627\u0646"
+ "|\u0641\u0644\u0633\u0637\u064a\u0646|\u0642\u0637\u0631|\u0643\u0648\u0645"
+ "|\u0645\u0635\u0631|\u0645\u0644\u064a\u0633\u064a\u0627|\u0645\u0648\u0642\u0639"
+ "|\u0915\u0949\u092e|\u0928\u0947\u091f|\u092d\u093e\u0930\u0924"
+ "|\u0938\u0902\u0917\u0920\u0928|\u09ad\u09be\u09b0\u09a4|\u0a2d\u0a3e\u0a30\u0a24"
+ "|\u0aad\u0abe\u0ab0\u0aa4|\u0b87\u0ba8\u0bcd\u0ba4\u0bbf\u0baf\u0bbe"
+ "|\u0b87\u0bb2\u0b99\u0bcd\u0b95\u0bc8"
+ "|\u0b9a\u0bbf\u0b99\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0bc2\u0bb0\u0bcd"
+ "|\u0c2d\u0c3e\u0c30\u0c24\u0c4d|\u0dbd\u0d82\u0d9a\u0dcf|\u0e04\u0e2d\u0e21"
+ "|\u0e44\u0e17\u0e22|\u10d2\u10d4|\u307f\u3093\u306a|\u30b0\u30fc\u30b0\u30eb"
+ "|\u30b3\u30e0|\u4e16\u754c|\u4e2d\u4fe1|\u4e2d\u56fd|\u4e2d\u570b|\u4e2d\u6587\u7f51"
+ "|\u4f01\u4e1a|\u4f5b\u5c71|\u4fe1\u606f|\u5065\u5eb7|\u516b\u5366|\u516c\u53f8"
+ "|\u516c\u76ca|\u53f0\u6e7e|\u53f0\u7063|\u5546\u57ce|\u5546\u5e97|\u5546\u6807"
+ "|\u5728\u7ebf|\u5927\u62ff|\u5a31\u4e50|\u5de5\u884c|\u5e7f\u4e1c|\u6148\u5584"
+ "|\u6211\u7231\u4f60|\u624b\u673a|\u653f\u52a1|\u653f\u5e9c|\u65b0\u52a0\u5761"
+ "|\u65b0\u95fb|\u65f6\u5c1a|\u673a\u6784|\u6de1\u9a6c\u9521|\u6e38\u620f|\u70b9\u770b"
+ "|\u79fb\u52a8|\u7ec4\u7ec7\u673a\u6784|\u7f51\u5740|\u7f51\u5e97|\u7f51\u7edc"
+ "|\u8c37\u6b4c|\u96c6\u56e2|\u98de\u5229\u6d66|\u9910\u5385|\u9999\u6e2f|\ub2f7\ub137"
+ "|\ub2f7\ucef4|\uc0bc\uc131|\ud55c\uad6d|xbox|xerox|xin|xn\\-\\-11b4c3d"
+ "|xn\\-\\-1qqw23a|xn\\-\\-30rr7y|xn\\-\\-3bst00m|xn\\-\\-3ds443g|xn\\-\\-3e0b707e"
+ "|xn\\-\\-3pxu8k|xn\\-\\-42c2d9a|xn\\-\\-45brj9c|xn\\-\\-45q11c|xn\\-\\-4gbrim"
+ "|xn\\-\\-55qw42g|xn\\-\\-55qx5d|xn\\-\\-6frz82g|xn\\-\\-6qq986b3xl|xn\\-\\-80adxhks"
+ "|xn\\-\\-80ao21a|xn\\-\\-80asehdb|xn\\-\\-80aswg|xn\\-\\-90a3ac|xn\\-\\-90ais"
+ "|xn\\-\\-9dbq2a|xn\\-\\-9et52u|xn\\-\\-b4w605ferd|xn\\-\\-c1avg|xn\\-\\-c2br7g"
+ "|xn\\-\\-cg4bki|xn\\-\\-clchc0ea0b2g2a9gcd|xn\\-\\-czr694b|xn\\-\\-czrs0t"
+ "|xn\\-\\-czru2d|xn\\-\\-d1acj3b|xn\\-\\-d1alf|xn\\-\\-efvy88h|xn\\-\\-estv75g"
+ "|xn\\-\\-fhbei|xn\\-\\-fiq228c5hs|xn\\-\\-fiq64b|xn\\-\\-fiqs8s|xn\\-\\-fiqz9s"
+ "|xn\\-\\-fjq720a|xn\\-\\-flw351e|xn\\-\\-fpcrj9c3d|xn\\-\\-fzc2c9e2c|xn\\-\\-gecrj9c"
+ "|xn\\-\\-h2brj9c|xn\\-\\-hxt814e|xn\\-\\-i1b6b1a6a2e|xn\\-\\-imr513n|xn\\-\\-io0a7i"
+ "|xn\\-\\-j1aef|xn\\-\\-j1amh|xn\\-\\-j6w193g|xn\\-\\-kcrx77d1x4a|xn\\-\\-kprw13d"
+ "|xn\\-\\-kpry57d|xn\\-\\-kput3i|xn\\-\\-l1acc|xn\\-\\-lgbbat1ad8j|xn\\-\\-mgb9awbf"
+ "|xn\\-\\-mgba3a3ejt|xn\\-\\-mgba3a4f16a|xn\\-\\-mgbaam7a8h|xn\\-\\-mgbab2bd"
+ "|xn\\-\\-mgbayh7gpa|xn\\-\\-mgbbh1a71e|xn\\-\\-mgbc0a9azcg|xn\\-\\-mgberp4a5d4ar"
+ "|xn\\-\\-mgbpl2fh|xn\\-\\-mgbtx2b|xn\\-\\-mgbx4cd0ab|xn\\-\\-mk1bu44c|xn\\-\\-mxtq1m"
+ "|xn\\-\\-ngbc5azd|xn\\-\\-node|xn\\-\\-nqv7f|xn\\-\\-nqv7fs00ema|xn\\-\\-nyqy26a"
+ "|xn\\-\\-o3cw4h|xn\\-\\-ogbpf8fl|xn\\-\\-p1acf|xn\\-\\-p1ai|xn\\-\\-pgbs0dh"
+ "|xn\\-\\-pssy2u|xn\\-\\-q9jyb4c|xn\\-\\-qcka1pmc|xn\\-\\-qxam|xn\\-\\-rhqv96g"
+ "|xn\\-\\-s9brj9c|xn\\-\\-ses554g|xn\\-\\-t60b56a|xn\\-\\-tckwe|xn\\-\\-unup4y"
+ "|xn\\-\\-vermgensberater\\-ctb|xn\\-\\-vermgensberatung\\-pwb|xn\\-\\-vhquv"
+ "|xn\\-\\-vuq861b|xn\\-\\-wgbh1c|xn\\-\\-wgbl6a|xn\\-\\-xhq521b|xn\\-\\-xkc2al3hye2a"
+ "|xn\\-\\-xkc2dl3a5ee0h|xn\\-\\-y9a3aq|xn\\-\\-yfro4i67o|xn\\-\\-ygbi2ammx"
+ "|xn\\-\\-zfr164b|xperia|xxx|xyz)"
+ "|(?:yachts|yamaxun|yandex|yodobashi|yoga|yokohama|youtube|y[et])"
+ "|(?:zara|zip|zone|zuerich|z[amw]))";
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// CHANGED: Removed unused code //
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
public static final Pattern IP_ADDRESS
= Pattern.compile(
public static final Pattern IP_ADDRESS = Pattern.compile(
"((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]"
+ "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]"
+ "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}"
@ -204,28 +62,11 @@ public final class PatternsCompat {
*/
private static final String LABEL_CHAR = "a-zA-Z0-9" + UCS_CHAR;
/**
* Valid characters for IRI TLD defined in RFC 3987.
*/
private static final String TLD_CHAR = "a-zA-Z" + UCS_CHAR;
/**
* RFC 1035 Section 2.3.4 limits the labels to a maximum 63 octets.
*/
private static final String IRI_LABEL
= "[" + LABEL_CHAR + "](?:[" + LABEL_CHAR + "_\\-]{0,61}[" + LABEL_CHAR + "]){0,1}";
/**
* RFC 3492 references RFC 1034 and limits Punycode algorithm output to 63 characters.
*/
private static final String PUNYCODE_TLD = "xn\\-\\-[\\w\\-]{0,58}\\w";
private static final String TLD = "(" + PUNYCODE_TLD + "|" + "[" + TLD_CHAR + "]{2,63}" + ")";
private static final String HOST_NAME = "(" + IRI_LABEL + "\\.)+" + TLD;
public static final Pattern DOMAIN_NAME
= Pattern.compile("(" + HOST_NAME + "|" + IP_ADDRESS + ")");
private static final String IRI_LABEL =
"[" + LABEL_CHAR + "](?:[" + LABEL_CHAR + "_\\-]{0,61}[" + LABEL_CHAR + "]){0,1}";
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// CHANGED: Removed rtsp from supported protocols //
@ -245,59 +86,11 @@ public final class PatternsCompat {
+ ";/\\?:@&=#~" // plus optional query params
+ "\\-\\.\\+!\\*'\\(\\),_\\$])|(?:%[a-fA-F0-9]{2}))*";
/**
* Regular expression pattern to match most part of RFC 3987
* Internationalized URLs, aka IRIs.
*/
public static final Pattern WEB_URL = Pattern.compile("("
+ "("
+ "(?:" + PROTOCOL + "(?:" + USER_INFO + ")?" + ")?"
+ "(?:" + DOMAIN_NAME + ")"
+ "(?:" + PORT_NUMBER + ")?"
+ ")"
+ "(" + PATH_AND_QUERY + ")?"
+ WORD_BOUNDARY
+ ")");
/**
* Regular expression that matches known TLDs and punycode TLDs.
*/
private static final String STRICT_TLD = "(?:"
+ IANA_TOP_LEVEL_DOMAINS + "|" + PUNYCODE_TLD + ")";
/**
* Regular expression that matches host names using {@link #STRICT_TLD}.
*/
private static final String STRICT_HOST_NAME = "(?:(?:" + IRI_LABEL + "\\.)+"
+ STRICT_TLD + ")";
/**
* Regular expression that matches domain names using either {@link #STRICT_HOST_NAME} or
* {@link #IP_ADDRESS}.
*/
private static final Pattern STRICT_DOMAIN_NAME
= Pattern.compile("(?:" + STRICT_HOST_NAME + "|" + IP_ADDRESS + ")");
/**
* Regular expression that matches domain names without a TLD.
*/
private static final String RELAXED_DOMAIN_NAME
= "(?:" + "(?:" + IRI_LABEL + "(?:\\.(?=\\S))" + "?)+" + "|" + IP_ADDRESS + ")";
/**
* Regular expression to match strings that do not start with a supported protocol. The TLDs
* are expected to be one of the known TLDs.
*/
private static final String WEB_URL_WITHOUT_PROTOCOL = "("
+ WORD_BOUNDARY
+ "(?<!:\\/\\/)"
+ "("
+ "(?:" + STRICT_DOMAIN_NAME + ")"
+ "(?:" + PORT_NUMBER + ")?"
+ ")"
+ "(?:" + PATH_AND_QUERY + ")?"
+ WORD_BOUNDARY
+ ")";
private static final String RELAXED_DOMAIN_NAME =
"(?:" + "(?:" + IRI_LABEL + "(?:\\.(?=\\S))" + "?)+" + "|" + IP_ADDRESS + ")";
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// CHANGED: Field visibility was modified //
@ -317,59 +110,6 @@ public final class PatternsCompat {
+ WORD_BOUNDARY
+ ")";
/**
* Regular expression pattern to match IRIs. If a string starts with http(s):// the expression
* tries to match the URL structure with a relaxed rule for TLDs. If the string does not start
* with http(s):// the TLDs are expected to be one of the known TLDs.
*
* @hide
*/
@RestrictTo(LIBRARY_GROUP_PREFIX)
public static final Pattern AUTOLINK_WEB_URL = Pattern.compile(
"(" + WEB_URL_WITH_PROTOCOL + "|" + WEB_URL_WITHOUT_PROTOCOL + ")");
/**
* Regular expression for valid email characters. Does not include some of the valid characters
* defined in RFC5321: #&~!^`{}/=$*?|
*/
private static final String EMAIL_CHAR = LABEL_CHAR + "\\+\\-_%'";
/**
* Regular expression for local part of an email address. RFC5321 section 4.5.3.1.1 limits
* the local part to be at most 64 octets.
*/
private static final String EMAIL_ADDRESS_LOCAL_PART
= "[" + EMAIL_CHAR + "]" + "(?:[" + EMAIL_CHAR + "\\.]{0,62}[" + EMAIL_CHAR + "])?";
/**
* Regular expression for the domain part of an email address. RFC5321 section 4.5.3.1.2 limits
* the domain to be at most 255 octets.
*/
private static final String EMAIL_ADDRESS_DOMAIN
= "(?=.{1,255}(?:\\s|$|^))" + HOST_NAME;
/**
* Regular expression pattern to match email addresses. It excludes double quoted local parts
* and the special characters #&~!^`{}/=$*?| that are included in RFC5321.
* @hide
*/
@RestrictTo(LIBRARY_GROUP_PREFIX)
public static final Pattern AUTOLINK_EMAIL_ADDRESS = Pattern.compile("(" + WORD_BOUNDARY
+ "(?:" + EMAIL_ADDRESS_LOCAL_PART + "@" + EMAIL_ADDRESS_DOMAIN + ")"
+ WORD_BOUNDARY + ")"
);
public static final Pattern EMAIL_ADDRESS
= Pattern.compile(
"[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}"
+ "\\@"
+ "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}"
+ "("
+ "\\."
+ "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}"
+ ")+"
);
/**
* Do not create this static utility class.
*/