Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Downloaded Tile Region does not have tiles for highest requested zoom level #1016

Closed
RodrigoSAlves opened this issue Jan 12, 2022 · 6 comments
Assignees
Labels
bug 🪲 Something is broken!

Comments

@RodrigoSAlves
Copy link

RodrigoSAlves commented Jan 12, 2022

Environment

  • Xcode version: Version 13.2.1 (13C100)
  • iOS version: 15.1.1
  • Devices affected: iPhone 12 mini
  • Maps SDK Version: 10.2.0

Observed behavior and steps to reproduce

After downloading a StylePack and TileRegion for a given square (Polygon with 2.5km side) - using an adaptation of the example provided for downloading tile regions - if the user zooms in to high zoom levels where you would expect to clearly see details in residential areas (example: pools, parked cars, etc...), it is not possible to see these details. (Video displaying the behaviour)

I was able to reproduce this by tweaking the code from the example mentioned previously:

  • use zoom range between 5 and 18;
  • change the TileRegionLoadOptions to receive a Polygon that is a square with 2.5km side, centred in a location in Palm Beach Florida (I guess any location would do).

In the example, the HTTP requests are disabled after the download is finished. For testing purposes, after 10 seconds I re-enabled connection and the MapView instance auto-refreshed itself to show crystal clear imagery.

Expected behavior

Expected to see the terrain details while offline and with a high zoom level, just like we can while online. (see video below)

Notes / preliminary analysis

I've tried to pass in higher zoom, but no luck.
I'm not 100% sure, but I've also tried to use the OfflineRegionManager legacy API and this behaviour does not happen. I was able to clearly see the details of the terrain in high zoom levels.

Heres the tweaked code from the example:

// swiftlint:disable file_length
import Foundation
import UIKit
import MapboxMaps
import Turf

/// Example that shows how to use OfflineManager and TileStore to
/// download regions for offline use.
///
/// By default, users may download up to 250MB of data for offline
/// use without incurring additional charges. This limit is subject
/// to change.
@objc(OfflineManagerExample)
final class OfflineManagerExample: UIViewController, ExampleProtocol {
    // This example uses a Storyboard to setup the following views
    @IBOutlet private var mapViewContainer: UIView!
    @IBOutlet private var logView: UITextView!
    @IBOutlet private var button: UIButton!
    @IBOutlet private var stylePackProgressView: UIProgressView!
    @IBOutlet private var tileRegionProgressView: UIProgressView!
    @IBOutlet private var progressContainer: UIView!

    private var mapView: MapView?
    private var tileStore: TileStore?
    private var logger: OfflineManagerLogWriter!

    // Default MapInitOptions. If you use a custom path for a TileStore, you would
    // need to create a custom MapInitOptions to reference that TileStore.
    private lazy var mapInitOptions: MapInitOptions = {
        MapInitOptions(cameraOptions: CameraOptions(center: palmBeachCoord, zoom: zoom),
                       styleURI: .satelliteStreets)
    }()

    private lazy var offlineManager: OfflineManager = {
        return OfflineManager(resourceOptions: mapInitOptions.resourceOptions)
    }()

    // Regions and style pack downloads
    private var downloads: [Cancelable] = []

    private let palmBeachCoord = CLLocationCoordinate2D(latitude: 26.728087, longitude:  -80.038828)
    private let zoom: CGFloat = 16
    private let tileRegionId = UUID().uuidString
    
    var selectedBounds : BoundingBox?

    private enum State {
        case unknown
        case initial
        case downloading
        case downloaded
        case mapViewDisplayed
        case backOnline
        case finished
    }

    deinit {
        OfflineSwitch.shared.isMapboxStackConnected = true
        removeTileRegionAndStylePack()
        tileStore = nil
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // Initialize a logger that writes into the text view
        logger = OfflineManagerLogWriter(textView: logView)
        state = .initial
    }

    // MARK: - Actions
    
    private func getBoundingBox(for center: CLLocationCoordinate2D) -> BoundingBox? {
        var boundingBox : BoundingBox?
    
        let circle = Turf.Polygon(center: center, radius: 2500 / 2, vertices: 4)
        
        if let circleVerticesCoordinates = circle.coordinates.first {
            boundingBox = Turf.BoundingBox(from: circleVerticesCoordinates)
        }
        
        return boundingBox
    }

