1//
2//  AIRMapUrlTileCachedOverlay.m
3//  Airmaps
4//
5//  Created by Markus Suomi on 10/04/2021.
6//
7
8#import "AIRMapUrlTileCachedOverlay.h"
9
10@interface AIRMapUrlTileCachedOverlay ()
11
12@end
13
14@implementation AIRMapUrlTileCachedOverlay {
15    CIContext *_ciContext;
16    CGColorSpaceRef _colorspace;
17    NSURLSession *_urlSession;
18}
19
20- (void)loadTileAtPath:(MKTileOverlayPath)path result:(void (^)(NSData *, NSError *))result
21{
22    if (!result) return;
23
24    NSInteger maximumZ = self.maximumNativeZ ? self.maximumNativeZ : path.z;
25    [self scaleIfNeededLowerZoomTile:path maximumZ:maximumZ result:^(NSData *image, NSError *error) {
26        if (!image && self.offlineMode && self.tileCachePath) {
27            NSInteger zoomLevelToStart = (path.z > maximumZ) ? maximumZ - 1 : path.z - 1;
28            NSInteger minimumZoomToSearch = self.minimumZ >= zoomLevelToStart - 3 ? self.minimumZ : zoomLevelToStart - 3;
29            [self findLowerZoomTileAndScale:path tryZ:zoomLevelToStart minZ:minimumZoomToSearch result:result];
30        } else {
31            result(image, error);
32        }
33    }];
34}
35
36- (void)scaleIfNeededLowerZoomTile:(MKTileOverlayPath)path maximumZ:(NSInteger)maximumZ result:(void (^)(NSData *, NSError *))result
37{
38    NSInteger overZoomLevel = path.z - maximumZ;
39    if (overZoomLevel <= 0) {
40        [self getTileImage:path result:result];
41        return;
42    }
43
44    NSInteger zoomFactor = 1 << overZoomLevel;
45
46    MKTileOverlayPath parentTile;
47    parentTile.x = path.x >> overZoomLevel;
48    parentTile.y = path.y >> overZoomLevel;
49    parentTile.z = path.z - overZoomLevel;
50    parentTile.contentScaleFactor = path.contentScaleFactor;
51
52    NSInteger xOffset = path.x % zoomFactor;
53    NSInteger yOffset = path.y % zoomFactor;
54    NSInteger subTileSize = self.tileSize.width / zoomFactor;
55
56    if (!_ciContext) _ciContext = [CIContext context];
57    if (!_colorspace) _colorspace = CGColorSpaceCreateDeviceRGB();
58
59    [self getTileImage:parentTile result:^(NSData *image, NSError *error) {
60        if (!image) {
61            result(nil, nil);
62            return;
63        }
64
65        CIImage* originalCIImage = [CIImage imageWithData:image];
66
67        CGRect rect;
68        rect.origin.x = xOffset * subTileSize;
69        rect.origin.y = self.tileSize.width - (yOffset + 1) * subTileSize;
70        rect.size.width = subTileSize;
71        rect.size.height = subTileSize;
72        CIVector *inputRect = [CIVector vectorWithCGRect:rect];
73        CIFilter* cropFilter = [CIFilter filterWithName:@"CICrop"];
74        [cropFilter setValue:originalCIImage forKey:@"inputImage"];
75        [cropFilter setValue:inputRect forKey:@"inputRectangle"];
76
77        CGAffineTransform trans = CGAffineTransformMakeScale(zoomFactor, zoomFactor);
78        CIImage* scaledCIImage = [cropFilter.outputImage imageByApplyingTransform:trans];
79
80        NSData *finalImage = [_ciContext PNGRepresentationOfImage:scaledCIImage format:kCIFormatABGR8 colorSpace:_colorspace options:nil];
81        result(finalImage, nil);
82    }];
83}
84
85- (void)findLowerZoomTileAndScale:(MKTileOverlayPath)path tryZ:(NSInteger)tryZ minZ:(NSInteger)minZ result:(void (^)(NSData *, NSError *))result
86{
87    [self scaleIfNeededLowerZoomTile:path maximumZ:tryZ result:^(NSData *image, NSError *error) {
88        if (image) {
89            result(image, error);
90        } else if (tryZ >= minZ) {
91            [self findLowerZoomTileAndScale:path tryZ:tryZ - 1 minZ:minZ result:result];
92        } else {
93            result(nil, nil);
94        }
95    }];
96}
97
98- (void)getTileImage:(MKTileOverlayPath)path result:(void (^)(NSData *, NSError *))result
99{
100    NSData *image;
101    NSURL *tileCacheFileDirectory = [NSURL URLWithString:[NSString stringWithFormat:@"%d/%d/", (int)path.z, (int)path.x] relativeToURL:self.tileCachePath];
102    NSURL *tileCacheFilePath = [NSURL URLWithString:[NSString stringWithFormat:@"%d", (int)path.y] relativeToURL:tileCacheFileDirectory];
103
104    if (self.tileCachePath) {
105        image = [self readTileImage:path fromFilePath:tileCacheFilePath];
106        if (image) {
107            result(image, nil);
108            if (!self.offlineMode && self.tileCacheMaxAge) {
109                [self checkForRefresh:path fromFilePath:tileCacheFilePath];
110            }
111        }
112    }
113
114    if (!image) {
115        if (!self.offlineMode) {
116            [self fetchTile:path result:^(NSData *image, NSError *error) {
117                result(image, error);
118                if (image && self.tileCachePath) {
119                    [self writeTileImage:tileCacheFileDirectory withTileCacheFilePath:tileCacheFilePath withTileData:image];
120                }
121            }];
122        } else {
123            result(nil, nil);
124        }
125    }
126}
127
128- (NSData *)readTileImage:(MKTileOverlayPath)path fromFilePath:(NSURL *)tileCacheFilePath
129{
130    NSError *error;
131
132    if ([[NSFileManager defaultManager] fileExistsAtPath:[tileCacheFilePath path]]) {
133        if (!self.tileCacheMaxAge) {
134            [[NSFileManager defaultManager] setAttributes:@{NSFileModificationDate:[NSDate date]}
135                            ofItemAtPath:[tileCacheFilePath path]
136                                    error:&error];
137        }
138
139        NSData *tile = [NSData dataWithContentsOfFile:[tileCacheFilePath path]];
140        NSLog(@"tileCache HIT for %d_%d_%d", (int)path.z, (int)path.x, (int)path.y);
141        NSLog(@"tileCache HIT, with max age set at %d", self.tileCacheMaxAge);
142        return tile;
143    } else {
144        NSLog(@"tileCache MISS for %d_%d_%d", (int)path.z, (int)path.x, (int)path.y);
145        return nil;
146    }
147}
148
149- (void)fetchTile:(MKTileOverlayPath)path result:(void (^)(NSData *, NSError *))result
150{
151    if (!_urlSession) [self createURLSession];
152
153    [[_urlSession dataTaskWithURL:[self URLForTilePath:path]
154        completionHandler:^(NSData *data,
155                            NSURLResponse *response,
156                            NSError *error) {
157            result(data, error);
158        }] resume];
159}
160
161- (void)writeTileImage:(NSURL *)tileCacheFileDirectory withTileCacheFilePath:(NSURL *)tileCacheFilePath withTileData:(NSData *)data
162{
163    NSError *error;
164
165    if (![[NSFileManager defaultManager] fileExistsAtPath:[tileCacheFileDirectory path]]) {
166        [[NSFileManager defaultManager] createDirectoryAtPath:[tileCacheFileDirectory path] withIntermediateDirectories:YES attributes:nil error:&error];
167        if (error) {
168            NSLog(@"Error: %@", error);
169            return;
170        }
171    }
172
173    [[NSFileManager defaultManager] createFileAtPath:[tileCacheFilePath path] contents:data attributes:nil];
174    NSLog(@"tileCache SAVED tile %@", [tileCacheFilePath path]);
175}
176
177- (void)createTileCacheDirectory
178{
179    NSError *error;
180    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
181    NSString *documentsDirectory = [paths objectAtIndex:0];
182    NSString *tileCacheBaseDirectory = [NSString stringWithFormat:@"%@/tileCache", documentsDirectory];
183    self.tileCachePath = [NSURL fileURLWithPath:tileCacheBaseDirectory isDirectory:YES];
184
185    if (![[NSFileManager defaultManager] fileExistsAtPath:[self.tileCachePath path]])
186        [[NSFileManager defaultManager] createDirectoryAtPath:[self.tileCachePath path] withIntermediateDirectories:NO attributes:nil error:&error];
187}
188
189- (void)createURLSession
190{
191 if (!_urlSession) {
192     _urlSession = [NSURLSession sharedSession];
193 }
194}
195
196- (void)checkForRefresh:(MKTileOverlayPath)path fromFilePath:(NSURL *)tileCacheFilePath
197{
198    if ([self doesFileNeedRefresh:path fromFilePath:tileCacheFilePath withMaxAge:self.tileCacheMaxAge]) {
199        dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^ {
200            // This code runs asynchronously!
201            if ([self doesFileNeedRefresh:path fromFilePath:tileCacheFilePath withMaxAge:self.tileCacheMaxAge]) {
202                if (!_urlSession) [self createURLSession];
203
204                [[_urlSession dataTaskWithURL:[self URLForTilePath:path]
205                    completionHandler:^(NSData *data,
206                                        NSURLResponse *response,
207                                        NSError *error) {
208                    if (!error) {
209                        [[NSFileManager defaultManager] createFileAtPath:[tileCacheFilePath path] contents:data attributes:nil];
210                        NSLog(@"tileCache File refreshed at %@", [tileCacheFilePath path]);
211                    }
212                }] resume];
213            }
214        });
215    }
216}
217
218- (BOOL)doesFileNeedRefresh:(MKTileOverlayPath)path fromFilePath:(NSURL *)tileCacheFilePath withMaxAge:(NSInteger)tileCacheMaxAge
219{
220    NSError *error;
221    NSDictionary<NSFileAttributeKey, id> *fileAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:[tileCacheFilePath path] error:&error];
222
223    if (fileAttributes) {
224        NSDate *modificationDate = fileAttributes[@"NSFileModificationDate"];
225        if (modificationDate) {
226            if (-1 * (int)modificationDate.timeIntervalSinceNow > tileCacheMaxAge) {
227                return YES;
228            }
229        }
230    }
231
232    return NO;
233}
234
235@end
236