Files
RyukGram/src/Utils.m
T
faroukbmiled 86eaa95019 [release] RyukGram v1.2.0
### Features
- **Open Instagram links in app (Safari extension)** — bundled Safari web extension (sideload IPA only). Enable in Safari → Extensions; instagram.com links open in the app.
- **Localization** — every user-facing string flows through a central translation layer. Globe button in Settings; missing keys fall back to English. Ships English only — see the "Translating RyukGram" section in the README to add more.
- **Action buttons** — context-aware menus on feed, reels, and stories (expand, repost, download, copy caption, etc.) with per-context default tap action and carousel/multi-story bulk download
- **Enhanced HD downloads** — up to 1080p via DASH + FFmpegKit with quality picker, preview playback, encoding-speed options, and 720p fallback
- **Repost**, **media viewer**, **media zoom** (long-press), **download pill** (frosted glass, stacks concurrent downloads)
- **Fake location** — overrides CoreLocation app-wide, map picker + saved presets, optional quick-toggle button on the Friends Map
- **Messages-only mode** — strips every tab except DM inbox + profile
- **Launch tab** — pick which tab the app opens to
- Full last active date in DMs — show full date instead of "Active 2h ago"
- Custom date format — 12 formats with per-surface toggles (feed, notes/comments/stories, DMs)
- Send files in DMs (experimental)
- View story mentions
- Hide suggested stories
- Story tray long-press actions — view HD profile picture from the tray menu
- Advance on story reply — auto-skip to next story after sending a reply or reaction
- Mark story as seen on reply or emoji reaction
- Hide metrics (likes, comments, shares counts)
- Hide messages tab
- Hide voice/video call buttons in DM thread header (independent toggles)
- Disable app haptics
- Disable reels tab refresh
- Disable disappearing messages mode in DMs
- Follow indicator — shows whether the profile user follows you
- Copy note text on long press
- Zoom profile photo — long press opens full-screen viewer
- Notes actions — copy text, download GIF/audio from notes long-press menu
- Confirm unfollow
- Feed refresh controls — disable background refresh, home button refresh, and home button scroll

### Improvements
- Default tap action: added copy URL, repost, and view mentions options; dynamic menu generation per context
- Settings pages reordered: General → Feed → Stories → Reels → Messages → Profile → Navigation → Saving → Confirmations
- Fake location picker: native Apple Maps-style UI (search, long-press to drop pin, current location)
- Liquid glass floating tab bar + dynamic sizing
- Upload audio: FFmpegKit re-encode + trim for any audio/video input
- Settings reorganized with per-context action button config; new Profile page
- Highlight cover: full-screen viewer replaces direct download
- Switched HD encoder to `h264_videotoolbox` (hardware) — no GPL FFmpegKit required
- Legacy long-press download deprecated (off by default), replaced by action buttons

### Fixes
- Hide suggested stories no longer removes followed users' stories on scroll
- Settings search bar transparency with liquid glass off; auto-deactivates on push
- HD download cancel: tapping pill aborts in-flight downloads + FFmpeg sessions cleanly
- Download pill stuck state on background/foreground, progress reset per download
- Disappearing messages mode confirmation not firing on swipe
- Detailed color picker not working on story draw `†`
- DM seen toggle menu not updating after tap
- Reel refresh confirmation appearing on first app launch `†`
- Reels action button displacing profile pictures on photo reels
- Disappearing DM media download (expand, share, save to Photos with progress pill)
- Carousel "Download all" not showing item count in feed
- Encoding speed setting being ignored for HD downloads
- Various upstream SCInsta merges (Meta AI hiding, suggested chats hiding, notes tray) — marked `†`

