mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2025-10-03 09:49:21 +02:00
Add more specific error messages and deduplicate their handling
This commit is contained in:
parent
d17eae9bad
commit
38064be702
8 changed files with 52 additions and 79 deletions
|
@ -214,7 +214,7 @@ dependencies {
|
||||||
// the corresponding commit hash, since JitPack sometimes deletes artifacts.
|
// the corresponding commit hash, since JitPack sometimes deletes artifacts.
|
||||||
// If there’s already a git hash, just add more of it to the end (or remove a letter)
|
// If there’s already a git hash, just add more of it to the end (or remove a letter)
|
||||||
// to cause jitpack to regenerate the artifact.
|
// to cause jitpack to regenerate the artifact.
|
||||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.8'
|
implementation 'com.github.Stypox:NewPipeExtractor:b8bd4cda8cca00a14940933e3d3635d5aafec222'
|
||||||
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
|
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
|
||||||
|
|
||||||
/** Checkstyle **/
|
/** Checkstyle **/
|
||||||
|
|
|
@ -58,20 +58,13 @@ import org.schabi.newpipe.extractor.NewPipe;
|
||||||
import org.schabi.newpipe.extractor.StreamingService;
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
import org.schabi.newpipe.extractor.StreamingService.LinkType;
|
import org.schabi.newpipe.extractor.StreamingService.LinkType;
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
|
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.PaidContentException;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
|
|
||||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
|
||||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||||
import org.schabi.newpipe.player.PlayerType;
|
import org.schabi.newpipe.player.PlayerType;
|
||||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||||
|
@ -279,28 +272,11 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
final Intent intent = new Intent(context, ReCaptchaActivity.class);
|
final Intent intent = new Intent(context, ReCaptchaActivity.class);
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
context.startActivity(intent);
|
context.startActivity(intent);
|
||||||
} else if (errorInfo.getThrowable() != null
|
} else if (errorInfo.getThrowable() instanceof ContentNotAvailableException
|
||||||
&& ExceptionUtils.isNetworkRelated(errorInfo.getThrowable())) {
|
|| errorInfo.getThrowable() instanceof ContentNotSupportedException) {
|
||||||
Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show();
|
// this exception does not usually indicate a problem that should be reported,
|
||||||
} else if (errorInfo.getThrowable() instanceof AgeRestrictedContentException) {
|
// so just show a toast instead of the notification
|
||||||
Toast.makeText(context, R.string.restricted_video_no_stream,
|
Toast.makeText(context, errorInfo.getMessageStringId(), Toast.LENGTH_LONG).show();
|
||||||
Toast.LENGTH_LONG).show();
|
|
||||||
} else if (errorInfo.getThrowable() instanceof GeographicRestrictionException) {
|
|
||||||
Toast.makeText(context, R.string.georestricted_content, Toast.LENGTH_LONG).show();
|
|
||||||
} else if (errorInfo.getThrowable() instanceof PaidContentException) {
|
|
||||||
Toast.makeText(context, R.string.paid_content, Toast.LENGTH_LONG).show();
|
|
||||||
} else if (errorInfo.getThrowable() instanceof PrivateContentException) {
|
|
||||||
Toast.makeText(context, R.string.private_content, Toast.LENGTH_LONG).show();
|
|
||||||
} else if (errorInfo.getThrowable() instanceof SoundCloudGoPlusContentException) {
|
|
||||||
Toast.makeText(context, R.string.soundcloud_go_plus_content,
|
|
||||||
Toast.LENGTH_LONG).show();
|
|
||||||
} else if (errorInfo.getThrowable() instanceof YoutubeMusicPremiumContentException) {
|
|
||||||
Toast.makeText(context, R.string.youtube_music_premium_content,
|
|
||||||
Toast.LENGTH_LONG).show();
|
|
||||||
} else if (errorInfo.getThrowable() instanceof ContentNotAvailableException) {
|
|
||||||
Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show();
|
|
||||||
} else if (errorInfo.getThrowable() instanceof ContentNotSupportedException) {
|
|
||||||
Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show();
|
|
||||||
} else {
|
} else {
|
||||||
ErrorUtil.createNotification(context, errorInfo);
|
ErrorUtil.createNotification(context, errorInfo);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,14 +3,25 @@ package org.schabi.newpipe.error
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import com.google.android.exoplayer2.ExoPlaybackException
|
import com.google.android.exoplayer2.ExoPlaybackException
|
||||||
|
import com.google.android.exoplayer2.upstream.HttpDataSource
|
||||||
|
import com.google.android.exoplayer2.upstream.Loader
|
||||||
import kotlinx.parcelize.IgnoredOnParcel
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.extractor.Info
|
import org.schabi.newpipe.extractor.Info
|
||||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.PaidContentException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.PrivateContentException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.YoutubeSignInConfirmNotBotException
|
||||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||||
import org.schabi.newpipe.util.ServiceHelper
|
import org.schabi.newpipe.util.ServiceHelper
|
||||||
|
|
||||||
|
@ -91,11 +102,28 @@ class ErrorInfo(
|
||||||
action: UserAction
|
action: UserAction
|
||||||
): Int {
|
): Int {
|
||||||
return when {
|
return when {
|
||||||
|
// content not available exceptions
|
||||||
throwable is AccountTerminatedException -> R.string.account_terminated
|
throwable is AccountTerminatedException -> R.string.account_terminated
|
||||||
|
throwable is AgeRestrictedContentException -> R.string.restricted_video_no_stream
|
||||||
|
throwable is GeographicRestrictionException -> R.string.georestricted_content
|
||||||
|
throwable is PaidContentException -> R.string.paid_content
|
||||||
|
throwable is PrivateContentException -> R.string.private_content
|
||||||
|
throwable is SoundCloudGoPlusContentException -> R.string.soundcloud_go_plus_content
|
||||||
|
throwable is UnsupportedContentInCountryException -> R.string.unsupported_content_in_country
|
||||||
|
throwable is YoutubeMusicPremiumContentException -> R.string.youtube_music_premium_content
|
||||||
|
throwable is YoutubeSignInConfirmNotBotException -> R.string.youtube_sign_in_confirm_not_bot_error
|
||||||
throwable is ContentNotAvailableException -> R.string.content_not_available
|
throwable is ContentNotAvailableException -> R.string.content_not_available
|
||||||
throwable != null && throwable.isNetworkRelated -> R.string.network_error
|
|
||||||
|
// ReCaptchas should have already been handled elsewhere,
|
||||||
|
// but return an error message here just in case
|
||||||
|
throwable is ReCaptchaException -> R.string.recaptcha_request_toast
|
||||||
|
|
||||||
|
// other extractor exceptions
|
||||||
throwable is ContentNotSupportedException -> R.string.content_not_supported
|
throwable is ContentNotSupportedException -> R.string.content_not_supported
|
||||||
|
throwable != null && throwable.isNetworkRelated -> R.string.network_error
|
||||||
throwable is ExtractionException -> R.string.parsing_error
|
throwable is ExtractionException -> R.string.parsing_error
|
||||||
|
|
||||||
|
// ExoPlayer exceptions
|
||||||
throwable is ExoPlaybackException -> {
|
throwable is ExoPlaybackException -> {
|
||||||
when (throwable.type) {
|
when (throwable.type) {
|
||||||
ExoPlaybackException.TYPE_SOURCE -> R.string.player_stream_failure
|
ExoPlaybackException.TYPE_SOURCE -> R.string.player_stream_failure
|
||||||
|
@ -103,13 +131,15 @@ class ErrorInfo(
|
||||||
else -> R.string.player_unrecoverable_failure
|
else -> R.string.player_unrecoverable_failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// user actions (in case the exception is unrecognizable)
|
||||||
action == UserAction.UI_ERROR -> R.string.app_ui_crash
|
action == UserAction.UI_ERROR -> R.string.app_ui_crash
|
||||||
action == UserAction.REQUESTED_COMMENTS -> R.string.error_unable_to_load_comments
|
action == UserAction.REQUESTED_COMMENTS -> R.string.error_unable_to_load_comments
|
||||||
action == UserAction.SUBSCRIPTION_CHANGE -> R.string.subscription_change_failed
|
action == UserAction.SUBSCRIPTION_CHANGE -> R.string.subscription_change_failed
|
||||||
action == UserAction.SUBSCRIPTION_UPDATE -> R.string.subscription_update_failed
|
action == UserAction.SUBSCRIPTION_UPDATE -> R.string.subscription_update_failed
|
||||||
action == UserAction.LOAD_IMAGE -> R.string.could_not_load_thumbnails
|
action == UserAction.LOAD_IMAGE -> R.string.could_not_load_thumbnails
|
||||||
action == UserAction.DOWNLOAD_OPEN_DIALOG -> R.string.could_not_setup_download_menu
|
action == UserAction.DOWNLOAD_OPEN_DIALOG -> R.string.could_not_setup_download_menu
|
||||||
else -> R.string.general_error
|
else -> R.string.error_snackbar_message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,19 +15,12 @@ import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import org.schabi.newpipe.MainActivity
|
import org.schabi.newpipe.MainActivity
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||||
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.PaidContentException
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.PrivateContentException
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
|
|
||||||
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
||||||
import org.schabi.newpipe.ktx.animate
|
import org.schabi.newpipe.ktx.animate
|
||||||
import org.schabi.newpipe.ktx.isInterruptedCaused
|
import org.schabi.newpipe.ktx.isInterruptedCaused
|
||||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
|
||||||
import org.schabi.newpipe.util.ServiceHelper
|
import org.schabi.newpipe.util.ServiceHelper
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
@ -127,7 +120,7 @@ class ErrorPanelHelper(
|
||||||
ErrorUtil.openActivity(context, errorInfo)
|
ErrorUtil.openActivity(context, errorInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
errorTextView.setText(getExceptionDescription(errorInfo.throwable))
|
errorTextView.setText(errorInfo.messageStringId)
|
||||||
|
|
||||||
if (errorInfo.throwable !is ContentNotAvailableException &&
|
if (errorInfo.throwable !is ContentNotAvailableException &&
|
||||||
errorInfo.throwable !is ContentNotSupportedException
|
errorInfo.throwable !is ContentNotSupportedException
|
||||||
|
@ -192,27 +185,5 @@ class ErrorPanelHelper(
|
||||||
companion object {
|
companion object {
|
||||||
val TAG: String = ErrorPanelHelper::class.simpleName!!
|
val TAG: String = ErrorPanelHelper::class.simpleName!!
|
||||||
val DEBUG: Boolean = MainActivity.DEBUG
|
val DEBUG: Boolean = MainActivity.DEBUG
|
||||||
|
|
||||||
@StringRes
|
|
||||||
fun getExceptionDescription(throwable: Throwable?): Int {
|
|
||||||
return when (throwable) {
|
|
||||||
is AgeRestrictedContentException -> R.string.restricted_video_no_stream
|
|
||||||
is GeographicRestrictionException -> R.string.georestricted_content
|
|
||||||
is PaidContentException -> R.string.paid_content
|
|
||||||
is PrivateContentException -> R.string.private_content
|
|
||||||
is SoundCloudGoPlusContentException -> R.string.soundcloud_go_plus_content
|
|
||||||
is YoutubeMusicPremiumContentException -> R.string.youtube_music_premium_content
|
|
||||||
is ContentNotAvailableException -> R.string.content_not_available
|
|
||||||
is ContentNotSupportedException -> R.string.content_not_supported
|
|
||||||
else -> {
|
|
||||||
// show retry button only for content which is not unavailable or unsupported
|
|
||||||
if (throwable != null && throwable.isNetworkRelated) {
|
|
||||||
R.string.network_error
|
|
||||||
} else {
|
|
||||||
R.string.error_snackbar_message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -156,7 +156,7 @@ class ErrorUtil {
|
||||||
// fallback to showing a notification if no root view is available
|
// fallback to showing a notification if no root view is available
|
||||||
createNotification(context, errorInfo)
|
createNotification(context, errorInfo)
|
||||||
} else {
|
} else {
|
||||||
Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG)
|
Snackbar.make(rootView, errorInfo.messageStringId, Snackbar.LENGTH_LONG)
|
||||||
.setActionTextColor(Color.YELLOW)
|
.setActionTextColor(Color.YELLOW)
|
||||||
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
|
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
|
||||||
openActivity(context, errorInfo)
|
openActivity(context, errorInfo)
|
||||||
|
|
|
@ -33,7 +33,8 @@ public enum UserAction {
|
||||||
SHARE_TO_NEWPIPE("share to newpipe"),
|
SHARE_TO_NEWPIPE("share to newpipe"),
|
||||||
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
|
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
|
||||||
OPEN_INFO_ITEM_DIALOG("open info item dialog"),
|
OPEN_INFO_ITEM_DIALOG("open info item dialog"),
|
||||||
GETTING_MAIN_SCREEN_TAB("getting main screen tab");
|
GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
|
||||||
|
PLAY_ON_POPUP("play on popup");
|
||||||
|
|
||||||
private final String message;
|
private final String message;
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
package org.schabi.newpipe.util.text;
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.MainActivity;
|
import org.schabi.newpipe.MainActivity;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.error.ErrorInfo;
|
||||||
import org.schabi.newpipe.error.ErrorPanelHelper;
|
import org.schabi.newpipe.error.ErrorUtil;
|
||||||
|
import org.schabi.newpipe.error.UserAction;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
import org.schabi.newpipe.extractor.StreamingService;
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
|
@ -158,19 +157,13 @@ public final class InternalUrlsHandler {
|
||||||
disposables.add(single.subscribeOn(Schedulers.io())
|
disposables.add(single.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(info -> {
|
.subscribe(info -> {
|
||||||
final PlayQueue playQueue =
|
final PlayQueue playQueue = new SinglePlayQueue(info, seconds * 1000L);
|
||||||
new SinglePlayQueue(info, seconds * 1000L);
|
|
||||||
NavigationHelper.playOnPopupPlayer(context, playQueue, false);
|
NavigationHelper.playOnPopupPlayer(context, playQueue, false);
|
||||||
}, throwable -> {
|
}, throwable -> {
|
||||||
if (DEBUG) {
|
final var errorInfo = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP, url);
|
||||||
Log.e(TAG, "Could not play on popup: " + url, throwable);
|
// This will only show a snackbar if the passed context has a root view:
|
||||||
}
|
// otherwise it will resort to showing a notification, so we are safe here.
|
||||||
new AlertDialog.Builder(context)
|
ErrorUtil.showSnackbar(context, errorInfo);
|
||||||
.setTitle(R.string.player_stream_failure)
|
|
||||||
.setMessage(
|
|
||||||
ErrorPanelHelper.Companion.getExceptionDescription(throwable))
|
|
||||||
.setPositiveButton(R.string.ok, null)
|
|
||||||
.show();
|
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -877,4 +877,6 @@
|
||||||
<string name="trending_movies">Trending movies and shows</string>
|
<string name="trending_movies">Trending movies and shows</string>
|
||||||
<string name="trending_music">Trending music</string>
|
<string name="trending_music">Trending music</string>
|
||||||
<string name="entry_deleted">Entry deleted</string>
|
<string name="entry_deleted">Entry deleted</string>
|
||||||
|
<string name="youtube_sign_in_confirm_not_bot_error">YouTube refused to provide data, asking for a login.\n\nYour IP might have been temporarily banned by YouTube, 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).</string>
|
||||||
|
<string name="unsupported_content_in_country">This content is not available for the currently selected content country.\n\nChange your selection from \"Settings > Content > Default content country\".</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue