Skip to content

Commit

Permalink
Add support for Reddit share links
Browse files Browse the repository at this point in the history
  • Loading branch information
JeffreyCA committed Nov 30, 2023
1 parent 33a7e18 commit 1f8e0e8
Show file tree
Hide file tree
Showing 8 changed files with 310 additions and 7 deletions.
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [v1.0.4] - 2023-11-29

Add support for share links (e.g. `reddit.com/r/subreddit/s/xxxxxx`) in Apollo. These links are obfuscated and require loading them in the background to resolve them to the standard Reddit link format that can be understood by 3rd party apps.

The tweak uses the workaround and further optimizes it by pre-resolving and caching share links in the background for a smoother user experience. You may still see the occassional (brief) loading alert when tapping a share link while it resolves in the background.

There are currently a few limitations:
- Share links in private messages still open in the in-app browser
- Long-tapping share links still pop open a browser page

## [v1.0.3b] - 2023-11-26
- Treat `x.com` links as Twitter links so they can be opened in Twitter app
Expand All @@ -17,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [v1.0.0] - 2023-10-13
- Initial release

[v1.0.4]: https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/compare/v1.0.3b...v1.0.4
[v1.0.3b]: https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/compare/v1.0.2c...v1.0.3b
[v1.0.2c]: https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/compare/v1.0.1...v1.0.2c
[v1.0.1]: https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/compare/v1.0.0...v1.0.1
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ include $(THEOS)/makefiles/common.mk

TWEAK_NAME = ApolloImprovedCustomApi

ApolloImprovedCustomApi_FILES = Tweak.x CustomAPIViewController.m fishhook.c
ApolloImprovedCustomApi_FILES = Tweak.xm CustomAPIViewController.m UIWindow+Apollo.m fishhook.c
ApolloImprovedCustomApi_FRAMEWORKS = UIKit
ApolloImprovedCustomApi_CFLAGS = -fobjc-arc -Wno-unguarded-availability-new
ApolloImprovedCustomApi_CFLAGS = -fobjc-arc -Wno-unguarded-availability-new -Wno-module-import-in-extern-c

include $(THEOS_MAKE_PATH)/tweak.mk
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
# Apollo-ImprovedCustomApi
[![Build and release](https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/actions/workflows/buildapp.yml/badge.svg)](https://github.com/JeffreyCA/Apollo-ImprovedCustomApi/actions/workflows/buildapp.yml)

Apollo for Reddit with in-app configurable API keys. This tweak includes several fixes with Twitter posts (handles x.com links properly), Imgur integration (viewing & uploading) that other similar tweaks may have, and also suppresses the annoying wallpaper popup.
Apollo for Reddit with in-app configurable API keys and several fixes and improvements. Tested on version 1.15.11.

<img src="img/demo.gif" alt="demo" width="250"/>

## Features
- Use Apollo for Reddit with your own Reddit and Imgur API keys
- Working Imgur integration (view, delete, and upload single images and multi-image albums)
- Handle x.com links as Twitter links so that they can be opened in the Twitter app
- Suppress unwanted messages on app startup (wallpaper popup, in-app announcements, etc)
- Support new share link format (reddit.com/r/subreddit/s/xxxxxx) so they open like any other post and not in a browser

## Known issues
- Apollo Ultra features may cause app to crash
- Imgur multi-image upload
- Uploads usually fail on the first attempt but subsequent retries should succeed
- Share URLs in private messages and long-tapping them still open in the in-app browser

## Sideloadly
Recommended configuration:
Expand Down
14 changes: 14 additions & 0 deletions Tweak.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#import <Foundation/Foundation.h>

@interface ShareUrlTask : NSObject

@property (nonatomic) dispatch_group_t dispatchGroup;
@property (nonatomic, strong) NSString *resolvedURL;

@end

@interface RDKLink
@property(copy, nonatomic) NSURL *URL;
@end

@class _TtC6Apollo14LinkButtonNode;
247 changes: 245 additions & 2 deletions Tweak.x → Tweak.xm
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
#import <Foundation/Foundation.h>
#import <objc/runtime.h>

#import "fishhook.h"
#import "CustomAPIViewController.h"
#import "Tweak.h"
#import "UIWindow+Apollo.h"
#import "UserDefaultConstants.h"
#import "fishhook.h"

// Sideload fixes
static NSDictionary *stripGroupAccessAttr(CFDictionaryRef attributes) {
Expand Down Expand Up @@ -40,16 +42,253 @@ static NSArray *blockedUrls = @[
@"https://apollogur.download/api/goodbye_wallpaper"
];

// Regex for opaque share links
static NSString *const ShareLinkRegexPattern = @"^(?:https?:)?//(?:www\\.)?reddit\\.com/r/(\\w+)/s/(\\w+)$";
static NSRegularExpression *ShareLinkRegex;

// Cache storing resolved share URLs - this is an optimization so that we don't need to resolve the share URL every time
static NSCache <NSString *, ShareUrlTask *> *cache;

@implementation ShareUrlTask
- (instancetype)init {
self = [super init];
if (self) {
_dispatchGroup = NULL;
_resolvedURL = NULL;
}
return self;
}
@end

/// Helper functions for resolving share URLs

// Present loading alert on top of current view controller
static UIViewController *PresentResolvingShareLinkAlert() {
__block UIWindow *lastKeyWindow = nil;
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
if ([scene isKindOfClass:[UIWindowScene class]]) {
UIWindowScene *windowScene = (UIWindowScene *)scene;
if (windowScene.keyWindow) {
lastKeyWindow = windowScene.keyWindow;
}
}
}

UIViewController *visibleViewController = lastKeyWindow.visibleViewController;
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:nil message:@"Resolving share link..." preferredStyle:UIAlertControllerStyleAlert];

[visibleViewController presentViewController:alertController animated:YES completion:nil];
return alertController;
}

