@@ -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
0 commit comments