From 778d13ce8b28ca6df3a665530320e4a21a27ae44 Mon Sep 17 00:00:00 2001 From: xehpuk Date: Mon, 12 Jan 2026 23:28:15 +0100 Subject: [PATCH] feat(Strava): Add `Add media download` patch (#6449) Co-authored-by: oSumAtrIX --- .../app/revanced/extension/shared/Utils.java | 4 + extensions/strava/build.gradle.kts | 5 + .../strava/src/main/AndroidManifest.xml | 1 + .../strava/AddMediaDownloadPatch.java | 216 +++++++++++++ extensions/strava/stub/build.gradle.kts | 12 + .../strava/stub/src/main/AndroidManifest.xml | 1 + .../com/strava/core/data/MediaContent.java | 15 + .../com/strava/core/data/MediaDimension.java | 44 +++ .../java/com/strava/core/data/MediaType.java | 16 + .../strava/core/data/RemoteMediaContent.java | 17 ++ .../strava/core/data/RemoteMediaStatus.java | 11 + .../java/com/strava/photos/data/Media.java | 286 ++++++++++++++++++ patches/api/patches.api | 8 + .../strava/media/AddMediaDownloadPatch.kt | 116 +++++++ .../patches/strava/media/Fingerprints.kt | 15 + .../patches/strava/misc/extension/Hooks.kt | 9 + .../misc/extension/SharedExtensionPatch.kt | 5 + 17 files changed, 781 insertions(+) create mode 100644 extensions/strava/build.gradle.kts create mode 100644 extensions/strava/src/main/AndroidManifest.xml create mode 100644 extensions/strava/src/main/java/app/revanced/extension/strava/AddMediaDownloadPatch.java create mode 100644 extensions/strava/stub/build.gradle.kts create mode 100644 extensions/strava/stub/src/main/AndroidManifest.xml create mode 100644 extensions/strava/stub/src/main/java/com/strava/core/data/MediaContent.java create mode 100644 extensions/strava/stub/src/main/java/com/strava/core/data/MediaDimension.java create mode 100644 extensions/strava/stub/src/main/java/com/strava/core/data/MediaType.java create mode 100644 extensions/strava/stub/src/main/java/com/strava/core/data/RemoteMediaContent.java create mode 100644 extensions/strava/stub/src/main/java/com/strava/core/data/RemoteMediaStatus.java create mode 100644 extensions/strava/stub/src/main/java/com/strava/photos/data/Media.java create mode 100644 patches/src/main/kotlin/app/revanced/patches/strava/media/AddMediaDownloadPatch.kt create mode 100644 patches/src/main/kotlin/app/revanced/patches/strava/media/Fingerprints.kt create mode 100644 patches/src/main/kotlin/app/revanced/patches/strava/misc/extension/Hooks.kt create mode 100644 patches/src/main/kotlin/app/revanced/patches/strava/misc/extension/SharedExtensionPatch.kt 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)