Merge branch 'dev' into pr8221
This commit is contained in:
commit
e1ce3fef1b
1741 changed files with 56947 additions and 21110 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
151
app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java
Normal file
151
app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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("; *")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue