Compare commits

...

4 Commits

Author SHA1 Message Date
semantic-release-bot
77aea074a9 chore: Release v5.2.0-dev.5 [skip ci]
# [5.2.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.2.0-dev.4...v5.2.0-dev.5) (2024-11-27)

### Bug Fixes

* **YouTube - Spoof video streams:** Log out the iOS client to restore kids videos playback ([#4000](https://github.com/ReVanced/revanced-patches/issues/4000)) ([fe15213](fe15213cf9))

### Features

* **TikTok:** Add ReVanced settings about screen ([#4009](https://github.com/ReVanced/revanced-patches/issues/4009)) ([046bd3e](046bd3ec88))
2024-11-27 13:35:27 +00:00
oSumAtrIX
fe15213cf9 fix(YouTube - Spoof video streams): Log out the iOS client to restore kids videos playback (#4000) 2024-11-27 14:31:52 +01:00
LisoUseInAIKyrios
046bd3ec88 feat(TikTok): Add ReVanced settings about screen (#4009) 2024-11-27 17:30:55 +04:00
LisoUseInAIKyrios
d6bc998365 chore: Fix spoof version setting migration
Previously this failed because of cyclic initialization of the settings and the spoof version patch
2024-11-27 13:10:22 +04:00
11 changed files with 225 additions and 91 deletions

View File

@@ -1,3 +1,15 @@
# [5.2.0-dev.5](https://github.com/ReVanced/revanced-patches/compare/v5.2.0-dev.4...v5.2.0-dev.5) (2024-11-27)
### Bug Fixes
* **YouTube - Spoof video streams:** Log out the iOS client to restore kids videos playback ([#4000](https://github.com/ReVanced/revanced-patches/issues/4000)) ([cc2ac4e](https://github.com/ReVanced/revanced-patches/commit/cc2ac4e4cd15ca2a23d60abd160d915bc98f99b4))
### Features
* **TikTok:** Add ReVanced settings about screen ([#4009](https://github.com/ReVanced/revanced-patches/issues/4009)) ([12ea26b](https://github.com/ReVanced/revanced-patches/commit/12ea26b10ddea5ad39da1d35e2b8fd0b48c15d88))
# [5.2.0-dev.4](https://github.com/ReVanced/revanced-patches/compare/v5.2.0-dev.3...v5.2.0-dev.4) (2024-11-26)

View File

@@ -1,6 +1,5 @@
package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.sf;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.youtube.requests.Route.Method.GET;
@@ -13,6 +12,8 @@ import android.content.res.Configuration;
import android.graphics.Color;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.preference.Preference;
import android.util.AttributeSet;
import android.view.Window;
@@ -37,7 +38,7 @@ import app.revanced.extension.youtube.requests.Requester;
import app.revanced.extension.youtube.requests.Route;
/**
* Opens a dialog showing the links from {@link SocialLinksRoutes}.
* Opens a dialog showing official links.
*/
@SuppressWarnings({"unused", "deprecation"})
public class ReVancedAboutPreference extends Preference {
@@ -72,7 +73,16 @@ public class ReVancedAboutPreference extends Preference {
return Color.BLACK;
}
private String createDialogHtml(WebLink[] socialLinks) {
/**
* Apps that do not support bundling resources must override this.
*
* @return A localized string to display for the key.
*/
protected String getString(String key, Object ... args) {
return str(key, args);
}
private String createDialogHtml(WebLink[] aboutLinks) {
final boolean isNetworkConnected = Utils.isNetworkConnected();
StringBuilder builder = new StringBuilder();
@@ -91,7 +101,7 @@ public class ReVancedAboutPreference extends Preference {
builder.append("<img style=\"width: 100px; height: 100px;\" "
// Hide the image if it does not load.
+ "onerror=\"this.style.display='none';\" "
+ "src=\"https://revanced.app/favicon.ico\" />");
+ "src=\"").append(AboutLinksRoutes.aboutLogoUrl).append("\" />");
}
String patchesVersion = Utils.getPatchesReleaseVersion();
@@ -103,29 +113,29 @@ public class ReVancedAboutPreference extends Preference {
builder.append("<p>")
// Replace hyphens with non breaking dashes so the version number does not break lines.
.append(useNonBreakingHyphens(str("revanced_settings_about_links_body", patchesVersion)))
.append(useNonBreakingHyphens(getString("revanced_settings_about_links_body", patchesVersion)))
.append("</p>");
// Add a disclaimer if using a dev release.
if (patchesVersion.contains("dev")) {
builder.append("<h3>")
// English text 'Pre-release' can break lines.
.append(useNonBreakingHyphens(str("revanced_settings_about_links_dev_header")))
.append(useNonBreakingHyphens(getString("revanced_settings_about_links_dev_header")))
.append("</h3>");
builder.append("<p>")
.append(str("revanced_settings_about_links_dev_body"))
.append(getString("revanced_settings_about_links_dev_body"))
.append("</p>");
}
builder.append("<h2 style=\"margin-top: 30px;\">")
.append(str("revanced_settings_about_links_header"))
.append(getString("revanced_settings_about_links_header"))
.append("</h2>");
builder.append("<div>");
for (WebLink social : socialLinks) {
for (WebLink link : aboutLinks) {
builder.append("<div style=\"margin-bottom: 20px;\">");
builder.append(String.format("<a href=\"%s\">%s</a>", social.url, social.name));
builder.append(String.format("<a href=\"%s\">%s</a>", link.url, link.name));
builder.append("</div>");
}
builder.append("</div>");
@@ -137,25 +147,44 @@ public class ReVancedAboutPreference extends Preference {
{
setOnPreferenceClickListener(pref -> {
// Show a progress spinner if the social links are not fetched yet.
if (!SocialLinksRoutes.hasFetchedLinks() && Utils.isNetworkConnected()) {
if (!AboutLinksRoutes.hasFetchedLinks() && Utils.isNetworkConnected()) {
// Show a progress spinner, but only if the api fetch takes more than a half a second.
final long delayToShowProgressSpinner = 500;
ProgressDialog progress = new ProgressDialog(getContext());
progress.setProgressStyle(ProgressDialog.STYLE_SPINNER);
progress.show();
Utils.runOnBackgroundThread(() -> fetchLinksAndShowDialog(progress));
Handler handler = new Handler(Looper.getMainLooper());
Runnable showDialogRunnable = progress::show;
handler.postDelayed(showDialogRunnable, delayToShowProgressSpinner);
Utils.runOnBackgroundThread(() ->
fetchLinksAndShowDialog(handler, showDialogRunnable, progress));
} else {
// No network call required and can run now.
fetchLinksAndShowDialog(null);
fetchLinksAndShowDialog(null, null, null);
}
return false;
});
}
private void fetchLinksAndShowDialog(@Nullable ProgressDialog progress) {
WebLink[] socialLinks = SocialLinksRoutes.fetchSocialLinks();
String htmlDialog = createDialogHtml(socialLinks);
private void fetchLinksAndShowDialog(@Nullable Handler handler,
Runnable showDialogRunnable,
@Nullable ProgressDialog progress) {
WebLink[] links = AboutLinksRoutes.fetchAboutLinks();
String htmlDialog = createDialogHtml(links);
// Enable to randomly force a delay to debug the spinner logic.
final boolean debugSpinnerDelayLogic = false;
//noinspection ConstantConditions
if (debugSpinnerDelayLogic && handler != null && Math.random() < 0.5f) {
Utils.doNothingForDuration((long) (Math.random() * 4000));
}
Utils.runOnMainThreadNowOrLater(() -> {
if (handler != null) {
handler.removeCallbacks(showDialogRunnable);
}
if (progress != null) {
progress.dismiss();
}
@@ -224,7 +253,7 @@ class WebViewDialog extends Dialog {
class WebLink {
final boolean preferred;
final String name;
String name;
final String url;
WebLink(JSONObject json) throws JSONException {
@@ -243,7 +272,7 @@ class WebLink {
@NonNull
@Override
public String toString() {
return "ReVancedSocialLink{" +
return "WebLink{" +
"preferred=" + preferred +
", name='" + name + '\'' +
", url='" + url + '\'' +
@@ -251,25 +280,21 @@ class WebLink {
}
}
class SocialLinksRoutes {
class AboutLinksRoutes {
/**
* Simple link to the website donate page,
* rather than fetching and parsing the donation links using the API.
* Backup icon url if the API call fails.
*/
public static final WebLink DONATE_LINK = new WebLink(true,
sf("revanced_settings_about_links_donate").toString(),
"https://revanced.app/donate");
public static volatile String aboutLogoUrl = "https://revanced.app/favicon.ico";
/**
* Links to use if fetch links api call fails.
*/
private static final WebLink[] NO_CONNECTION_STATIC_LINKS = {
new WebLink(true, "ReVanced.app", "https://revanced.app"),
DONATE_LINK,
new WebLink(true, "ReVanced.app", "https://revanced.app")
};
private static final String SOCIAL_LINKS_PROVIDER = "https://api.revanced.app/v2";
private static final Route.CompiledRoute GET_SOCIAL = new Route(GET, "/socials").compile();
private static final String SOCIAL_LINKS_PROVIDER = "https://api.revanced.app/v4";
private static final Route.CompiledRoute GET_SOCIAL = new Route(GET, "/about").compile();
@Nullable
private static volatile WebLink[] fetchedLinks;
@@ -278,7 +303,7 @@ class SocialLinksRoutes {
return fetchedLinks != null;
}
static WebLink[] fetchSocialLinks() {
static WebLink[] fetchAboutLinks() {
try {
if (hasFetchedLinks()) return fetchedLinks;
@@ -298,11 +323,22 @@ class SocialLinksRoutes {
}
JSONObject json = Requester.parseJSONObjectAndDisconnect(connection);
JSONArray socials = json.getJSONArray("socials");
aboutLogoUrl = json.getJSONObject("branding").getString("logo");
List<WebLink> links = new ArrayList<>();
links.add(DONATE_LINK); // Show donate link first.
JSONArray donations = json.getJSONObject("donations").getJSONArray("links");
for (int i = 0, length = donations.length(); i < length; i++) {
WebLink link = new WebLink(donations.getJSONObject(i));
if (link.preferred) {
// This could be localized, but TikTok does not support localized resources.
// All link names returned by the api are also non localized.
link.name = "Donate";
links.add(link);
}
}
JSONArray socials = json.getJSONArray("socials");
for (int i = 0, length = socials.length(); i < length; i++) {
WebLink link = new WebLink(socials.getJSONObject(i));
links.add(link);

View File

@@ -0,0 +1,56 @@
package app.revanced.extension.tiktok.settings.preference;
import android.content.Context;
import android.util.AttributeSet;
import java.util.Map;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference;
@SuppressWarnings("unused")
public class ReVancedTikTokAboutPreference extends ReVancedAboutPreference {
/**
* Because resources cannot be added to TikTok,
* these strings are copied from the shared strings.xml file.
*
* Changes here must also be made in strings.xml
*/
private final Map<String, String> aboutStrings = Map.of(
"revanced_settings_about_links_body", "You are using ReVanced Patches version <i>%s</i>",
"revanced_settings_about_links_dev_header", "Note",
"revanced_settings_about_links_dev_body", "This version is a pre-release and you may experience unexpected issues",
"revanced_settings_about_links_header", "Official links"
);
{
//noinspection deprecation
setTitle("About");
}
public ReVancedTikTokAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public ReVancedTikTokAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public ReVancedTikTokAboutPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ReVancedTikTokAboutPreference(Context context) {
super(context);
}
@Override
protected String getString(String key, Object ... args) {
String format = aboutStrings.get(key);
if (format == null) {
Logger.printException(() -> "Unknown key: " + key);
return "";
}
return String.format(format, args);
}
}

View File

@@ -4,13 +4,14 @@ import android.content.Context;
import android.preference.PreferenceScreen;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.tiktok.settings.preference.ReVancedTikTokAboutPreference;
import app.revanced.extension.tiktok.settings.preference.TogglePreference;
@SuppressWarnings("deprecation")
public class ExtensionPreferenceCategory extends ConditionalPreferenceCategory {
public ExtensionPreferenceCategory(Context context, PreferenceScreen screen) {
super(context, screen);
setTitle("Extension");
setTitle("Miscellaneous");
}
@Override
@@ -20,6 +21,8 @@ public class ExtensionPreferenceCategory extends ConditionalPreferenceCategory {
@Override
public void addPreferences(Context context) {
addPreference(new ReVancedTikTokAboutPreference(context));
addPreference(new TogglePreference(context,
"Enable debug log",
"Show extension debug log.",

View File

@@ -8,6 +8,17 @@ import android.os.Build;
import androidx.annotation.Nullable;
public enum ClientType {
// Specific purpose for age restricted, or private videos, because the iOS client is not logged in.
ANDROID_VR(28,
"Quest 3",
"12",
"com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
"32", // Android 12.1
"1.56.21",
"ANDROID_VR",
true
),
// Specific for kids videos.
// https://dumps.tadiphone.dev/dumps/oculus/eureka
IOS(5,
// iPhone 15 supports AV1 hardware decoding.
@@ -25,14 +36,9 @@ public enum ClientType {
null,
// Version number should be a valid iOS release.
// https://www.ipa4fun.com/history/185230
"19.10.7"
),
ANDROID_VR(28,
"Quest 3",
"12",
"com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
"32", // Android 12.1
"1.56.21"
"19.10.7",
"IOS",
false
);
/**
@@ -44,7 +50,7 @@ public enum ClientType {
/**
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
*/
public final String model;
public final String deviceModel;
/**
* Device OS version.
@@ -63,17 +69,37 @@ public enum ClientType {
@Nullable
public final String androidSdkVersion;
/**
* Client name.
*/
public final String clientName;
/**
* App version.
*/
public final String appVersion;
public final String clientVersion;
ClientType(int id, String model, String osVersion, String userAgent, @Nullable String androidSdkVersion, String appVersion) {
/**
* If the client can access the API logged in.
*/
public final boolean canLogin;
ClientType(int id,
String deviceModel,
String osVersion,
String userAgent,
@Nullable String androidSdkVersion,
String clientVersion,
String clientName,
boolean canLogin
) {
this.id = id;
this.model = model;
this.deviceModel = deviceModel;
this.osVersion = osVersion;
this.userAgent = userAgent;
this.androidSdkVersion = androidSdkVersion;
this.appVersion = appVersion;
this.clientVersion = clientVersion;
this.clientName = clientName;
this.canLogin = canLogin;
}
}

View File

@@ -12,15 +12,13 @@ import app.revanced.extension.youtube.requests.Requester;
import app.revanced.extension.youtube.requests.Route;
final class PlayerRoutes {
private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/";
static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
Route.Method.POST,
"player" +
"?fields=streamingData" +
"&alt=proto"
).compile();
private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/";
/**
* TCP connection and HTTP read timeout
*/
@@ -30,15 +28,15 @@ final class PlayerRoutes {
}
static String createInnertubeBody(ClientType clientType) {
JSONObject innerTubeBody = new JSONObject();
JSONObject innerTubeBody = new JSONObject();
try {
JSONObject context = new JSONObject();
JSONObject client = new JSONObject();
client.put("clientName", clientType.name());
client.put("clientVersion", clientType.appVersion);
client.put("deviceModel", clientType.model);
client.put("clientVersion", clientType.clientVersion);
client.put("deviceModel", clientType.deviceModel);
client.put("osVersion", clientType.osVersion);
if (clientType.androidSdkVersion != null) {
client.put("androidSdkVersion", clientType.androidSdkVersion);
@@ -57,7 +55,9 @@ final class PlayerRoutes {
return innerTubeBody.toString();
}
/** @noinspection SameParameterValue*/
/**
* @noinspection SameParameterValue
*/
static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route);

View File

@@ -28,7 +28,7 @@ import app.revanced.extension.youtube.settings.Settings;
/**
* Video streaming data. Fetching is tied to the behavior YT uses,
* where this class fetches the streams only when YT fetches.
*
* <p>
* Effectively the cache expiration of these fetches is the same as the stock app,
* since the stock app would not use expired streams and therefor
* the extension replace stream hook is called only if YT
@@ -37,38 +37,20 @@ import app.revanced.extension.youtube.settings.Settings;
public class StreamingDataRequest {
private static final ClientType[] CLIENT_ORDER_TO_USE;
static {
ClientType[] allClientTypes = ClientType.values();
ClientType preferredClient = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length];
CLIENT_ORDER_TO_USE[0] = preferredClient;
int i = 1;
for (ClientType c : allClientTypes) {
if (c != preferredClient) {
CLIENT_ORDER_TO_USE[i++] = c;
}
}
}
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String[] REQUEST_HEADER_KEYS = {
"Authorization", // Available only to logged in users.
AUTHORIZATION_HEADER, // Available only to logged-in users.
"X-GOOG-API-FORMAT-VERSION",
"X-Goog-Visitor-Id"
};
/**
* TCP connection and HTTP read timeout.
*/
private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000;
/**
* Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS}
*/
private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000;
private static final Map<String, StreamingDataRequest> cache = Collections.synchronizedMap(
new LinkedHashMap<>(100) {
/**
@@ -86,8 +68,32 @@ public class StreamingDataRequest {
}
});
static {
ClientType[] allClientTypes = ClientType.values();
ClientType preferredClient = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length];
CLIENT_ORDER_TO_USE[0] = preferredClient;
int i = 1;
for (ClientType c : allClientTypes) {
if (c != preferredClient) {
CLIENT_ORDER_TO_USE[i++] = c;
}
}
}
private final String videoId;
private final Future<ByteBuffer> future;
private StreamingDataRequest(String videoId, Map<String, String> playerHeaders) {
Objects.requireNonNull(playerHeaders);
this.videoId = videoId;
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders));
}
public static void fetchRequest(String videoId, Map<String, String> fetchHeaders) {
// Always fetch, even if there is a existing request for the same video.
// Always fetch, even if there is an existing request for the same video.
cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders));
}
@@ -119,6 +125,10 @@ public class StreamingDataRequest {
connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
for (String key : REQUEST_HEADER_KEYS) {
if (!clientType.canLogin && key.equals(AUTHORIZATION_HEADER)) {
continue;
}
String value = playerHeaders.get(key);
if (value != null) {
connection.setRequestProperty(key, value);
@@ -186,15 +196,6 @@ public class StreamingDataRequest {
return null;
}
private final String videoId;
private final Future<ByteBuffer> future;
private StreamingDataRequest(String videoId, Map<String, String> playerHeaders) {
Objects.requireNonNull(playerHeaders);
this.videoId = videoId;
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders));
}
public boolean fetchCompleted() {
return future.isDone();
}

View File

@@ -20,7 +20,6 @@ import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.StillIm
import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.ThumbnailOption;
import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.ThumbnailStillTime;
import app.revanced.extension.youtube.patches.spoof.ClientType;
import app.revanced.extension.youtube.patches.spoof.SpoofAppVersionPatch;
import app.revanced.extension.youtube.patches.spoof.SpoofVideoStreamsPatch;
import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings;
@@ -380,7 +379,7 @@ public class Settings extends BaseSettings {
migrateOldSettingToNew(DEPRECATED_HIDE_PLAYER_FLYOUT_VIDEO_QUALITY_FOOTER, HIDE_PLAYER_FLYOUT_VIDEO_QUALITY_FOOTER);
// Old spoof versions that no longer work reliably.
if (SpoofAppVersionPatch.isSpoofingToLessThan(SPOOF_APP_VERSION_TARGET.defaultValue)) {
if (SPOOF_APP_VERSION_TARGET.get().compareTo(SPOOF_APP_VERSION_TARGET.defaultValue) < 0) {
Logger.printInfo(() -> "Resetting spoof app version target");
SPOOF_APP_VERSION_TARGET.resetToDefault();
}

View File

@@ -3,4 +3,4 @@ org.gradle.jvmargs = -Xms512M -Xmx2048M
org.gradle.parallel = true
android.useAndroidX = true
kotlin.code.style = official
version = 5.2.0-dev.4
version = 5.2.0-dev.5

View File

@@ -176,7 +176,7 @@
</patch>
<patch id="chat.autoclaim.autoClaimChannelPointsPatch">
</patch>
<patch id="ad.embedded.embeddedAdsPatch">
<patch id="ad.embedded.embeddedAdsPatch">
<string-array name="revanced_block_embedded_ads_entries">
<item>@string/revanced_block_embedded_ads_entry_1</item>
<item>@string/revanced_block_embedded_ads_entry_2</item>

View File

@@ -60,7 +60,8 @@ This is because Crowdin requires temporarily flattening this file and removing t
<string name="revanced_settings_about_links_dev_header">Note</string>
<string name="revanced_settings_about_links_dev_body">This version is a pre-release and you may experience unexpected issues</string>
<string name="revanced_settings_about_links_header">Official links</string>
<string name="revanced_settings_about_links_donate">Donate</string>
<!-- NOTE: the about strings above are duplicated in the TikTok about screen code,
and changes made here must also be made there. -->
</patch>
<patch id="misc.gms.gmsCoreSupportResourcePatch">
<!-- Translations of this should not be longer than the original English text, otherwise the text can be clipped and not entirely shown. -->
@@ -1224,9 +1225,9 @@ This is because Crowdin requires temporarily flattening this file and removing t
<string name="revanced_spoof_video_streams_ios_force_avc_no_hardware_vp9_summary_on">Your device does not have VP9 hardware decoding, and this setting is always on when Client spoofing is enabled</string>
<string name="revanced_spoof_video_streams_ios_force_avc_user_dialog_message">Enabling this might improve battery life and fix playback stuttering.\n\nAVC has a maximum resolution of 1080p, and video playback will use more internet data than VP9 or AV1.</string>
<string name="revanced_spoof_video_streams_about_ios_title">iOS spoofing side effects</string>
<string name="revanced_spoof_video_streams_about_ios_summary">Movies or paid videos may not play\n• Livestreams start from the beginning\n• Videos may end 1 second early\n• No opus audio codec</string>
<string name="revanced_spoof_video_streams_about_ios_summary">Private kids videos may not play\n• Livestreams start from the beginning\n• Videos may end 1 second early\n• No opus audio codec</string>
<string name="revanced_spoof_video_streams_about_android_vr_title">Android VR spoofing side effects</string>
<string name="revanced_spoof_video_streams_about_android_vr_summary">• Audio track menu is missing\n• Stable volume is not available</string>
<string name="revanced_spoof_video_streams_about_android_vr_summary"> Kids videos may not play\n• Audio track menu is missing\n• Stable volume is not available</string>
</patch>
</app>
<app id="twitch">