// Strip tracking parameters from resolved share URL
static NSURL *RemoveShareTrackingParams(NSURL *url) {
NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
NSMutableArray *queryItems = [NSMutableArray arrayWithArray:components.queryItems];
[queryItems filterUsingPredicate:[NSPredicate predicateWithFormat:@"name == %@", @"context"]];
components.queryItems = queryItems;
return components.URL;
}

// Start async task to resolve share URL
static void StartShareURLResolveTask(NSString *urlString) {
__block ShareUrlTask *task;
@synchronized(cache) { // needed?
task = [cache objectForKey:urlString];
if (task) {
return;
}

dispatch_group_t dispatch_group = dispatch_group_create();
task = [[ShareUrlTask alloc] init];
task.dispatchGroup = dispatch_group;
[cache setObject:task forKey:urlString];
}

NSURL *url = [NSURL URLWithString:urlString];
dispatch_group_enter(task.dispatchGroup);
NSURLSessionTask *getTask = [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (!error) {
NSURL *redirectedURL = [(NSHTTPURLResponse *)response URL];
NSURL *cleanedURL = RemoveShareTrackingParams(redirectedURL);
NSString *cleanUrlString = [cleanedURL absoluteString];
task.resolvedURL = cleanUrlString;
} else {
task.resolvedURL = urlString;
}
dispatch_group_leave(task.dispatchGroup);
}];

[getTask resume];
}

// Asynchronously wait for share URL to resolve
static void TryResolveShareUrl(NSString *urlString, void (^successHandler)(NSString *), void (^ignoreHandler)(void)){
ShareUrlTask *task = [cache objectForKey:urlString];
if (!task) {
// The NSURL initWithString hook might not catch every share URL, so check one more time and enqueue a task if needed
NSTextCheckingResult *match = [ShareLinkRegex firstMatchInString:urlString options:0 range:NSMakeRange(0, [urlString length])];
if (!match) {
ignoreHandler();
return;
}
StartShareURLResolveTask(urlString);
task = [cache objectForKey:urlString];
}

if (task.resolvedURL) {
successHandler(task.resolvedURL);
return;
} else {
// Wait for task to finish and show loading alert to not block main thread
UIViewController *shareAlertController = PresentResolvingShareLinkAlert();
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
dispatch_group_wait(task.dispatchGroup, DISPATCH_TIME_FOREVER);
dispatch_async(dispatch_get_main_queue(), ^{
[shareAlertController dismissViewControllerAnimated:YES completion:^{
successHandler(task.resolvedURL);
}];
});
});
}
}