> `†` Merged from upstream [SCInsta](https://github.com/SoCuul/SCInsta) by SoCuul

### Credits
- Thanks to [@erupts0](https://github.com/erupts0) (John) for testing and feature suggestions
- Thanks to [@euoradan](https://t.me/euoradan) (Radan) for experimental Instagram feature flag research
- Safari extension forked/cleaned from [BillyCurtis/OpenInstagramSafariExtension](https://github.com/BillyCurtis/OpenInstagramSafariExtension)

### Known Issues
- 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)
2026-04-16 03:03:30 +01:00

386 lines
15 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;
}
+ (void)cleanCache {
NSFileManager *fileManager = [NSFileManager defaultManager];
NSMutableArray<NSError *> *deletionErrors = [NSMutableArray array];
// Temp folder
// * disabled bc app crashed trying to delete certain files inside it
// todo: remove the above disclaimer if this new code doesn't cause crashing
NSArray *tempFolderContents = [fileManager contentsOfDirectoryAtURL:[NSURL fileURLWithPath:NSTemporaryDirectory()] includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsHiddenFiles error:nil];
for (NSURL *fileURL in tempFolderContents) {
NSError *cacheItemDeletionError;
[fileManager removeItemAtURL:fileURL error:&cacheItemDeletionError];
if (cacheItemDeletionError) [deletionErrors addObject:cacheItemDeletionError];
}
// Analytics folder
NSString *analyticsFolder = [[NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES) objectAtIndex:0] stringByAppendingPathComponent:@"Application Support/com.burbn.instagram/analytics"];
NSArray *analyticsFolderContents = [fileManager contentsOfDirectoryAtURL:[[NSURL alloc] initFileURLWithPath:analyticsFolder] includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsHiddenFiles error:nil];
for (NSURL *fileURL in analyticsFolderContents) {
NSError *cacheItemDeletionError;
[fileManager removeItemAtURL:fileURL error:&cacheItemDeletionError];
if (cacheItemDeletionError) [deletionErrors addObject:cacheItemDeletionError];
}
// Caches folder
NSString *cachesFolder = [[NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES) objectAtIndex:0] stringByAppendingPathComponent:@"Caches"];
NSArray *cachesFolderContents = [fileManager contentsOfDirectoryAtURL:[[NSURL alloc] initFileURLWithPath:cachesFolder] includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsHiddenFiles error:nil];
for (NSURL *fileURL in cachesFolderContents) {
NSError *cacheItemDeletionError;
[fileManager removeItemAtURL:fileURL error:&cacheItemDeletionError];
if (cacheItemDeletionError) [deletionErrors addObject:cacheItemDeletionError];
}
// Log errors
if (deletionErrors.count > 1) {
for (NSError *error in deletionErrors) {
NSLog(@"[SCInsta] File Deletion Error: %@", error);
}
}
}
// 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
+ (NSURL *)getPhotoUrl:(IGPhoto *)photo {
if (!photo) return nil;
// Get highest quality photo link
NSURL *photoUrl = [photo imageURLForWidth:100000.00];
return photoUrl;
}
+ (NSURL *)getPhotoUrlForMedia:(IGMedia *)media {
if (!media) return nil;
IGPhoto *photo = media.photo;
return [SCIUtils getPhotoUrl:photo];
}
+ (NSURL *)getVideoUrl:(IGVideo *)video {
if (!video) return nil;
// The past (pre v398)
if ([video respondsToSelector:@selector(sortedVideoURLsBySize)]) {
NSArray<NSDictionary *> *sorted = [video sortedVideoURLsBySize];
NSString *urlString = sorted.firstObject[@"url"];
return urlString.length ? [NSURL URLWithString:urlString] : nil;
}
// The present (post v398)
if ([video respondsToSelector:@selector(allVideoURLs)]) {
return [[video allVideoURLs] anyObject];
}
return nil;
}
+ (NSURL *)getVideoUrlForMedia:(IGMedia *)media {
if (!media) return nil;
IGVideo *video = media.video;
if (!video) return nil;
return [SCIUtils getVideoUrl:video];
}
// 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);
}
@end