    private func downloadTileRegions() {
        guard let tileStore = tileStore else {
            preconditionFailure()
        }

        precondition(downloads.isEmpty)

        let dispatchGroup = DispatchGroup()
        var downloadError = false

        // - - - - - - - -

        // 1. Create style package with loadStylePack() call.
        let stylePackLoadOptions = StylePackLoadOptions(glyphsRasterizationMode: .ideographsRasterizedLocally,
                                                        metadata: ["tag": "my-outdoors-style-pack"])!
        dispatchGroup.enter()
        let stylePackDownload = offlineManager.loadStylePack(for: .satelliteStreets, loadOptions: stylePackLoadOptions) { [weak self] progress in
            // These closures do not get called from the main thread. In this case
            // we're updating the UI, so it's important to dispatch to the main
            // queue.
            DispatchQueue.main.async {
                guard let stylePackProgressView = self?.stylePackProgressView else {
                    return
                }

                //self?.logger?.log(message: "StylePack = \(progress)", category: "Example")
                stylePackProgressView.progress = Float(progress.completedResourceCount) / Float(progress.requiredResourceCount)
            }

        } completion: { [weak self] result in
            DispatchQueue.main.async {
                defer {
                    dispatchGroup.leave()
                }

                switch result {
                case let .success(stylePack):
                    self?.logger?.log(message: "StylePack = \(stylePack)", category: "Example")

                case let .failure(error):
                    self?.logger?.log(message: "stylePack download Error = \(error)", category: "Example", color: .red)
                    downloadError = true
                }
            }
        }

        // - - - - - - - -

        guard let boundingBox = self.getBoundingBox(for: palmBeachCoord) else { return }

        self.logger?.log(message: "Attempting to download the following coords", category: "Example", color: .red)
            
        for coordinate in boundingBox.corners {
            self.logger?.log(message: coordinate.toString(), category: "Example", color: .red)
        }
        
        // 2. Create an offline region with tiles for the outdoors style
        let outdoorsOptions = TilesetDescriptorOptions(styleURI: .satelliteStreets,
                                                       zoomRange: 5...18)
        
        self.logger?.log(message: "Selected zoom range from \(5) to \(18)", category: "Example", color: .red)

        let tileDescriptor = offlineManager.createTilesetDescriptor(for: outdoorsOptions)

        // Load the tile region
        let tileRegionLoadOptions = TileRegionLoadOptions(
            geometry: .polygon(Polygon([boundingBox.corners])),
            descriptors: [tileDescriptor],
            metadata: ["tag": "my-outdoors-tile-region"],
            acceptExpired: true)!

        // Use the the default TileStore to load this region. You can create
        // custom TileStores are are unique for a particular file path, i.e.
        // there is only ever one TileStore per unique path.
        dispatchGroup.enter()
        let tileRegionDownload = tileStore.loadTileRegion(forId: tileRegionId,
                                                          loadOptions: tileRegionLoadOptions) { [weak self] (progress) in
            // These closures do not get called from the main thread. In this case
            // we're updating the UI, so it's important to dispatch to the main
            // queue.
            DispatchQueue.main.async {
                guard let tileRegionProgressView = self?.tileRegionProgressView else {
                    return
                }

                //self?.logger?.log(message: "\(progress)", category: "Example")

                // Update the progress bar
                tileRegionProgressView.progress = Float(progress.completedResourceCount) / Float(progress.requiredResourceCount)
            }
        } completion: { [weak self] result in
            DispatchQueue.main.async {
                defer {
                    dispatchGroup.leave()
                }

                switch result {
                case let .success(tileRegion):
                    self?.logger?.log(message: "tileRegion = \(tileRegion)", category: "Example")

                case let .failure(error):
                    self?.logger?.log(message: "tileRegion download Error = \(error)", category: "Example", color: .red)
                    downloadError = true
                }
            }
        }

        // Wait for both downloads before moving to the next state
        dispatchGroup.notify(queue: .main) {
            self.downloads = []
            self.state = downloadError ? .finished : .downloaded
        }

        downloads = [stylePackDownload, tileRegionDownload]
        state = .downloading
    }

