diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
index 5d6bb492d..ea37cfcde 100644
--- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
+++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java
@@ -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"));
}
diff --git a/extensions/strava/build.gradle.kts b/extensions/strava/build.gradle.kts
new file mode 100644
index 000000000..f282f41ea
--- /dev/null
+++ b/extensions/strava/build.gradle.kts
@@ -0,0 +1,5 @@
+dependencies {
+ compileOnly(project(":extensions:shared:library"))
+ compileOnly(project(":extensions:strava:stub"))
+ compileOnly(libs.okhttp)
+}
diff --git a/extensions/strava/src/main/AndroidManifest.xml b/extensions/strava/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..9b65eb06c
--- /dev/null
+++ b/extensions/strava/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/extensions/strava/src/main/java/app/revanced/extension/strava/AddMediaDownloadPatch.java b/extensions/strava/src/main/java/app/revanced/extension/strava/AddMediaDownloadPatch.java
new file mode 100644
index 000000000..4c57cc792
--- /dev/null
+++ b/extensions/strava/src/main/java/app/revanced/extension/strava/AddMediaDownloadPatch.java
@@ -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> 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 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 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;
+ }
+}
diff --git a/extensions/strava/stub/build.gradle.kts b/extensions/strava/stub/build.gradle.kts
new file mode 100644
index 000000000..ffdfac5a6
--- /dev/null
+++ b/extensions/strava/stub/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "app.revanced.extension"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 21
+ }
+}
diff --git a/extensions/strava/stub/src/main/AndroidManifest.xml b/extensions/strava/stub/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..15e7c2ae6
--- /dev/null
+++ b/extensions/strava/stub/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/extensions/strava/stub/src/main/java/com/strava/core/data/MediaContent.java b/extensions/strava/stub/src/main/java/com/strava/core/data/MediaContent.java
new file mode 100644
index 000000000..ba3ab4ecd
--- /dev/null
+++ b/extensions/strava/stub/src/main/java/com/strava/core/data/MediaContent.java
@@ -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);
+}
diff --git a/extensions/strava/stub/src/main/java/com/strava/core/data/MediaDimension.java b/extensions/strava/stub/src/main/java/com/strava/core/data/MediaDimension.java
new file mode 100644
index 000000000..6f4b3d104
--- /dev/null
+++ b/extensions/strava/stub/src/main/java/com/strava/core/data/MediaDimension.java
@@ -0,0 +1,44 @@
+package com.strava.core.data;
+
+import java.io.Serializable;
+
+public final class MediaDimension implements Comparable, 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;
+ }
+}
diff --git a/extensions/strava/stub/src/main/java/com/strava/core/data/MediaType.java b/extensions/strava/stub/src/main/java/com/strava/core/data/MediaType.java
new file mode 100644
index 000000000..7fb100b5c
--- /dev/null
+++ b/extensions/strava/stub/src/main/java/com/strava/core/data/MediaType.java
@@ -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;
+ }
+}
diff --git a/extensions/strava/stub/src/main/java/com/strava/core/data/RemoteMediaContent.java b/extensions/strava/stub/src/main/java/com/strava/core/data/RemoteMediaContent.java
new file mode 100644
index 000000000..4c190ab56
--- /dev/null
+++ b/extensions/strava/stub/src/main/java/com/strava/core/data/RemoteMediaContent.java
@@ -0,0 +1,17 @@
+package com.strava.core.data;
+
+import java.util.SortedMap;
+
+public interface RemoteMediaContent extends MediaContent {
+ MediaDimension getLargestSize();
+
+ String getLargestUrl();
+
+ SortedMap getSizes();
+
+ String getSmallestUrl();
+
+ RemoteMediaStatus getStatus();
+
+ SortedMap getUrls();
+}
diff --git a/extensions/strava/stub/src/main/java/com/strava/core/data/RemoteMediaStatus.java b/extensions/strava/stub/src/main/java/com/strava/core/data/RemoteMediaStatus.java
new file mode 100644
index 000000000..65dda77ed
--- /dev/null
+++ b/extensions/strava/stub/src/main/java/com/strava/core/data/RemoteMediaStatus.java
@@ -0,0 +1,11 @@
+package com.strava.core.data;
+
+public enum RemoteMediaStatus {
+ NEW,
+ PENDING,
+ PROCESSED,
+ REPORTED,
+ REINSTATED,
+ DELETED,
+ FAILED
+}
diff --git a/extensions/strava/stub/src/main/java/com/strava/photos/data/Media.java b/extensions/strava/stub/src/main/java/com/strava/photos/data/Media.java
new file mode 100644
index 000000000..46b4add71
--- /dev/null
+++ b/extensions/strava/stub/src/main/java/com/strava/photos/data/Media.java
@@ -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 sizes;
+ private final RemoteMediaStatus status;
+ private final String tag;
+ private final MediaType type;
+ private final SortedMap 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 getSizes() {
+ return sizes;
+ }
+
+ @Override
+ public RemoteMediaStatus getStatus() {
+ return status;
+ }
+
+ @Override
+ public String getTag() {
+ return tag;
+ }
+
+ @Override
+ public MediaType getType() {
+ return type;
+ }
+
+ @Override
+ public SortedMap getUrls() {
+ return urls;
+ }
+
+ @Override
+ public void setCaption(String caption) {
+ this.caption = caption;
+ }
+
+ public Photo(String id,
+ String caption,
+ SortedMap urls,
+ SortedMap 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 sizes;
+ private final RemoteMediaStatus status;
+ private final String tag;
+ private final MediaType type;
+ private final SortedMap 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 getSizes() {
+ return sizes;
+ }
+
+ @Override
+ public RemoteMediaStatus getStatus() {
+ return status;
+ }
+
+ @Override
+ public String getTag() {
+ return tag;
+ }
+
+ @Override
+ public MediaType getType() {
+ return type;
+ }
+
+ @Override
+ public SortedMap 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 urls,
+ SortedMap 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() {
+ }
+}
diff --git a/patches/api/patches.api b/patches/api/patches.api
index 8142a54dc..91942f440 100644
--- a/patches/api/patches.api
+++ b/patches/api/patches.api
@@ -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;
}
diff --git a/patches/src/main/kotlin/app/revanced/patches/strava/media/AddMediaDownloadPatch.kt b/patches/src/main/kotlin/app/revanced/patches/strava/media/AddMediaDownloadPatch.kt
new file mode 100644
index 000000000..227b3e675
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/strava/media/AddMediaDownloadPatch.kt
@@ -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(setTrueIndex).registerB
+ val actionRegister = instructions.first { instruction ->
+ instruction.getReference()?.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->(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()?.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
+ }
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/strava/media/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/strava/media/Fingerprints.kt
new file mode 100644
index 000000000..ebdf10fda
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/strava/media/Fingerprints.kt
@@ -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;")
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/strava/misc/extension/Hooks.kt b/patches/src/main/kotlin/app/revanced/patches/strava/misc/extension/Hooks.kt
new file mode 100644
index 000000000..6c2a97769
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/strava/misc/extension/Hooks.kt
@@ -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;")
+ }
+}
diff --git a/patches/src/main/kotlin/app/revanced/patches/strava/misc/extension/SharedExtensionPatch.kt b/patches/src/main/kotlin/app/revanced/patches/strava/misc/extension/SharedExtensionPatch.kt
new file mode 100644
index 000000000..404bf6e9a
--- /dev/null
+++ b/patches/src/main/kotlin/app/revanced/patches/strava/misc/extension/SharedExtensionPatch.kt
@@ -0,0 +1,5 @@
+package app.revanced.patches.strava.misc.extension
+
+import app.revanced.patches.shared.misc.extension.sharedExtensionPatch
+
+val sharedExtensionPatch = sharedExtensionPatch("strava", applicationOnCreateHook)