[release] RyukGram v1.2.2

- Profile Analyzer (beta) — follower/following scans with mutuals, non-followbacks, new/lost trackers, and profile change history; searchable lists with batch follow/unfollow
- Theme settings — force dark mode, Full OLED, OLED chat theme, and keyboard theme picker
- Confirm story like
- Confirm story emoji reaction
- Swipe down to dismiss media viewer
- Manually add users to story/chat exclusion lists by username
- Keep stories visually seen locally
- Auto-scroll reels mode
- Quality picker: audio-only and raw photo download rows
- Clear cache button with optional auto-clear interval
- Spanish, Russian, Korean, Arabic, and Chinese (Traditional) translations
- About page with version, credits, and links
- Release notes popup on first launch of a new version
- Anonymous live viewing
- Toggle live comments
- Disappearing DM media overlay — action button, mark-as-viewed eye, and audio toggle
- Hide RyukGram UI on screenshots, screen recordings, and mirroring
- Open link from clipboard — long-press the search tab
- Messages-only mode: optional "Hide tab bar" sub-toggle
- Fake profile stats — verified badge and follower/following/post counts on your own profile
- Language switcher + import/export localization from Debug
- Reveal poll/slider vote counts and quiz answers on stories and reels before interacting
- Force legacy Quiz sticker back into the story composer tray
- Advanced experimental features menu — toggle hidden IG experiments (QuickSnap, Homecoming, Prism, Direct Notes reply types) with apply-on-restart batching and a crash-loop auto-reset
- Shortcut to Advanced experimental features from the General experimental features section

- Push notifications render with rich previews on sideload again
- IG 426 compatibility across story audio toggle, like confirmation, seen-on-like, live comments, notes audio download
- Call confirm split into separate voice-call and video-call toggles
- Messages-only mode: tab swiping disabled
- Settings quick-access broken in non-English languages
- Story seen-receipt block restored on IG v426
- Block selected mode no longer marks listed stories as seen
- Hide explore posts grid works again on recent IG versions
- Hide suggested stories no longer breaks profile highlights
- Hide trending searches now also hides the category chip bar
- Story eye long-press menu opens next to the button
- Disable video autoplay: tap-to-play now works on videos inside carousels
- Disable vanish mode swipe fixed on IG 426

- "Confirm shh mode" renamed to "Confirm vanish mode" across all languages
- Confirm sticker interaction split into separate story and highlight toggles
- Shared link embed presets: added eeinstagram.com and vxinstagram.com
- Downloaded media filenames follow `@username_context_timestamp`
- Reels pause mode: optional tap-to-mute on photo reels
- Backup & Restore — scope picker with live preview for Settings / Excluded lists / Analyzer data
- Profile Analyzer: filter by Not verified
- Settings header: tap to open a sheet with GitHub and Telegram channel links