%hook NSURL
// Asynchronously resolve share URLs in background
// This is an optimization to "pre-resolve" share URLs so that by the time one taps a share URL it should already be resolved
// On slower network connections, there may still be a loading alert
- (id)initWithString:(id)string {
NSTextCheckingResult *match = [ShareLinkRegex firstMatchInString:string options:0 range:NSMakeRange(0, [string length])];
if (match) {
// This exits early if already in cache
StartShareURLResolveTask(string);
}
return %orig;
}

// Rewrite x.com links as twitter.com
- (NSString *)host {
NSString *originalHost = %orig;
// Rewrite x.com links as twitter.com
if ([originalHost isEqualToString:@"x.com"]) {
return @"twitter.com";
}
return originalHost;
}
%end

%hook _TtC6Apollo13InboxCellNode

-(void)textNode:(id)textNode tappedLinkAttribute:(id)attr value:(NSURL *)url atPoint:(struct CGPoint)point textRange:(struct _NSRange)range {
void (^ignoreHandler)(void) = ^{
%orig;
};
void (^successHandler)(NSString *) = ^(NSString *resolvedURL) {
%orig(textNode, attr, [NSURL URLWithString:resolvedURL], point, range);
};
TryResolveShareUrl([url absoluteString], successHandler, ignoreHandler);
}

%end

%hook _TtC6Apollo12MarkdownNode

-(void)textNode:(id)textNode tappedLinkAttribute:(id)attr value:(NSURL *)url atPoint:(struct CGPoint)point textRange:(struct _NSRange)range {
void (^ignoreHandler)(void) = ^{
%orig;
};
void (^successHandler)(NSString *) = ^(NSString *resolvedURL) {
%orig(textNode, attr, [NSURL URLWithString:resolvedURL], point, range);
};
TryResolveShareUrl([url absoluteString], successHandler, ignoreHandler);
}

%end

%hook _TtC6Apollo13RichMediaNode
- (void)linkButtonTappedWithSender:(_TtC6Apollo14LinkButtonNode *)arg1 {
RDKLink *rdkLink = MSHookIvar<RDKLink *>(self, "link");
NSURL *rdkLinkURL;
if (rdkLink) {
rdkLinkURL = rdkLink.URL;
}

NSURL *url = MSHookIvar<NSURL *>(arg1, "url");
NSString *urlString = [url absoluteString];

void (^ignoreHandler)(void) = ^{
%orig;
};
void (^successHandler)(NSString *) = ^(NSString *resolvedURL) {
NSURL *newURL = [NSURL URLWithString:resolvedURL];
MSHookIvar<NSURL *>(arg1, "url") = newURL;
if (rdkLink) {
MSHookIvar<RDKLink *>(self, "link").URL = newURL;
}
%orig;
MSHookIvar<NSURL *>(arg1, "url") = url;
MSHookIvar<RDKLink *>(self, "link").URL = rdkLinkURL;
};
TryResolveShareUrl(urlString, successHandler, ignoreHandler);
}

-(void)textNode:(id)textNode tappedLinkAttribute:(id)attr value:(NSURL *)url atPoint:(struct CGPoint)point textRange:(struct _NSRange)range {
void (^ignoreHandler)(void) = ^{
%orig;
};
void (^successHandler)(NSString *) = ^(NSString *resolvedURL) {
%orig(textNode, attr, [NSURL URLWithString:resolvedURL], point, range);
};
TryResolveShareUrl([url absoluteString], successHandler, ignoreHandler);
}

%end

%hook _TtC6Apollo15CommentCellNode

