summaryrefslogtreecommitdiff
path: root/platform/osx
diff options
context:
space:
mode:
authorJohn Firebaugh <john.firebaugh@gmail.com>2016-03-30 17:01:54 -0700
committerJohn Firebaugh <john.firebaugh@gmail.com>2016-03-30 17:43:37 -0700
commit5eda74a514964d1cac684483bafa08d458175f9a (patch)
treea38f886f5742d52a915c3c72959a5eba50b9fa0e /platform/osx
parentb6a181097c3e7c3168be3575d5d7e95820fc74ba (diff)
parent7b5a1ca1670a0346cdbf2af689fabde4e70ed561 (diff)
downloadqtlocation-mapboxgl-5eda74a514964d1cac684483bafa08d458175f9a.tar.gz
Merge branch 'release-ios-3.2.0-android-4.0.0'
Diffstat (limited to 'platform/osx')
-rw-r--r--platform/osx/app/AppDelegate.h2
-rw-r--r--platform/osx/app/AppDelegate.m108
-rw-r--r--platform/osx/app/MainMenu.xib203
-rw-r--r--platform/osx/app/MapDocument.m38
-rw-r--r--platform/osx/app/OfflinePackNameValueTransformer.h5
-rw-r--r--platform/osx/app/OfflinePackNameValueTransformer.m33
-rw-r--r--platform/osx/app/mapboxgl-app.gypi2
-rw-r--r--platform/osx/src/MGLMapView.mm62
-rw-r--r--platform/osx/test/MGLOfflinePackTests.m40
-rw-r--r--platform/osx/test/MGLOfflineRegionTests.m35
-rw-r--r--platform/osx/test/MGLOfflineStorageTests.m126
-rw-r--r--platform/osx/test/osxtest.gypi3
12 files changed, 611 insertions, 46 deletions
diff --git a/platform/osx/app/AppDelegate.h b/platform/osx/app/AppDelegate.h
index 45d389f546..ca9edab773 100644
--- a/platform/osx/app/AppDelegate.h
+++ b/platform/osx/app/AppDelegate.h
@@ -15,8 +15,8 @@ extern NSString * const MGLMapboxAccessTokenDefaultsKey;
@property (assign) double pendingZoomLevel;
@property (copy) MGLMapCamera *pendingCamera;
+@property (assign) MGLCoordinateBounds pendingVisibleCoordinateBounds;
@property (copy) NSURL *pendingStyleURL;
@property (assign) MGLMapDebugMaskOptions pendingDebugMask;
@end
-
diff --git a/platform/osx/app/AppDelegate.m b/platform/osx/app/AppDelegate.m
index b54ab4fe74..d9fd967410 100644
--- a/platform/osx/app/AppDelegate.m
+++ b/platform/osx/app/AppDelegate.m
@@ -7,8 +7,59 @@ NSString * const MGLLastMapCameraDefaultsKey = @"MGLLastMapCamera";
NSString * const MGLLastMapStyleURLDefaultsKey = @"MGLLastMapStyleURL";
NSString * const MGLLastMapDebugMaskDefaultsKey = @"MGLLastMapDebugMask";
+/**
+ Some convenience methods to make offline pack properties easier to bind to.
+ */
+@implementation MGLOfflinePack (Additions)
+
++ (NSSet *)keyPathsForValuesAffectingStateImage {
+ return [NSSet setWithObjects:@"state", nil];
+}
+
+- (NSImage *)stateImage {
+ switch (self.state) {
+ case MGLOfflinePackStateComplete:
+ return [NSImage imageNamed:@"NSMenuOnStateTemplate"];
+
+ case MGLOfflinePackStateActive:
+ return [NSImage imageNamed:@"NSFollowLinkFreestandingTemplate"];
+
+ default:
+ return nil;
+ }
+}
+
++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingCountOfResourcesCompleted {
+ return [NSSet setWithObjects:@"progress", nil];
+}
+
+- (uint64_t)countOfResourcesCompleted {
+ return self.progress.countOfResourcesCompleted;
+}
+
++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingCountOfResourcesExpected {
+ return [NSSet setWithObjects:@"progress", nil];
+}
+
+- (uint64_t)countOfResourcesExpected {
+ return self.progress.countOfResourcesExpected;
+}
+
++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingCountOfBytesCompleted {
+ return [NSSet setWithObjects:@"progress", nil];
+}
+
+- (uint64_t)countOfBytesCompleted {
+ return self.progress.countOfBytesCompleted;
+}
+
+@end
+
@interface AppDelegate ()
+@property (weak) IBOutlet NSArrayController *offlinePacksArrayController;
+@property (weak) IBOutlet NSPanel *offlinePacksPanel;
+
@end
@implementation AppDelegate
@@ -63,6 +114,8 @@ NSString * const MGLLastMapDebugMaskDefaultsKey = @"MGLLastMapDebugMask";
[alert runModal];
[self showPreferences:nil];
}
+
+ [self.offlinePacksArrayController bind:@"content" toObject:[MGLOfflineStorage sharedOfflineStorage] withKeyPath:@"packs" options:nil];
}
- (void)applicationWillTerminate:(NSNotification *)notification {
@@ -79,6 +132,8 @@ NSString * const MGLLastMapDebugMaskDefaultsKey = @"MGLLastMapDebugMask";
[[NSUserDefaults standardUserDefaults] setInteger:mapView.debugMask forKey:MGLLastMapDebugMaskDefaultsKey];
}
}
+
+ [self.offlinePacksArrayController unbind:@"content"];
}
#pragma mark Services
@@ -121,6 +176,53 @@ NSString * const MGLLastMapDebugMaskDefaultsKey = @"MGLLastMapDebugMask";
[[NSDocumentController sharedDocumentController] openUntitledDocumentAndDisplay:YES error:NULL];
}
+#pragma mark Offline pack management
+
+- (IBAction)showOfflinePacksPanel:(id)sender {
+ [self.offlinePacksPanel makeKeyAndOrderFront:sender];
+
+ for (MGLOfflinePack *pack in self.offlinePacksArrayController.arrangedObjects) {
+ [pack requestProgress];
+ }
+}
+
+- (IBAction)delete:(id)sender {
+ for (MGLOfflinePack *pack in self.offlinePacksArrayController.selectedObjects) {
+ [[MGLOfflineStorage sharedOfflineStorage] removePack:pack withCompletionHandler:^(NSError * _Nullable error) {
+ if (error) {
+ [[NSAlert alertWithError:error] runModal];
+ }
+ }];
+ }
+}
+
+- (IBAction)chooseOfflinePack:(id)sender {
+ for (MGLOfflinePack *pack in self.offlinePacksArrayController.selectedObjects) {
+ switch (pack.state) {
+ case MGLOfflinePackStateComplete:
+ {
+ if ([pack.region isKindOfClass:[MGLTilePyramidOfflineRegion class]]) {
+ MGLTilePyramidOfflineRegion *region = (MGLTilePyramidOfflineRegion *)pack.region;
+ self.pendingVisibleCoordinateBounds = region.bounds;
+ [[NSDocumentController sharedDocumentController] openUntitledDocumentAndDisplay:YES error:NULL];
+ }
+ break;
+ }
+
+ case MGLOfflinePackStateInactive:
+ [pack resume];
+ break;
+
+ case MGLOfflinePackStateActive:
+ [pack suspend];
+ break;
+
+ default:
+ break;
+ }
+ }
+}
+
#pragma mark Help methods
- (IBAction)showShortcuts:(id)sender {
@@ -154,6 +256,12 @@ NSString * const MGLLastMapDebugMaskDefaultsKey = @"MGLLastMapDebugMask";
if (menuItem.action == @selector(showPreferences:)) {
return YES;
}
+ if (menuItem.action == @selector(showOfflinePacksPanel:)) {
+ return YES;
+ }
+ if (menuItem.action == @selector(delete:)) {
+ return self.offlinePacksArrayController.selectedObjects.count;
+ }
return NO;
}
diff --git a/platform/osx/app/MainMenu.xib b/platform/osx/app/MainMenu.xib
index 64ff4e550d..646c4ae40d 100644
--- a/platform/osx/app/MainMenu.xib
+++ b/platform/osx/app/MainMenu.xib
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="10109" systemVersion="15E39d" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
+<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="10116" systemVersion="15E65" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
- <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="10109"/>
+ <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="10116"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@@ -17,6 +17,8 @@
</customObject>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate">
<connections>
+ <outlet property="offlinePacksArrayController" destination="dWe-R6-sRz" id="Ar5-xu-ABm"/>
+ <outlet property="offlinePacksPanel" destination="Jjv-gs-Tx6" id="0vK-rR-3ZX"/>
<outlet property="preferencesWindow" destination="UWc-yQ-qda" id="Ota-aT-Mz2"/>
</connections>
</customObject>
@@ -113,6 +115,12 @@
<action selector="saveDocumentAs:" target="-1" id="mDf-zr-I0C"/>
</connections>
</menuItem>
+ <menuItem title="Save Offline Pack…" id="UXB-sj-C7i">
+ <modifierMask key="keyEquivalentModifierMask"/>
+ <connections>
+ <action selector="addOfflinePack:" target="-1" id="Usu-xO-QEx"/>
+ </connections>
+ </menuItem>
<menuItem title="Revert to Saved" id="KaW-ft-85H">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
@@ -516,6 +524,13 @@
<action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
</connections>
</menuItem>
+ <menuItem isSeparatorItem="YES" id="Uix-g7-fAt"/>
+ <menuItem title="Offline Packs" id="YW3-jR-knj">
+ <modifierMask key="keyEquivalentModifierMask"/>
+ <connections>
+ <action selector="showOfflinePacksPanel:" target="Voe-Tx-rLC" id="kj9-ht-KmF"/>
+ </connections>
+ </menuItem>
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
<modifierMask key="keyEquivalentModifierMask"/>
@@ -551,7 +566,7 @@
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="109" y="131" width="350" height="62"/>
- <rect key="screenRect" x="0.0" y="0.0" width="1680" height="1050"/>
+ <rect key="screenRect" x="0.0" y="0.0" width="1280" height="777"/>
<view key="contentView" id="eA4-n3-qPe">
<rect key="frame" x="0.0" y="0.0" width="350" height="62"/>
<autoresizingMask key="autoresizingMask"/>
@@ -609,8 +624,190 @@
<point key="canvasLocation" x="754" y="210"/>
</window>
<userDefaultsController representsSharedInstance="YES" id="45S-yT-WUN"/>
+ <window title="Offline Packs" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" hidesOnDeactivate="YES" oneShot="NO" releasedWhenClosed="NO" showsToolbarButton="NO" visibleAtLaunch="NO" frameAutosaveName="MBXOfflinePacksPanel" animationBehavior="default" id="Jjv-gs-Tx6" customClass="NSPanel">
+ <windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES" utility="YES"/>
+ <windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
+ <rect key="contentRect" x="830" y="430" width="400" height="300"/>
+ <rect key="screenRect" x="0.0" y="0.0" width="1280" height="777"/>
+ <view key="contentView" id="8ha-hw-zOD">
+ <rect key="frame" x="0.0" y="0.0" width="400" height="300"/>
+ <autoresizingMask key="autoresizingMask"/>
+ <subviews>
+ <scrollView autohidesScrollers="YES" horizontalLineScroll="19" horizontalPageScroll="10" verticalLineScroll="19" verticalPageScroll="10" usesPredominantAxisScrolling="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Q8b-0e-dLv">
+ <rect key="frame" x="-1" y="20" width="402" height="281"/>
+ <clipView key="contentView" id="J9U-Yx-o2S">
+ <rect key="frame" x="1" y="0.0" width="400" height="280"/>
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+ <subviews>
+ <tableView verticalHuggingPriority="750" allowsExpansionToolTips="YES" columnAutoresizingStyle="lastColumnOnly" autosaveColumns="NO" headerView="MAZ-Iq-hBi" id="Ato-Vu-HYT">
+ <rect key="frame" x="0.0" y="0.0" width="400" height="257"/>
+ <autoresizingMask key="autoresizingMask"/>
+ <size key="intercellSpacing" width="3" height="2"/>
+ <color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
+ <color key="gridColor" name="gridColor" catalog="System" colorSpace="catalog"/>
+ <tableColumns>
+ <tableColumn identifier="" editable="NO" width="16" minWidth="10" maxWidth="3.4028234663852886e+38" id="xtw-hQ-8C5" userLabel="State">
+ <tableHeaderCell key="headerCell" lineBreakMode="truncatingTail" borderStyle="border" alignment="left">
+ <font key="font" metaFont="smallSystem"/>
+ <color key="textColor" name="headerTextColor" catalog="System" colorSpace="catalog"/>
+ <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/>
+ </tableHeaderCell>
+ <imageCell key="dataCell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" id="edU-Yw-20f"/>
+ <tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
+ <connections>
+ <binding destination="dWe-R6-sRz" name="value" keyPath="arrangedObjects.stateImage" id="2wd-1J-TZt"/>
+ </connections>
+ </tableColumn>
+ <tableColumn editable="NO" width="116" minWidth="40" maxWidth="1000" id="2hD-LN-h0L">
+ <tableHeaderCell key="headerCell" lineBreakMode="truncatingTail" borderStyle="border" title="Name">
+ <font key="font" metaFont="smallSystem"/>
+ <color key="textColor" name="headerTextColor" catalog="System" colorSpace="catalog"/>
+ <color key="backgroundColor" name="headerColor" catalog="System" colorSpace="catalog"/>
+ </tableHeaderCell>
+ <textFieldCell key="dataCell" lineBreakMode="truncatingTail" selectable="YES" editable="YES" title="Text Cell" id="oys-QZ-34I">
+ <font key="font" metaFont="system"/>
+ <color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
+ <color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
+ </textFieldCell>
+ <tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
+ <connections>
+ <binding destination="dWe-R6-sRz" name="value" keyPath="arrangedObjects.context" id="NtD-s5-ZUq">
+ <dictionary key="options">
+ <string key="NSValueTransformerName">OfflinePackNameValueTransformer</string>
+ </dictionary>
+ </binding>
+ </connections>
+ </tableColumn>
+ <tableColumn editable="NO" width="50" minWidth="40" maxWidth="1000" id="pkI-c7-xoD">
+ <tableHeaderCell key="headerCell" lineBreakMode="truncatingTail" borderStyle="border" title="Downloaded">
+ <font key="font" metaFont="smallSystem"/>
+ <color key="textColor" name="headerTextColor" catalog="System" colorSpace="catalog"/>
+ <color key="backgroundColor" name="headerColor" catalog="System" colorSpace="catalog"/>
+ </tableHeaderCell>
+ <textFieldCell key="dataCell" lineBreakMode="truncatingTail" selectable="YES" editable="YES" title="Text Cell" id="WfC-qb-HsW">
+ <numberFormatter key="formatter" formatterBehavior="default10_4" numberStyle="decimal" usesGroupingSeparator="NO" groupingSize="0" minimumIntegerDigits="0" maximumIntegerDigits="42" id="sNm-Qn-ne6"/>
+ <font key="font" metaFont="system"/>
+ <color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
+ <color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
+ </textFieldCell>
+ <tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
+ <connections>
+ <binding destination="dWe-R6-sRz" name="value" keyPath="arrangedObjects.countOfResourcesCompleted" id="mu6-Jg-GiU"/>
+ </connections>
+ </tableColumn>
+ <tableColumn identifier="" editable="NO" width="50" minWidth="10" maxWidth="3.4028234663852886e+38" id="Rrd-A9-jqc">
+ <tableHeaderCell key="headerCell" lineBreakMode="truncatingTail" borderStyle="border" alignment="left" title="Total">
+ <font key="font" metaFont="smallSystem"/>
+ <color key="textColor" name="headerTextColor" catalog="System" colorSpace="catalog"/>
+ <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/>
+ </tableHeaderCell>
+ <textFieldCell key="dataCell" lineBreakMode="truncatingTail" selectable="YES" editable="YES" alignment="left" title="Text Cell" id="mHy-qJ-rOA">
+ <numberFormatter key="formatter" formatterBehavior="default10_4" numberStyle="decimal" usesGroupingSeparator="NO" groupingSize="0" minimumIntegerDigits="0" maximumIntegerDigits="42" id="kyx-ZP-OBH"/>
+ <font key="font" metaFont="system"/>
+ <color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
+ <color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
+ </textFieldCell>
+ <tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
+ <connections>
+ <binding destination="dWe-R6-sRz" name="value" keyPath="arrangedObjects.countOfResourcesExpected" id="mh2-k0-vvB"/>
+ </connections>
+ </tableColumn>
+ <tableColumn identifier="" editable="NO" width="60" minWidth="10" maxWidth="3.4028234663852886e+38" id="h7m-6l-KaS">
+ <tableHeaderCell key="headerCell" lineBreakMode="truncatingTail" borderStyle="border" alignment="left" title="Size">
+ <font key="font" metaFont="smallSystem"/>
+ <color key="textColor" name="headerTextColor" catalog="System" colorSpace="catalog"/>
+ <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/>
+ </tableHeaderCell>
+ <textFieldCell key="dataCell" lineBreakMode="truncatingTail" selectable="YES" editable="YES" alignment="left" title="Text Cell" id="701-bg-k6L">
+ <byteCountFormatter key="formatter" allowsNonnumericFormatting="NO" id="IXV-J9-sP3"/>
+ <font key="font" metaFont="system"/>
+ <color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
+ <color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
+ </textFieldCell>
+ <tableColumnResizingMask key="resizingMask" resizeWithTable="YES" userResizable="YES"/>
+ <connections>
+ <binding destination="dWe-R6-sRz" name="value" keyPath="arrangedObjects.countOfBytesCompleted" id="Zsa-Na-yFN"/>
+ </connections>
+ </tableColumn>
+ </tableColumns>
+ <connections>
+ <action trigger="doubleAction" selector="chooseOfflinePack:" target="-1" id="pUN-eT-zRT"/>
+ </connections>
+ </tableView>
+ </subviews>
+ <color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
+ </clipView>
+ <scroller key="horizontalScroller" hidden="YES" verticalHuggingPriority="750" horizontal="YES" id="QLr-6P-Ogs">
+ <rect key="frame" x="1" y="7" width="0.0" height="16"/>
+ <autoresizingMask key="autoresizingMask"/>
+ </scroller>
+ <scroller key="verticalScroller" hidden="YES" verticalHuggingPriority="750" horizontal="NO" id="q0K-eE-mzL">
+ <rect key="frame" x="224" y="17" width="15" height="102"/>
+ <autoresizingMask key="autoresizingMask"/>
+ </scroller>
+ <tableHeaderView key="headerView" id="MAZ-Iq-hBi">
+ <rect key="frame" x="0.0" y="0.0" width="400" height="23"/>
+ <autoresizingMask key="autoresizingMask"/>
+ </tableHeaderView>
+ </scrollView>
+ <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="wzf-ce-Spm">
+ <rect key="frame" x="0.0" y="-1" width="21" height="21"/>
+ <constraints>
+ <constraint firstAttribute="width" constant="21" id="5ST-tY-8Ph"/>
+ </constraints>
+ <buttonCell key="cell" type="smallSquare" bezelStyle="smallSquare" image="NSAddTemplate" imagePosition="overlaps" alignment="center" lineBreakMode="truncatingTail" state="on" imageScaling="proportionallyDown" inset="2" id="sew-F7-i5T">
+ <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
+ <font key="font" metaFont="system"/>
+ </buttonCell>
+ <connections>
+ <action selector="addOfflinePack:" target="-1" id="SN0-PM-HoU"/>
+ </connections>
+ </button>
+ <button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="7L7-hr-zId">
+ <rect key="frame" x="20" y="0.0" width="21" height="19"/>
+ <constraints>
+ <constraint firstAttribute="width" constant="21" id="JYb-AF-8gZ"/>
+ </constraints>
+ <buttonCell key="cell" type="smallSquare" bezelStyle="smallSquare" image="NSRemoveTemplate" imagePosition="overlaps" alignment="center" lineBreakMode="truncatingTail" state="on" imageScaling="proportionallyDown" inset="2" id="oTF-3m-6qT">
+ <behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
+ <font key="font" metaFont="system"/>
+ <string key="keyEquivalent" base64-UTF8="YES">
+CA
+</string>
+ </buttonCell>
+ <connections>
+ <action selector="delete:" target="-1" id="EGL-bf-yUD"/>
+ </connections>
+ </button>
+ </subviews>
+ <constraints>
+ <constraint firstItem="7L7-hr-zId" firstAttribute="centerY" secondItem="wzf-ce-Spm" secondAttribute="centerY" id="7TI-6w-bf1"/>
+ <constraint firstAttribute="bottom" secondItem="Q8b-0e-dLv" secondAttribute="bottom" constant="20" symbolic="YES" id="DZa-ly-bhV"/>
+ <constraint firstItem="wzf-ce-Spm" firstAttribute="top" secondItem="Q8b-0e-dLv" secondAttribute="bottom" id="LhK-5z-CQA"/>
+ <constraint firstItem="Q8b-0e-dLv" firstAttribute="leading" secondItem="8ha-hw-zOD" secondAttribute="leading" constant="-1" id="Oyo-ch-rZo"/>
+ <constraint firstAttribute="bottom" secondItem="7L7-hr-zId" secondAttribute="bottom" id="TtY-j1-T5h"/>
+ <constraint firstItem="Q8b-0e-dLv" firstAttribute="top" secondItem="8ha-hw-zOD" secondAttribute="top" constant="-1" id="WDk-Ig-Grr"/>
+ <constraint firstAttribute="trailing" secondItem="Q8b-0e-dLv" secondAttribute="trailing" constant="-1" id="hHf-rd-Wcv"/>
+ <constraint firstItem="7L7-hr-zId" firstAttribute="leading" secondItem="8ha-hw-zOD" secondAttribute="leading" constant="20" symbolic="YES" id="iKJ-ph-ACS"/>
+ <constraint firstAttribute="bottom" secondItem="wzf-ce-Spm" secondAttribute="bottom" constant="-1" id="jFV-Xi-fWr"/>
+ <constraint firstItem="wzf-ce-Spm" firstAttribute="leading" secondItem="8ha-hw-zOD" secondAttribute="leading" id="kJt-oJ-72R"/>
+ </constraints>
+ </view>
+ <point key="canvasLocation" x="720" y="317"/>
+ </window>
+ <arrayController objectClassName="MGLOfflinePack" editable="NO" avoidsEmptySelection="NO" id="dWe-R6-sRz" userLabel="Offline Packs Array Controller">
+ <declaredKeys>
+ <string>context</string>
+ <string>countOfResourcesCompleted</string>
+ <string>countOfResourcesExpected</string>
+ <string>countOfBytesCompleted</string>
+ <string>stateImage</string>
+ </declaredKeys>
+ </arrayController>
</objects>
<resources>
+ <image name="NSAddTemplate" width="11" height="11"/>
<image name="NSFollowLinkFreestandingTemplate" width="14" height="14"/>
+ <image name="NSRemoveTemplate" width="11" height="11"/>
</resources>
</document>
diff --git a/platform/osx/app/MapDocument.m b/platform/osx/app/MapDocument.m
index 7c42bc9802..16d15c4ffd 100644
--- a/platform/osx/app/MapDocument.m
+++ b/platform/osx/app/MapDocument.m
@@ -204,6 +204,10 @@ static const CLLocationCoordinate2D WorldTourDestinations[] = {
appDelegate.pendingZoomLevel = -1;
appDelegate.pendingCamera = nil;
}
+ if (!MGLCoordinateBoundsIsEmpty(appDelegate.pendingVisibleCoordinateBounds)) {
+ self.mapView.visibleCoordinateBounds = appDelegate.pendingVisibleCoordinateBounds;
+ appDelegate.pendingVisibleCoordinateBounds = (MGLCoordinateBounds){ { 0, 0 }, { 0, 0 } };
+ }
if (appDelegate.pendingDebugMask) {
self.mapView.debugMask = appDelegate.pendingDebugMask;
}
@@ -350,6 +354,36 @@ static const CLLocationCoordinate2D WorldTourDestinations[] = {
[self.mapView addAnnotation:line];
}
+#pragma mark Offline packs
+
+- (IBAction)addOfflinePack:(id)sender {
+ NSAlert *namePrompt = [[NSAlert alloc] init];
+ namePrompt.messageText = @"Add offline pack";
+ namePrompt.informativeText = @"Choose a name for the pack:";
+ NSTextField *nameTextField = [[NSTextField alloc] initWithFrame:NSZeroRect];
+ nameTextField.placeholderString = MGLStringFromCoordinateBounds(self.mapView.visibleCoordinateBounds);
+ [nameTextField sizeToFit];
+ NSRect textFieldFrame = nameTextField.frame;
+ textFieldFrame.size.width = 300;
+ nameTextField.frame = textFieldFrame;
+ namePrompt.accessoryView = nameTextField;
+ [namePrompt addButtonWithTitle:@"Add"];
+ [namePrompt addButtonWithTitle:@"Cancel"];
+ if ([namePrompt runModal] != NSAlertFirstButtonReturn) {
+ return;
+ }
+
+ id <MGLOfflineRegion> region = [[MGLTilePyramidOfflineRegion alloc] initWithStyleURL:self.mapView.styleURL bounds:self.mapView.visibleCoordinateBounds fromZoomLevel:self.mapView.zoomLevel toZoomLevel:self.mapView.maximumZoomLevel];
+ NSData *context = [[NSValueTransformer valueTransformerForName:@"OfflinePackNameValueTransformer"] reverseTransformedValue:nameTextField.stringValue];
+ [[MGLOfflineStorage sharedOfflineStorage] addPackForRegion:region withContext:context completionHandler:^(MGLOfflinePack * _Nullable pack, NSError * _Nullable error) {
+ if (error) {
+ [[NSAlert alertWithError:error] runModal];
+ } else {
+ [pack resume];
+ }
+ }];
+}
+
#pragma mark Help methods
- (IBAction)giveFeedback:(id)sender {
@@ -500,6 +534,10 @@ static const CLLocationCoordinate2D WorldTourDestinations[] = {
if (menuItem.action == @selector(drawPolygonAndPolyLineAnnotations:)) {
return !_isShowingPolygonAndPolylineAnnotations;
}
+ if (menuItem.action == @selector(addOfflinePack:)) {
+ NSURL *styleURL = self.mapView.styleURL;
+ return !styleURL.isFileURL;
+ }
if (menuItem.action == @selector(giveFeedback:)) {
return YES;
}
diff --git a/platform/osx/app/OfflinePackNameValueTransformer.h b/platform/osx/app/OfflinePackNameValueTransformer.h
new file mode 100644
index 0000000000..11fe3ff441
--- /dev/null
+++ b/platform/osx/app/OfflinePackNameValueTransformer.h
@@ -0,0 +1,5 @@
+#import <Foundation/Foundation.h>
+
+@interface OfflinePackNameValueTransformer : NSValueTransformer
+
+@end
diff --git a/platform/osx/app/OfflinePackNameValueTransformer.m b/platform/osx/app/OfflinePackNameValueTransformer.m
new file mode 100644
index 0000000000..2825e48ed3
--- /dev/null
+++ b/platform/osx/app/OfflinePackNameValueTransformer.m
@@ -0,0 +1,33 @@
+#import "OfflinePackNameValueTransformer.h"
+
+static NSString * const MBXOfflinePackContextNameKey = @"Name";
+
+@implementation OfflinePackNameValueTransformer
+
++ (Class)transformedValueClass {
+ return [NSString class];
+}
+
++ (BOOL)allowsReverseTransformation {
+ return YES;
+}
+
+- (NSString *)transformedValue:(NSData *)context {
+ NSAssert([context isKindOfClass:[NSData class]], @"Context should be NSData.");
+
+ NSDictionary *userInfo = [NSKeyedUnarchiver unarchiveObjectWithData:context];
+ NSAssert([userInfo isKindOfClass:[NSDictionary class]], @"Context of offline pack isn’t a dictionary.");
+ NSString *name = userInfo[MBXOfflinePackContextNameKey];
+ NSAssert([name isKindOfClass:[NSString class]], @"Name of offline pack isn’t a string.");
+ return name;
+}
+
+- (NSData *)reverseTransformedValue:(NSString *)name {
+ NSAssert([name isKindOfClass:[NSString class]], @"Name should be a string.");
+
+ return [NSKeyedArchiver archivedDataWithRootObject:@{
+ MBXOfflinePackContextNameKey: name,
+ }];
+}
+
+@end
diff --git a/platform/osx/app/mapboxgl-app.gypi b/platform/osx/app/mapboxgl-app.gypi
index 7b39f5d9eb..8b8cc17276 100644
--- a/platform/osx/app/mapboxgl-app.gypi
+++ b/platform/osx/app/mapboxgl-app.gypi
@@ -29,6 +29,8 @@
'./LocationCoordinate2DTransformer.m',
'./MapDocument.h',
'./MapDocument.m',
+ './OfflinePackNameValueTransformer.h',
+ './OfflinePackNameValueTransformer.m',
'./TimeIntervalTransformer.h',
'./TimeIntervalTransformer.m',
'./NSValue+Additions.h',
diff --git a/platform/osx/src/MGLMapView.mm b/platform/osx/src/MGLMapView.mm
index 6f3cd8e329..f8bbb61d05 100644
--- a/platform/osx/src/MGLMapView.mm
+++ b/platform/osx/src/MGLMapView.mm
@@ -4,10 +4,11 @@
#import "MGLOpenGLLayer.h"
#import "MGLStyle.h"
-#import "../../darwin/src/MGLAccountManager_Private.h"
#import "../../darwin/src/MGLGeometry_Private.h"
#import "../../darwin/src/MGLMultiPoint_Private.h"
+#import "../../darwin/src/MGLOfflineStorage_Private.h"
+#import "MGLAccountManager.h"
#import "MGLMapCamera.h"
#import "MGLPolygon.h"
#import "MGLPolyline.h"
@@ -151,7 +152,6 @@ public:
/// Cross-platform map view controller.
mbgl::Map *_mbglMap;
MGLMapViewImpl *_mbglView;
- mbgl::DefaultFileSource *_mbglFileSource;
NSPanGestureRecognizer *_panGestureRecognizer;
NSMagnificationGestureRecognizer *_magnificationGestureRecognizer;
@@ -232,36 +232,25 @@ public:
// Set up cross-platform controllers and resources.
_mbglView = new MGLMapViewImpl(self, [NSScreen mainScreen].backingScaleFactor);
- // Place the cache in a location that can be shared among all the
- // applications that embed the Mapbox OS X SDK.
- NSURL *cacheDirectoryURL = [[NSFileManager defaultManager] URLForDirectory:NSCachesDirectory
- inDomain:NSUserDomainMask
- appropriateForURL:nil
- create:YES
- error:nil];
- cacheDirectoryURL = [cacheDirectoryURL URLByAppendingPathComponent:
- [[NSBundle mgl_frameworkBundle] bundleIdentifier]];
- [[NSFileManager defaultManager] createDirectoryAtURL:cacheDirectoryURL
- withIntermediateDirectories:YES
- attributes:nil
- error:nil];
- NSURL *cacheURL = [cacheDirectoryURL URLByAppendingPathComponent:@"cache.db"];
- NSString *cachePath = cacheURL ? cacheURL.path : @"";
- _mbglFileSource = new mbgl::DefaultFileSource(cachePath.UTF8String, [[[[NSBundle mainBundle] resourceURL] path] UTF8String]);
-
- _mbglMap = new mbgl::Map(*_mbglView, *_mbglFileSource, mbgl::MapMode::Continuous, mbgl::GLContextMode::Unique, mbgl::ConstrainMode::None);
+ // Delete the pre-offline ambient cache at
+ // ~/Library/Caches/com.mapbox.sdk.ios/cache.db.
+ NSURL *cachesDirectoryURL = [[NSFileManager defaultManager] URLForDirectory:NSCachesDirectory
+ inDomain:NSUserDomainMask
+ appropriateForURL:nil
+ create:NO
+ error:nil];
+ cachesDirectoryURL = [cachesDirectoryURL URLByAppendingPathComponent:
+ [NSBundle mgl_frameworkBundle].bundleIdentifier];
+ NSURL *legacyCacheURL = [cachesDirectoryURL URLByAppendingPathComponent:@"cache.db"];
+ [[NSFileManager defaultManager] removeItemAtURL:legacyCacheURL error:NULL];
+
+ mbgl::DefaultFileSource *mbglFileSource = [MGLOfflineStorage sharedOfflineStorage].mbglFileSource;
+ _mbglMap = new mbgl::Map(*_mbglView, *mbglFileSource, mbgl::MapMode::Continuous, mbgl::GLContextMode::Unique, mbgl::ConstrainMode::None);
// Install the OpenGL layer. Interface Builder’s synchronous drawing means
// we can’t display a map, so don’t even bother to have a map layer.
self.layer = _isTargetingInterfaceBuilder ? [CALayer layer] : [MGLOpenGLLayer layer];
- // Observe for changes to the global access token (and find out the current one).
- [[MGLAccountManager sharedManager] addObserver:self
- forKeyPath:@"accessToken"
- options:(NSKeyValueObservingOptionInitial |
- NSKeyValueObservingOptionNew)
- context:NULL];
-
// Notify map object when network reachability status changes.
MGLReachability *reachability = [MGLReachability reachabilityForInternetConnection];
reachability.reachableBlock = ^(MGLReachability *) {
@@ -443,7 +432,6 @@ public:
}
- (void)dealloc {
- [[MGLAccountManager sharedManager] removeObserver:self forKeyPath:@"accessToken"];
[self.window removeObserver:self forKeyPath:@"contentLayoutRect"];
[self.window removeObserver:self forKeyPath:@"titlebarAppearsTransparent"];
@@ -455,25 +443,15 @@ public:
delete _mbglMap;
_mbglMap = nullptr;
}
- if (_mbglFileSource) {
- delete _mbglFileSource;
- _mbglFileSource = nullptr;
- }
if (_mbglView) {
delete _mbglView;
_mbglView = nullptr;
}
}
-- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(__unused void *)context {
- // Synchronize mbgl::Map’s access token with the global one in MGLAccountManager.
- if ([keyPath isEqualToString:@"accessToken"] && object == [MGLAccountManager sharedManager]) {
- NSString *accessToken = change[NSKeyValueChangeNewKey];
- if (![accessToken isKindOfClass:[NSNull class]]) {
- _mbglFileSource->setAccessToken((std::string)accessToken.UTF8String);
- }
- } else if ([keyPath isEqualToString:@"contentLayoutRect"] ||
- [keyPath isEqualToString:@"titlebarAppearsTransparent"]) {
+- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(__unused id)object change:(__unused NSDictionary *)change context:(__unused void *)context {
+ if ([keyPath isEqualToString:@"contentLayoutRect"] ||
+ [keyPath isEqualToString:@"titlebarAppearsTransparent"]) {
[self adjustContentInsets];
}
}
@@ -2182,7 +2160,7 @@ public:
convertedPoint.x,
// mbgl origin is at the top-left corner.
NSHeight(self.bounds) - convertedPoint.y,
- });
+ }).wrapped();
}
- (NSRect)convertCoordinateBounds:(MGLCoordinateBounds)bounds toRectToView:(nullable NSView *)view {
diff --git a/platform/osx/test/MGLOfflinePackTests.m b/platform/osx/test/MGLOfflinePackTests.m
new file mode 100644
index 0000000000..41262d16c7
--- /dev/null
+++ b/platform/osx/test/MGLOfflinePackTests.m
@@ -0,0 +1,40 @@
+#import <Mapbox/Mapbox.h>
+
+#pragma clang diagnostic ignored "-Wgnu-statement-expression"
+#pragma clang diagnostic ignored "-Wgnu-zero-variadic-macro-arguments"
+
+#import <XCTest/XCTest.h>
+
+@interface MGLOfflinePackTests : XCTestCase
+
+@end
+
+@implementation MGLOfflinePackTests
+
+- (void)testInvalidation {
+ MGLOfflinePack *invalidPack = [[MGLOfflinePack alloc] init];
+
+ XCTAssertEqual(invalidPack.state, MGLOfflinePackStateInvalid, @"Offline pack should be invalid when initialized independently of MGLOfflineStorage.");
+
+ XCTAssertThrowsSpecificNamed(invalidPack.region, NSException, @"Invalid offline pack", @"Invalid offline pack should raise an exception when accessing its region.");
+ XCTAssertThrowsSpecificNamed(invalidPack.context, NSException, @"Invalid offline pack", @"Invalid offline pack should raise an exception when accessing its context.");
+ XCTAssertThrowsSpecificNamed([invalidPack resume], NSException, @"Invalid offline pack", @"Invalid offline pack should raise an exception when being resumed.");
+ XCTAssertThrowsSpecificNamed([invalidPack suspend], NSException, @"Invalid offline pack", @"Invalid offline pack should raise an exception when being suspended.");
+}
+
+- (void)testProgressBoxing {
+ MGLOfflinePackProgress progress = {
+ .countOfResourcesCompleted = 1,
+ .countOfResourcesExpected = 2,
+ .countOfBytesCompleted = 7,
+ .maximumResourcesExpected = UINT64_MAX,
+ };
+ MGLOfflinePackProgress roundTrippedProgress = [NSValue valueWithMGLOfflinePackProgress:progress].MGLOfflinePackProgressValue;
+
+ XCTAssertEqual(progress.countOfResourcesCompleted, roundTrippedProgress.countOfResourcesCompleted, @"Completed resources should round-trip.");
+ XCTAssertEqual(progress.countOfResourcesExpected, roundTrippedProgress.countOfResourcesExpected, @"Expected resources should round-trip.");
+ XCTAssertEqual(progress.countOfBytesCompleted, roundTrippedProgress.countOfBytesCompleted, @"Completed bytes should round-trip.");
+ XCTAssertEqual(progress.maximumResourcesExpected, roundTrippedProgress.maximumResourcesExpected, @"Maximum expected resources should round-trip.");
+}
+
+@end
diff --git a/platform/osx/test/MGLOfflineRegionTests.m b/platform/osx/test/MGLOfflineRegionTests.m
new file mode 100644
index 0000000000..63befdf14c
--- /dev/null
+++ b/platform/osx/test/MGLOfflineRegionTests.m
@@ -0,0 +1,35 @@
+#import <Mapbox/Mapbox.h>
+
+#pragma clang diagnostic ignored "-Wgnu-statement-expression"
+#pragma clang diagnostic ignored "-Wgnu-zero-variadic-macro-arguments"
+
+#import <XCTest/XCTest.h>
+
+@interface MGLOfflineRegionTests : XCTestCase
+
+@end
+
+@implementation MGLOfflineRegionTests
+
+- (void)testStyleURLs {
+ MGLCoordinateBounds bounds = MGLCoordinateBoundsMake(kCLLocationCoordinate2DInvalid, kCLLocationCoordinate2DInvalid);
+ MGLTilePyramidOfflineRegion *region = [[MGLTilePyramidOfflineRegion alloc] initWithStyleURL:nil bounds:bounds fromZoomLevel:0 toZoomLevel:DBL_MAX];
+ XCTAssertEqualObjects(region.styleURL, [MGLStyle streetsStyleURL], @"Streets isn’t the default style.");
+
+ NSURL *localURL = [NSURL URLWithString:@"beautiful.style"];
+ XCTAssertThrowsSpecificNamed([[MGLTilePyramidOfflineRegion alloc] initWithStyleURL:localURL bounds:bounds fromZoomLevel:0 toZoomLevel:DBL_MAX], NSException, @"Invalid style URL", @"No exception raised when initializing region with a local file URL as the style URL.");
+}
+
+- (void)testEquality {
+ MGLCoordinateBounds bounds = MGLCoordinateBoundsMake(kCLLocationCoordinate2DInvalid, kCLLocationCoordinate2DInvalid);
+ MGLTilePyramidOfflineRegion *original = [[MGLTilePyramidOfflineRegion alloc] initWithStyleURL:[MGLStyle lightStyleURL] bounds:bounds fromZoomLevel:5 toZoomLevel:10];
+ MGLTilePyramidOfflineRegion *copy = [original copy];
+ XCTAssertEqualObjects(original, copy, @"Tile pyramid region should be equal to its copy.");
+
+ XCTAssertEqualObjects(original.styleURL, copy.styleURL, @"Style URL has changed.");
+ XCTAssert(MGLCoordinateBoundsEqualToCoordinateBounds(original.bounds, copy.bounds), @"Bounds have changed.");
+ XCTAssertEqual(original.minimumZoomLevel, original.minimumZoomLevel, @"Minimum zoom level has changed.");
+ XCTAssertEqual(original.maximumZoomLevel, original.maximumZoomLevel, @"Maximum zoom level has changed.");
+}
+
+@end
diff --git a/platform/osx/test/MGLOfflineStorageTests.m b/platform/osx/test/MGLOfflineStorageTests.m
new file mode 100644
index 0000000000..8ffa1207ce
--- /dev/null
+++ b/platform/osx/test/MGLOfflineStorageTests.m
@@ -0,0 +1,126 @@
+#import <Mapbox/Mapbox.h>
+
+#pragma clang diagnostic ignored "-Wgnu-statement-expression"
+#pragma clang diagnostic ignored "-Wgnu-zero-variadic-macro-arguments"
+
+#import <XCTest/XCTest.h>
+
+@interface MGLOfflineStorageTests : XCTestCase
+
+@end
+
+@implementation MGLOfflineStorageTests
+
+- (void)testSharedObject {
+ XCTAssertEqual([MGLOfflineStorage sharedOfflineStorage], [MGLOfflineStorage sharedOfflineStorage], @"There should only be one shared offline storage object.");
+}
+
+// This test needs to come first so it can test the initial loading of packs.
+- (void)testAAALoadPacks {
+ XCTestExpectation *kvoExpectation = [self keyValueObservingExpectationForObject:[MGLOfflineStorage sharedOfflineStorage] keyPath:@"packs" handler:^BOOL(id _Nonnull observedObject, NSDictionary * _Nonnull change) {
+ NSKeyValueChange changeKind = [change[NSKeyValueChangeKindKey] unsignedIntegerValue];
+ return changeKind = NSKeyValueChangeSetting;
+ }];
+
+ [self waitForExpectationsWithTimeout:1 handler:nil];
+
+ XCTAssertNotNil([MGLOfflineStorage sharedOfflineStorage].packs, @"Shared offline storage object should have a non-nil collection of packs by this point.");
+}
+
+- (void)testAddPack {
+ NSUInteger countOfPacks = [MGLOfflineStorage sharedOfflineStorage].packs.count;
+
+ NSURL *styleURL = [MGLStyle lightStyleURL];
+ /// Somewhere near Grape Grove, Ohio, United States.
+ MGLCoordinateBounds bounds = {
+ { 39.70358155855172, -83.69506472545841 },
+ { 39.703818870225376, -83.69420641857361 },
+ };
+ double zoomLevel = 20;
+ MGLTilePyramidOfflineRegion *region = [[MGLTilePyramidOfflineRegion alloc] initWithStyleURL:styleURL bounds:bounds fromZoomLevel:zoomLevel toZoomLevel:zoomLevel];
+
+ NSString *nameKey = @"Name";
+ NSString *name = @"🍇 Grape Grove";
+
+ NSData *context = [NSKeyedArchiver archivedDataWithRootObject:@{
+ nameKey: name,
+ }];
+
+ __block MGLOfflinePack *pack;
+ [self keyValueObservingExpectationForObject:[MGLOfflineStorage sharedOfflineStorage] keyPath:@"packs" handler:^BOOL(id _Nonnull observedObject, NSDictionary * _Nonnull change) {
+ NSKeyValueChange changeKind = [change[NSKeyValueChangeKindKey] unsignedIntegerValue];
+ NSIndexSet *indices = change[NSKeyValueChangeIndexesKey];
+ return changeKind == NSKeyValueChangeInsertion && indices.count == 1;
+ }];
+ XCTestExpectation *additionCompletionHandlerExpectation = [self expectationWithDescription:@"add pack completion handler"];
+ [[MGLOfflineStorage sharedOfflineStorage] addPackForRegion:region withContext:context completionHandler:^(MGLOfflinePack * _Nullable completionHandlerPack, NSError * _Nullable error) {
+ XCTAssertNotNil(completionHandlerPack, @"Added pack should exist.");
+ XCTAssertEqual(completionHandlerPack.state, MGLOfflinePackStateInactive, @"New pack should initially have inactive state.");
+ pack = completionHandlerPack;
+ [additionCompletionHandlerExpectation fulfill];
+ }];
+ [self waitForExpectationsWithTimeout:1 handler:nil];
+
+ XCTAssertEqual([MGLOfflineStorage sharedOfflineStorage].packs.count, countOfPacks + 1, @"Added pack should have been added to the canonical collection of packs owned by the shared offline storage object. This assertion can fail if this test is run before -testAAALoadPacks.");
+
+ XCTAssertEqual(pack, [MGLOfflineStorage sharedOfflineStorage].packs.lastObject, @"Pack should be appended to end of packs array.");
+
+ XCTAssertEqualObjects(pack.region, region, @"Added pack’s region has changed.");
+
+ NSDictionary *userInfo = [NSKeyedUnarchiver unarchiveObjectWithData:pack.context];
+ XCTAssert([userInfo isKindOfClass:[NSDictionary class]], @"Context of offline pack isn’t a dictionary.");
+ XCTAssert([userInfo[nameKey] isKindOfClass:[NSString class]], @"Name of offline pack isn’t a string.");
+ XCTAssertEqualObjects(userInfo[nameKey], name, @"Name of offline pack has changed.");
+
+ XCTAssertEqual(pack.state, MGLOfflinePackStateInactive, @"New pack should initially have inactive state.");
+
+ [self keyValueObservingExpectationForObject:pack keyPath:@"state" handler:^BOOL(id _Nonnull observedObject, NSDictionary * _Nonnull change) {
+ NSKeyValueChange changeKind = [change[NSKeyValueChangeKindKey] unsignedIntegerValue];
+ MGLOfflinePackState state = [change[NSKeyValueChangeNewKey] integerValue];
+ return changeKind == NSKeyValueChangeSetting && state == MGLOfflinePackStateInactive;
+ }];
+ [self expectationForNotification:MGLOfflinePackProgressChangedNotification object:pack handler:^BOOL(NSNotification * _Nonnull notification) {
+ MGLOfflinePack *notificationPack = notification.object;
+ XCTAssert([notificationPack isKindOfClass:[MGLOfflinePack class]], @"Object of notification should be an MGLOfflinePack.");
+
+ NSDictionary *userInfo = notification.userInfo;
+ XCTAssertNotNil(userInfo, @"Progress change notification should have a userInfo dictionary.");
+
+ NSNumber *stateNumber = userInfo[MGLOfflinePackStateUserInfoKey];
+ XCTAssert([stateNumber isKindOfClass:[NSNumber class]], @"Progress change notification’s state should be an NSNumber.");
+ XCTAssertEqual(stateNumber.integerValue, pack.state, @"State in a progress change notification should match the pack’s state.");
+
+ NSValue *progressValue = userInfo[MGLOfflinePackProgressUserInfoKey];
+ XCTAssert([progressValue isKindOfClass:[NSValue class]], @"Progress change notification’s progress should be an NSValue.");
+ XCTAssertEqualObjects(progressValue, [NSValue valueWithMGLOfflinePackProgress:pack.progress], @"Progress change notification’s progress should match pack’s progress.");
+
+ return notificationPack == pack && pack.state == MGLOfflinePackStateInactive;
+ }];
+ [pack requestProgress];
+ [self waitForExpectationsWithTimeout:1 handler:nil];
+}
+
+- (void)testRemovePack {
+ NSUInteger countOfPacks = [MGLOfflineStorage sharedOfflineStorage].packs.count;
+
+ MGLOfflinePack *pack = [MGLOfflineStorage sharedOfflineStorage].packs.lastObject;
+ XCTAssertNotNil(pack, @"Added pack should still exist.");
+
+ XCTestExpectation *kvoExpectation = [self keyValueObservingExpectationForObject:[MGLOfflineStorage sharedOfflineStorage] keyPath:@"packs" handler:^BOOL(id _Nonnull observedObject, NSDictionary * _Nonnull change) {
+ NSKeyValueChange changeKind = [change[NSKeyValueChangeKindKey] unsignedIntegerValue];
+ NSIndexSet *indices = change[NSKeyValueChangeIndexesKey];
+ return changeKind = NSKeyValueChangeRemoval && indices.count == 1;
+ }];
+ XCTestExpectation *completionHandlerExpectation = [self expectationWithDescription:@"remove pack completion handler"];
+ [[MGLOfflineStorage sharedOfflineStorage] removePack:pack withCompletionHandler:^(NSError * _Nullable error) {
+ XCTAssertEqual(pack.state, MGLOfflinePackStateInvalid, @"Removed pack should be invalid in the completion handler.");
+ [completionHandlerExpectation fulfill];
+ }];
+ [self waitForExpectationsWithTimeout:1 handler:nil];
+
+ XCTAssertEqual(pack.state, MGLOfflinePackStateInvalid, @"Removed pack should have been invalidated synchronously.");
+
+ XCTAssertEqual([MGLOfflineStorage sharedOfflineStorage].packs.count, countOfPacks - 1, @"Removed pack should have been removed from the canonical collection of packs owned by the shared offline storage object. This assertion can fail if this test is run before -testAAALoadPacks or -testAddPack.");
+}
+
+@end
diff --git a/platform/osx/test/osxtest.gypi b/platform/osx/test/osxtest.gypi
index 30bced31c4..6165b6fa88 100644
--- a/platform/osx/test/osxtest.gypi
+++ b/platform/osx/test/osxtest.gypi
@@ -44,6 +44,9 @@
'sources': [
'./MGLGeometryTests.mm',
+ './MGLOfflinePackTests.m',
+ './MGLOfflineRegionTests.m',
+ './MGLOfflineStorageTests.m',
'./MGLStyleTests.mm',
],