    private func cancelDownloads() {
        // Canceling will trigger `.canceled` errors that will then change state
        downloads.forEach { $0.cancel() }
    }

    private func logDownloadResult<T, Error>(message: String, result: Result<[T], Error>) {
        switch result {
        case let .success(array):
            logger?.log(message: message, category: "Example")
            for element in array {
                logger?.log(message: "\t\(element)", category: "Example")
            }

        case let .failure(error):
            logger?.log(message: "\(message) \(error)", category: "Example", color: .red)
        }
    }

    private func showDownloadedRegions() {
        guard let tileStore = tileStore else {
            preconditionFailure()
        }

        offlineManager.allStylePacks { result in
            self.logDownloadResult(message: "Style packs:", result: result)
        }

        tileStore.allTileRegions { result in
            self.logDownloadResult(message: "Tile regions:", result: result)
        }
        logger?.log(message: "\n", category: "Example")
    }

    // Remove downloaded region and style pack
    private func removeTileRegionAndStylePack() {
        // Clean up after the example. Typically, you'll have custom business
        // logic to decide when to evict tile regions and style packs

        // Remove the tile region with the tile region ID.
        // Note this will not remove the downloaded tile packs, instead, it will
        // just mark the tileset as not a part of a tile region. The tiles still
        // exists in a predictive cache in the TileStore.
        tileStore?.removeTileRegion(forId: tileRegionId)

        // Set the disk quota to zero, so that tile regions are fully evicted
        // when removed. The TileStore is also used when `ResourceOptions.isLoadTilePacksFromNetwork`
        // is `true`, and also by the Navigation SDK.
        // This removes the tiles from the predictive cache.
        tileStore?.setOptionForKey(TileStoreOptions.diskQuota, value: 0)

        // Remove the style pack with the style uri.
        // Note this will not remove the downloaded style pack, instead, it will
        // just mark the resources as not a part of the existing style pack. The
        // resources still exists in the disk cache.
        offlineManager.removeStylePack(for: .satelliteStreets)
    }

    // MARK: - State changes

    @IBAction private func didTapButton(_ button: UIButton) {
        switch state {
        case .unknown:
            state = .initial
        case .initial:
            downloadTileRegions()
        case .downloading:
            // Cancel
            cancelDownloads()
        case .downloaded:
            state = .mapViewDisplayed
        case .mapViewDisplayed:
            showDownloadedRegions()
            state = .finished
        case .backOnline:
            showDownloadedRegions()
            state = .finished
        case .finished:
            removeTileRegionAndStylePack()
            showDownloadedRegions()
            state = .initial
        }
    }

    private var state: State = .unknown {
        didSet {
            logger?.log(message: "Changing state from \(oldValue) -> \(state)", category: "Example", color: .orange)

            switch (oldValue, state) {
            case (_, .initial):
                resetUI()

                let tileStore = TileStore.default
                let accessToken = ResourceOptionsManager.default.resourceOptions.accessToken
                tileStore.setOptionForKey(TileStoreOptions.mapboxAccessToken, value: accessToken)

                self.tileStore = tileStore

                logger?.log(message: "Enabling HTTP stack network connection", category: "Example", color: .orange)
                OfflineSwitch.shared.isMapboxStackConnected = true

            case (.initial, .downloading):
                // Can cancel
                button.setTitle("Cancel Downloads", for: .normal)

            case (.downloading, .downloaded):
                logger?.log(message: "Disabling HTTP stack network connection", category: "Example", color: .orange)
                OfflineSwitch.shared.isMapboxStackConnected = false
                enableShowMapView()

            case (.downloaded, .mapViewDisplayed):
                showMapView()
                
            case (.mapViewDisplayed, .backOnline):
                logger?.log(message: "Enabling HTTP stack network connection", category: "Example", color: .orange)
                OfflineSwitch.shared.isMapboxStackConnected = true

            case (.mapViewDisplayed, .finished),
                 (.downloading, .finished):
                button.setTitle("Reset", for: .normal)

            default:
                fatalError("Invalid transition from \(oldValue) to \(state)")
            }
        }
    }

    // MARK: - UI changes

