diff --git a/app/src/debug/java/org/schabi/newpipe/DebugApp.kt b/app/src/debug/java/org/schabi/newpipe/DebugApp.kt index 70b9ec280..24a0bfa05 100644 --- a/app/src/debug/java/org/schabi/newpipe/DebugApp.kt +++ b/app/src/debug/java/org/schabi/newpipe/DebugApp.kt @@ -26,7 +26,8 @@ class DebugApp : App() { override fun getDownloader(): Downloader { val downloader = DownloaderImpl.init( OkHttpClient.Builder() - .addNetworkInterceptor(StethoInterceptor()) + .addNetworkInterceptor(StethoInterceptor()), + this ) setCookiesToDownloader(downloader) return downloader diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index cf41aad46..92cb0f2a0 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -141,7 +141,7 @@ public class App extends Application { } protected Downloader getDownloader() { - final DownloaderImpl downloader = DownloaderImpl.init(null); + final DownloaderImpl downloader = DownloaderImpl.init(null, this); setCookiesToDownloader(downloader); return downloader; } diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java index 041e91396..59ab0b61d 100644 --- a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java +++ b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java @@ -11,6 +11,7 @@ import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.downloader.Request; import org.schabi.newpipe.extractor.downloader.Response; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; +import org.schabi.newpipe.util.ProxyManager; import org.schabi.newpipe.util.InfoCache; import java.io.IOException; @@ -52,11 +53,18 @@ public final class DownloaderImpl extends Downloader { * It's recommended to call exactly once in the entire lifetime of the application. * * @param builder if null, default builder will be used + * @param context the context to use * @return a new instance of {@link DownloaderImpl} */ - public static DownloaderImpl init(@Nullable final OkHttpClient.Builder builder) { - instance = new DownloaderImpl( - builder != null ? builder : new OkHttpClient.Builder()); + public static DownloaderImpl init(@Nullable final OkHttpClient.Builder builder, + final Context context) { + final OkHttpClient.Builder builderToUse = builder != null ? builder + : new OkHttpClient.Builder(); + final ProxyManager proxyManager = new ProxyManager(context); + if (proxyManager.isProxyEnabled()) { + builderToUse.proxy(proxyManager.getProxy()); + } + instance = new DownloaderImpl(builderToUse); return instance; } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java index 4cdb649a3..44c0f8458 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java @@ -20,6 +20,7 @@ import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl; import static java.lang.Math.min; +import android.content.Context; import android.net.Uri; import androidx.annotation.NonNull; @@ -45,6 +46,7 @@ import com.google.common.collect.Sets; import com.google.common.net.HttpHeaders; import org.schabi.newpipe.DownloaderImpl; +import org.schabi.newpipe.util.ProxyManager; import java.io.IOException; import java.io.InputStream; @@ -54,6 +56,7 @@ import java.lang.reflect.Method; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.NoRouteToHostException; +import java.net.Proxy; import java.net.URL; import java.util.HashMap; import java.util.List; @@ -84,6 +87,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD */ public static final class Factory implements HttpDataSource.Factory { + private final Context context; private final RequestProperties defaultRequestProperties; @Nullable @@ -100,8 +104,10 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD /** * Creates an instance. + * @param context the context to use */ - public Factory() { + public Factory(final Context context) { + this.context = context; defaultRequestProperties = new RequestProperties(); connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; @@ -220,7 +226,6 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD *

The default is {@code null}. * *

See {@link DataSource#addTransferListener(TransferListener)}. - * * @param transferListenerToUse The listener that will be used. * @return This factory. */ @@ -247,6 +252,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD @Override public YoutubeHttpDataSource createDataSource() { final YoutubeHttpDataSource dataSource = new YoutubeHttpDataSource( + context, connectTimeoutMs, readTimeoutMs, allowCrossProtocolRedirects, @@ -272,6 +278,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD private static final String YOUTUBE_BASE_URL = "https://www.youtube.com"; private static final byte[] POST_BODY = new byte[] {0x78, 0}; + private final Context context; private final boolean allowCrossProtocolRedirects; private final boolean rangeParameterEnabled; private final boolean rnParameterEnabled; @@ -299,7 +306,8 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD private long requestNumber; @SuppressWarnings("checkstyle:ParameterNumber") - private YoutubeHttpDataSource(final int connectTimeoutMillis, + private YoutubeHttpDataSource(final Context context, + final int connectTimeoutMillis, final int readTimeoutMillis, final boolean allowCrossProtocolRedirects, final boolean rangeParameterEnabled, @@ -308,6 +316,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD @Nullable final Predicate contentTypePredicate, final boolean keepPostFor302Redirects) { super(true); + this.context = context; this.connectTimeoutMillis = connectTimeoutMillis; this.readTimeoutMillis = readTimeoutMillis; this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; @@ -708,6 +717,11 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD * @return an {@link HttpURLConnection} created with the {@code url} */ private HttpURLConnection openConnection(@NonNull final URL url) throws IOException { + final ProxyManager proxyManager = new ProxyManager(context); + final Proxy proxy = proxyManager.getProxy(); + if (proxy != null) { + return (HttpURLConnection) url.openConnection(proxy); + } return (HttpURLConnection) url.openConnection(); } @@ -1006,4 +1020,3 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD } } } - diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 506b643fe..dd1b2f028 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -18,12 +18,10 @@ import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; @@ -86,20 +84,22 @@ public class PlayerDataSource { // make sure the static cache was created: needed by CacheFactories below instantiateCacheIfNeeded(context); - // generic data source factories use DefaultHttpDataSource.Factory + // generic data source factories now use YoutubeHttpDataSource.Factory to support proxies + final YoutubeHttpDataSource.Factory youtubeHttpDataSourceFactory = + getYoutubeHttpDataSourceFactory(context, false, false); cachelessDataSourceFactory = new DefaultDataSource.Factory(context, - new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)) + youtubeHttpDataSourceFactory) .setTransferListener(transferListener); cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)); + youtubeHttpDataSourceFactory); // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - getYoutubeHttpDataSourceFactory(false, false)); + getYoutubeHttpDataSourceFactory(context, false, false)); ytDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - getYoutubeHttpDataSourceFactory(true, true)); + getYoutubeHttpDataSourceFactory(context, true, true)); ytProgressiveDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - getYoutubeHttpDataSourceFactory(false, true)); + getYoutubeHttpDataSourceFactory(context, false, true)); // set the maximum size to manifest creators YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); @@ -198,9 +198,10 @@ public class PlayerDataSource { } private static YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( + final Context context, final boolean rangeParameterEnabled, final boolean rnParameterEnabled) { - return new YoutubeHttpDataSource.Factory() + return new YoutubeHttpDataSource.Factory(context) .setRangeParameterEnabled(rangeParameterEnabled) .setRnParameterEnabled(rnParameterEnabled); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/ProxySettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ProxySettingsFragment.java new file mode 100644 index 000000000..8bf99d6f1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/ProxySettingsFragment.java @@ -0,0 +1,70 @@ +package org.schabi.newpipe.settings; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.SharedPreferences; +import android.os.Bundle; +import androidx.annotation.Nullable; +import org.schabi.newpipe.R; +import org.schabi.newpipe.util.NavigationHelper; + +/** + * A fragment that displays proxy settings. + */ +public class ProxySettingsFragment extends BasePreferenceFragment { + + private boolean preferencesChanged = false; + private SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener; + + @Override + public void onCreatePreferences(@Nullable final Bundle savedInstanceState, + @Nullable final String rootKey) { + //addPreferencesFromResource(R.xml.proxy_settings); + addPreferencesFromResourceRegistry(); + preferenceChangeListener = (sharedPreferences, key) -> { + preferencesChanged = true; + }; + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(preferenceChangeListener); + } + + @Override + public void onStop() { + super.onStop(); + if (preferencesChanged && getActivity() != null && !getActivity().isFinishing()) { + showRestartDialog(); + } + } + + private void showRestartDialog() { + // Show Alert Dialogue + final Activity activity = getActivity(); + if (activity == null) { + return; + } + final AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); + builder.setMessage(R.string.restart_app_message); + builder.setTitle(R.string.restart_app_title); + builder.setCancelable(true); + builder.setPositiveButton(R.string.ok, (dialogInterface, i) -> { + // Restarts the app + if (activity == null) { + return; + } + NavigationHelper.restartApp(activity); + }); + builder.setNegativeButton(R.string.cancel, (dialogInterface, i) -> { + }); + final android.app.AlertDialog alertDialog = builder.create(); + alertDialog.show(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (preferenceChangeListener != null && getPreferenceScreen() != null) { + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java index 06e0a7c1e..43ccdb2a4 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java @@ -42,6 +42,7 @@ public final class SettingsResourceRegistry { add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings); add(ExoPlayerSettingsFragment.class, R.xml.exoplayer_settings); add(BackupRestoreSettingsFragment.class, R.xml.backup_restore_settings); + add(ProxySettingsFragment.class, R.xml.proxy_settings); } private SettingRegistryEntry add( diff --git a/app/src/main/java/org/schabi/newpipe/util/ProxyManager.java b/app/src/main/java/org/schabi/newpipe/util/ProxyManager.java new file mode 100644 index 000000000..cec665ba7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ProxyManager.java @@ -0,0 +1,79 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.preference.PreferenceManager; + +import java.net.InetSocketAddress; +import java.net.Proxy; + +/** + * A class to manage proxy settings. + */ +public class ProxyManager { + + private final SharedPreferences sharedPreferences; + + /** + * Creates a new ProxyManager. + * @param context the context to use + */ + public ProxyManager(final Context context) { + this.sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + } + + /** + * Checks if the proxy is enabled. + * @return true if the proxy is enabled, false otherwise + */ + public boolean isProxyEnabled() { + return sharedPreferences.getBoolean("use_proxy", false); + } + + /** + * Gets the proxy host. + * @return the proxy host + */ + public String getProxyHost() { + return sharedPreferences.getString("proxy_host", "127.0.0.1"); + } + + /** + * Gets the proxy port. + * @return the proxy port + */ + public int getProxyPort() { + final String portString = sharedPreferences.getString("proxy_port", "1080"); + try { + return Integer.parseInt(portString); + } catch (final NumberFormatException e) { + return 1080; + } + } + + /** + * Gets the proxy type. + * @return the proxy type + */ + public Proxy.Type getProxyType() { + final String type = sharedPreferences.getString("proxy_type", "SOCKS"); + if ("SOCKS".equals(type)) { + return Proxy.Type.SOCKS; + } else { + return Proxy.Type.HTTP; + } + } + + /** + * Gets the proxy. + * @return the proxy, or null if the proxy is not enabled + */ + public Proxy getProxy() { + if (!isProxyEnabled()) { + return null; + } + return new Proxy(getProxyType(), new InetSocketAddress(getProxyHost(), getProxyPort())); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java index 4b116bdf9..ef513064d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java @@ -23,6 +23,7 @@ import com.squareup.picasso.Transformation; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.util.ProxyManager; import java.io.File; import java.io.IOException; @@ -49,12 +50,17 @@ public final class PicassoHelper { public static void init(final Context context) { picassoCache = new LruCache(10 * 1024 * 1024); - picassoDownloaderClient = new OkHttpClient.Builder() + final ProxyManager proxyManager = new ProxyManager(context); + final OkHttpClient.Builder builder = new OkHttpClient.Builder() .cache(new okhttp3.Cache(new File(context.getExternalCacheDir(), "picasso"), 50L * 1024L * 1024L)) // this should already be the default timeout in OkHttp3, but just to be sure... - .callTimeout(15, TimeUnit.SECONDS) - .build(); + .callTimeout(15, TimeUnit.SECONDS); + + if (proxyManager.isProxyEnabled()) { + builder.proxy(proxyManager.getProxy()); + } + picassoDownloaderClient = builder.build(); picassoInstance = new Picasso.Builder(context) .memoryCache(picassoCache) // memory cache diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index 54340ce5d..77cfb0bab 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -1,5 +1,6 @@ package us.shandian.giga.get; +import android.content.Context; import android.os.Handler; import android.system.ErrnoException; import android.system.OsConstants; @@ -17,6 +18,7 @@ import java.io.InterruptedIOException; import java.io.Serializable; import java.net.ConnectException; import java.net.HttpURLConnection; +import java.net.Proxy; import java.net.SocketTimeoutException; import java.net.URL; import java.net.UnknownHostException; @@ -25,6 +27,7 @@ import java.util.Objects; import javax.net.ssl.SSLException; +import org.schabi.newpipe.util.ProxyManager; import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManagerService; @@ -34,7 +37,7 @@ import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadMission extends Mission { private static final long serialVersionUID = 6L;// last bump: 07 october 2019 - + private final Context context; static final int BUFFER_SIZE = 64 * 1024; static final int BLOCK_SIZE = 512 * 1024; @@ -153,9 +156,10 @@ public class DownloadMission extends Mission { public transient Thread[] threads = new Thread[0]; public transient Thread init = null; - public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { + public DownloadMission(final Context context, String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { if (Objects.requireNonNull(urls).length < 1) throw new IllegalArgumentException("urls array is empty"); + this.context = context; this.urls = urls; this.kind = kind; this.offsets = new long[urls.length]; @@ -164,6 +168,7 @@ public class DownloadMission extends Mission { this.storage = storage; this.psAlgorithm = psInstance; + if (DEBUG && psInstance == null && urls.length > 1) { Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); } @@ -219,7 +224,14 @@ public class DownloadMission extends Mission { } HttpURLConnection openConnection(String url, boolean headRequest, long rangeStart, long rangeEnd) throws IOException { - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + final ProxyManager proxyManager = new ProxyManager(context); + final Proxy proxy = proxyManager.getProxy(); + final HttpURLConnection conn; + if (proxy != null) { + conn = (HttpURLConnection) new URL(url).openConnection(proxy); + } else { + conn = (HttpURLConnection) new URL(url).openConnection(); + } conn.setInstanceFollowRedirects(true); conn.setRequestProperty("User-Agent", DownloaderImpl.USER_AGENT); conn.setRequestProperty("Accept", "*/*"); diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index 76da18b2d..65c8b623a 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -408,7 +408,7 @@ public class DownloadManagerService extends Service { else ps = Postprocessing.getAlgorithm(psName, psArgs, streamInfo); - final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); + final DownloadMission mission = new DownloadMission(this, urls, storage, kind, ps); mission.threadCount = threads; mission.source = streamInfo.getUrl(); mission.nearLength = nearLength; diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fc32c0da3..bc21d9120 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -34,6 +34,14 @@ Внешний видеоплеер Внешний аудиоплеер Воспроизведение в фоновом режиме + Настройки прокси + Использовать прокси + Перенаправлять трафик через прокси + Хост прокси + Имя хоста или IP-адрес прокси + Порт прокси + Номер порта прокси + Введите номер порта прокси Тема Тёмная Светлая @@ -868,4 +876,6 @@ Во время воспроизведения получена ошибка HTTP 403 от сервера, вероятно, вызванная блокировкой IP-адреса или проблемами деобфускации URL-адреса потоковой передачи %1$s отказался предоставить данные, запросив логин для подтверждения, что запросчик не бот.\n\nВозможно, ваш IP-адрес временно заблокирован %1$s. Вы можете подождать некоторое время или переключиться на другой IP-адрес (например, включив/выключив VPN или переключившись с Wi-Fi на мобильный интернет). Этот контент недоступен для выбранной страны контента.\n\nИзмените свой выбор в разделе «Настройки > Контент > Страна контента по умолчанию». + Перезапуск приложения + Настройки прокси изменены. Для применения изменений требуется перезапуск приложения. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9095fe927..4df1e1497 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,6 +159,23 @@ Player notification Configure current playing stream notification Backup and restore + Proxy Settings + Use proxy + Redirect traffic through a proxy + Proxy host + Hostname or IP address of the proxy + Proxy port + Port number of the proxy + Enter the proxy port number + Proxy type + + HTTP + SOCKS + + + HTTP + SOCKS + Playing in background Playing in popup mode Content @@ -882,4 +899,6 @@ HTTP error 403 received from server while playing, likely caused by an IP ban or streaming URL deobfuscation issues %1$s refused to provide data, asking for a login to confirm the requester is not a bot.\n\nYour IP might have been temporarily banned by %1$s, you can wait some time or switch to a different IP (for example by turning on/off a VPN, or by switching from WiFi to mobile data). This content is not available for the currently selected content country.\n\nChange your selection from \"Settings > Content > Default content country\". + Restart application + The proxy settings have been changed. A restart of the application is required for the changes to take effect. diff --git a/app/src/main/res/xml/main_settings.xml b/app/src/main/res/xml/main_settings.xml index 5f96989f9..bdb02355f 100644 --- a/app/src/main/res/xml/main_settings.xml +++ b/app/src/main/res/xml/main_settings.xml @@ -16,6 +16,12 @@ android:title="@string/settings_category_downloads_title" app:iconSpaceReserved="false" /> + + + + + + + + + + + + + \ No newline at end of file