mirror of
https://github.com/ReVanced/revanced-patches.git
synced 2026-01-19 09:03:58 +00:00
feat(Strava): Add Add media download patch (#6449)
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
This commit is contained in:
@@ -311,6 +311,10 @@ public class Utils {
|
||||
return resourceId;
|
||||
}
|
||||
|
||||
public static String getResourceString(int id) throws Resources.NotFoundException {
|
||||
return getContext().getResources().getString(id);
|
||||
}
|
||||
|
||||
public static int getResourceInteger(String resourceIdentifierName) throws Resources.NotFoundException {
|
||||
return getContext().getResources().getInteger(getResourceIdentifierOrThrow(resourceIdentifierName, "integer"));
|
||||
}
|
||||
|
||||
5
extensions/strava/build.gradle.kts
Normal file
5
extensions/strava/build.gradle.kts
Normal file
@@ -0,0 +1,5 @@
|
||||
dependencies {
|
||||
compileOnly(project(":extensions:shared:library"))
|
||||
compileOnly(project(":extensions:strava:stub"))
|
||||
compileOnly(libs.okhttp)
|
||||
}
|
||||
1
extensions/strava/src/main/AndroidManifest.xml
Normal file
1
extensions/strava/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
||||
<manifest/>
|
||||
@@ -0,0 +1,216 @@
|
||||
package app.revanced.extension.strava;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.provider.MediaStore;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import com.strava.core.data.MediaType;
|
||||
import com.strava.photos.data.Media;
|
||||
|
||||
import okhttp3.*;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import app.revanced.extension.shared.Utils;
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
public final class AddMediaDownloadPatch {
|
||||
public static final int ACTION_DOWNLOAD = -1;
|
||||
public static final int ACTION_OPEN_LINK = -2;
|
||||
public static final int ACTION_COPY_LINK = -3;
|
||||
|
||||
private static final OkHttpClient client = new OkHttpClient();
|
||||
|
||||
public static boolean handleAction(int actionId, Media media) {
|
||||
String url = getUrl(media);
|
||||
switch (actionId) {
|
||||
case ACTION_DOWNLOAD:
|
||||
String name = media.getId();
|
||||
if (media.getType() == MediaType.VIDEO) {
|
||||
downloadVideo(url, name);
|
||||
} else {
|
||||
downloadPhoto(url, name);
|
||||
}
|
||||
return true;
|
||||
case ACTION_OPEN_LINK:
|
||||
Utils.openLink(url);
|
||||
return true;
|
||||
case ACTION_COPY_LINK:
|
||||
copyLink(url);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyLink(CharSequence url) {
|
||||
Utils.setClipboard(url);
|
||||
showInfoToast("link_copied_to_clipboard", "🔗");
|
||||
}
|
||||
|
||||
public static void downloadPhoto(String url, String name) {
|
||||
showInfoToast("loading", "⏳");
|
||||
Utils.runOnBackgroundThread(() -> {
|
||||
try (Response response = fetch(url)) {
|
||||
ResponseBody body = response.body();
|
||||
String mimeType = body.contentType().toString();
|
||||
String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
|
||||
ContentResolver resolver = Utils.getContext().getContentResolver();
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.Images.Media.DISPLAY_NAME, name + '.' + extension);
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 1);
|
||||
values.put(MediaStore.Images.Media.MIME_TYPE, mimeType);
|
||||
values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Strava");
|
||||
Uri collection = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
? MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
: MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
|
||||
Uri row = resolver.insert(collection, values);
|
||||
try (OutputStream outputStream = resolver.openOutputStream(row)) {
|
||||
transferTo(body.byteStream(), outputStream);
|
||||
} finally {
|
||||
values.clear();
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 0);
|
||||
resolver.update(row, values, null);
|
||||
}
|
||||
showInfoToast("yis_2024_local_save_image_success", "✔️");
|
||||
} catch (IOException e) {
|
||||
showErrorToast("download_failure", "❌", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a video in the M3U8 / HLS (HTTP Live Streaming) format.
|
||||
*/
|
||||
public static void downloadVideo(String url, String name) {
|
||||
// The first request yields multiple URLs with different stream options.
|
||||
// In case of Strava, the first one is always of highest quality.
|
||||
// Each stream can consist of multiple chunks.
|
||||
// The second request yields the URLs of all of these chunks.
|
||||
// Fetch all of them concurrently and pipe their streams into the file in order.
|
||||
showInfoToast("loading", "⏳");
|
||||
Utils.runOnBackgroundThread(() -> {
|
||||
try {
|
||||
String highestQualityStreamUrl;
|
||||
try (Response response = fetch(url)) {
|
||||
highestQualityStreamUrl = replaceFileName(url, lines(response).findFirst().get());
|
||||
}
|
||||
List<Future<Response>> futures;
|
||||
try (Response response = fetch(highestQualityStreamUrl)) {
|
||||
futures = lines(response)
|
||||
.map(line -> replaceFileName(highestQualityStreamUrl, line))
|
||||
.map(chunkUrl -> Utils.submitOnBackgroundThread(() -> fetch(chunkUrl)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
ContentResolver resolver = Utils.getContext().getContentResolver();
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.Video.Media.DISPLAY_NAME, name + '.' + "mp4");
|
||||
values.put(MediaStore.Video.Media.IS_PENDING, 1);
|
||||
values.put(MediaStore.Video.Media.MIME_TYPE, MimeTypeMap.getSingleton().getMimeTypeFromExtension("mp4"));
|
||||
values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + "/Strava");
|
||||
Uri collection = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
? MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
: MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
|
||||
Uri row = resolver.insert(collection, values);
|
||||
try (OutputStream outputStream = resolver.openOutputStream(row)) {
|
||||
Throwable error = null;
|
||||
for (Future<Response> future : futures) {
|
||||
if (error != null) {
|
||||
if (future.cancel(true)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try (Response response = future.get()) {
|
||||
if (error == null) {
|
||||
transferTo(response.body().byteStream(), outputStream);
|
||||
}
|
||||
} catch (InterruptedException | IOException e) {
|
||||
error = e;
|
||||
} catch (ExecutionException e) {
|
||||
error = e.getCause();
|
||||
}
|
||||
}
|
||||
if (error != null) {
|
||||
throw new IOException(error);
|
||||
}
|
||||
} finally {
|
||||
values.clear();
|
||||
values.put(MediaStore.Video.Media.IS_PENDING, 0);
|
||||
resolver.update(row, values, null);
|
||||
}
|
||||
showInfoToast("yis_2024_local_save_video_success", "✔️");
|
||||
} catch (IOException e) {
|
||||
showErrorToast("download_failure", "❌", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String getUrl(Media media) {
|
||||
return media.getType() == MediaType.VIDEO
|
||||
? ((Media.Video) media).getVideoUrl()
|
||||
: media.getLargestUrl();
|
||||
}
|
||||
|
||||
private static String getString(String name, String fallback) {
|
||||
int id = Utils.getResourceIdentifier(name, "string");
|
||||
return id != 0
|
||||
? Utils.getResourceString(id)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static void showInfoToast(String resourceName, String fallback) {
|
||||
String text = getString(resourceName, fallback);
|
||||
Utils.showToastShort(text);
|
||||
}
|
||||
|
||||
private static void showErrorToast(String resourceName, String fallback, IOException exception) {
|
||||
String text = getString(resourceName, fallback);
|
||||
Utils.showToastLong(text + ' ' + exception.getLocalizedMessage());
|
||||
}
|
||||
|
||||
private static Response fetch(String url) throws IOException {
|
||||
Request request = new Request.Builder().url(url).build();
|
||||
Response response = client.newCall(request).execute();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IOException("Got HTTP status code " + response.code());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code inputStream.transferTo(outputStream)} is "too new".
|
||||
*/
|
||||
private static void transferTo(InputStream in, OutputStream out) throws IOException {
|
||||
byte[] buffer = new byte[1024 * 8];
|
||||
int length;
|
||||
while ((length = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all file names.
|
||||
*/
|
||||
private static Stream<String> lines(Response response) {
|
||||
BufferedReader reader = new BufferedReader(response.body().charStream());
|
||||
return reader.lines().filter(line -> !line.startsWith("#"));
|
||||
}
|
||||
|
||||
private static String replaceFileName(String uri, String newName) {
|
||||
return uri.substring(0, uri.lastIndexOf('/') + 1) + newName;
|
||||
}
|
||||
}
|
||||
12
extensions/strava/stub/build.gradle.kts
Normal file
12
extensions/strava/stub/build.gradle.kts
Normal file
@@ -0,0 +1,12 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.revanced.extension"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
}
|
||||
}
|
||||
1
extensions/strava/stub/src/main/AndroidManifest.xml
Normal file
1
extensions/strava/stub/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
||||
<manifest/>
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.strava.core.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public interface MediaContent extends Serializable {
|
||||
String getCaption();
|
||||
|
||||
String getId();
|
||||
|
||||
String getReferenceId();
|
||||
|
||||
MediaType getType();
|
||||
|
||||
void setCaption(String caption);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.strava.core.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public final class MediaDimension implements Comparable<MediaDimension>, Serializable {
|
||||
private final int height;
|
||||
private final int width;
|
||||
|
||||
public MediaDimension(int width, int height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public float getHeightScale() {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return 1f;
|
||||
}
|
||||
return height / width;
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public float getWidthScale() {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return 1f;
|
||||
}
|
||||
return width / height;
|
||||
}
|
||||
|
||||
public boolean isLandscape() {
|
||||
return width > 0 && width >= height;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(MediaDimension other) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.strava.core.data;
|
||||
|
||||
public enum MediaType {
|
||||
PHOTO(1),
|
||||
VIDEO(2);
|
||||
|
||||
private final int remoteValue;
|
||||
|
||||
private MediaType(int remoteValue) {
|
||||
this.remoteValue = remoteValue;
|
||||
}
|
||||
|
||||
public int getRemoteValue() {
|
||||
return remoteValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.strava.core.data;
|
||||
|
||||
import java.util.SortedMap;
|
||||
|
||||
public interface RemoteMediaContent extends MediaContent {
|
||||
MediaDimension getLargestSize();
|
||||
|
||||
String getLargestUrl();
|
||||
|
||||
SortedMap<Integer, MediaDimension> getSizes();
|
||||
|
||||
String getSmallestUrl();
|
||||
|
||||
RemoteMediaStatus getStatus();
|
||||
|
||||
SortedMap<Integer, String> getUrls();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.strava.core.data;
|
||||
|
||||
public enum RemoteMediaStatus {
|
||||
NEW,
|
||||
PENDING,
|
||||
PROCESSED,
|
||||
REPORTED,
|
||||
REINSTATED,
|
||||
DELETED,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.strava.photos.data;
|
||||
|
||||
import com.strava.core.data.MediaDimension;
|
||||
import com.strava.core.data.MediaType;
|
||||
import com.strava.core.data.RemoteMediaContent;
|
||||
import com.strava.core.data.RemoteMediaStatus;
|
||||
import java.util.SortedMap;
|
||||
|
||||
public abstract class Media implements RemoteMediaContent {
|
||||
public static final class Photo extends Media {
|
||||
private final Long activityId;
|
||||
private final String activityName;
|
||||
private final long athleteId;
|
||||
private String caption;
|
||||
private final String createdAt;
|
||||
private final String createdAtLocal;
|
||||
private final String cursor;
|
||||
private final String id;
|
||||
private final SortedMap<Integer, MediaDimension> sizes;
|
||||
private final RemoteMediaStatus status;
|
||||
private final String tag;
|
||||
private final MediaType type;
|
||||
private final SortedMap<Integer, String> urls;
|
||||
|
||||
@Override
|
||||
public Long getActivityId() {
|
||||
return activityId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getActivityName() {
|
||||
return activityName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getAthleteId() {
|
||||
return athleteId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCaption() {
|
||||
return caption;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCreatedAtLocal() {
|
||||
return createdAtLocal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCursor() {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SortedMap<Integer, MediaDimension> getSizes() {
|
||||
return sizes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMediaStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTag() {
|
||||
return tag;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SortedMap<Integer, String> getUrls() {
|
||||
return urls;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCaption(String caption) {
|
||||
this.caption = caption;
|
||||
}
|
||||
|
||||
public Photo(String id,
|
||||
String caption,
|
||||
SortedMap<Integer, String> urls,
|
||||
SortedMap<Integer, MediaDimension> sizes,
|
||||
long athleteId,
|
||||
String createdAt,
|
||||
String createdAtLocal,
|
||||
Long activityId,
|
||||
String activityName,
|
||||
RemoteMediaStatus status,
|
||||
String tag,
|
||||
String cursor) {
|
||||
this.id = id;
|
||||
this.caption = caption;
|
||||
this.urls = urls;
|
||||
this.sizes = sizes;
|
||||
this.athleteId = athleteId;
|
||||
this.createdAt = createdAt;
|
||||
this.createdAtLocal = createdAtLocal;
|
||||
this.activityId = activityId;
|
||||
this.activityName = activityName;
|
||||
this.status = status;
|
||||
this.tag = tag;
|
||||
this.cursor = cursor;
|
||||
this.type = MediaType.PHOTO;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Video extends Media {
|
||||
private final Long activityId;
|
||||
private final String activityName;
|
||||
private final long athleteId;
|
||||
private String caption;
|
||||
private final String createdAt;
|
||||
private final String createdAtLocal;
|
||||
private final String cursor;
|
||||
private final Float durationSeconds;
|
||||
private final String id;
|
||||
private final SortedMap<Integer, MediaDimension> sizes;
|
||||
private final RemoteMediaStatus status;
|
||||
private final String tag;
|
||||
private final MediaType type;
|
||||
private final SortedMap<Integer, String> urls;
|
||||
private final String videoUrl;
|
||||
|
||||
@Override
|
||||
public Long getActivityId() {
|
||||
return activityId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getActivityName() {
|
||||
return activityName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getAthleteId() {
|
||||
return athleteId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCaption() {
|
||||
return caption;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCreatedAtLocal() {
|
||||
return createdAtLocal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCursor() {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public final Float getDurationSeconds() {
|
||||
return durationSeconds;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SortedMap<Integer, MediaDimension> getSizes() {
|
||||
return sizes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMediaStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTag() {
|
||||
return tag;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SortedMap<Integer, String> getUrls() {
|
||||
return urls;
|
||||
}
|
||||
|
||||
public final String getVideoUrl() {
|
||||
return videoUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCaption(String caption) {
|
||||
this.caption = caption;
|
||||
}
|
||||
|
||||
public Video(String id,
|
||||
String caption,
|
||||
SortedMap<Integer, String> urls,
|
||||
SortedMap<Integer, MediaDimension> sizes,
|
||||
long athleteId,
|
||||
String createdAt,
|
||||
String createdAtLocal,
|
||||
Long activityId,
|
||||
String activityName,
|
||||
RemoteMediaStatus status,
|
||||
String videoUrl,
|
||||
Float durationSeconds,
|
||||
String tag,
|
||||
String cursor) {
|
||||
this.id = id;
|
||||
this.caption = caption;
|
||||
this.urls = urls;
|
||||
this.sizes = sizes;
|
||||
this.athleteId = athleteId;
|
||||
this.createdAt = createdAt;
|
||||
this.createdAtLocal = createdAtLocal;
|
||||
this.activityId = activityId;
|
||||
this.activityName = activityName;
|
||||
this.status = status;
|
||||
this.videoUrl = videoUrl;
|
||||
this.durationSeconds = durationSeconds;
|
||||
this.tag = tag;
|
||||
this.cursor = cursor;
|
||||
this.type = MediaType.VIDEO;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Long getActivityId();
|
||||
|
||||
public abstract String getActivityName();
|
||||
|
||||
public abstract long getAthleteId();
|
||||
|
||||
public abstract String getCreatedAt();
|
||||
|
||||
public abstract String getCreatedAtLocal();
|
||||
|
||||
public abstract String getCursor();
|
||||
|
||||
@Override
|
||||
public MediaDimension getLargestSize() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLargestUrl() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getReferenceId() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSmallestUrl() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public abstract String getTag();
|
||||
|
||||
private Media() {
|
||||
}
|
||||
}
|
||||
@@ -1200,10 +1200,18 @@ public final class app/revanced/patches/stocard/layout/HideStoryBubblesPatchKt {
|
||||
public static final fun getHideStoryBubblesPatch ()Lapp/revanced/patcher/patch/ResourcePatch;
|
||||
}
|
||||
|
||||
public final class app/revanced/patches/strava/media/AddMediaDownloadPatchKt {
|
||||
public static final fun getAddMediaDownloadPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
|
||||
}
|
||||
|
||||
public final class app/revanced/patches/strava/mediaupload/OverwriteMediaUploadParametersPatchKt {
|
||||
public static final fun getOverwriteMediaUploadParametersPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
|
||||
}
|
||||
|
||||
public final class app/revanced/patches/strava/misc/extension/SharedExtensionPatchKt {
|
||||
public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
|
||||
}
|
||||
|
||||
public final class app/revanced/patches/strava/password/EnablePasswordLoginPatchKt {
|
||||
public static final fun getEnablePasswordLoginPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package app.revanced.patches.strava.media
|
||||
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.instructions
|
||||
import app.revanced.patcher.patch.bytecodePatch
|
||||
import app.revanced.patcher.util.smali.ExternalLabel
|
||||
import app.revanced.patches.shared.misc.mapping.get
|
||||
import app.revanced.patches.shared.misc.mapping.resourceMappingPatch
|
||||
import app.revanced.patches.shared.misc.mapping.resourceMappings
|
||||
import app.revanced.patches.strava.misc.extension.sharedExtensionPatch
|
||||
import app.revanced.util.getReference
|
||||
import app.revanced.util.writeRegister
|
||||
import com.android.tools.smali.dexlib2.Opcode
|
||||
import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction22c
|
||||
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
|
||||
import com.android.tools.smali.dexlib2.iface.reference.TypeReference
|
||||
|
||||
private const val ACTION_CLASS_DESCRIPTOR = "Lcom/strava/bottomsheet/Action;"
|
||||
private const val MEDIA_CLASS_DESCRIPTOR = "Lcom/strava/photos/data/Media;"
|
||||
private const val MEDIA_DOWNLOAD_CLASS_DESCRIPTOR = "Lapp/revanced/extension/strava/AddMediaDownloadPatch;"
|
||||
|
||||
@Suppress("unused")
|
||||
val addMediaDownloadPatch = bytecodePatch(
|
||||
name = "Add media download",
|
||||
description = "Extends the full-screen media viewer menu with items to copy or open their URLs or download them directly."
|
||||
) {
|
||||
compatibleWith("com.strava")
|
||||
|
||||
dependsOn(
|
||||
resourceMappingPatch,
|
||||
sharedExtensionPatch
|
||||
)
|
||||
|
||||
execute {
|
||||
val fragmentClass = classBy { it.endsWith("/FullscreenMediaFragment;") }!!.mutableClass
|
||||
|
||||
// region Extend menu of `FullscreenMediaFragment` with actions.
|
||||
|
||||
createAndShowFragmentFingerprint.match(fragmentClass).method.apply {
|
||||
val setTrueIndex = instructions.indexOfFirst { instruction ->
|
||||
instruction.opcode == Opcode.IPUT_BOOLEAN
|
||||
}
|
||||
val actionRegistrarRegister = getInstruction<BuilderInstruction22c>(setTrueIndex).registerB
|
||||
val actionRegister = instructions.first { instruction ->
|
||||
instruction.getReference<TypeReference>()?.type == ACTION_CLASS_DESCRIPTOR
|
||||
}.writeRegister!!
|
||||
|
||||
fun addMenuItem(actionId: String, string: String, color: String, drawable: String) = addInstructions(
|
||||
setTrueIndex + 1,
|
||||
"""
|
||||
new-instance v$actionRegister, $ACTION_CLASS_DESCRIPTOR
|
||||
sget v${actionRegister + 1}, $MEDIA_DOWNLOAD_CLASS_DESCRIPTOR->$actionId:I
|
||||
const v${actionRegister + 2}, 0x0
|
||||
const v${actionRegister + 3}, ${resourceMappings["string", string]}
|
||||
const v${actionRegister + 4}, ${resourceMappings["color", color]}
|
||||
const v${actionRegister + 5}, ${resourceMappings["drawable", drawable]}
|
||||
move/from16 v${actionRegister + 6}, v${actionRegister + 4}
|
||||
invoke-direct/range { v$actionRegister .. v${actionRegister + 7} }, $ACTION_CLASS_DESCRIPTOR-><init>(ILjava/lang/String;IIIILjava/io/Serializable;)V
|
||||
invoke-virtual { v$actionRegistrarRegister, v$actionRegister }, Lcom/strava/bottomsheet/a;->a(Lcom/strava/bottomsheet/BottomSheetItem;)V
|
||||
"""
|
||||
)
|
||||
|
||||
addMenuItem("ACTION_COPY_LINK", "copy_link", "core_o3", "actions_link_normal_xsmall")
|
||||
addMenuItem("ACTION_OPEN_LINK", "fallback_menu_item_open_in_browser", "core_o3", "actions_link_external_normal_xsmall")
|
||||
addMenuItem("ACTION_DOWNLOAD", "download", "core_o3", "actions_download_normal_xsmall")
|
||||
|
||||
// Move media to last parameter of `Action` constructor.
|
||||
val getMediaInstruction = instructions.first { instruction ->
|
||||
instruction.getReference<FieldReference>()?.type == MEDIA_CLASS_DESCRIPTOR
|
||||
}
|
||||
addInstruction(
|
||||
getMediaInstruction.location.index + 1,
|
||||
"move-object/from16 v${actionRegister + 7}, v${getMediaInstruction.writeRegister}"
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Handle new actions.
|
||||
|
||||
val actionClass = classes.first { clazz ->
|
||||
clazz.type == ACTION_CLASS_DESCRIPTOR
|
||||
}
|
||||
val actionSerializableField = actionClass.instanceFields.first { field ->
|
||||
field.type == "Ljava/io/Serializable;"
|
||||
}
|
||||
|
||||
// Handle "copy link" & "open link" & "download" actions.
|
||||
handleMediaActionFingerprint.match(fragmentClass).method.apply {
|
||||
// Call handler if action ID < 0 (= custom).
|
||||
val moveInstruction = instructions.first { instruction ->
|
||||
instruction.opcode == Opcode.MOVE_RESULT
|
||||
}
|
||||
val indexAfterMoveInstruction = moveInstruction.location.index + 1
|
||||
val actionIdRegister = moveInstruction.writeRegister
|
||||
addInstructionsWithLabels(
|
||||
indexAfterMoveInstruction,
|
||||
"""
|
||||
if-gez v$actionIdRegister, :move
|
||||
check-cast p2, $ACTION_CLASS_DESCRIPTOR
|
||||
iget-object v0, p2, $actionSerializableField
|
||||
check-cast v0, $MEDIA_CLASS_DESCRIPTOR
|
||||
invoke-static { v$actionIdRegister, v0 }, $MEDIA_DOWNLOAD_CLASS_DESCRIPTOR->handleAction(I$MEDIA_CLASS_DESCRIPTOR)Z
|
||||
move-result v0
|
||||
return v0
|
||||
""",
|
||||
ExternalLabel("move", instructions[indexAfterMoveInstruction])
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package app.revanced.patches.strava.media
|
||||
|
||||
import app.revanced.patcher.fingerprint
|
||||
import com.android.tools.smali.dexlib2.AccessFlags
|
||||
|
||||
internal val createAndShowFragmentFingerprint = fingerprint {
|
||||
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
|
||||
returns("V")
|
||||
parameters("L")
|
||||
strings("mediaType")
|
||||
}
|
||||
|
||||
internal val handleMediaActionFingerprint = fingerprint {
|
||||
parameters("Landroid/view/View;", "Lcom/strava/bottomsheet/BottomSheetItem;")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package app.revanced.patches.strava.misc.extension
|
||||
|
||||
import app.revanced.patches.shared.misc.extension.extensionHook
|
||||
|
||||
internal val applicationOnCreateHook = extensionHook {
|
||||
custom { method, classDef ->
|
||||
method.name == "onCreate" && classDef.endsWith("/StravaApplication;")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package app.revanced.patches.strava.misc.extension
|
||||
|
||||
import app.revanced.patches.shared.misc.extension.sharedExtensionPatch
|
||||
|
||||
val sharedExtensionPatch = sharedExtensionPatch("strava", applicationOnCreateHook)
|
||||
Reference in New Issue
Block a user