- (void)linkButtonTappedWithSender:(_TtC6Apollo14LinkButtonNode *)arg1 {
%log;
NSURL *url = MSHookIvar<NSURL *>(arg1, "url");
NSString *urlString = [url absoluteString];

void (^ignoreHandler)(void) = ^{
%orig;
};
void (^successHandler)(NSString *) = ^(NSString *resolvedURL) {
MSHookIvar<NSURL *>(arg1, "url") = [NSURL URLWithString:resolvedURL];
%orig;
MSHookIvar<NSURL *>(arg1, "url") = url;
};
TryResolveShareUrl(urlString, successHandler, ignoreHandler);
}

%end

%hook _TtC6Apollo22CommentsHeaderCellNode

-(void)linkButtonNodeTappedWithSender:(_TtC6Apollo14LinkButtonNode *)arg1 {
RDKLink *rdkLink = MSHookIvar<RDKLink *>(self, "link");
NSURL *rdkLinkURL;
if (rdkLink) {
rdkLinkURL = rdkLink.URL;
}
NSURL *url = MSHookIvar<NSURL *>(arg1, "url");
NSString *urlString = [url absoluteString];

void (^ignoreHandler)(void) = ^{
%orig;
};
void (^successHandler)(NSString *) = ^(NSString *resolvedURL) {
NSURL *newURL = [NSURL URLWithString:resolvedURL];
MSHookIvar<NSURL *>(arg1, "url") = newURL;
if (rdkLink) {
MSHookIvar<RDKLink *>(self, "link").URL = newURL;
}
%orig;
MSHookIvar<NSURL *>(arg1, "url") = url;
MSHookIvar<RDKLink *>(self, "link").URL = rdkLinkURL;
};
TryResolveShareUrl(urlString, successHandler, ignoreHandler);
}

%end

Expand Down Expand Up @@ -259,6 +498,10 @@ static NSString *imageID;
%end

%ctor {
cache = [NSCache new];
NSError *error = NULL;
ShareLinkRegex = [NSRegularExpression regularExpressionWithPattern:ShareLinkRegexPattern options:NSRegularExpressionCaseInsensitive error:&error];

sRedditClientId = (NSString *)[[[NSUserDefaults standardUserDefaults] objectForKey:UDKeyRedditClientId] ?: @"" copy];
sImgurClientId = (NSString *)[[[NSUserDefaults standardUserDefaults] objectForKey:UDKeyImgurClientId] ?: @"" copy];

Expand Down
5 changes: 5 additions & 0 deletions UIWindow+Apollo.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#import <UIKit/UIKit.h>

@interface UIWindow (Apollo)
- (UIViewController *) visibleViewController;
@end
23 changes: 23 additions & 0 deletions UIWindow+Apollo.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#import "UIWindow+Apollo.h"

// https://stackoverflow.com/a/21848247
@implementation UIWindow (Apollo)
- (UIViewController *)visibleViewController {
UIViewController *rootViewController = self.rootViewController;
return [UIWindow getVisibleViewControllerFrom:rootViewController];
}

+ (UIViewController *) getVisibleViewControllerFrom:(UIViewController *) vc {
if ([vc isKindOfClass:[UINavigationController class]]) {
return [UIWindow getVisibleViewControllerFrom:[((UINavigationController *) vc) visibleViewController]];
} else if ([vc isKindOfClass:[UITabBarController class]]) {
return [UIWindow getVisibleViewControllerFrom:[((UITabBarController *) vc) selectedViewController]];
} else {
if (vc.presentedViewController) {
return [UIWindow getVisibleViewControllerFrom:vc.presentedViewController];
} else {
return vc;
}
}
}
@end
2 changes: 1 addition & 1 deletion control
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: ca.jeffrey.apollo-improvedcustomapi
Name: Apollo-ImprovedCustomApi
Version: 1.0.3b
Version: 1.0.4
Architecture: iphoneos-arm
Description: Apollo for Reddit tweak with in-app configurable API keys
Maintainer: JeffreyCA
Expand Down

0 comments on commit 1f8e0e8

Please sign in to comment.