diff --git a/.gitignore b/.gitignore index 2e0ce4c..e1b2683 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ upstream-scinsta *.dylib deploy.sh PENDING_CHANGES.md +wrapper/ diff --git a/README.md b/README.md index 1412764..94155f8 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Hide ads - Hide Meta AI - Copy description +- Profile copy button **\*** - Do not save recent searches - Use detailed (native) color picker - Enable liquid glass buttons @@ -85,6 +86,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Manually mark messages as seen (button or toggle mode) **\*** - Auto mark seen on send (marks messages as read when you send any message) **\*** - Auto mark seen on typing (marks messages as read the moment you start typing, even when typing status is hidden) **\*** +- Mark seen on story like **\*** - Send audio as file — send audio files as voice messages from the DM plus menu **\*** - Download voice messages — adds a Download option to the long-press menu on voice messages, saves as M4A via share sheet **\*** - Disable typing status @@ -123,6 +125,14 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Confirm changing direct message theme - Confirm sticker interaction +### Tweak settings **\*** +- Search bar in the main settings page — recursively finds any setting across nested pages with a breadcrumb to its location + +### Backup & Restore **\*** +- Export RyukGram settings as a JSON file or scannable QR code +- Import settings from a file in Files or a QR code from your photo library +- Searchable, collapsible, editable preview before saving or applying + ### Optimization - Automatically clears unneeded cache folders, reducing the size of your Instagram installation diff --git a/src/Features/General/ProfileCopyButton.x b/src/Features/General/ProfileCopyButton.x new file mode 100644 index 0000000..49d8760 --- /dev/null +++ b/src/Features/General/ProfileCopyButton.x @@ -0,0 +1,241 @@ +#import "../../Utils.h" +#import "../../InstagramHeaders.h" +#import "../../../modules/JGProgressHUD/JGProgressHUD.h" +#import +#import + +// Profile page copy button: hooks IG's native nav header builder to insert +// a copy button alongside IG's own buttons, then opens a menu to copy +// username/name/bio. + +@interface IGProfileViewController : UIViewController +@end + +static id sci_safeValueForKey(id obj, NSString *key) { + @try { return [obj valueForKey:key]; } + @catch (__unused NSException *e) { return nil; } +} + +static id sci_valueForAnyKey(id obj, NSArray *keys) { + for (NSString *k in keys) { + id v = sci_safeValueForKey(obj, k); + if (v && v != [NSNull null]) return v; + } + return nil; +} + +static id sci_findUserOnVC(UIViewController *vc) { + id user = sci_valueForAnyKey(vc, @[@"user", @"userGQL", @"profileUser", @"loggedInUser", @"currentUser"]); + if (user) return user; + + Class userCls = NSClassFromString(@"IGUser"); + Class c = [vc class]; + while (c && c != [NSObject class]) { + unsigned int count = 0; + Ivar *ivars = class_copyIvarList(c, &count); + for (unsigned int i = 0; i < count; i++) { + id v = object_getIvar(vc, ivars[i]); + if (userCls && [v isKindOfClass:userCls]) { + free(ivars); + return v; + } + } + if (ivars) free(ivars); + c = class_getSuperclass(c); + } + return nil; +} + +static UIViewController *sci_findProfileVC(UIView *view) { + Class profileCls = NSClassFromString(@"IGProfileViewController"); + UIResponder *r = view; + while (r) { + if (profileCls && [r isKindOfClass:profileCls]) return (UIViewController *)r; + r = [r nextResponder]; + } + return nil; +} + +static void sci_copyAndToast(NSString *value, NSString *label) { + if (value.length == 0) return; + [UIPasteboard generalPasteboard].string = value; + + JGProgressHUD *HUD = [[JGProgressHUD alloc] init]; + HUD.textLabel.text = [NSString stringWithFormat:@"Copied %@", label]; + HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init]; + UIView *host = nil; + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w.isKeyWindow) { host = w; break; } + } + if (host) { + [HUD showInView:host]; + [HUD dismissAfterDelay:1.5]; + } +} + +// Singleton target for the copy button so we don't have to track lifetime. +@interface SCIProfileCopyTarget : NSObject ++ (instancetype)shared; +- (void)handleTap:(UIButton *)sender; +@end + +@implementation SCIProfileCopyTarget ++ (instancetype)shared { + static SCIProfileCopyTarget *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [[SCIProfileCopyTarget alloc] init]; }); + return s; +} + +- (void)handleTap:(UIButton *)sender { + UIViewController *vc = sci_findProfileVC(sender); + if (!vc) { + NSLog(@"[SCInsta] copy button: no IGProfileViewController in responder chain"); + return; + } + + id user = sci_findUserOnVC(vc); + if (!user) { + NSLog(@"[SCInsta] copy button: no IGUser found on %@", vc.class); + return; + } + + NSString *username = [sci_valueForAnyKey(user, @[@"username"]) description]; + NSString *fullName = [sci_valueForAnyKey(user, @[@"fullName", @"fullname", @"name"]) description]; + NSString *biography = [sci_valueForAnyKey(user, @[@"biography", @"bio", @"profileBiography"]) description]; + + NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu", + username, fullName, (unsigned long)biography.length); + + UIAlertController *menu = [UIAlertController alertControllerWithTitle:@"Copy from profile" + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + + if (username.length) { + [menu addAction:[UIAlertAction actionWithTitle:[NSString stringWithFormat:@"Copy username (@%@)", username] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_) { sci_copyAndToast(username, @"username"); }]]; + } + if (fullName.length) { + [menu addAction:[UIAlertAction actionWithTitle:@"Copy name" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_) { sci_copyAndToast(fullName, @"name"); }]]; + } + if (biography.length) { + [menu addAction:[UIAlertAction actionWithTitle:@"Copy bio" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_) { sci_copyAndToast(biography, @"bio"); }]]; + } + + NSMutableArray *parts = [NSMutableArray array]; + if (username.length) [parts addObject:[NSString stringWithFormat:@"Username: @%@", username]]; + if (fullName.length) [parts addObject:[NSString stringWithFormat:@"Name: %@", fullName]]; + if (biography.length) [parts addObject:[NSString stringWithFormat:@"Bio:\n%@", biography]]; + + if (parts.count >= 2) { + NSString *combined = [parts componentsJoinedByString:@"\n\n"]; + [menu addAction:[UIAlertAction actionWithTitle:@"Copy all" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_) { sci_copyAndToast(combined, @"all"); }]]; + } + + if (menu.actions.count == 0) { + [menu addAction:[UIAlertAction actionWithTitle:@"Nothing to copy" style:UIAlertActionStyleDefault handler:nil]]; + } + + [menu addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + + if (sender) { + menu.popoverPresentationController.sourceView = sender; + menu.popoverPresentationController.sourceRect = sender.bounds; + } + + [vc presentViewController:menu animated:YES completion:nil]; +} +@end + +static UIView *sci_buildCopyButton(void) { + UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem]; + btn.accessibilityIdentifier = @"sci-profile-copy-button"; + btn.accessibilityLabel = @"Copy profile info"; + UIImageSymbolConfiguration *cfg = + [UIImageSymbolConfiguration configurationWithPointSize:16 + weight:UIImageSymbolWeightRegular]; + UIImage *icon = [[UIImage systemImageNamed:@"doc.on.doc"] imageByApplyingSymbolConfiguration:cfg]; + [btn setImage:icon forState:UIControlStateNormal]; + btn.tintColor = [UIColor labelColor]; + btn.frame = CGRectMake(0, 0, 24, 44); + [btn addTarget:[SCIProfileCopyTarget shared] + action:@selector(handleTap:) + forControlEvents:UIControlEventTouchUpInside]; + return btn; +} + +static void (*orig_configureHeaderView)(id, SEL, id, id, id, BOOL); + +static void hooked_configureHeaderView(id self, SEL _cmd, + id titleView, + id leftButtons, + id rightButtons, + BOOL titleIsCentered) { + if (![SCIUtils getBoolPref:@"profile_copy_button"]) { + orig_configureHeaderView(self, _cmd, titleView, leftButtons, rightButtons, titleIsCentered); + return; + } + + NSArray *patched = rightButtons; + if ([rightButtons isKindOfClass:[NSArray class]] && [(NSArray *)rightButtons count] > 0) { + NSArray *rb = (NSArray *)rightButtons; + + BOOL alreadyHas = NO; + for (id wrapper in rb) { + UIView *v = sci_safeValueForKey(wrapper, @"view"); + if ([v isKindOfClass:[UIView class]] && + [v.accessibilityIdentifier isEqualToString:@"sci-profile-copy-button"]) { + alreadyHas = YES; + break; + } + } + + if (!alreadyHas) { + Class wrapperCls = NSClassFromString(@"IGProfileNavigationHeaderViewButtonSwift.IGProfileNavigationHeaderViewButton"); + // Mirror an existing button's type so IG's layout treats ours the same. + NSInteger type = 0; + id sample = rb.firstObject; + id typeVal = sci_safeValueForKey(sample, @"type"); + if ([typeVal respondsToSelector:@selector(integerValue)]) { + type = [typeVal integerValue]; + } + + UIView *btn = sci_buildCopyButton(); + id wrapper = nil; + if (wrapperCls) { + wrapper = [wrapperCls alloc]; + SEL initSel = @selector(initWithType:view:); + if ([wrapper respondsToSelector:initSel]) { + id (*ctor)(id, SEL, NSInteger, id) = + (id (*)(id, SEL, NSInteger, id))objc_msgSend; + wrapper = ctor(wrapper, initSel, type, btn); + } + } + + if (wrapper) { + NSMutableArray *m = [rb mutableCopy]; + [m insertObject:wrapper atIndex:0]; + patched = m; + } + } + } + + orig_configureHeaderView(self, _cmd, titleView, leftButtons, patched, titleIsCentered); +} + +%ctor { + Class cls = objc_getClass("IGProfileNavigationSwift.IGProfileNavigationHeaderView"); + if (!cls) return; + SEL sel = @selector(configureWithTitleView:leftButtons:rightButtons:titleIsCentered:); + if (![cls instancesRespondToSelector:sel]) return; + MSHookMessageEx(cls, sel, + (IMP)hooked_configureHeaderView, + (IMP *)&orig_configureHeaderView); +} diff --git a/src/Features/StoriesAndMessages/DisableStorySeen.x b/src/Features/StoriesAndMessages/DisableStorySeen.x index 1168ed1..ca8ebf5 100644 --- a/src/Features/StoriesAndMessages/DisableStorySeen.x +++ b/src/Features/StoriesAndMessages/DisableStorySeen.x @@ -1,5 +1,8 @@ // Story seen receipt blocking + visual seen state blocking #import "StoryHelpers.h" +#import +#import +#import BOOL sciSeenBypassActive = NO; NSMutableSet *sciAllowedSeenPKs = nil; @@ -92,3 +95,99 @@ static BOOL sciShouldBlockSeenVisual() { - (void)setSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; } - (void)updateRingForSeenState:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; } %end + +// ============ MARK SEEN ON STORY LIKE ============ +// Story likes are dispatched through several classes — IGSundialViewerControlsOverlayController, +// IGStoryLikesInteractionControllingImpl, and IGSundialLikeButton. Hook every +// known entry so any UI path (heart button, future double-tap, etc.) is caught. +// On a like, we defer to the manual seen-button handler in OverlayButtons.xm +// (sciMarkSeenTapped:) which is the only flow IG actually treats as a real seen. + +static __weak UIViewController *sciActiveStoryVC = nil; + +%hook IGStoryViewerViewController +- (void)viewDidAppear:(BOOL)animated { + %orig; + sciActiveStoryVC = self; +} +- (void)viewWillDisappear:(BOOL)animated { + if (sciActiveStoryVC == (UIViewController *)self) sciActiveStoryVC = nil; + %orig; +} +%end + +static UIView *sciFindStoryOverlayView(UIViewController *vc) { + if (!vc) return nil; + Class targetCls = NSClassFromString(@"IGStoryFullscreenOverlayView"); + if (!targetCls) return nil; + NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view]; + while (stack.count > 0) { + UIView *v = stack.lastObject; + [stack removeLastObject]; + if ([v isKindOfClass:targetCls]) return v; + for (UIView *sub in v.subviews) [stack addObject:sub]; + } + return nil; +} + +static void sciMarkActiveStorySeen(void) { + if (![SCIUtils getBoolPref:@"seen_on_story_like"]) return; + UIView *overlay = sciFindStoryOverlayView(sciActiveStoryVC); + if (!overlay) return; + SEL sel = NSSelectorFromString(@"sciMarkSeenTapped:"); + if ([overlay respondsToSelector:sel]) { + ((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil); + } +} + +static void (*orig_didLikeSundial)(id, SEL, id); +static void new_didLikeSundial(id self, SEL _cmd, id pk) { + orig_didLikeSundial(self, _cmd, pk); + sciMarkActiveStorySeen(); +} + +static void (*orig_overlaySetIsLiked)(id, SEL, BOOL, BOOL); +static void new_overlaySetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) { + orig_overlaySetIsLiked(self, _cmd, isLiked, animated); + if (isLiked) sciMarkActiveStorySeen(); +} + +static void (*orig_handleLikeTap)(id, SEL, id); +static void new_handleLikeTap(id self, SEL _cmd, id button) { + orig_handleLikeTap(self, _cmd, button); + sciMarkActiveStorySeen(); +} + +static void (*orig_likeButtonSetIsLiked)(id, SEL, BOOL, BOOL); +static void new_likeButtonSetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) { + orig_likeButtonSetIsLiked(self, _cmd, isLiked, animated); + if (isLiked) sciMarkActiveStorySeen(); +} + +%ctor { + Class overlayCtl = NSClassFromString(@"IGSundialViewerControlsOverlayController"); + if (overlayCtl) { + SEL didLike = NSSelectorFromString(@"didLikeSundialWithMediaPK:"); + if (class_getInstanceMethod(overlayCtl, didLike)) + MSHookMessageEx(overlayCtl, didLike, (IMP)new_didLikeSundial, (IMP *)&orig_didLikeSundial); + + SEL setLiked = @selector(setIsLiked:animated:); + if (class_getInstanceMethod(overlayCtl, setLiked)) + MSHookMessageEx(overlayCtl, setLiked, (IMP)new_overlaySetIsLiked, (IMP *)&orig_overlaySetIsLiked); + } + + Class likesImpl = NSClassFromString(@"IGStoryLikesInteractionControllingImpl"); + if (likesImpl) { + SEL handleTap = NSSelectorFromString(@"handleStoryLikeTapWithButton:"); + if (class_getInstanceMethod(likesImpl, handleTap)) + MSHookMessageEx(likesImpl, handleTap, (IMP)new_handleLikeTap, (IMP *)&orig_handleLikeTap); + } + + Class likeBtn = NSClassFromString(@"IGSundialViewerUFI.IGSundialLikeButton"); + if (likeBtn) { + SEL setLiked = @selector(setIsLiked:animated:); + if (class_getInstanceMethod(likeBtn, setLiked)) + MSHookMessageEx(likeBtn, setLiked, (IMP)new_likeButtonSetIsLiked, (IMP *)&orig_likeButtonSetIsLiked); + } + +} diff --git a/src/Features/StoriesAndMessages/KeepDeletedMessages.x b/src/Features/StoriesAndMessages/KeepDeletedMessages.x index 9940ebb..9e52b38 100644 --- a/src/Features/StoriesAndMessages/KeepDeletedMessages.x +++ b/src/Features/StoriesAndMessages/KeepDeletedMessages.x @@ -28,6 +28,13 @@ static NSMutableArray *sciPendingUpdates = nil; // Server message ID -> timestamp the reason=2 (delete-for-you) was observed. static NSMutableDictionary *sciDeleteForYouKeys = nil; static NSMutableSet *sciPreservedIds = nil; +// Server message ID -> content class name for messages we recognize as +// reaction/action-log bookkeeping (e.g. "X liked a message" thread entries). +// Populated by hooking the data model class init. Used to skip preserving +// these IDs when their remove arrives. +static NSMutableDictionary *sciMessageContentClasses = nil; +#define SCI_CONTENT_CLASSES_MAX 4000 +#define SCI_PENDING_MAX 50 #define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds" #define SCI_PRESERVED_MAX 200 @@ -53,6 +60,34 @@ void sciClearPreservedIds() { [[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY]; } +static NSMutableDictionary *sciGetContentClasses() { + if (!sciMessageContentClasses) sciMessageContentClasses = [NSMutableDictionary dictionary]; + return sciMessageContentClasses; +} + +static void sciTrackInsertedMessage(NSString *sid, NSString *className) { + if (!sid.length || !className.length) return; + NSMutableDictionary *map = sciGetContentClasses(); + map[sid] = className; + if (map.count > SCI_CONTENT_CLASSES_MAX) { + // Drop ~10% oldest by simply removing arbitrary keys + NSArray *keys = [map allKeys]; + for (NSUInteger i = 0; i < keys.count / 10; i++) [map removeObjectForKey:keys[i]]; + } +} + +// Returns YES if the message at this server ID is known to be reaction-related +// (action log entry, reaction record, etc.) — i.e. should never be preserved. +static BOOL sciIsReactionRelatedMessage(NSString *sid) { + if (!sid.length) return NO; + NSString *className = sciGetContentClasses()[sid]; + if (!className.length) return NO; + return [className containsString:@"Reaction"] || + [className containsString:@"ActionLog"] || + [className containsString:@"reaction"] || + [className containsString:@"actionLog"]; +} + // ============ ALLOC TRACKING ============ static id (*orig_msgUpdate_alloc)(id self, SEL _cmd); @@ -62,13 +97,14 @@ static id new_msgUpdate_alloc(id self, SEL _cmd) { if (!sciPendingUpdates) sciPendingUpdates = [NSMutableArray array]; @synchronized(sciPendingUpdates) { [sciPendingUpdates addObject:instance]; - while (sciPendingUpdates.count > 10) + while (sciPendingUpdates.count > SCI_PENDING_MAX) [sciPendingUpdates removeObjectAtIndex:0]; } } return instance; } + // ============ REMOTE UNSEND DETECTION ============ static NSString *sciExtractServerId(id key) { @@ -92,13 +128,17 @@ static void sciPruneStaleDeleteForYouKeys() { } } -static BOOL sciConsumeRemoteUnsend() { - if (!sciPendingUpdates) return NO; +// Walks every pending IGDirectMessageUpdate, preserves the IDs of any reason=0 +// remove that isn't a delete-for-you follow-up, and returns the set of preserved +// IDs. The caller decides whether to actually block + show a toast based on +// whether those IDs match real (rendered) messages. +static NSSet *sciConsumePendingPreserves() { + NSMutableSet *preserved = [NSMutableSet set]; + if (!sciPendingUpdates) return preserved; if (!sciDeleteForYouKeys) sciDeleteForYouKeys = [NSMutableDictionary dictionary]; sciPruneStaleDeleteForYouKeys(); - BOOL shouldBlock = NO; @synchronized(sciPendingUpdates) { for (id update in [sciPendingUpdates copy]) { @try { @@ -114,8 +154,7 @@ static BOOL sciConsumeRemoteUnsend() { reason = *(long long *)((char *)(__bridge void *)update + off); } - // Delete-for-you initiator: remember the keys for the upcoming - // reason=0 follow-up so we don't block it. + // Delete-for-you initiator — remember keys for the follow-up. if (reason == 2) { NSDate *now = [NSDate date]; for (id key in keys) { @@ -125,44 +164,53 @@ static BOOL sciConsumeRemoteUnsend() { continue; } - if (reason == 0 && !sciLocalDeleteInProgress) { - // If every key matches a recent delete-for-you, this is the - // expected follow-up — let it through. - BOOL allMatched = YES; - for (id key in keys) { - NSString *sid = sciExtractServerId(key); - if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; } - } - if (allMatched) { - for (id key in keys) { - NSString *sid = sciExtractServerId(key); - if (sid) [sciDeleteForYouKeys removeObjectForKey:sid]; - } - continue; - } + if (reason != 0 || sciLocalDeleteInProgress) continue; - // Otherwise this is a genuine remote unsend — preserve the - // affected message IDs and block the entire apply call. + // If every key matches a recent delete-for-you, drop the + // tracking entries and let it through (it's the follow-up). + BOOL allMatched = YES; + for (id key in keys) { + NSString *sid = sciExtractServerId(key); + if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; } + } + if (allMatched) { for (id key in keys) { NSString *sid = sciExtractServerId(key); - if (sid) [sciGetPreservedIds() addObject:sid]; + if (sid) [sciDeleteForYouKeys removeObjectForKey:sid]; } - sciSavePreservedIds(); - shouldBlock = YES; - break; + continue; + } + + // Real remove — preserve only keys whose content class isn't a + // known reaction / action-log entry. Reaction events also fire + // reason=0 removes for the action-log record they create. + for (id key in keys) { + NSString *sid = sciExtractServerId(key); + if (!sid) continue; + if (sciIsReactionRelatedMessage(sid)) continue; + [sciGetPreservedIds() addObject:sid]; + [preserved addObject:sid]; } } @catch(id e) {} } [sciPendingUpdates removeAllObjects]; } - return shouldBlock; + if (preserved.count > 0) sciSavePreservedIds(); + return preserved; } // ============ CACHE UPDATE HOOK ============ static void (*orig_applyUpdates)(id self, SEL _cmd, id updates, id completion, id userAccess); static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id userAccess) { - if (sciKeepDeletedEnabled() && sciConsumeRemoteUnsend()) { + if (!sciKeepDeletedEnabled()) { + orig_applyUpdates(self, _cmd, updates, completion, userAccess); + return; + } + + NSSet *preserved = sciConsumePendingPreserves(); + + if (preserved.count > 0) { dispatch_async(dispatch_get_main_queue(), ^{ // Refresh visible cells so newly preserved messages show the // "Unsent" indicator immediately without waiting for a scroll. @@ -371,15 +419,52 @@ static void new_cellLayoutSubviews(id self, SEL _cmd) { sciUpdateCellIndicator(self); } +// ============ ACTION LOG TRACKING ============ +// +// IGDirectThreadActionLog is the local data-model class for "X liked a +// message" thread entries. IG instantiates one whenever an action log row +// is created — reaction add/remove, theme change, etc. We hook its full +// init, grab the message ID via the messageId getter, and store the class +// name in our content-class map. Later when a remove for that ID arrives, +// the consume path recognizes it as bookkeeping and skips preserving it. +static id (*orig_actionLogFullInit)(id, SEL, id, id, id, id, id, BOOL, BOOL, id); +static id new_actionLogFullInit(id self, SEL _cmd, + id message, id title, id textAttributes, id textParts, + id actionLogType, BOOL collapsible, BOOL hidden, id genAIMetadata) { + id result = orig_actionLogFullInit(self, _cmd, message, title, textAttributes, textParts, + actionLogType, collapsible, hidden, genAIMetadata); + @try { + SEL midSel = @selector(messageId); + if ([result respondsToSelector:midSel]) { + id mid = ((id(*)(id, SEL))objc_msgSend)(result, midSel); + if ([mid isKindOfClass:[NSString class]]) { + sciTrackInsertedMessage(mid, @"IGDirectThreadActionLog"); + } + } + } @catch(id e) {} + return result; +} + // ============ RUNTIME HOOKS ============ %ctor { + // Action log entries (e.g. "X liked a message") — record their message IDs + // when IG creates them so we can later recognize a remove for those IDs as + // action-log bookkeeping rather than a real unsend. + Class actionLogCls = NSClassFromString(@"IGDirectThreadActionLog"); + if (actionLogCls) { + SEL fullInit = NSSelectorFromString(@"initWithMessage:title:textAttributes:textParts:actionLogType:collapsible:hidden:genAIMetadata:"); + if (class_getInstanceMethod(actionLogCls, fullInit)) + MSHookMessageEx(actionLogCls, fullInit, (IMP)new_actionLogFullInit, (IMP *)&orig_actionLogFullInit); + } + Class msgUpdateClass = NSClassFromString(@"IGDirectMessageUpdate"); if (msgUpdateClass) { MSHookMessageEx(object_getClass(msgUpdateClass), @selector(alloc), (IMP)new_msgUpdate_alloc, (IMP *)&orig_msgUpdate_alloc); } + Class cacheClass = NSClassFromString(@"IGDirectCacheUpdatesApplicator"); if (cacheClass) { SEL sel = NSSelectorFromString(@"_applyThreadUpdates:completion:userAccess:"); diff --git a/src/Features/StoriesAndMessages/OverlayButtons.xm b/src/Features/StoriesAndMessages/OverlayButtons.xm index ae38bc6..739272e 100644 --- a/src/Features/StoriesAndMessages/OverlayButtons.xm +++ b/src/Features/StoriesAndMessages/OverlayButtons.xm @@ -155,6 +155,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { } } + // download handler — works for both stories and DM visual messages %new - (void)sciDownloadTapped:(UIButton *)sender { UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; @@ -280,4 +281,32 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { [SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]]; } } + +%end + +// Mirror IG's chrome alpha onto our injected seen + download buttons so they +// fade in sync during hold/zoom. Walks up from a fading sibling to find the +// IGStoryFullscreenOverlayView and updates its tagged subviews. +static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) { + Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView"); + if (!overlayCls) return; + UIView *cur = self_; + while (cur) { + for (UIView *sib in cur.superview.subviews) { + if (![sib isKindOfClass:overlayCls]) continue; + UIView *seen = [sib viewWithTag:1339]; + UIView *dl = [sib viewWithTag:1340]; + if (seen) seen.alpha = alpha; + if (dl) dl.alpha = alpha; + return; + } + cur = cur.superview; + } +} + +%hook IGStoryFullscreenHeaderView +- (void)setAlpha:(CGFloat)alpha { + %orig; + sciSyncStoryButtonsAlpha((UIView *)self, alpha); +} %end diff --git a/src/Settings/SCISettingsBackup.h b/src/Settings/SCISettingsBackup.h new file mode 100644 index 0000000..3bce3aa --- /dev/null +++ b/src/Settings/SCISettingsBackup.h @@ -0,0 +1,12 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCISettingsBackup : NSObject + ++ (void)presentExport; ++ (void)presentImport; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/Settings/SCISettingsBackup.m b/src/Settings/SCISettingsBackup.m new file mode 100644 index 0000000..02cf02c --- /dev/null +++ b/src/Settings/SCISettingsBackup.m @@ -0,0 +1,890 @@ +#import "SCISettingsBackup.h" +#import "TweakSettings.h" +#import "SCISetting.h" +#import "../Utils.h" +#import "../Tweak.h" +#import +#import +#import "../../modules/JGProgressHUD/JGProgressHUD.h" + +// Settings backup/restore: export prefs as JSON file or QR, import from file +// or photo. Import resets known prefs to defaults then applies imported ones. + +#pragma mark - Preview view controller + +typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { + SCIBackupPreviewRowKindReadOnly, + SCIBackupPreviewRowKindSwitch, + SCIBackupPreviewRowKindMenu, +}; + +@interface SCIBackupPreviewRow : NSObject +@property (nonatomic, copy) NSString *title; +@property (nonatomic, copy) NSString *value; +@property (nonatomic, copy, nullable) NSString *defaultsKey; +@property (nonatomic) SCIBackupPreviewRowKind kind; +@property (nonatomic, strong, nullable) NSArray *menuOptions; +@end +@implementation SCIBackupPreviewRow +@end + +@interface SCIBackupPreviewGroup : NSObject +@property (nonatomic, copy) NSString *title; +@property (nonatomic, strong) NSMutableArray *rows; +@property (nonatomic) BOOL collapsed; +@end +@implementation SCIBackupPreviewGroup +@end + +@class SCIBackupPreviewVC, SCIBackupPreviewGroup; +@interface SCISettingsBackup (PreviewBuilder) ++ (NSArray *)buildPreviewGroupsForSettings:(NSDictionary *)values; ++ (void)collectOptionsFromMenu:(UIMenu *)menu defaultsKeyOut:(NSString **)outKey into:(NSMutableArray *)out; ++ (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw; +@end + +@interface SCIBackupPreviewVC : UIViewController +@property (nonatomic, strong) NSMutableDictionary *mutableSettings; +@property (nonatomic, strong, nullable) UIImage *qrImage; +@property (nonatomic, copy) NSString *primaryActionTitle; +@property (nonatomic, copy) void (^primaryAction)(SCIBackupPreviewVC *vc); + +@property (nonatomic, strong) NSArray *allGroups; +@property (nonatomic, strong) NSArray *visibleGroups; +@property (nonatomic, copy) NSString *searchText; +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) UISearchController *searchController; +@property (nonatomic, strong) UIBarButtonItem *editToggleItem; +@property (nonatomic) BOOL editMode; +@end + +@implementation SCIBackupPreviewVC + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor systemGroupedBackgroundColor]; + + self.navigationItem.leftBarButtonItem = + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel + target:self + action:@selector(cancel)]; + + NSMutableArray *rightItems = [NSMutableArray array]; + if (self.primaryActionTitle.length && self.primaryAction) { + [rightItems addObject:[[UIBarButtonItem alloc] initWithTitle:self.primaryActionTitle + style:UIBarButtonItemStyleDone + target:self + action:@selector(runPrimary)]]; + } + self.editToggleItem = [[UIBarButtonItem alloc] initWithTitle:@"Edit" + style:UIBarButtonItemStylePlain + target:self + action:@selector(toggleEditMode)]; + [rightItems addObject:self.editToggleItem]; + self.navigationItem.rightBarButtonItems = rightItems; + + UITableView *table = [[UITableView alloc] initWithFrame:self.view.bounds + style:UITableViewStyleInsetGrouped]; + table.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + table.dataSource = self; + table.delegate = self; + table.rowHeight = UITableViewAutomaticDimension; + table.estimatedRowHeight = 50; + table.sectionHeaderHeight = UITableViewAutomaticDimension; + table.estimatedSectionHeaderHeight = 44; + [self.view addSubview:table]; + self.tableView = table; + + if (self.qrImage) { + CGFloat headerHeight = 280; + UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, table.bounds.size.width, headerHeight)]; + UIImageView *qr = [[UIImageView alloc] init]; + qr.image = self.qrImage; + qr.contentMode = UIViewContentModeScaleAspectFit; + qr.layer.magnificationFilter = kCAFilterNearest; + qr.backgroundColor = [UIColor whiteColor]; + qr.layer.cornerRadius = 12; + qr.layer.masksToBounds = YES; + qr.translatesAutoresizingMaskIntoConstraints = NO; + [header addSubview:qr]; + [NSLayoutConstraint activateConstraints:@[ + [qr.centerXAnchor constraintEqualToAnchor:header.centerXAnchor], + [qr.topAnchor constraintEqualToAnchor:header.topAnchor constant:20], + [qr.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-20], + [qr.widthAnchor constraintEqualToConstant:240], + [qr.heightAnchor constraintEqualToConstant:240], + ]]; + table.tableHeaderView = header; + } + + UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil]; + sc.searchResultsUpdater = self; + sc.obscuresBackgroundDuringPresentation = NO; + sc.searchBar.placeholder = @"Search settings"; + self.navigationItem.searchController = sc; + self.navigationItem.hidesSearchBarWhenScrolling = NO; + self.searchController = sc; + + self.allGroups = [SCISettingsBackup buildPreviewGroupsForSettings:self.mutableSettings]; + self.visibleGroups = self.allGroups; +} + +#pragma mark Search + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController { + NSString *q = searchController.searchBar.text ?: @""; + self.searchText = q; + if (q.length == 0) { + self.visibleGroups = self.allGroups; + } else { + NSMutableArray *out = [NSMutableArray array]; + for (SCIBackupPreviewGroup *g in self.allGroups) { + NSMutableArray *matches = [NSMutableArray array]; + for (SCIBackupPreviewRow *r in g.rows) { + if ([r.title rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound) { + [matches addObject:r]; + } + } + if (matches.count) { + SCIBackupPreviewGroup *clone = [SCIBackupPreviewGroup new]; + clone.title = g.title; + clone.rows = matches; + clone.collapsed = NO; // force-expand while searching + [out addObject:clone]; + } + } + self.visibleGroups = out; + } + [self.tableView reloadData]; +} + +#pragma mark Table data source + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return self.visibleGroups.count; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + SCIBackupPreviewGroup *g = self.visibleGroups[section]; + return g.collapsed ? 0 : g.rows.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + SCIBackupPreviewGroup *g = self.visibleGroups[indexPath.section]; + SCIBackupPreviewRow *row = g.rows[indexPath.row]; + + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"row"]; + if (!cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:@"row"]; + } + cell.textLabel.text = row.title; + cell.textLabel.numberOfLines = 0; + cell.textLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + + if (row.kind == SCIBackupPreviewRowKindSwitch && row.defaultsKey.length) { + UISwitch *sw = [[UISwitch alloc] init]; + id raw = self.mutableSettings[row.defaultsKey]; + sw.on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO; + sw.enabled = self.editMode; + objc_setAssociatedObject(sw, "sci_key", row.defaultsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [sw addTarget:self action:@selector(switchToggled:) forControlEvents:UIControlEventValueChanged]; + cell.accessoryView = sw; + cell.detailTextLabel.text = nil; + cell.accessoryType = UITableViewCellAccessoryNone; + } else if (row.kind == SCIBackupPreviewRowKindMenu && row.defaultsKey.length) { + cell.accessoryView = nil; + cell.detailTextLabel.text = row.value; + cell.accessoryType = self.editMode ? UITableViewCellAccessoryDisclosureIndicator : UITableViewCellAccessoryNone; + cell.selectionStyle = self.editMode ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone; + } else { + cell.accessoryView = nil; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.detailTextLabel.text = row.value; + } + return cell; +} + +- (void)switchToggled:(UISwitch *)sender { + NSString *key = objc_getAssociatedObject(sender, "sci_key"); + if (!key.length) return; + self.mutableSettings[key] = @(sender.isOn); +} + +- (void)toggleEditMode { + self.editMode = !self.editMode; + self.editToggleItem.title = self.editMode ? @"Done" : @"Edit"; + self.editToggleItem.style = self.editMode ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain; + [self.tableView reloadData]; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (!self.editMode) return; + + SCIBackupPreviewGroup *g = self.visibleGroups[indexPath.section]; + SCIBackupPreviewRow *row = g.rows[indexPath.row]; + if (row.kind != SCIBackupPreviewRowKindMenu || !row.menuOptions.count || !row.defaultsKey.length) return; + + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:row.title + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + NSString *currentValue = [self.mutableSettings[row.defaultsKey] description]; + for (NSDictionary *opt in row.menuOptions) { + NSString *optTitle = opt[@"title"]; + NSString *optValue = opt[@"value"]; + if (!optTitle.length || !optValue.length) continue; + NSString *display = [optValue isEqualToString:currentValue] + ? [NSString stringWithFormat:@"%@ ✓", optTitle] + : optTitle; + [sheet addAction:[UIAlertAction actionWithTitle:display + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_) { + self.mutableSettings[row.defaultsKey] = optValue; + row.value = optTitle; + [self.tableView reloadRowsAtIndexPaths:@[indexPath] + withRowAnimation:UITableViewRowAnimationFade]; + }]]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; + sheet.popoverPresentationController.sourceView = cell; + sheet.popoverPresentationController.sourceRect = cell.bounds; + [self presentViewController:sheet animated:YES completion:nil]; +} + +#pragma mark Section headers (collapsible) + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { + SCIBackupPreviewGroup *g = self.visibleGroups[section]; + UIView *header = [[UIView alloc] init]; + header.backgroundColor = [UIColor clearColor]; + + UILabel *label = [[UILabel alloc] init]; + label.text = g.title; + label.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]; + label.textColor = [UIColor secondaryLabelColor]; + label.translatesAutoresizingMaskIntoConstraints = NO; + + UIImageView *chev = [[UIImageView alloc] init]; + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold]; + chev.image = [[UIImage systemImageNamed:(g.collapsed ? @"chevron.right" : @"chevron.down")] + imageByApplyingSymbolConfiguration:cfg]; + chev.tintColor = [UIColor secondaryLabelColor]; + chev.translatesAutoresizingMaskIntoConstraints = NO; + + [header addSubview:label]; + [header addSubview:chev]; + + [NSLayoutConstraint activateConstraints:@[ + [label.leadingAnchor constraintEqualToAnchor:header.layoutMarginsGuide.leadingAnchor], + [label.centerYAnchor constraintEqualToAnchor:header.centerYAnchor], + [label.trailingAnchor constraintLessThanOrEqualToAnchor:chev.leadingAnchor constant:-8], + [chev.trailingAnchor constraintEqualToAnchor:header.layoutMarginsGuide.trailingAnchor], + [chev.centerYAnchor constraintEqualToAnchor:header.centerYAnchor], + [header.heightAnchor constraintGreaterThanOrEqualToConstant:36], + ]]; + + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(sectionHeaderTapped:)]; + header.tag = section; + [header addGestureRecognizer:tap]; + return header; +} + +- (void)sectionHeaderTapped:(UITapGestureRecognizer *)tap { + NSInteger section = tap.view.tag; + if (section < 0 || section >= (NSInteger)self.visibleGroups.count) return; + SCIBackupPreviewGroup *g = self.visibleGroups[section]; + g.collapsed = !g.collapsed; + [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:section] + withRowAnimation:UITableViewRowAnimationFade]; + UIView *header = [self.tableView headerViewForSection:section] ?: [self tableView:self.tableView viewForHeaderInSection:section]; + for (UIView *sub in header.subviews) { + if ([sub isKindOfClass:[UIImageView class]]) { + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold]; + ((UIImageView *)sub).image = [[UIImage systemImageNamed:(g.collapsed ? @"chevron.right" : @"chevron.down")] + imageByApplyingSymbolConfiguration:cfg]; + } + } +} + +- (void)cancel { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)runPrimary { + if (self.primaryAction) self.primaryAction(self); +} + +@end + +@class SCIBackupPreviewGroup; +@interface SCISettingsBackup () ++ (void)showError:(NSString *)message; ++ (void)showSuccessHUD:(NSString *)message; ++ (NSData *)decodeQRDataFromImage:(UIImage *)image; ++ (void)presentApplyConfirmationForData:(NSData *)data; ++ (void)pickFromFiles; ++ (void)pickFromPhotos; ++ (NSArray *)buildPreviewGroupsForSettings:(NSDictionary *)values; +@end + +#pragma mark - Helper singleton (delegates for pickers) + +@interface SCIBackupHelper : NSObject +@property (nonatomic) BOOL expectingExportPick; +@end + +@implementation SCIBackupHelper + ++ (instancetype)shared { + static SCIBackupHelper *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [[SCIBackupHelper alloc] init]; }); + return s; +} + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { + if (self.expectingExportPick) { + self.expectingExportPick = NO; + [SCISettingsBackup showSuccessHUD:@"Settings exported"]; + return; + } + NSURL *url = urls.firstObject; + if (!url) return; + BOOL access = [url startAccessingSecurityScopedResource]; + NSData *data = [NSData dataWithContentsOfURL:url]; + if (access) [url stopAccessingSecurityScopedResource]; + if (!data) { + [SCISettingsBackup showError:@"Could not read file."]; + return; + } + [SCISettingsBackup presentApplyConfirmationForData:data]; +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { + self.expectingExportPick = NO; +} + +- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info { + UIImage *image = info[UIImagePickerControllerOriginalImage]; + [picker dismissViewControllerAnimated:YES completion:^{ + NSData *data = [SCISettingsBackup decodeQRDataFromImage:image]; + if (!data) { + [SCISettingsBackup showError:@"No RyukGram QR code found in the selected photo."]; + return; + } + [SCISettingsBackup presentApplyConfirmationForData:data]; + }]; +} + +- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { + [picker dismissViewControllerAnimated:YES completion:nil]; +} + +@end + +#pragma mark - SCISettingsBackup + +@implementation SCISettingsBackup + +#pragma mark Key discovery + ++ (NSSet *)allPrefKeys { + NSMutableSet *keys = [NSMutableSet set]; + [self collectKeysFromSections:[SCITweakSettings sections] into:keys]; + return keys; +} + ++ (void)collectKeysFromSections:(NSArray *)sections into:(NSMutableSet *)keys { + for (id section in sections) { + if (![section isKindOfClass:[NSDictionary class]]) continue; + NSArray *rows = ((NSDictionary *)section)[@"rows"]; + for (id row in rows) { + if (![row isKindOfClass:[SCISetting class]]) continue; + SCISetting *s = row; + if (s.defaultsKey.length) [keys addObject:s.defaultsKey]; + if (s.baseMenu) [self collectKeysFromMenu:s.baseMenu into:keys]; + if (s.navSections) [self collectKeysFromSections:s.navSections into:keys]; + } + } +} + ++ (void)collectKeysFromMenu:(UIMenu *)menu into:(NSMutableSet *)keys { + for (id child in menu.children) { + if ([child isKindOfClass:[UIMenu class]]) { + [self collectKeysFromMenu:child into:keys]; + } else if ([child isKindOfClass:[UICommand class]]) { + id pl = [(UICommand *)child propertyList]; + if ([pl isKindOfClass:[NSDictionary class]]) { + NSString *k = ((NSDictionary *)pl)[@"defaultsKey"]; + if ([k isKindOfClass:[NSString class]] && k.length) [keys addObject:k]; + } + } + } +} + +#pragma mark Snapshot / serialize / apply + ++ (NSDictionary *)snapshotCurrentSettings { + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + for (NSString *key in [self allPrefKeys]) { + id v = [d objectForKey:key]; + if (v && [NSJSONSerialization isValidJSONObject:@{@"v": v}]) { + out[key] = v; + } + } + return out; +} + ++ (NSData *)serializeSettings:(NSDictionary *)settings { + NSDictionary *wrapped = @{ + @"app": @"RyukGram", + @"version": SCIVersionString ?: @"unknown", + @"settings": settings ?: @{} + }; + NSError *err = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:wrapped + options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys + error:&err]; + if (err) NSLog(@"[SCInsta] backup: serialize failed: %@", err); + return data; +} + ++ (NSDictionary *)parseSettingsFromData:(NSData *)data { + if (!data) return nil; + NSError *err = nil; + id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err]; + if (err || ![obj isKindOfClass:[NSDictionary class]]) return nil; + NSDictionary *root = obj; + NSDictionary *settings = root[@"settings"]; + if ([settings isKindOfClass:[NSDictionary class]]) return settings; + return root; +} + ++ (void)applySettings:(NSDictionary *)settings { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + NSSet *known = [self allPrefKeys]; + for (NSString *key in known) [d removeObjectForKey:key]; + for (NSString *key in settings) { + if ([known containsObject:key]) { + [d setObject:settings[key] forKey:key]; + } + } + [d synchronize]; +} + +#pragma mark QR + ++ (UIImage *)qrCodeForData:(NSData *)data { + if (!data) return nil; + CIFilter *filter = [CIFilter filterWithName:@"CIQRCodeGenerator"]; + if (!filter) return nil; + [filter setValue:data forKey:@"inputMessage"]; + [filter setValue:@"M" forKey:@"inputCorrectionLevel"]; + CIImage *output = filter.outputImage; + if (!output) return nil; + output = [output imageByApplyingTransform:CGAffineTransformMakeScale(8, 8)]; + return [UIImage imageWithCIImage:output]; +} + ++ (NSData *)decodeQRDataFromImage:(UIImage *)image { + if (!image) return nil; + CIImage *ci = image.CIImage; + if (!ci && image.CGImage) ci = [CIImage imageWithCGImage:image.CGImage]; + if (!ci) return nil; + CIDetector *det = [CIDetector detectorOfType:CIDetectorTypeQRCode + context:nil + options:@{CIDetectorAccuracy: CIDetectorAccuracyHigh}]; + NSArray *features = [det featuresInImage:ci]; + for (CIQRCodeFeature *f in features) { + if ([f isKindOfClass:[CIQRCodeFeature class]] && f.messageString) { + return [f.messageString dataUsingEncoding:NSUTF8StringEncoding]; + } + } + return nil; +} + +#pragma mark Helpers + ++ (NSString *)timestampString { + NSDateFormatter *fmt = [[NSDateFormatter alloc] init]; + fmt.dateFormat = @"yyyyMMdd-HHmmss"; + return [fmt stringFromDate:[NSDate date]]; +} + ++ (NSString *)prettyJSONForSettings:(NSDictionary *)settings { + NSData *d = [self serializeSettings:settings]; + return [[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding] ?: @""; +} + +#pragma mark Human-readable preview groups + ++ (NSArray *)buildPreviewGroupsForSettings:(NSDictionary *)values { + NSMutableArray *groups = [NSMutableArray array]; + [self collectGroupsFromSections:[SCITweakSettings sections] + breadcrumb:@"" + values:values + out:groups]; + + NSSet *known = [self allPrefKeys]; + NSMutableArray *unknown = [NSMutableArray array]; + for (NSString *k in values) { + if (![known containsObject:k]) [unknown addObject:k]; + } + if (unknown.count) { + [unknown sortUsingSelector:@selector(compare:)]; + SCIBackupPreviewGroup *g = [SCIBackupPreviewGroup new]; + g.title = @"OTHER"; + g.rows = [NSMutableArray array]; + for (NSString *k in unknown) { + SCIBackupPreviewRow *r = [SCIBackupPreviewRow new]; + r.title = k; + r.value = [self displayStringForValue:values[k]]; + r.kind = SCIBackupPreviewRowKindReadOnly; + [g.rows addObject:r]; + } + [groups addObject:g]; + } + return groups; +} + ++ (void)collectGroupsFromSections:(NSArray *)sections + breadcrumb:(NSString *)breadcrumb + values:(NSDictionary *)values + out:(NSMutableArray *)out { + for (id sectionObj in sections) { + if (![sectionObj isKindOfClass:[NSDictionary class]]) continue; + NSDictionary *section = sectionObj; + NSString *sectionHeader = section[@"header"] ?: @""; + NSArray *rows = section[@"rows"]; + + SCIBackupPreviewGroup *currentGroup = nil; + + for (id rowObj in rows) { + if (![rowObj isKindOfClass:[SCISetting class]]) continue; + SCISetting *s = rowObj; + + if (s.navSections) { + NSString *childBreadcrumb = breadcrumb.length + ? [NSString stringWithFormat:@"%@ › %@", breadcrumb, s.title] + : s.title; + [self collectGroupsFromSections:s.navSections + breadcrumb:childBreadcrumb + values:values + out:out]; + continue; + } + + BOOL isMenu = (s.type == SCITableCellMenu); + if (!s.defaultsKey.length && !isMenu) continue; + + SCIBackupPreviewRow *r = [SCIBackupPreviewRow new]; + r.title = s.title.length ? s.title : (s.defaultsKey ?: @"?"); + r.defaultsKey = s.defaultsKey; + + if (s.type == SCITableCellSwitch) { + r.kind = SCIBackupPreviewRowKindSwitch; + id raw = values[s.defaultsKey]; + BOOL on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO; + r.value = on ? @"On" : @"Off"; + } else if (s.type == SCITableCellStepper) { + r.kind = SCIBackupPreviewRowKindReadOnly; + id raw = values[s.defaultsKey]; + NSString *display = @"—"; + if (raw) { + double d = [raw doubleValue]; + if (fmod(d, 1.0) == 0.0) display = [NSString stringWithFormat:@"%lld", (long long)d]; + else display = [NSString stringWithFormat:@"%g", d]; + if (s.label.length) display = [display stringByAppendingFormat:@" %@", s.label]; + } + r.value = display; + } else if (isMenu) { + r.kind = SCIBackupPreviewRowKindMenu; + NSMutableArray *opts = [NSMutableArray array]; + NSString *defKey = nil; + [self collectOptionsFromMenu:s.baseMenu defaultsKeyOut:&defKey into:opts]; + r.menuOptions = opts; + r.defaultsKey = defKey ?: s.defaultsKey; + NSString *menuTitle = [self menuTitleForBaseMenu:s.baseMenu values:values resolvedKey:NULL]; + r.value = menuTitle ?: @"—"; + } else { + r.kind = SCIBackupPreviewRowKindReadOnly; + r.value = [self displayStringForValue:values[s.defaultsKey]]; + } + + if (!currentGroup) { + currentGroup = [SCIBackupPreviewGroup new]; + NSMutableString *hdr = [NSMutableString string]; + if (breadcrumb.length) [hdr appendString:breadcrumb]; + if (sectionHeader.length) { + if (hdr.length) [hdr appendString:@" — "]; + [hdr appendString:sectionHeader]; + } + if (!hdr.length) hdr = [NSMutableString stringWithString:@"General"]; + currentGroup.title = [hdr uppercaseString]; + currentGroup.rows = [NSMutableArray array]; + [out addObject:currentGroup]; + } + [currentGroup.rows addObject:r]; + } + } +} + ++ (NSString *)displayStringForValue:(id)raw { + if (!raw || raw == [NSNull null]) return @"—"; + if ([raw isKindOfClass:[NSNumber class]]) { + NSNumber *n = raw; + const char *t = n.objCType; + if (t && strcmp(t, "c") == 0) return n.boolValue ? @"On" : @"Off"; + return n.stringValue; + } + if ([raw isKindOfClass:[NSString class]]) return raw; + return [NSString stringWithFormat:@"%@", raw]; +} + ++ (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw { + if (!menu) return nil; + NSString *defaultsKey = nil; + UICommand *match = [self findMatchingCommandInMenu:menu values:values defaultsKeyOut:&defaultsKey]; + if (defaultsKey && outRaw) *outRaw = values[defaultsKey]; + if (match) return match.title; + if (defaultsKey) return [self displayStringForValue:values[defaultsKey]]; + return nil; +} + ++ (void)collectOptionsFromMenu:(UIMenu *)menu defaultsKeyOut:(NSString **)outKey into:(NSMutableArray *)out { + if (!menu) return; + for (id child in menu.children) { + if ([child isKindOfClass:[UIMenu class]]) { + [self collectOptionsFromMenu:child defaultsKeyOut:outKey into:out]; + } else if ([child isKindOfClass:[UICommand class]]) { + UICommand *cmd = child; + id pl = cmd.propertyList; + if ([pl isKindOfClass:[NSDictionary class]]) { + NSString *k = ((NSDictionary *)pl)[@"defaultsKey"]; + NSString *v = ((NSDictionary *)pl)[@"value"]; + if ([k isKindOfClass:[NSString class]] && k.length && + [v isKindOfClass:[NSString class]] && v.length) { + if (outKey && !*outKey) *outKey = k; + [out addObject:@{ @"value": v, @"title": cmd.title ?: v }]; + } + } + } + } +} + ++ (UICommand *)findMatchingCommandInMenu:(UIMenu *)menu values:(NSDictionary *)values defaultsKeyOut:(NSString **)outKey { + for (id child in menu.children) { + if ([child isKindOfClass:[UIMenu class]]) { + UICommand *m = [self findMatchingCommandInMenu:child values:values defaultsKeyOut:outKey]; + if (m) return m; + } else if ([child isKindOfClass:[UICommand class]]) { + UICommand *cmd = child; + id pl = cmd.propertyList; + if ([pl isKindOfClass:[NSDictionary class]]) { + NSString *k = ((NSDictionary *)pl)[@"defaultsKey"]; + NSString *v = ((NSDictionary *)pl)[@"value"]; + if ([k isKindOfClass:[NSString class]] && k.length) { + if (outKey && !*outKey) *outKey = k; + id current = values[k]; + if (current && v && [[NSString stringWithFormat:@"%@", current] isEqualToString:v]) { + return cmd; + } + } + } + } + } + return nil; +} + ++ (void)showSuccessHUD:(NSString *)message { + UINotificationFeedbackGenerator *fb = [[UINotificationFeedbackGenerator alloc] init]; + [fb prepare]; + [fb notificationOccurred:UINotificationFeedbackTypeSuccess]; + + UIView *host = nil; + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w.isKeyWindow) { host = w; break; } + } + if (!host) host = topMostController().view; + if (!host) return; + + JGProgressHUD *HUD = [[JGProgressHUD alloc] init]; + HUD.textLabel.text = message; + HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init]; + [HUD showInView:host]; + [HUD dismissAfterDelay:1.5]; +} + ++ (void)showError:(NSString *)message { + UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Import failed" + message:message + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; + [topMostController() presentViewController:a animated:YES completion:nil]; +} + +#pragma mark Export + ++ (void)presentExport { + NSDictionary *snap = [self snapshotCurrentSettings]; + + SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init]; + vc.title = @"Export settings"; + vc.mutableSettings = [snap mutableCopy]; + vc.qrImage = [self qrCodeForData:[self serializeSettings:snap]]; + vc.primaryActionTitle = @"Save"; + vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) { + NSData *data = [self serializeSettings:previewVC.mutableSettings]; + UIImage *qr = [self qrCodeForData:data]; + + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Save settings" + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Save as JSON file" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_) { + NSString *fname = [NSString stringWithFormat:@"ryukgram-settings-%@.json", [self timestampString]]; + NSURL *tmp = [[NSFileManager defaultManager].temporaryDirectory URLByAppendingPathComponent:fname]; + NSError *err = nil; + [data writeToURL:tmp options:NSDataWritingAtomic error:&err]; + if (err) { [self showError:@"Could not write temporary file."]; return; } + UIDocumentPickerViewController *p = + [[UIDocumentPickerViewController alloc] initForExportingURLs:@[tmp]]; + SCIBackupHelper *helper = [SCIBackupHelper shared]; + helper.expectingExportPick = YES; + p.delegate = helper; + [previewVC presentViewController:p animated:YES completion:nil]; + }]]; + if (qr) { + [sheet addAction:[UIAlertAction actionWithTitle:@"Save QR code as image" + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_) { + // Flatten CIImage-backed QR into a CGImage-backed UIImage so the share sheet can save it. + UIGraphicsImageRendererFormat *fmt = [UIGraphicsImageRendererFormat defaultFormat]; + fmt.scale = 1.0; + CGSize sz = CGSizeMake(900, 1020); + UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz format:fmt]; + UIImage *flat = [r imageWithActions:^(UIGraphicsImageRendererContext *ctx) { + [[UIColor whiteColor] setFill]; + [ctx fillRect:CGRectMake(0, 0, sz.width, sz.height)]; + NSString *title = @"RyukGram settings"; + NSString *subtitle = [NSString stringWithFormat:@"Scan in RyukGram → Backup & Restore → Import\n%@", SCIVersionString ?: @""]; + NSDictionary *titleAttrs = @{ + NSFontAttributeName: [UIFont systemFontOfSize:54 weight:UIFontWeightBold], + NSForegroundColorAttributeName: [UIColor blackColor], + }; + NSMutableParagraphStyle *p = [NSMutableParagraphStyle new]; + p.alignment = NSTextAlignmentCenter; + NSDictionary *subAttrs = @{ + NSFontAttributeName: [UIFont systemFontOfSize:22 weight:UIFontWeightRegular], + NSForegroundColorAttributeName: [UIColor darkGrayColor], + NSParagraphStyleAttributeName: p, + }; + NSMutableParagraphStyle *pc = [NSMutableParagraphStyle new]; + pc.alignment = NSTextAlignmentCenter; + NSDictionary *titleAttrsCentered = @{ + NSFontAttributeName: titleAttrs[NSFontAttributeName], + NSForegroundColorAttributeName: titleAttrs[NSForegroundColorAttributeName], + NSParagraphStyleAttributeName: pc, + }; + [title drawInRect:CGRectMake(40, 30, sz.width - 80, 70) withAttributes:titleAttrsCentered]; + [subtitle drawInRect:CGRectMake(40, 100, sz.width - 80, 60) withAttributes:subAttrs]; + [qr drawInRect:CGRectMake(60, 180, sz.width - 120, sz.width - 120)]; + }]; + UIActivityViewController *share = + [[UIActivityViewController alloc] initWithActivityItems:@[flat] applicationActivities:nil]; + share.completionWithItemsHandler = ^(UIActivityType _Nullable activityType, BOOL completed, NSArray * _Nullable returnedItems, NSError * _Nullable activityError) { + if (completed) [SCISettingsBackup showSuccessHUD:@"QR code saved"]; + }; + share.popoverPresentationController.sourceView = previewVC.view; + share.popoverPresentationController.sourceRect = CGRectMake(previewVC.view.bounds.size.width / 2, + previewVC.view.bounds.size.height / 2, + 1, 1); + [previewVC presentViewController:share animated:YES completion:nil]; + }]]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + sheet.popoverPresentationController.barButtonItem = previewVC.navigationItem.rightBarButtonItem; + [previewVC presentViewController:sheet animated:YES completion:nil]; + }; + + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; + nav.modalPresentationStyle = UIModalPresentationFormSheet; + [topMostController() presentViewController:nav animated:YES completion:nil]; +} + +#pragma mark Import + ++ (void)presentImport { + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Import settings" + message:@"Importing will reset all RyukGram settings to defaults and apply the imported values." + preferredStyle:UIAlertControllerStyleActionSheet]; + [sheet addAction:[UIAlertAction actionWithTitle:@"From Files" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [self pickFromFiles]; + }]]; + [sheet addAction:[UIAlertAction actionWithTitle:@"From Photos (QR code)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [self pickFromPhotos]; + }]]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + + UIViewController *top = topMostController(); + sheet.popoverPresentationController.sourceView = top.view; + sheet.popoverPresentationController.sourceRect = CGRectMake(top.view.bounds.size.width / 2, + top.view.bounds.size.height / 2, + 1, 1); + [top presentViewController:sheet animated:YES completion:nil]; +} + ++ (void)pickFromFiles { + UIDocumentPickerViewController *p = + [[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[@"public.json", @"public.text", @"public.data"] + inMode:UIDocumentPickerModeImport]; + p.delegate = [SCIBackupHelper shared]; + p.allowsMultipleSelection = NO; + [topMostController() presentViewController:p animated:YES completion:nil]; +} + ++ (void)pickFromPhotos { + UIImagePickerController *p = [[UIImagePickerController alloc] init]; + p.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; + p.delegate = [SCIBackupHelper shared]; + [topMostController() presentViewController:p animated:YES completion:nil]; +} + ++ (void)presentApplyConfirmationForData:(NSData *)data { + NSDictionary *settings = [self parseSettingsFromData:data]; + if (!settings) { + [self showError:@"File is not a valid RyukGram settings export."]; + return; + } + + SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init]; + vc.title = @"Import preview"; + vc.mutableSettings = [settings mutableCopy]; + vc.qrImage = nil; + vc.primaryActionTitle = @"Apply"; + vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) { + UIAlertController *confirm = + [UIAlertController alertControllerWithTitle:@"Apply imported settings?" + message:@"All RyukGram settings will be reset to defaults and the imported values applied. The app will need to restart for some changes to take effect." + preferredStyle:UIAlertControllerStyleAlert]; + [confirm addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [confirm addAction:[UIAlertAction actionWithTitle:@"Apply" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) { + [SCISettingsBackup applySettings:previewVC.mutableSettings]; + [previewVC dismissViewControllerAnimated:YES completion:^{ + [SCISettingsBackup showSuccessHUD:@"Settings imported"]; + [SCIUtils showRestartConfirmation]; + }]; + }]]; + [previewVC presentViewController:confirm animated:YES completion:nil]; + }; + + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; + nav.modalPresentationStyle = UIModalPresentationFormSheet; + [topMostController() presentViewController:nav animated:YES completion:nil]; +} + +@end diff --git a/src/Settings/SCISettingsViewController.m b/src/Settings/SCISettingsViewController.m index 5df6860..8cd2e56 100644 --- a/src/Settings/SCISettingsViewController.m +++ b/src/Settings/SCISettingsViewController.m @@ -2,12 +2,16 @@ static char rowStaticRef[] = "row"; -@interface SCISettingsViewController () +@interface SCISettingsViewController () @property (nonatomic, strong) UITableView *tableView; @property (nonatomic, copy) NSArray *sections; @property (nonatomic) BOOL reduceMargin; +@property (nonatomic, strong) UISearchController *searchController; +@property (nonatomic, copy) NSArray *searchResults; +@property (nonatomic) BOOL isRoot; + @end /// @@ -20,6 +24,7 @@ static char rowStaticRef[] = "row"; if (self) { self.title = title; self.reduceMargin = reduceMargin; + self.isRoot = reduceMargin; // root call uses reduceMargin=YES // Exclude development cells from release builds NSMutableArray *mutableSections = [sections mutableCopy]; @@ -64,6 +69,83 @@ static char rowStaticRef[] = "row"; self.tableView.delegate = self; [self.view addSubview:self.tableView]; + + if (self.isRoot) { + UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil]; + sc.searchResultsUpdater = self; + sc.obscuresBackgroundDuringPresentation = NO; + sc.searchBar.placeholder = @"Search settings"; + self.navigationItem.searchController = sc; + self.navigationItem.hidesSearchBarWhenScrolling = NO; + self.searchController = sc; + } +} + +#pragma mark - Search + +- (BOOL)isSearching { + return self.searchController.isActive && self.searchController.searchBar.text.length > 0; +} + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController { + NSString *q = searchController.searchBar.text ?: @""; + if (q.length == 0) { + self.searchResults = @[]; + } else { + NSMutableArray *out = [NSMutableArray array]; + [self collectMatchingFromSections:self.sections breadcrumb:@"" query:q into:out]; + self.searchResults = out; + } + [self.tableView reloadData]; +} + +- (void)collectMatchingFromSections:(NSArray *)sections + breadcrumb:(NSString *)breadcrumb + query:(NSString *)q + into:(NSMutableArray *)out +{ + for (id sectionObj in sections) { + if (![sectionObj isKindOfClass:[NSDictionary class]]) continue; + NSDictionary *section = sectionObj; + NSString *header = section[@"header"] ?: @""; + NSArray *rows = section[@"rows"]; + for (id rowObj in rows) { + if (![rowObj isKindOfClass:[SCISetting class]]) continue; + SCISetting *row = rowObj; + + NSString *titleHay = row.title ?: @""; + NSString *subHay = row.subtitle ?: @""; + BOOL matches = [titleHay rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound + || [subHay rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound; + + if (matches) { + NSMutableString *crumb = [NSMutableString string]; + if (breadcrumb.length) [crumb appendString:breadcrumb]; + if (header.length) { + if (crumb.length) [crumb appendString:@" › "]; + [crumb appendString:header]; + } + [out addObject:@{ @"setting": row, @"breadcrumb": crumb ?: @"" }]; + } + + if (row.navSections) { + NSString *child = breadcrumb.length + ? [NSString stringWithFormat:@"%@ › %@", breadcrumb, row.title ?: @""] + : (row.title ?: @""); + [self collectMatchingFromSections:row.navSections breadcrumb:child query:q into:out]; + } + } + } +} + +- (SCISetting *)settingForIndexPath:(NSIndexPath *)indexPath breadcrumbOut:(NSString **)outCrumb { + if ([self isSearching]) { + if (indexPath.row >= (NSInteger)self.searchResults.count) return nil; + NSDictionary *entry = self.searchResults[indexPath.row]; + if (outCrumb) *outCrumb = entry[@"breadcrumb"]; + return entry[@"setting"]; + } + return self.sections[indexPath.section][@"rows"][indexPath.row]; } - (void)viewWillDisappear:(BOOL)animated { @@ -89,17 +171,19 @@ static char rowStaticRef[] = "row"; // MARK: - UITableViewDataSource - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - SCISetting *row = self.sections[indexPath.section][@"rows"][indexPath.row]; + NSString *searchBreadcrumb = nil; + SCISetting *row = [self settingForIndexPath:indexPath breadcrumbOut:&searchBreadcrumb]; if (!row) return nil; UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; UIListContentConfiguration *cellContentConfig = cell.defaultContentConfiguration; cellContentConfig.text = row.title; - - // Subtitle - if (row.subtitle.length) { - cellContentConfig.secondaryText = row.subtitle; + + // While searching, show the breadcrumb path instead of the row subtitle. + NSString *displaySubtitle = [self isSearching] && searchBreadcrumb.length ? searchBreadcrumb : row.subtitle; + if (displaySubtitle.length) { + cellContentConfig.secondaryText = displaySubtitle; cellContentConfig.textToSecondaryTextVerticalPadding = 4.5; } @@ -209,25 +293,32 @@ static char rowStaticRef[] = "row"; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + if ([self isSearching]) return self.searchResults.count; return [self.sections[section][@"rows"] count]; } - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { + if ([self isSearching]) { + NSUInteger n = self.searchResults.count; + return n ? [NSString stringWithFormat:@"%lu result%@", (unsigned long)n, n == 1 ? @"" : @"s"] : @"No results"; + } return self.sections[section][@"header"]; } - (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section { + if ([self isSearching]) return nil; return self.sections[section][@"footer"]; } - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + if ([self isSearching]) return 1; return self.sections.count; } // MARK: - UITableViewDelegate - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { - SCISetting *row = self.sections[indexPath.section][@"rows"][indexPath.row]; + SCISetting *row = [self settingForIndexPath:indexPath breadcrumbOut:NULL]; if (!row) return; if (row.type == SCITableCellLink) { diff --git a/src/Settings/TweakSettings.m b/src/Settings/TweakSettings.m index dbc2615..9ecef14 100644 --- a/src/Settings/TweakSettings.m +++ b/src/Settings/TweakSettings.m @@ -1,4 +1,5 @@ #import "TweakSettings.h" +#import "SCISettingsBackup.h" @implementation SCITweakSettings @@ -18,7 +19,7 @@ @{ @"header": @"", @"rows": @[ - [SCISetting linkCellWithTitle:@"Donate" subtitle:@"Consider donating to support this tweak's development!" icon:[SCISymbol symbolWithName:@"heart.circle.fill" color:[UIColor systemPinkColor] size:20.0] url:@"https://ko-fi.com/SoCuul"] + [SCISetting linkCellWithTitle:@"RyukGram on GitHub" subtitle:[NSString stringWithFormat:@"%@ — view source, report issues, see releases", SCIVersionString] imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled/RyukGram"] ] }, @{ @@ -33,6 +34,7 @@ [SCISetting switchCellWithTitle:@"Hide ads" subtitle:@"Removes all ads from the Instagram app" defaultsKey:@"hide_ads"], [SCISetting switchCellWithTitle:@"Hide Meta AI" subtitle:@"Hides the meta ai buttons/functionality within the app" defaultsKey:@"hide_meta_ai"], [SCISetting switchCellWithTitle:@"Copy description" subtitle:@"Copy description text fields by long-pressing on them" defaultsKey:@"copy_description"], + [SCISetting switchCellWithTitle:@"Profile copy button" subtitle:@"Adds a button next to the burger menu on profiles to copy username, name or bio" defaultsKey:@"profile_copy_button"], [SCISetting switchCellWithTitle:@"Do not save recent searches" subtitle:@"Search bars will no longer save your recent searches" defaultsKey:@"no_recent_searches"], [SCISetting switchCellWithTitle:@"Use detailed color picker" subtitle:@"Long press on the eyedropper tool in stories to customize the text color more precisely" defaultsKey:@"detailed_color_picker"], [SCISetting switchCellWithTitle:@"Enable liquid glass buttons" subtitle:@"Enables experimental liquid glass buttons within the app" defaultsKey:@"liquid_glass_buttons" requiresRestart:YES], @@ -181,6 +183,7 @@ [SCISetting switchCellWithTitle:@"Disable screenshot detection" subtitle:@"Removes the screenshot-prevention features for visual messages in DMs" defaultsKey:@"remove_screenshot_alert"], [SCISetting switchCellWithTitle:@"Disable story seen receipt" subtitle:@"Hides the notification for others when you view their story" defaultsKey:@"no_seen_receipt"], [SCISetting switchCellWithTitle:@"Keep stories visually unseen" subtitle:@"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" defaultsKey:@"no_seen_visual"], + [SCISetting switchCellWithTitle:@"Mark seen on story like" subtitle:@"Marks a story as seen the moment you tap the heart, even with seen blocking on" defaultsKey:@"seen_on_story_like"], [SCISetting switchCellWithTitle:@"Stop story auto-advance" subtitle:@"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" defaultsKey:@"stop_story_auto_advance"], [SCISetting switchCellWithTitle:@"Disable instants creation" subtitle:@"Hides the functionality to create/send instants" defaultsKey:@"disable_instants_creation" requiresRestart:YES] ] @@ -236,6 +239,26 @@ @{ @"header": @"", @"rows": @[ + [SCISetting navigationCellWithTitle:@"Backup & Restore" + subtitle:@"" + icon:[SCISymbol symbolWithName:@"arrow.up.arrow.down.square"] + navSections:@[@{ + @"header": @"", + @"footer": @"Export your RyukGram settings to a JSON file or QR code, and import them later from Files or a photo. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes.", + @"rows": @[ + [SCISetting buttonCellWithTitle:@"Export settings" + subtitle:@"Save as a file or scannable QR code" + icon:[SCISymbol symbolWithName:@"square.and.arrow.up"] + action:^(void) { [SCISettingsBackup presentExport]; } + ], + [SCISetting buttonCellWithTitle:@"Import settings" + subtitle:@"From a file or QR code in your library" + icon:[SCISymbol symbolWithName:@"square.and.arrow.down"] + action:^(void) { [SCISettingsBackup presentImport]; } + ] + ] + }] + ], // [SCISetting navigationCellWithTitle:@"Experimental" // subtitle:@"" // icon:[SCISymbol symbolWithName:@"testtube.2"] @@ -315,7 +338,8 @@ @"rows": @[ [SCISetting linkCellWithTitle:@"Original Developer" subtitle:@"SoCuul (SCInsta)" imageUrl:@"https://i.imgur.com/c9CbytZ.png" url:@"https://github.com/SoCuul/SCInsta"], [SCISetting linkCellWithTitle:@"Modded by" subtitle:@"Ryuk" imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled"], - [SCISetting linkCellWithTitle:@"View Repo" subtitle:@"View the source code on GitHub" imageUrl:@"https://i.imgur.com/BBUNzeP.png" url:@"https://github.com/faroukbmiled/RyukGram"] + [SCISetting linkCellWithTitle:@"View Repo" subtitle:@"View the source code on GitHub" imageUrl:@"https://i.imgur.com/BBUNzeP.png" url:@"https://github.com/faroukbmiled/RyukGram"], + [SCISetting linkCellWithTitle:@"Donate to SCInsta dev" subtitle:@"Support SoCuul" icon:[SCISymbol symbolWithName:@"heart.circle.fill" color:[UIColor systemPinkColor] size:20.0] url:@"https://ko-fi.com/SoCuul"] ], @"footer": [NSString stringWithFormat:@"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul", SCIVersionString, [SCIUtils IGVersionString]] } diff --git a/src/Tweak.x b/src/Tweak.x index ba385a6..909478c 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -25,6 +25,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; NSDictionary *sciDefaults = @{ @"hide_ads": @(YES), @"copy_description": @(YES), + @"profile_copy_button": @(YES), @"detailed_color_picker": @(YES), @"remove_screenshot_alert": @(YES), @"call_confirm": @(YES), @@ -53,6 +54,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"seen_mode": @"button", @"seen_auto_on_interact": @(NO), @"seen_auto_on_typing": @(NO), + @"seen_on_story_like": @(NO), @"indicate_unsent_messages": @(NO), @"unsent_message_toast": @(NO), @"warn_refresh_clears_preserved": @(NO)