    private func resetUI() {
        logger?.reset()
        logView.textContainerInset.bottom = view.safeAreaInsets.bottom
        logView.scrollIndicatorInsets.bottom = view.safeAreaInsets.bottom

        progressContainer.isHidden = false
        stylePackProgressView.progress = 0.0
        tileRegionProgressView.progress = 0.0

        button.setTitle("Start Downloads", for: .normal)

        mapView?.removeFromSuperview()
        mapView = nil
    }

    private func enableShowMapView() {
        button.setTitle("Show Map", for: .normal)
    }

    private func showMapView() {
        button.setTitle("Show Downloads", for: .normal)
        progressContainer.isHidden = true

        // It's important that the MapView use the same ResourceOptions as the
        // OfflineManager
        let mapView = MapView(frame: mapViewContainer.bounds, mapInitOptions: mapInitOptions)
        mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        mapViewContainer.addSubview(mapView)

        // Add a point annotation that shows the point geometry that were passed
        // to the tile region API.
        mapView.mapboxMap.onNext(.styleLoaded) { [weak self] _ in
            guard let self = self,
                  let mapView = self.mapView else {
                return
            }

            var pointAnnotation = PointAnnotation(coordinate: self.palmBeachCoord)
            pointAnnotation.image = .init(image: UIImage(named: "custom_marker")!, name: "custom-marker")

            let pointAnnotationManager = mapView.annotations.makePointAnnotationManager()
            pointAnnotationManager.annotations = [pointAnnotation]
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
                guard let self = self else { return }
                self.state = .backOnline
            }
        }

        self.mapView = mapView
    }
}

// MARK: - Convenience classes for tile and style classes

extension TileRegionLoadProgress {
    public override var description: String {
        "TileRegionLoadProgress: \(completedResourceCount) / \(requiredResourceCount)"
    }
}

extension StylePackLoadProgress {
    public override var description: String {
        "StylePackLoadProgress: \(completedResourceCount) / \(requiredResourceCount)"
    }
}

extension TileRegion {
    public override var description: String {
        "TileRegion \(id): \(completedResourceCount) / \(requiredResourceCount)"
    }
}

extension StylePack {
    public override var description: String {
        "StylePack \(styleURI): \(completedResourceCount) / \(requiredResourceCount)"
    }
}

/// Convenience logger to write logs to the text view
final class OfflineManagerLogWriter {
    weak var textView: UITextView?
    var log: NSMutableAttributedString

    init(textView: UITextView) {
        self.log = NSMutableAttributedString()
        self.textView = textView
    }

    func reset() {
        log = NSMutableAttributedString()
        textView?.attributedText = log
    }

    func log(message: String, category: String?, color: UIColor = .black) {
        print("[\(category ?? "")] \(message)")

        DispatchQueue.main.async { [weak self] in
            guard let textView = self?.textView,
                  let log = self?.log else {
                return
            }

            let message = NSMutableAttributedString(string: "\(message)\n", attributes: [NSAttributedString.Key.foregroundColor: color])
            log.append(message)

            textView.attributedText = log
        }
    }
}

extension BoundingBox {
    
    /// The northwesternmost position contained in the bounding box.
    public var northWest: LocationCoordinate2D {
        return LocationCoordinate2D(latitude: northEast.latitude, longitude: southWest.longitude)
    }
    
    /// The southeasternmost position contained in the bounding box.
    public var southEast: LocationCoordinate2D {
        return LocationCoordinate2D(latitude: southWest.latitude, longitude: northEast.longitude)
    }
    
    public var coordinateBounds : CoordinateBounds {
        return CoordinateBounds(southwest: southWest, northeast: northEast)
    }
    
    public var corners : [CLLocationCoordinate2D] {
        return [northWest, northEast, southEast, southWest]
    }
}

extension CLLocationCoordinate2D {
    func toString() -> String {
        let latStr = String(format: "%.6f", latitude)
        let lngStr = String(format: "%.6f", longitude)
        
        return "\(latStr), \(lngStr)"
    }
}

Additional links and references

Video displaying the behaviour

@RodrigoSAlves RodrigoSAlves added the bug 🪲 Something is broken! label Jan 12, 2022
@ZiZasaurus ZiZasaurus self-assigned this Jan 13, 2022
@ZiZasaurus
Copy link
Contributor

@RodrigoSAlves, when I change my center coordinate and geometry to match yours, I am unable to reproduce any issues with the map's display at lower zoom levels on v10.2.0.

