Skip to content

Commit af6130e

Browse files
sonlichaododahoho
andcommitted
feat(scheme-handler): Improve memory usage & Range support (apache#1481)
* refactor(schemes): Read files on a background thread Closes apacheGH-909. * feat(schemes): Support range requests Based on code from ionic-team/cordova-plugin-ionic-webview#692 Closes apacheGH-1033. Co-Authored-By: David Holmgren <[email protected]> --------- Co-authored-by: David Holmgren <[email protected]>
1 parent 27332f6 commit af6130e

File tree

6 files changed

+243
-70
lines changed

6 files changed

+243
-70
lines changed

CordovaLib/Classes/Private/Plugins/CDVWebViewEngine/CDVWebViewEngine.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ - (void)pluginInitialize
217217

218218
// Do not configure the scheme handler if the scheme is default (file)
219219
if(!self.cdvIsFileScheme) {
220-
self.schemeHandler = [[CDVURLSchemeHandler alloc] initWithVC:vc];
220+
self.schemeHandler = [[CDVURLSchemeHandler alloc] initWithViewController:self.viewController];
221221
[configuration setURLSchemeHandler:self.schemeHandler forURLScheme:scheme];
222222
}
223223

CordovaLib/Classes/Public/CDVURLSchemeHandler.m

Lines changed: 222 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -19,119 +19,277 @@ Licensed to the Apache Software Foundation (ASF) under one
1919

2020

2121
#import <Cordova/CDVURLSchemeHandler.h>
22+
#import <Cordova/CDVViewController.h>
23+
#import <Cordova/CDVPlugin.h>
24+
#import <Foundation/Foundation.h>
2225
#import <MobileCoreServices/MobileCoreServices.h>
2326

2427
#import <objc/message.h>
2528

26-
@implementation CDVURLSchemeHandler
29+
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000
30+
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
31+
#endif
32+
33+
static const NSUInteger FILE_BUFFER_SIZE = 1024 * 1024 * 4; // 4 MiB
34+
35+
@interface CDVURLSchemeHandler ()
36+
37+
@end
2738

39+
@implementation CDVURLSchemeHandler
2840

29-
- (instancetype)initWithVC:(CDVViewController *)controller
41+
- (instancetype)initWithViewController:(CDVViewController *)controller
3042
{
3143
self = [super init];
3244
if (self) {
3345
_viewController = controller;
46+
_handlerMap = [NSMapTable weakToWeakObjectsMapTable];
3447
}
3548
return self;
3649
}
3750

3851
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask
3952
{
40-
NSString * startPath = [[NSBundle mainBundle] pathForResource:self.viewController.wwwFolderName ofType: nil];
41-
NSURL * url = urlSchemeTask.request.URL;
42-
NSString * stringToLoad = url.path;
43-
NSString * scheme = url.scheme;
44-
45-
CDVViewController* vc = (CDVViewController*)self.viewController;
46-
47-
/*
48-
* Give plugins the chance to handle the url
49-
*/
50-
BOOL anyPluginsResponded = NO;
51-
BOOL handledRequest = NO;
52-
53-
NSDictionary *pluginObjects = [[vc pluginObjects] copy];
53+
// Give plugins the chance to handle the url
54+
NSDictionary *pluginObjects = [[self.viewController pluginObjects] copy];
5455
for (NSString* pluginName in pluginObjects) {
55-
self.schemePlugin = [vc.pluginObjects objectForKey:pluginName];
56+
CDVPlugin *plugin = [self.viewController.pluginObjects objectForKey:pluginName];
5657
SEL selector = NSSelectorFromString(@"overrideSchemeTask:");
57-
if ([self.schemePlugin respondsToSelector:selector]) {
58-
handledRequest = (((BOOL (*)(id, SEL, id <WKURLSchemeTask>))objc_msgSend)(self.schemePlugin, selector, urlSchemeTask));
58+
if ([plugin respondsToSelector:selector]) {
59+
BOOL handledRequest = (((BOOL (*)(id, SEL, id <WKURLSchemeTask>))objc_msgSend)(plugin, selector, urlSchemeTask));
5960
if (handledRequest) {
60-
anyPluginsResponded = YES;
61-
break;
61+
// Store the plugin that is handling this particular request
62+
[self.handlerMap setObject:plugin forKey:urlSchemeTask];
63+
return;
6264
}
6365
}
6466
}
6567

66-
if (!anyPluginsResponded) {
67-
if ([scheme isEqualToString:self.viewController.appScheme]) {
68-
if ([stringToLoad hasPrefix:@"/_app_file_"]) {
69-
startPath = [stringToLoad stringByReplacingOccurrencesOfString:@"/_app_file_" withString:@""];
70-
} else {
71-
if ([stringToLoad isEqualToString:@""] || [url.pathExtension isEqualToString:@""]) {
72-
startPath = [startPath stringByAppendingPathComponent:self.viewController.startPage];
73-
} else {
74-
startPath = [startPath stringByAppendingPathComponent:stringToLoad];
75-
}
68+
NSURLRequest *req = urlSchemeTask.request;
69+
if (![req.URL.scheme isEqualToString:self.viewController.appScheme]) {
70+
return;
71+
}
72+
73+
// Indicate that we are handling this task, by adding an entry with a null plugin
74+
// We do this so that we can (in future) detect if the task is cancelled before we finished feeding it response data
75+
[self.handlerMap setObject:(id)[NSNull null] forKey:urlSchemeTask];
76+
77+
[self.viewController.commandDelegate runInBackground:^{
78+
NSURL *fileURL = [self fileURLForRequestURL:req.URL];
79+
NSError *error;
80+
81+
NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingFromURL:fileURL error:&error];
82+
if (!fileHandle || error) {
83+
if ([self taskActive:urlSchemeTask]) {
84+
[urlSchemeTask didFailWithError:error];
7685
}
77-
}
7886

79-
NSError * fileError = nil;
80-
NSData * data = nil;
81-
if ([self isMediaExtension:url.pathExtension]) {
82-
data = [NSData dataWithContentsOfFile:startPath options:NSDataReadingMappedIfSafe error:&fileError];
87+
@synchronized(self.handlerMap) {
88+
[self.handlerMap removeObjectForKey:urlSchemeTask];
89+
}
90+
return;
8391
}
84-
if (!data || fileError) {
85-
data = [[NSData alloc] initWithContentsOfFile:startPath];
92+
93+
NSInteger statusCode = 200; // Default to 200 OK status
94+
NSString *mimeType = [self getMimeType:fileURL] ?: @"application/octet-stream";
95+
NSNumber *fileLength;
96+
[fileURL getResourceValue:&fileLength forKey:NSURLFileSizeKey error:nil];
97+
98+
NSNumber *responseSize = fileLength;
99+
NSUInteger responseSent = 0;
100+
101+
NSMutableDictionary *headers = [NSMutableDictionary dictionaryWithCapacity:5];
102+
headers[@"Content-Type"] = mimeType;
103+
headers[@"Cache-Control"] = @"no-cache";
104+
headers[@"Content-Length"] = [responseSize stringValue];
105+
106+
// Check for Range header - only for media files
107+
NSString *rangeHeader = [urlSchemeTask.request valueForHTTPHeaderField:@"Range"];
108+
if (rangeHeader && [self isMediaFile:fileURL mimeType:mimeType]) {
109+
NSRange range = NSMakeRange(NSNotFound, 0);
110+
111+
if ([rangeHeader hasPrefix:@"bytes="]) {
112+
NSString *byteRange = [rangeHeader substringFromIndex:6];
113+
NSArray<NSString *> *rangeParts = [byteRange componentsSeparatedByString:@"-"];
114+
NSUInteger start = (NSUInteger)[rangeParts[0] integerValue];
115+
NSUInteger end = rangeParts.count > 1 && ![rangeParts[1] isEqualToString:@""] ? (NSUInteger)[rangeParts[1] integerValue] : [fileLength unsignedIntegerValue] - 1;
116+
range = NSMakeRange(start, end - start + 1);
117+
}
118+
119+
if (range.location != NSNotFound) {
120+
// Ensure range is valid
121+
if (range.location >= [fileLength unsignedIntegerValue] && [self taskActive:urlSchemeTask]) {
122+
headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes */%@", fileLength];
123+
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:req.URL statusCode:416 HTTPVersion:@"HTTP/1.1" headerFields:headers];
124+
[urlSchemeTask didReceiveResponse:response];
125+
[urlSchemeTask didFinish];
126+
127+
@synchronized(self.handlerMap) {
128+
[self.handlerMap removeObjectForKey:urlSchemeTask];
129+
}
130+
return;
131+
}
132+
133+
[fileHandle seekToFileOffset:range.location];
134+
responseSize = [NSNumber numberWithUnsignedInteger:range.length];
135+
statusCode = 206; // Partial Content
136+
headers[@"Content-Range"] = [NSString stringWithFormat:@"bytes %lu-%lu/%@", (unsigned long)range.location, (unsigned long)(range.location + range.length - 1), fileLength];
137+
headers[@"Content-Length"] = [NSString stringWithFormat:@"%lu", (unsigned long)range.length];
138+
}
86139
}
87-
NSInteger statusCode = 200;
88-
if (!data) {
89-
statusCode = 404;
140+
141+
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:req.URL statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:headers];
142+
if ([self taskActive:urlSchemeTask]) {
143+
[urlSchemeTask didReceiveResponse:response];
90144
}
91-
NSURL * localUrl = [NSURL URLWithString:url.absoluteString];
92-
NSString * mimeType = [self getMimeType:url.pathExtension];
93-
id response = nil;
94-
if (data && [self isMediaExtension:url.pathExtension]) {
95-
response = [[NSURLResponse alloc] initWithURL:localUrl MIMEType:mimeType expectedContentLength:data.length textEncodingName:nil];
145+
146+
// Use chunked reading only for media files, otherwise read entire file
147+
if ([self isMediaFile:fileURL mimeType:mimeType]) {
148+
while ([self taskActive:urlSchemeTask] && responseSent < [responseSize unsignedIntegerValue]) {
149+
@autoreleasepool {
150+
NSData *data = [self readFromFileHandle:fileHandle upTo:FILE_BUFFER_SIZE error:&error];
151+
if (!data || error) {
152+
if ([self taskActive:urlSchemeTask]) {
153+
[urlSchemeTask didFailWithError:error];
154+
}
155+
break;
156+
}
157+
158+
if ([self taskActive:urlSchemeTask]) {
159+
[urlSchemeTask didReceiveData:data];
160+
}
161+
162+
responseSent += data.length;
163+
}
164+
}
96165
} else {
97-
NSDictionary * headers = @{ @"Content-Type" : mimeType, @"Cache-Control": @"no-cache"};
98-
response = [[NSHTTPURLResponse alloc] initWithURL:localUrl statusCode:statusCode HTTPVersion:nil headerFields:headers];
166+
// For non-media files, read entire file at once (original behavior)
167+
NSData *data = [self readFromFileHandle:fileHandle upTo:[responseSize unsignedIntegerValue] error:&error];
168+
if (data && [self taskActive:urlSchemeTask]) {
169+
[urlSchemeTask didReceiveData:data];
170+
} else if (error && [self taskActive:urlSchemeTask]) {
171+
[urlSchemeTask didFailWithError:error];
172+
}
99173
}
100174

101-
[urlSchemeTask didReceiveResponse:response];
102-
if (data) {
103-
[urlSchemeTask didReceiveData:data];
175+
[fileHandle closeFile];
176+
177+
if ([self taskActive:urlSchemeTask]) {
178+
[urlSchemeTask didFinish];
104179
}
105-
[urlSchemeTask didFinish];
106-
}
180+
181+
@synchronized(self.handlerMap) {
182+
[self.handlerMap removeObjectForKey:urlSchemeTask];
183+
}
184+
}];
107185
}
108186

109187
- (void)webView:(nonnull WKWebView *)webView stopURLSchemeTask:(nonnull id<WKURLSchemeTask>)urlSchemeTask
110188
{
189+
CDVPlugin *plugin;
190+
@synchronized(self.handlerMap) {
191+
plugin = [self.handlerMap objectForKey:urlSchemeTask];
192+
}
193+
194+
if (![plugin isEqual:[NSNull null]] && [plugin respondsToSelector:@selector(stopSchemeTask:)]) {
111195
SEL selector = NSSelectorFromString(@"stopSchemeTask:");
112-
if (self.schemePlugin != nil && [self.schemePlugin respondsToSelector:selector]) {
113-
(((void (*)(id, SEL, id <WKURLSchemeTask>))objc_msgSend)(self.schemePlugin, selector, urlSchemeTask));
196+
(((void (*)(id, SEL, id <WKURLSchemeTask>))objc_msgSend)(plugin, selector, urlSchemeTask));
197+
}
198+
199+
@synchronized(self.handlerMap) {
200+
[self.handlerMap removeObjectForKey:urlSchemeTask];
114201
}
115202
}
116203

117-
-(NSString *) getMimeType:(NSString *)fileExtension {
118-
if (fileExtension && ![fileExtension isEqualToString:@""]) {
119-
NSString *UTI = (__bridge_transfer NSString *)UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)fileExtension, NULL);
120-
NSString *contentType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)UTI, kUTTagClassMIMEType);
121-
return contentType ? contentType : @"application/octet-stream";
204+
#pragma mark - Utility methods
205+
206+
- (NSURL *)fileURLForRequestURL:(NSURL *)url
207+
{
208+
NSURL *resDir = [[NSBundle mainBundle] URLForResource:self.viewController.wwwFolderName withExtension:nil];
209+
NSURL *filePath;
210+
211+
if ([url.path hasPrefix:@"/_app_file_"]) {
212+
NSString *path = [url.path stringByReplacingOccurrencesOfString:@"/_app_file_" withString:@""];
213+
filePath = [resDir URLByAppendingPathComponent:path];
122214
} else {
123-
return @"text/html";
215+
if ([url.path isEqualToString:@""] || [url.pathExtension isEqualToString:@""]) {
216+
filePath = [resDir URLByAppendingPathComponent:self.viewController.startPage];
217+
} else {
218+
filePath = [resDir URLByAppendingPathComponent:url.path];
219+
}
220+
}
221+
222+
return filePath.URLByStandardizingPath;
223+
}
224+
225+
-(NSString *)getMimeType:(NSURL *)url
226+
{
227+
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000
228+
if (@available(iOS 14.0, *)) {
229+
UTType *uti;
230+
[url getResourceValue:&uti forKey:NSURLContentTypeKey error:nil];
231+
return [uti preferredMIMEType];
232+
}
233+
#endif
234+
235+
NSString *type;
236+
[url getResourceValue:&type forKey:NSURLTypeIdentifierKey error:nil];
237+
return (NSString *)UTTypeCopyPreferredTagWithClass((CFStringRef)type, kUTTagClassMIMEType);
238+
}
239+
240+
- (nullable NSData *)readFromFileHandle:(NSFileHandle *)handle upTo:(NSUInteger)length error:(NSError **)err
241+
{
242+
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000
243+
if (@available(iOS 14.0, *)) {
244+
return [handle readDataUpToLength:length error:err];
245+
}
246+
#endif
247+
248+
@try {
249+
return [handle readDataOfLength:length];
250+
}
251+
@catch (NSError *error) {
252+
if (err != nil) {
253+
*err = error;
254+
}
255+
return nil;
256+
}
257+
}
258+
259+
- (BOOL)taskActive:(id <WKURLSchemeTask>)task
260+
{
261+
@synchronized(self.handlerMap) {
262+
return [self.handlerMap objectForKey:task] != nil;
124263
}
125264
}
126265

127-
-(BOOL) isMediaExtension:(NSString *) pathExtension {
128-
NSArray * mediaExtensions = @[@"m4v", @"mov", @"mp4",
129-
@"aac", @"ac3", @"aiff", @"au", @"flac", @"m4a", @"mp3", @"wav"];
266+
- (BOOL)isMediaExtension:(NSString *)pathExtension
267+
{
268+
NSArray *mediaExtensions = @[@"m4v", @"mov", @"mp4", @"mpeg",
269+
@"aac", @"ac3", @"aiff", @"au", @"flac", @"m4a", @"mp3", @"wav", @"wave"];
130270
if ([mediaExtensions containsObject:pathExtension.lowercaseString]) {
131271
return YES;
132272
}
133273
return NO;
134274
}
135275

276+
- (BOOL)isMediaFile:(NSURL *)fileURL mimeType:(NSString *)mimeType
277+
{
278+
// Check by file extension
279+
if ([self isMediaExtension:fileURL.pathExtension]) {
280+
return YES;
281+
}
282+
283+
// Check by MIME type
284+
NSArray *audioMimeTypes = @[@"audio/m4a", @"audio/mp3", @"audio/mp4", @"audio/mpeg", @"audio/vnd.wave", @"audio/wav"];
285+
NSArray *videoMimeTypes = @[@"video/mp4", @"video/quicktime"];
286+
287+
if ([audioMimeTypes containsObject:mimeType.lowercaseString] ||
288+
[videoMimeTypes containsObject:mimeType.lowercaseString]) {
289+
return YES;
290+
}
291+
292+
return NO;
293+
}
136294

137295
@end

CordovaLib/Classes/Public/CDVViewController.m

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ - (void)loadSettings
164164
self.startPage = @"index.html";
165165
}
166166

167+
self.appScheme = [self.settings cordovaSettingForKey:@"Scheme"] ?: @"app";
168+
167169
// Initialize the plugin objects dict.
168170
self.pluginObjects = [[NSMutableDictionary alloc] initWithCapacity:20];
169171
}

CordovaLib/include/Cordova/CDVURLSchemeHandler.h

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@
2424

2525
@interface CDVURLSchemeHandler : NSObject <WKURLSchemeHandler>
2626

27-
@property (nonatomic, weak) CDVViewController* viewController;
27+
@property (nonatomic, assign) CDVViewController* viewController;
2828

29-
@property (nonatomic) CDVPlugin* schemePlugin;
29+
@property (nonatomic, strong) NSMapTable <id <WKURLSchemeTask>, CDVPlugin *> *handlerMap;
3030

31-
- (instancetype)initWithVC:(CDVViewController *)controller;
31+
- (instancetype)initWithViewController:(CDVViewController *)controller;
3232

3333

3434
@end

CordovaLib/include/Cordova/CDVViewController.h

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,20 @@ NS_ASSUME_NONNULL_BEGIN
6565
@property (nonatomic, readonly, strong) NSMutableDictionary* settings;
6666
@property (nonatomic, readonly, strong) NSXMLParser* configParser;
6767

68-
@property (nonatomic, readwrite, copy) NSString* appScheme;
68+
/*
69+
The scheme being used to load web content from the app bundle into the Cordova
70+
web view.
71+
72+
The default value is `app` but can be customized via the `Scheme` preference
73+
in the Cordova XML configuration file. Setting this to `file` will results in
74+
web content being loaded using the File URL protocol, which has inherent
75+
security limitations. It is encouraged that you use a custom scheme to load
76+
your app content.
77+
78+
It is not valid to set this to an existing protocol scheme such as `http` or
79+
`https`.
80+
*/
81+
@property (nonatomic, nullable, readwrite, copy) NSString* appScheme;
6982
@property (nonatomic, readwrite, copy) NSString* configFile;
7083
@property (nonatomic, readwrite, copy) NSString* wwwFolderName;
7184
@property (nonatomic, readwrite, copy) NSString* startPage;

0 commit comments

Comments
 (0)