Files
RyukGram/src/Utils.m
T
faroukbmiled 2977873932 [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
2026-04-24 02:50:30 +01:00

446 lines
18 KiB
Objective-C

#import "Utils.h"
#import "PhotoAlbum.h"
#import "Settings/TweakSettings.h"
@implementation SCIUtils
+ (BOOL)getBoolPref:(NSString *)key {
if (![key length] || [[NSUserDefaults standardUserDefaults] objectForKey:key] == nil) return false;
return [[NSUserDefaults standardUserDefaults] boolForKey:key];
}
+ (double)getDoublePref:(NSString *)key {
if (![key length] || [[NSUserDefaults standardUserDefaults] objectForKey:key] == nil) return 0;
return [[NSUserDefaults standardUserDefaults] doubleForKey:key];
}
+ (NSString *)getStringPref:(NSString *)key {
if (![key length] || [[NSUserDefaults standardUserDefaults] objectForKey:key] == nil) return @"";
return [[NSUserDefaults standardUserDefaults] stringForKey:key];
}
static NSDictionary *sciRegisteredDefaultsRef = nil;
+ (NSDictionary<NSString *, id> *)sciRegisteredDefaults { return sciRegisteredDefaultsRef ?: @{}; }
+ (void)setSciRegisteredDefaults:(NSDictionary<NSString *, id> *)defaults {
sciRegisteredDefaultsRef = [defaults copy];
}
+ (_Bool)liquidGlassEnabledBool:(_Bool)fallback {
BOOL setting = [SCIUtils getBoolPref:@"liquid_glass_surfaces"];
return setting ? true : fallback;
}
// Displaying View Controllers
+ (void)showQuickLookVC:(NSArray<id> *)items {
UIViewController *topVC = topMostController();
if (!topVC) {
NSLog(@"[SCInsta] No view controller available to present QuickLook");
return;
}
QLPreviewController *previewController = [[QLPreviewController alloc] init];
QuickLookDelegate *quickLookDelegate = [[QuickLookDelegate alloc] initWithPreviewItemURLs:items];
previewController.dataSource = quickLookDelegate;
[topVC presentViewController:previewController animated:true completion:nil];
}
+ (void)showShareVC:(id)item {
UIViewController *topVC = topMostController();
if (!topVC) {
NSLog(@"[SCInsta] No view controller available to present share sheet");
return;
}
UIActivityViewController *acVC = [[UIActivityViewController alloc] initWithActivityItems:@[item] applicationActivities:nil];
if (is_iPad()) {
acVC.popoverPresentationController.sourceView = topVC.view;
acVC.popoverPresentationController.sourceRect = CGRectMake(topVC.view.bounds.size.width / 2.0, topVC.view.bounds.size.height / 2.0, 1.0, 1.0);
}
// If the user picks "Save to Photos" from the share sheet, route the new
// asset into the RyukGram album via a one-shot photo library observer.
if ([self getBoolPref:@"save_to_ryukgram_album"]) {
[SCIPhotoAlbum watchForNextSavedAsset];
}
[topVC presentViewController:acVC animated:true completion:nil];
}
+ (void)showSettingsVC:(UIWindow *)window {
UIViewController *rootController = [window rootViewController];
SCISettingsViewController *settingsViewController = [SCISettingsViewController new];
UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:settingsViewController];
if ([SCIUtils getBoolPref:@"settings_pause_playback"])
navigationController.modalPresentationStyle = UIModalPresentationFullScreen;
[rootController presentViewController:navigationController animated:YES completion:nil];
}
// Open settings and push straight into a named top-level entry (e.g. "Messages").
+ (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle {
UIViewController *rootController = [window rootViewController];
while (rootController.presentedViewController) rootController = rootController.presentedViewController;
SCISettingsViewController *root = [SCISettingsViewController new];
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:root];
if ([SCIUtils getBoolPref:@"settings_pause_playback"])
nav.modalPresentationStyle = UIModalPresentationFullScreen;
NSArray *targetNavSections = nil;
for (NSDictionary *section in [SCITweakSettings sections]) {
for (SCISetting *row in section[@"rows"]) {
if (row.type == SCITableCellNavigation && [row.title isEqualToString:entryTitle]) {
targetNavSections = row.navSections;
break;
}
}
if (targetNavSections) break;
}
if (targetNavSections) {
SCISettingsViewController *child = [[SCISettingsViewController alloc]
initWithTitle:entryTitle sections:targetNavSections reduceMargin:NO];
child.title = entryTitle;
[nav pushViewController:child animated:NO];
}
[rootController presentViewController:nav animated:YES completion:nil];
}
// Colours
+ (UIColor *)SCIColor_Primary {
return [UIColor colorWithRed:0/255.0 green:152/255.0 blue:254/255.0 alpha:1];
};
// Errors
+ (NSError *)errorWithDescription:(NSString *)errorDesc {
return [self errorWithDescription:errorDesc code:1];
}
+ (NSError *)errorWithDescription:(NSString *)errorDesc code:(NSInteger)errorCode {
NSError *error = [ NSError errorWithDomain:@"com.socuul.scinsta" code:errorCode userInfo:@{ NSLocalizedDescriptionKey: errorDesc } ];
return error;
}
+ (JGProgressHUD *)showErrorHUDWithDescription:(NSString *)errorDesc {
return [self showErrorHUDWithDescription:errorDesc dismissAfterDelay:4.0];
}
+ (JGProgressHUD *)showErrorHUDWithDescription:(NSString *)errorDesc dismissAfterDelay:(CGFloat)dismissDelay {
JGProgressHUD *hud = [[JGProgressHUD alloc] init];
hud.textLabel.text = errorDesc;
hud.indicatorView = [[JGProgressHUDErrorIndicatorView alloc] init];
UIView *hudView = topMostController().view;
if (!hudView) hudView = [UIApplication sharedApplication].keyWindow;
if (hudView) {
[hud showInView:hudView];
[hud dismissAfterDelay:dismissDelay];
} else {
NSLog(@"[SCInsta] No valid view for error HUD: %@", errorDesc);
}
return hud;
}
// Media
// fieldCache fallback — reads the Pando-backed dict directly for when
// IG's exposed property accessors break between versions.
static id sciFieldCacheValue(id obj, NSString *key) {
if (!obj || !key.length) return nil;
Ivar iv = NULL;
for (Class c = [obj class]; c && !iv; c = class_getSuperclass(c))
iv = class_getInstanceVariable(c, "_fieldCache");
if (!iv) return nil;
@try {
NSDictionary *dict = object_getIvar(obj, iv);
if (![dict isKindOfClass:[NSDictionary class]]) return nil;
return dict[key];
} @catch (__unused id e) { return nil; }
}
+ (NSURL *)getPhotoUrl:(IGPhoto *)photo {
if (!photo) return nil;
@try {
if ([photo respondsToSelector:@selector(imageURLForWidth:)]) {
NSURL *url = [photo imageURLForWidth:100000.00];
if (url) return url;
}
} @catch (__unused NSException *e) {}
return nil;
}
+ (NSURL *)getPhotoUrlForMedia:(IGMedia *)media {
if (!media) return nil;
// fieldCache first — IGPhoto selectors crash on newer IG builds.
@try {
NSDictionary *imageVersions = sciFieldCacheValue(media, @"image_versions2");
NSArray *candidates = [imageVersions isKindOfClass:[NSDictionary class]] ? imageVersions[@"candidates"] : nil;
if ([candidates isKindOfClass:[NSArray class]] && candidates.count) {
NSDictionary *best = nil;
NSInteger bestW = -1;
for (id c in candidates) {
if (![c isKindOfClass:[NSDictionary class]]) continue;
NSInteger w = [[c objectForKey:@"width"] integerValue];
if (w > bestW) { bestW = w; best = c; }
}
NSString *urlStr = best[@"url"] ?: [[candidates firstObject] objectForKey:@"url"];
if ([urlStr isKindOfClass:[NSString class]] && urlStr.length) {
return [NSURL URLWithString:urlStr];
}
}
} @catch (__unused NSException *e) {}
IGPhoto *photo = nil;
@try {
if ([media respondsToSelector:@selector(photo)]) photo = media.photo;
} @catch (__unused NSException *e) {}
if (photo) return [SCIUtils getPhotoUrl:photo];
return nil;
}
+ (NSURL *)getVideoUrl:(IGVideo *)video {
if (!video) return nil;
@try {
if ([video respondsToSelector:@selector(sortedVideoURLsBySize)]) {
NSArray<NSDictionary *> *sorted = [video sortedVideoURLsBySize];
NSString *urlString = [sorted.firstObject isKindOfClass:[NSDictionary class]] ? sorted.firstObject[@"url"] : nil;
if ([urlString isKindOfClass:[NSString class]] && urlString.length) return [NSURL URLWithString:urlString];
}
} @catch (__unused NSException *e) {}
@try {
if ([video respondsToSelector:@selector(allVideoURLs)]) {
id set = [video allVideoURLs];
if ([set respondsToSelector:@selector(anyObject)]) {
id obj = [set anyObject];
if ([obj isKindOfClass:[NSURL class]]) {
NSString *abs = nil;
@try { abs = [(NSURL *)obj absoluteString]; } @catch (__unused NSException *e) {}
if (abs.length && ([abs hasPrefix:@"http"] || [abs hasPrefix:@"file:"])) {
return [NSURL URLWithString:abs];
}
} else if ([obj isKindOfClass:[NSString class]]) {
NSString *s = (NSString *)obj;
if (s.length && ([s hasPrefix:@"http"] || [s hasPrefix:@"file:"])) return [NSURL URLWithString:s];
}
}
}
} @catch (__unused NSException *e) {}
return nil;
}
+ (NSURL *)getVideoUrlForMedia:(IGMedia *)media {
if (!media) return nil;
// fieldCache first — IGVideo selectors crash on newer IG builds.
@try {
NSArray *versions = sciFieldCacheValue(media, @"video_versions");
if ([versions isKindOfClass:[NSArray class]] && versions.count) {
NSDictionary *best = nil;
NSInteger bestType = -1;
for (id v in versions) {
if (![v isKindOfClass:[NSDictionary class]]) continue;
NSInteger type = [[v objectForKey:@"type"] integerValue];
if (type > bestType) { bestType = type; best = v; }
}
NSString *urlStr = best[@"url"] ?: [[versions firstObject] objectForKey:@"url"];
if ([urlStr isKindOfClass:[NSString class]] && urlStr.length) {
return [NSURL URLWithString:urlStr];
}
}
} @catch (__unused NSException *e) {}
IGVideo *video = nil;
@try {
if ([media respondsToSelector:@selector(video)]) video = media.video;
} @catch (__unused NSException *e) {}
if (video) return [SCIUtils getVideoUrl:video];
return nil;
}
// View Controllers
+ (UIViewController *)viewControllerForView:(UIView *)view {
NSString *viewDelegate = @"viewDelegate";
if ([view respondsToSelector:NSSelectorFromString(viewDelegate)]) {
return [view valueForKey:viewDelegate];
}
return nil;
}
+ (UIViewController *)viewControllerForAncestralView:(UIView *)view {
NSString *_viewControllerForAncestor = @"_viewControllerForAncestor";
if ([view respondsToSelector:NSSelectorFromString(_viewControllerForAncestor)]) {
return [view valueForKey:_viewControllerForAncestor];
}
return nil;
}
+ (UIViewController *)nearestViewControllerForView:(UIView *)view {
return [self viewControllerForView:view] ?: [self viewControllerForAncestralView:view];
}
// Functions
+ (NSString *)IGVersionString {
return [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"];
};
+ (BOOL)isNotch {
return [[[UIApplication sharedApplication] keyWindow] safeAreaInsets].bottom > 0;
};
+ (BOOL)existingLongPressGestureRecognizerForView:(UIView *)view {
NSArray *allRecognizers = view.gestureRecognizers;
for (UIGestureRecognizer *recognizer in allRecognizers) {
if ([[recognizer class] isSubclassOfClass:[UILongPressGestureRecognizer class]]) {
return YES;
}
}
return NO;
}
// Alerts
+ (BOOL)showConfirmation:(void(^)(void))okHandler title:(NSString *)title {
UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:SCILocalized(@"Are you sure?") preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Yes") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
okHandler();
}]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"No!") style:UIAlertActionStyleCancel handler:nil]];
[topMostController() presentViewController:alert animated:YES completion:nil];
return nil;
};
+ (BOOL)showConfirmation:(void(^)(void))okHandler cancelHandler:(void(^)(void))cancelHandler title:(NSString *)title {
UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:SCILocalized(@"Are you sure?") preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Yes") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
okHandler();
}]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"No!") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
if (cancelHandler != nil) {
cancelHandler();
}
}]];
[topMostController() presentViewController:alert animated:YES completion:nil];
return nil;
};
+ (BOOL)showConfirmation:(void(^)(void))okHandler {
return [self showConfirmation:okHandler title:nil];
};
+ (BOOL)showConfirmation:(void(^)(void))okHandler cancelHandler:(void(^)(void))cancelHandler {
return [self showConfirmation:okHandler cancelHandler:cancelHandler title:nil];
}
+ (void)showRestartConfirmation {
UIAlertController* alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Restart required") message:SCILocalized(@"You must restart the app to apply this change") preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Restart") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
exit(0);
}]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Later") style:UIAlertActionStyleCancel handler:nil]];
[topMostController() presentViewController:alert animated:YES completion:nil];
};
// Toasts
+ (void)showToastForDuration:(double)duration title:(NSString *)title {
[SCIUtils showToastForDuration:duration title:title subtitle:nil];
}
+ (void)showToastForDuration:(double)duration title:(NSString *)title subtitle:(NSString *)subtitle {
// Root VC
Class rootVCClass = NSClassFromString(@"IGRootViewController");
UIViewController *topMostVC = topMostController();
if (![topMostVC isKindOfClass:rootVCClass]) return;
IGRootViewController *rootVC = (IGRootViewController *)topMostVC;
// Presenter
IGActionableConfirmationToastPresenter *toastPresenter = [rootVC toastPresenter];
if (toastPresenter == nil) return;
// View Model
Class modelClass = NSClassFromString(@"IGActionableConfirmationToastViewModel");
IGActionableConfirmationToastViewModel *model = [modelClass new];
[model setValue:title forKey:@"text_annotatedTitleText"];
[model setValue:subtitle forKey:@"text_annotatedSubtitleText"];
// Show new toast, after clearing existing one
[toastPresenter hideAlert];
[toastPresenter showAlertWithViewModel:model isAnimated:true animationDuration:duration presentationPriority:0 tapActionBlock:nil presentedHandler:nil dismissedHandler:nil];
}
// Math
+ (NSUInteger)decimalPlacesInDouble:(double)value {
NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
[formatter setNumberStyle:NSNumberFormatterDecimalStyle];
[formatter setMaximumFractionDigits:15]; // Allow enough digits for double precision
[formatter setMinimumFractionDigits:0];
[formatter setDecimalSeparator:@"."]; // Force dot for internal logic, then respect locale for final display if needed
NSString *stringValue = [formatter stringFromNumber:@(value)];
// Find decimal separator
NSRange decimalRange = [stringValue rangeOfString:formatter.decimalSeparator];
if (decimalRange.location == NSNotFound) {
return 0;
} else {
return stringValue.length - (decimalRange.location + decimalRange.length);
}
}
// Ivars
+ (id)getIvarForObj:(id)obj name:(const char *)name {
Ivar ivar = class_getInstanceVariable(object_getClass(obj), name);
if (!ivar) return nil;
return object_getIvar(obj, ivar);
}
+ (void)setIvarForObj:(id)obj name:(const char *)name value:(id)value {
Ivar ivar = class_getInstanceVariable(object_getClass(obj), name);
if (!ivar) return;
object_setIvarWithStrongDefault(obj, ivar, value);
}
+ (id)activeUserSession {
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
if (![scene isKindOfClass:[UIWindowScene class]]) continue;
for (UIWindow *w in ((UIWindowScene *)scene).windows) {
@try {
id s = [w valueForKey:@"userSession"];
if (s) return s;
} @catch (__unused id e) {}
}
}
return nil;
}
+ (NSString *)pkFromIGUser:(id)user {
if (!user) return nil;
Ivar pkIvar = NULL;
for (Class c = [user class]; c && !pkIvar; c = class_getSuperclass(c)) {
pkIvar = class_getInstanceVariable(c, "_pk");
}
if (!pkIvar) return nil;
id pk = object_getIvar(user, pkIvar);
return pk ? [pk description] : nil;
}
+ (NSString *)currentUserPK {
id session = [self activeUserSession];
if (!session) return nil;
@try {
id user = [session valueForKey:@"user"];
return [self pkFromIGUser:user];
} @catch (__unused id e) { return nil; }
}
@end