I've attached a screenshot of the same geometry for both offline examples below:

OfflineManager Example screenshot:
IMG_7277.PNG

OfflineRegionManager Example screenshot:
IMG_7275.PNG

@RodrigoSAlves
Copy link
Author

@ZiZasaurus Thanks for taking a look.
Could you re-attempt to upload the screenshots?

Also, could you provide some more details on how you tried to reproduce? The problem only appears to occur when the device has no connection.

@ZiZasaurus
Copy link
Contributor

Sure thing, I've added the screenshots below.

OfflineManager Example screenshot:
IMG_7277.PNG

OfflineRegionManager Example screenshot:
IMG_7275.PNG

In terms of reproducing, I used the original example but changed the center point and geometry to the coordinates and bounding box you used in your reproduction. I then ran each example online to download the offline region and then turned on airplane mode to view the downloaded region. Those screenshots are from each example when the device was offline.

@ZiZasaurus
Copy link
Contributor

ZiZasaurus commented Jan 14, 2022

@RodrigoSAlves it looks like my screenshots didn't attach properly again. Please note that the max zoom extent for Tile granularity st the streets level is zoom 16. Additionally, when you pull a tilepack you get all of the predefined zoom levels so, for example, if you say you only want zoom 0 you get 0-5. You aren't able to retrieve z18 because the maximum zoom extent for streets detail is z16.

More information on tile granularity can be found here.

@ZiZasaurus
Copy link
Contributor

@RodrigoSAlves closing this ticket but please feel free to reopen if you have additional questions.

@RodrigoSAlves
Copy link
Author

RodrigoSAlves commented Jan 20, 2022

@ZiZasaurus Sorry for the delay. I was just about to post new info on this when I saw your email... Been a little busy with the refactoring of other features also due to the upgrade.

Thank you for pointing me to the documentation. Unfortunately I had already read that section several times, and never understood that the zoom level was actually capped at 16. I would suggest this to be made more explicit in the documentation.

We have several key features in our app that rely on having great imagery in high level zooms (above 15). Our Android team has also confirmed that they are experiencing the exact same behaviour after upgrading to the new SDK version and converting to use the OfflineManager and TileRegion approach.

In order to simplify the code sample that can be used to reproduce the problem, and try to leave no margin for error in our side, I made a new code sample, using the same example provided here, and making only the following changes:

  • Changed the value for the "tokyoCoord" to CLLocationCoordinate2D(latitude: 26.681069, longitude: -80.037068), a location in Palm Beach that is rich street details such as pools and parked cars.
  • Changed the zoom range passed to TilesetDescriptorOptions to 5...18
  • Changed the styleURI used across the example from "outdoors" to "satelliteStreets"
  • Added code to re-enable connection after 10 seconds, using the OfflineSwitch.shared.isMapboxStackConnected property.
  • Remove verbose logs from the download progress.