- Thanks to Furamako for the Spanish translation
- Thanks to [ZomkaDEV](https://github.com/ZomkaDEV) for the Russian translation
- Thanks to [@ch1tmdgus](https://github.com/ch1tmdgus) (N4C) for the Korean translation
- Thanks to [@bruuhim](https://github.com/bruuhim) for the Arabic translation
- Thanks to [@jaydenjcpy](https://github.com/jaydenjcpy) for the Chinese (Traditional) translation
- Thanks to [@darthplagueiswise](https://github.com/darthplagueiswise) (Radan) for the experimental flag feature set
- Thanks to [@asdfzxcvbn](https://github.com/asdfzxcvbn) for [zxPluginsInject](https://github.com/asdfzxcvbn/zxPluginsInject) and [ipapatch](https://github.com/asdfzxcvbn/ipapatch)

- Preserved unsent messages can't be removed via "Delete for you"; pull-to-refresh clears them (warning available in settings)
- "Delete for you" detection uses a ~2s window after the local action — a real unsend landing in that window may be missed (rare)
- With Liquid Glass buttons + Hide UI on capture both on, the DM eye leaves an empty glass bubble in captures
This commit is contained in:
faroukbmiled
2026-04-24 02:50:30 +01:00
parent 3fd1d8e138
commit 2977873932
125 changed files with 14437 additions and 3368 deletions
+5
View File
@@ -118,11 +118,16 @@ const void *kSCIDismissKey = &kSCIDismissKey;
id media = provider(sender);
if (media == (id)kCFNull) return;
SCIActionContext tapCtx = (SCIActionContext)ctxNum.integerValue;
NSString *tapCtxLabel = [SCIMediaActions contextLabelForContext:tapCtx];
if ([tap isEqualToString:@"expand"]) {
[SCIMediaActions expandMedia:media fromView:sender caption:nil];
} else if ([tap isEqualToString:@"download_share"]) {
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]];
[SCIMediaActions downloadAndShareMedia:media];
} else if ([tap isEqualToString:@"download_photos"]) {
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]];
[SCIMediaActions downloadAndSaveMedia:media];
} else if ([tap isEqualToString:@"copy_link"]) {
[SCIMediaActions copyURLForMedia:media];
+23
View File
@@ -2,6 +2,7 @@
#import <UIKit/UIKit.h>
#import "../InstagramHeaders.h"
#import "../Downloader/Download.h"
#import "SCIActionMenu.h"
NS_ASSUME_NONNULL_BEGIN
@@ -16,6 +17,18 @@ typedef NS_ENUM(NSInteger, SCIActionContext) {
@interface SCIMediaActions : NSObject
// MARK: - Filename naming
// `@username_context_yyyyMMdd_HHmmss` (sanitized). UUID fallback on failure.
+ (NSString *)filenameStemForMedia:(nullable id)media contextLabel:(NSString *)ctxLabel;
// "feed" / "reels" / "stories".
+ (NSString *)contextLabelForContext:(SCIActionContext)ctx;
// Stem read by the download + mux write sites to name output files.
+ (nullable NSString *)currentFilenameStem;
+ (void)setCurrentFilenameStem:(nullable NSString *)stem;
// MARK: - Media extraction
/// Return the post's caption string. Tries selectors first, falls back to
@@ -28,6 +41,16 @@ typedef NS_ENUM(NSInteger, SCIActionContext) {
/// Ordered children of a carousel IGMedia. Empty array for non-carousels.
+ (NSArray *)carouselChildrenForMedia:(id)media;
/// YES if the media has an audio track (`has_audio` fieldCache == 1).
+ (BOOL)mediaHasAudio:(id)media;
/// Download the raw photo URL, skipping any video route.
+ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action;
/// Extract the audio-only track from the DASH manifest via FFmpeg. Photos
/// library can't hold audio, so both actions end at the share sheet.
+ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action;
/// Best URL for a single (non-carousel) media item. Prefers video URL, falls
/// back to photo URL. Returns nil if nothing extractable.
+ (nullable NSURL *)bestURLForMedia:(id)media;
+198 -2
View File
@@ -21,6 +21,57 @@ static SCIDownloadDelegate *sciActiveDownloadDelegate = nil;
extern void sciToggleStoryAudio(void);
extern BOOL sciIsStoryAudioEnabled(void);
// MARK: - Filename naming
static NSString *sciCurrentFilenameStem = nil;
static NSString *sciSanitizeFilenameComponent(NSString *s) {
if (!s.length) return @"";
NSMutableCharacterSet *bad = [NSMutableCharacterSet alphanumericCharacterSet];
[bad addCharactersInString:@"._-"];
NSCharacterSet *drop = bad.invertedSet;
NSArray *parts = [s componentsSeparatedByCharactersInSet:drop];
NSString *out = [parts componentsJoinedByString:@""];
if (out.length > 30) out = [out substringToIndex:30];
return out;
}
// IGAPIStorableObject's backing dict.
static NSDictionary *sciMediaFieldCache(id obj) {
if (!obj) return nil;
static Ivar fcIvar = NULL;
static dispatch_once_t once;
dispatch_once(&once, ^{
Class c = NSClassFromString(@"IGAPIStorableObject");
if (c) fcIvar = class_getInstanceVariable(c, "_fieldCache");
});
if (!fcIvar) return nil;
id v = object_getIvar(obj, fcIvar);
return [v isKindOfClass:[NSDictionary class]] ? v : nil;
}
static NSString *sciUsernameForMedia(id media) {
if (!media) return nil;
@try {
id user = nil;
@try { user = [media valueForKey:@"user"]; } @catch (__unused id e) {}
if (!user) {
NSDictionary *fc = sciMediaFieldCache(media);
user = fc[@"user"];
}
if (!user) return nil;
NSString *u = nil;
@try { u = [user valueForKey:@"username"]; } @catch (__unused id e) {}
if (![u isKindOfClass:[NSString class]] || !u.length) {
NSDictionary *ufc = sciMediaFieldCache(user);
id v = ufc[@"username"];
if ([v isKindOfClass:[NSString class]]) u = v;
else if ([user isKindOfClass:[NSDictionary class]]) u = ((NSDictionary *)user)[@"username"];
}
return [u isKindOfClass:[NSString class]] ? u : nil;
} @catch (__unused id e) { return nil; }
}
// Match keys used in the settings-entry title map for openSettingsForContext:
static NSString *sciSettingsTitleForContext(SCIActionContext ctx) {
switch (ctx) {
@@ -74,6 +125,38 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) {
@implementation SCIMediaActions
+ (NSString *)contextLabelForContext:(SCIActionContext)ctx {
switch (ctx) {
case SCIActionContextFeed: return @"feed";
case SCIActionContextReels: return @"reels";
case SCIActionContextStories: return @"stories";
}
return @"media";
}
+ (NSString *)filenameStemForMedia:(id)media contextLabel:(NSString *)ctxLabel {
@try {
NSString *user = sciSanitizeFilenameComponent(sciUsernameForMedia(media));
NSString *userPart = user.length ? [@"@" stringByAppendingString:user] : @"media";
NSString *ctxPart = sciSanitizeFilenameComponent(ctxLabel);
if (!ctxPart.length) ctxPart = @"media";
static NSDateFormatter *fmt = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
fmt = [NSDateFormatter new];
fmt.dateFormat = @"yyyyMMdd_HHmmss";
fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
});
NSString *ts = [fmt stringFromDate:[NSDate date]];
return [NSString stringWithFormat:@"%@_%@_%@", userPart, ctxPart, ts];
} @catch (__unused id e) {
return [[NSUUID UUID] UUIDString];
}
}
+ (NSString *)currentFilenameStem { return sciCurrentFilenameStem; }
+ (void)setCurrentFilenameStem:(NSString *)stem { sciCurrentFilenameStem = [stem copy]; }
// MARK: - Media extraction
+ (NSString *)captionForMedia:(id)media {
@@ -209,6 +292,96 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) {
return @[];
}
+ (BOOL)mediaHasAudio:(id)media {
if (!media) return NO;
// fieldCache on media (old IG path).
id v = sciFieldCache(media, @"has_audio");
if ([v respondsToSelector:@selector(boolValue)] && [v boolValue]) return YES;
// IGVideo.isAudioDetected — positive signal only; NO often means "IG
// hasn't decoded the manifest yet" for stories, not actually silent.
@try {
id video = nil;
if ([media respondsToSelector:@selector(video)])
video = ((id(*)(id, SEL))objc_msgSend)(media, @selector(video));
if (video && [video respondsToSelector:@selector(isAudioDetected)]) {
if (((BOOL(*)(id, SEL))objc_msgSend)(video, @selector(isAudioDetected))) return YES;
}
} @catch (__unused id e) {}
// Stories often carry audio but don't surface it in fieldCache. If any
// of these music/audio hints are present, treat as audio-bearing.
for (NSString *key in @[@"music_metadata", @"story_music_stickers",
@"is_story_image_with_music", @"story_sound_on",
@"spotify_stickers", @"story_music_lyric_stickers"]) {
id val = sciFieldCache(media, key);
if (val && ![val isKindOfClass:[NSNull class]]) {
if ([val respondsToSelector:@selector(boolValue)] && [val boolValue]) return YES;
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count]) return YES;
if ([val isKindOfClass:[NSDictionary class]] && [(NSDictionary *)val count]) return YES;
}
}
// Last resort: if a DASH manifest exists, assume audio is present.
return [SCIDashParser dashManifestForMedia:media].length > 0;
}
+ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action {
NSURL *url = [self hdPhotoURLForMedia:media];
if (!url) url = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media];
if (!url) url = [self fieldCachePhotoURLForMedia:media];
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo URL")]; return; }
NSString *ext = [[url lastPathComponent] pathExtension];
if (!ext.length) ext = @"jpg";
sciActiveDownloadDelegate = sciMakeDownloader(action, NO);
[sciActiveDownloadDelegate downloadFileWithURL:url fileExtension:ext hudLabel:nil];
}
// Photos library can't hold audio — save action falls back to share sheet.
+ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action {
NSString *manifest = [SCIDashParser dashManifestForMedia:media];
if (!manifest.length) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio stream available")];
return;
}
NSArray *reps = [SCIDashParser parseManifest:manifest];
SCIDashRepresentation *audio = [SCIDashParser bestAudioFromRepresentations:reps];
if (!audio.url) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio track found")];
return;
}
if (![SCIFFmpeg isAvailable]) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"FFmpeg not available")];
return;
}
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
NSString *ticket = [pill beginTicketWithTitle:SCILocalized(@"Downloading audio...")
onCancel:^{ [SCIFFmpeg cancelAll]; }];
NSString *audioStem = [self currentFilenameStem] ?: [[NSUUID UUID] UUIDString];
NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.m4a", audioStem]];
NSString *cmd = [NSString stringWithFormat:@"-i \"%@\" -vn -c:a copy -y \"%@\"",
audio.url.absoluteString, outPath];
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
dispatch_async(dispatch_get_main_queue(), ^{
if (!success) {
[pill finishTicket:ticket errorMessage:SCILocalized(@"Audio extract failed")];
return;
}
[pill finishTicket:ticket successMessage:SCILocalized(@"Audio ready")];
NSURL *fileURL = [NSURL fileURLWithPath:outPath];
switch (action) {
case quickLook: [SCIUtils showQuickLookVC:@[fileURL]]; break;
case share:
case saveToPhotos:
default: [SCIUtils showShareVC:fileURL]; break;
}
});
}];
}
+ (NSURL *)bestURLForMedia:(id)media {
if (!media) return nil;
@@ -328,6 +501,7 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) {
// Try enhanced HD path via reusable quality picker
BOOL handled = [SCIQualityPicker pickQualityForMedia:media
fromView:sourceView
action:action
picked:^(SCIDashRepresentation *video, SCIDashRepresentation *audio) {
[self downloadDASHVideo:video audio:audio action:action];
}
@@ -621,13 +795,18 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
NSMutableArray<NSURL *> *files = [NSMutableArray array];
NSLock *lock = [NSLock new];
__block NSUInteger completed = 0;
NSString *bulkStem = [self currentFilenameStem];
NSUInteger __idx = 0;
for (NSURL *url in urls) {
if (cancelled) break;
dispatch_group_enter(group);
NSString *ext = [[url lastPathComponent] pathExtension];
NSString *name = bulkStem
? [NSString stringWithFormat:@"%@_%lu", bulkStem, (unsigned long)(++__idx)]
: [[NSUUID UUID] UUIDString];
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString],
[NSString stringWithFormat:@"%@.%@", name,
ext.length ? ext : @"jpg"]];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
@@ -763,6 +942,12 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
fromView:(UIView *)sourceView {
NSMutableArray<SCIAction *> *out = [NSMutableArray array];
NSString *ctxLabel = [self contextLabelForContext:ctx];
// Stamp the filename stem before a download fires.
void (^stampStemForMedia)(id) = ^(id m) {
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:m contextLabel:ctxLabel]];
};
// Resolve parent media for carousel detection + bulk actions.
id parentMedia = media;
if (media && ![self isCarouselMedia:media]) {
@@ -946,9 +1131,11 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
[SCIMediaActions copyAllURLsForMedia:bulkMedia];
}],
[SCIAction actionWithTitle:SCILocalized(@"Download and share all") icon:@"square.and.arrow.up.on.square" handler:^{
stampStemForMedia(bulkMedia);
[SCIMediaActions downloadAllAndShareMedia:bulkMedia];
}],
[SCIAction actionWithTitle:SCILocalized(@"Download all to Photos") icon:@"square.and.arrow.down.on.square" handler:^{
stampStemForMedia(bulkMedia);
[SCIMediaActions downloadAllAndSaveMedia:bulkMedia];
}],
];
@@ -1068,6 +1255,7 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
if (u) [urls addObject:u];
}
if (!urls.count) return;
stampStemForMedia(capturedMedias.firstObject);
[SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Download all stories and share?") done:^(NSArray<NSURL *> *files) {
if (!files.count) return;
UIViewController *top = topMostController();
@@ -1083,6 +1271,7 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
if (u) [urls addObject:u];
}
if (!urls.count) return;
stampStemForMedia(capturedMedias.firstObject);
[SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Save all stories to Photos?") done:^(NSArray<NSURL *> *files) {
[SCIMediaActions bulkSaveFiles:files];
}];
@@ -1105,11 +1294,13 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download and share")
icon:@"square.and.arrow.up"
handler:^{
stampStemForMedia(media);
[SCIMediaActions downloadAndShareMedia:media];
}]];
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download to Photos")
icon:@"square.and.arrow.down"
handler:^{
stampStemForMedia(media);
[SCIMediaActions downloadAndSaveMedia:media];
}]];
@@ -1138,13 +1329,18 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
NSMutableArray<NSURL *> *files = [NSMutableArray array];
NSLock *lock = [NSLock new];
__block NSUInteger completed = 0;
NSString *bulkStem2 = [self currentFilenameStem];
NSUInteger __idx2 = 0;
for (NSURL *url in urls) {
if (cancelled) break;
dispatch_group_enter(group);
NSString *ext = [[url lastPathComponent] pathExtension];
NSString *name = bulkStem2
? [NSString stringWithFormat:@"%@_%lu", bulkStem2, (unsigned long)(++__idx2)]
: [[NSUUID UUID] UUIDString];
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString],
[NSString stringWithFormat:@"%@.%@", name,
ext.length ? ext : @"jpg"]];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
+5 -9
View File
@@ -1,5 +1,6 @@
#import "SCIMediaViewer.h"
#import "../Utils.h"
#import "../SCIImageCache.h"
#import <AVFoundation/AVFoundation.h>
#import <AVKit/AVKit.h>
@@ -57,15 +58,10 @@
[self.view addSubview:self.spinner];
[self.spinner startAnimating];
NSURL *url = [self.photoURL copy];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSData *data = [NSData dataWithContentsOfURL:url];
UIImage *img = data ? [UIImage imageWithData:data] : nil;
dispatch_async(dispatch_get_main_queue(), ^{
[self.spinner stopAnimating];
if (img) self.imageView.image = img;
});
});
[SCIImageCache loadImageFromURL:self.photoURL completion:^(UIImage *img) {
[self.spinner stopAnimating];
if (img) self.imageView.image = img;
}];
// Double-tap to zoom
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTap:)];