After running the new code, the result was the same as before. While the phone is "offline" (OfflineSwitch.shared.isMapboxStackConnected = false), the image quality in zoom 18 is very low and cannot differentiate street level details. After I re-enable the connection (OfflineSwitch.shared.isMapboxStackConnected = true) I can clearly see them. (I'm just using OfflineSwitch.shared.isMapboxStackConnected for simplicity. We originally discovered the problem using the airplane mode to toggle between having connection and not having connection).
Here's a screen recording demonstrating this: screen recording

Also experimented further with the "legacy" OfflineRegionManager and got the desired result. Street level details were possible to see using the downloaded tiles, while not having a connection.
It is important to know that once the tiles have been cached due to visiting the area while online, we clearly see the street details from then onwards. But the main point of our feature is to offer good imagery while offline, and without having "visited" the area before while online.

I guess our question is:

  • Is there any way to use the new OfflineManager and TileStore methods to get the desired result, or do we have to use the legacy OfflineRegionManager?
  • Is there something we're missing?
  • Is this a bug or simply a limitation put in place in this new SDK version?

Thanks in advance.

Code Sample

// swiftlint:disable file_length
import Foundation
import UIKit
import MapboxMaps

/// Example that shows how to use OfflineManager and TileStore to
/// download regions for offline use.
///
/// By default, users may download up to 250MB of data for offline
/// use without incurring additional charges. This limit is subject
/// to change.
@objc(OfflineManagerExample)
final class OfflineManagerExample: UIViewController, ExampleProtocol {
    // This example uses a Storyboard to setup the following views
    @IBOutlet private var mapViewContainer: UIView!
    @IBOutlet private var logView: UITextView!
    @IBOutlet private var button: UIButton!
    @IBOutlet private var stylePackProgressView: UIProgressView!
    @IBOutlet private var tileRegionProgressView: UIProgressView!
    @IBOutlet private var progressContainer: UIView!

    private var mapView: MapView?
    private var tileStore: TileStore?
    private var logger: OfflineManagerLogWriter!

    // Default MapInitOptions. If you use a custom path for a TileStore, you would
    // need to create a custom MapInitOptions to reference that TileStore.
    private lazy var mapInitOptions: MapInitOptions = {
        MapInitOptions(cameraOptions: CameraOptions(center: tokyoCoord, zoom: tokyoZoom),
                       styleURI: .satelliteStreets)
    }()

    private lazy var offlineManager: OfflineManager = {
        return OfflineManager(resourceOptions: mapInitOptions.resourceOptions)
    }()

    // Regions and style pack downloads
    private var downloads: [Cancelable] = []

    private let tokyoCoord = CLLocationCoordinate2D(latitude: 26.681069, longitude: -80.037068)
    private let tokyoZoom: CGFloat = 18
    private let tileRegionId = "myTileRegion"

    private enum State {
        case unknown
        case initial
        case downloading
        case downloaded
        case mapViewDisplayed
        case finished
    }

    deinit {
        OfflineSwitch.shared.isMapboxStackConnected = true
        removeTileRegionAndStylePack()
        tileStore = nil
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // Initialize a logger that writes into the text view
        logger = OfflineManagerLogWriter(textView: logView)
        state = .initial
    }

    // MARK: - Actions

    private func downloadTileRegions() {
        guard let tileStore = tileStore else {
            preconditionFailure()
        }

        precondition(downloads.isEmpty)

        let dispatchGroup = DispatchGroup()
        var downloadError = false

        // - - - - - - - -

        // 1. Create style package with loadStylePack() call.
        let stylePackLoadOptions = StylePackLoadOptions(glyphsRasterizationMode: .ideographsRasterizedLocally,
                                                        metadata: ["tag": "my-outdoors-style-pack"])!

        dispatchGroup.enter()
        let stylePackDownload = offlineManager.loadStylePack(for: .satelliteStreets, loadOptions: stylePackLoadOptions) { [weak self] progress in
            // These closures do not get called from the main thread. In this case
            // we're updating the UI, so it's important to dispatch to the main
            // queue.
            DispatchQueue.main.async {
                guard let stylePackProgressView = self?.stylePackProgressView else {
                    return
                }

                //self?.logger?.log(message: "StylePack = \(progress)", category: "Example")
                stylePackProgressView.progress = Float(progress.completedResourceCount) / Float(progress.requiredResourceCount)
            }

        } completion: { [weak self] result in
            DispatchQueue.main.async {
                defer {
                    dispatchGroup.leave()
                }

                switch result {
                case let .success(stylePack):
                    self?.logger?.log(message: "StylePack = \(stylePack)", category: "Example")

                case let .failure(error):
                    self?.logger?.log(message: "stylePack download Error = \(error)", category: "Example", color: .red)
                    downloadError = true
                }
            }
        }

        // - - - - - - - -

        // 2. Create an offline region with tiles for the outdoors style
        let outdoorsOptions = TilesetDescriptorOptions(styleURI: .satelliteStreets,
                                                       zoomRange: 5...18)

        let outdoorsDescriptor = offlineManager.createTilesetDescriptor(for: outdoorsOptions)

        // Load the tile region
        let tileRegionLoadOptions = TileRegionLoadOptions(
            geometry: .point(Point(tokyoCoord)),
            descriptors: [outdoorsDescriptor],
            metadata: ["tag": "my-outdoors-tile-region"],
            acceptExpired: true)!

        // Use the the default TileStore to load this region. You can create
        // custom TileStores are are unique for a particular file path, i.e.
        // there is only ever one TileStore per unique path.
        dispatchGroup.enter()
        let tileRegionDownload = tileStore.loadTileRegion(forId: tileRegionId,
                                                          loadOptions: tileRegionLoadOptions) { [weak self] (progress) in
            // These closures do not get called from the main thread. In this case
            // we're updating the UI, so it's important to dispatch to the main
            // queue.
            DispatchQueue.main.async {
                guard let tileRegionProgressView = self?.tileRegionProgressView else {
                    return
                }

                //self?.logger?.log(message: "\(progress)", category: "Example")

                // Update the progress bar
                tileRegionProgressView.progress = Float(progress.completedResourceCount) / Float(progress.requiredResourceCount)
            }
        } completion: { [weak self] result in
            DispatchQueue.main.async {
                defer {
                    dispatchGroup.leave()
                }

                switch result {
                case let .success(tileRegion):
                    self?.logger?.log(message: "tileRegion = \(tileRegion)", category: "Example")

                case let .failure(error):
                    self?.logger?.log(message: "tileRegion download Error = \(error)", category: "Example", color: .red)
                    downloadError = true
                }
            }
        }

        // Wait for both downloads before moving to the next state
        dispatchGroup.notify(queue: .main) {
            self.downloads = []
            self.state = downloadError ? .finished : .downloaded
        }

        downloads = [stylePackDownload, tileRegionDownload]
        state = .downloading
    }

    private func cancelDownloads() {
        // Canceling will trigger `.canceled` errors that will then change state
        downloads.forEach { $0.cancel() }
    }

    private func logDownloadResult<T, Error>(message: String, result: Result<[T], Error>) {
        switch result {
        case let .success(array):
            logger?.log(message: message, category: "Example")
            for element in array {
                logger?.log(message: "\t\(element)", category: "Example")
            }

        case let .failure(error):
            logger?.log(message: "\(message) \(error)", category: "Example", color: .red)
        }
    }

    private func showDownloadedRegions() {
        guard let tileStore = tileStore else {
            preconditionFailure()
        }

        offlineManager.allStylePacks { result in
            self.logDownloadResult(message: "Style packs:", result: result)
        }

        tileStore.allTileRegions { result in
            self.logDownloadResult(message: "Tile regions:", result: result)
        }
        logger?.log(message: "\n", category: "Example")
    }

    // Remove downloaded region and style pack
    private func removeTileRegionAndStylePack() {
        // Clean up after the example. Typically, you'll have custom business
        // logic to decide when to evict tile regions and style packs

        // Remove the tile region with the tile region ID.
        // Note this will not remove the downloaded tile packs, instead, it will
        // just mark the tileset as not a part of a tile region. The tiles still
        // exists in a predictive cache in the TileStore.
        tileStore?.removeTileRegion(forId: tileRegionId)

        // Set the disk quota to zero, so that tile regions are fully evicted
        // when removed. The TileStore is also used when `ResourceOptions.isLoadTilePacksFromNetwork`
        // is `true`, and also by the Navigation SDK.
        // This removes the tiles from the predictive cache.
        tileStore?.setOptionForKey(TileStoreOptions.diskQuota, value: 0)

        // Remove the style pack with the style uri.
        // Note this will not remove the downloaded style pack, instead, it will
        // just mark the resources as not a part of the existing style pack. The
        // resources still exists in the disk cache.
        offlineManager.removeStylePack(for: .satelliteStreets)
    }

    // MARK: - State changes

    @IBAction private func didTapButton(_ button: UIButton) {
        switch state {
        case .unknown:
            state = .initial
        case .initial:
            downloadTileRegions()
        case .downloading:
            // Cancel
            cancelDownloads()
        case .downloaded:
            state = .mapViewDisplayed
        case .mapViewDisplayed:
            showDownloadedRegions()
            state = .finished
        case .finished:
            removeTileRegionAndStylePack()
            showDownloadedRegions()
            state = .initial
        }
    }

    private var state: State = .unknown {
        didSet {
            logger?.log(message: "Changing state from \(oldValue) -> \(state)", category: "Example", color: .orange)

            switch (oldValue, state) {
            case (_, .initial):
                resetUI()

                let tileStore = TileStore.default
                let accessToken = ResourceOptionsManager.default.resourceOptions.accessToken
                tileStore.setOptionForKey(TileStoreOptions.mapboxAccessToken, value: accessToken)

                self.tileStore = tileStore

                logger?.log(message: "Enabling HTTP stack network connection", category: "Example", color: .orange)
                OfflineSwitch.shared.isMapboxStackConnected = true

            case (.initial, .downloading):
                // Can cancel
                button.setTitle("Cancel Downloads", for: .normal)

            case (.downloading, .downloaded):
                logger?.log(message: "Disabling HTTP stack network connection", category: "Example", color: .orange)
                OfflineSwitch.shared.isMapboxStackConnected = false
                enableShowMapView()

            case (.downloaded, .mapViewDisplayed):
                showMapView()

            case (.mapViewDisplayed, .finished),
                 (.downloading, .finished):
                button.setTitle("Reset", for: .normal)

            default:
                fatalError("Invalid transition from \(oldValue) to \(state)")
            }
        }
    }

    // MARK: - UI changes

    private func resetUI() {
        logger?.reset()
        logView.textContainerInset.bottom = view.safeAreaInsets.bottom
        logView.scrollIndicatorInsets.bottom = view.safeAreaInsets.bottom

        progressContainer.isHidden = false
        stylePackProgressView.progress = 0.0
        tileRegionProgressView.progress = 0.0

        button.setTitle("Start Downloads", for: .normal)

        mapView?.removeFromSuperview()
        mapView = nil
    }

    private func enableShowMapView() {
        button.setTitle("Show Map", for: .normal)
    }

    private func showMapView() {
        button.setTitle("Show Downloads", for: .normal)
        progressContainer.isHidden = true

        // It's important that the MapView use the same ResourceOptions as the
        // OfflineManager
        let mapView = MapView(frame: mapViewContainer.bounds, mapInitOptions: mapInitOptions)
        mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        mapViewContainer.addSubview(mapView)

        // Add a point annotation that shows the point geometry that were passed
        // to the tile region API.
        mapView.mapboxMap.onNext(.styleLoaded) { [weak self] _ in
            guard let self = self,
                  let mapView = self.mapView else {
                return
            }

            var pointAnnotation = PointAnnotation(coordinate: self.tokyoCoord)
            pointAnnotation.image = .init(image: UIImage(named: "custom_marker")!, name: "custom-marker")

            let pointAnnotationManager = mapView.annotations.makePointAnnotationManager()
            pointAnnotationManager.annotations = [pointAnnotation]
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
                OfflineSwitch.shared.isMapboxStackConnected = true
                self?.logger?.log(message: "Enabling HTTP stack network connection after 10 seconds", category: "Example", color: .orange)
            }
        }

        self.mapView = mapView
    }
}

// MARK: - Convenience classes for tile and style classes

extension TileRegionLoadProgress {
    public override var description: String {
        "TileRegionLoadProgress: \(completedResourceCount) / \(requiredResourceCount)"
    }
}

extension StylePackLoadProgress {
    public override var description: String {
        "StylePackLoadProgress: \(completedResourceCount) / \(requiredResourceCount)"
    }
}

extension TileRegion {
    public override var description: String {
        "TileRegion \(id): \(completedResourceCount) / \(requiredResourceCount)"
    }
}

extension StylePack {
    public override var description: String {
        "StylePack \(styleURI): \(completedResourceCount) / \(requiredResourceCount)"
    }
}

/// Convenience logger to write logs to the text view
final class OfflineManagerLogWriter {
    weak var textView: UITextView?
    var log: NSMutableAttributedString

    init(textView: UITextView) {
        self.log = NSMutableAttributedString()
        self.textView = textView
    }

    func reset() {
        log = NSMutableAttributedString()
        textView?.attributedText = log
    }

    func log(message: String, category: String?, color: UIColor = .black) {
        print("[\(category ?? "")] \(message)")

        DispatchQueue.main.async { [weak self] in
            guard let textView = self?.textView,
                  let log = self?.log else {
                return
            }

            let message = NSMutableAttributedString(string: "\(message)\n", attributes: [NSAttributedString.Key.foregroundColor: color])
            log.append(message)

            textView.attributedText = log
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug 🪲 Something is broken!
Projects
None yet
Development

No branches or pull requests

2 participants