I am using MapBox to download an offline map. So that my user has access to a specific area when they go travelling.
Using the MapBox Offline documentation, it appears that the MapBox Map always tries to download (re-download) whenever there is a connection.
How do I set up my MapBox so that it performs a check in storage to see if the map has been downloaded?
func startOfflinePackDownload() {
let region = MGLTilePyramidOfflineRegion(styleURL: mapView.styleURL, bounds: mapView.visibleCoordinateBounds, fromZoomLevel: mapView.zoomLevel, toZoomLevel: 13)
let userInfo = ["name": "My Offline Pack"]
let context = NSKeyedArchiver.archivedData(withRootObject: userInfo)
MGLOfflineStorage.shared().addPack(for: region, withContext: context) { (pack, error) in
guard error == nil else {
// The pack couldn’t be created for some reason.
print("Error: \(error?.localizedDescription ?? "unknown error")")
return
}
// Start downloading.
pack!.resume()
}
}
I found the below code to check to see if the download already exists... So this would go at the start of my 'startOfflinePackDownload()' function above.
However, the newer version of MapBox doesn't recognise the code. Is someone able to help me on this please?
MGLOfflineStorage.sharedOfflineStorage().getPacksWithCompletionHandler { (packs, error) in guard error == nil else {
return
}
for pack in packs {
let userInfo = NSKeyedUnarchiver.unarchiveObjectWithData(pack.context) as! [String: String]
if userInfo["name"] == "My Offline Pack" {
// allready downloaded
return
}
}
See the mapbox Doc , MGLOfflinePackStateUnknown = 0 means the the tiles is already downloaded, so we can check the Offline pack state likewise :
Code is in Objective c , You can convert to swift
-(void)mapViewDidFinishLoadingMap:(MGLMapView *)mapView {
NSArray *arrTiles = MGLOfflineStorage.sharedOfflineStorage.packs;
if (arrTiles.count==0) {
[self startOfflinePackDownload];
}
for (MGLOfflinePack *downloadPack in arrTiles) {
NSLog(#"title: %#",downloadPack.region.description );
switch (downloadPack.state) {
case MGLOfflinePackStateUnknown:
[downloadPack requestProgress];
break;
case MGLOfflinePackStateComplete:
break;
case MGLOfflinePackStateInactive:
[downloadPack resume];
break;
case MGLOfflinePackStateActive:
[self startOfflinePackDownload];
break;
case MGLOfflinePackStateInvalid:
// NSAssert(NO, #"Invalid offline pack at index path %#", indexPath);
break;
}
}
}
You should use MGLOfflineStorage.shared().packs, note that use this method only after map is fully loaded. Implement MGLMapViewDelegate method:
func mapViewDidFinishLoadingMap(_ mapView: MGLMapView) {
print(MGLOfflineStorage.shared().packs)
}
This code snippet will print all your packs, that currently stored on device. Don't do that in viewDidLoad or viewWillAppear methods, MGLOfflineStorage.shared().packs will return nil.
After you receive your packs you can iterate through them and choose that pack what your need to resume downloading or deleting it from offline storage
UPDATE
Save somewhere in code your content pack name of your downloading region and Bool variable to determine if your pack is already downloaded
let packageName = "YourPackageName"
var isPackageNameAlreadyDownloaded = false
Func below checks if packageName is already downloaded:
func downloadPackage() {
if let packs = MGLOfflineStorage.shared().packs {
if packs.count > 0 {
// Filter all packs that only have name
let filteredPacks = packs.filter({
guard let context = NSKeyedUnarchiver.unarchiveObject(with: $0.context) as? [String:String] else {
print("Error retrieving offline pack context")
return false
}
let packTitle = context["name"]!
return packTitle.contains("(Data)") ? false : true
})
// Check if filtered packs contains your downloaded region
for pack in filteredPacks {
var packInfo = [String:String]()
guard let context = NSKeyedUnarchiver.unarchiveObject(with: pack.context) as? [String:String] else {
print("Error retrieving offline pack context")
return
}
// Recieving packageName
let packTitle = context["name"]!
if packTitle == packageName {
// Simply prints how download progress
print("Expected: \(pack.progress.countOfResourcesExpected); Completed: \(pack.progress.countOfBytesCompleted)")
print("Tile bytes completed: \(pack.progress.countOfTileBytesCompleted); Tiles Completed: \(pack.progress.countOfTilesCompleted)")
// If package isn't fully downloaded resume progress. If it downloaded - it'll check and won't redownload it
pack.resume()
isPackageNameAlreadyDownloaded = true
break
} else {
// This is another region
}
}
}
}
// If region is downloaded - return
if isPackageNameAlreadyDownloaded {
return
}
// if not - create region, map style url (which you recieve from MapBox Styler
let region = MGLTilePyramidOfflineRegion(styleURL: URL(string: YourMapStyleUrl)!, bounds: YourBoundaries, fromZoomLevel: 12, toZoomLevel: 16.5)
// Save packageName in Library and archive in package context.
let userInfo = ["name": packageName]
let context = NSKeyedArchiver.archivedData(withRootObject: userInfo)
// Create and register an offline pack with the shared offline storage object.
MGLOfflineStorage.shared().addPack(for: region, withContext: context) { (pack, error) in
guard error == nil else {
// The pack couldn’t be created for some reason.
print("Error: \(error?.localizedDescription ?? "unknown error")")
return
}
// Start downloading.
pack!.resume()
print(MGLOfflineStorage.shared().packs)
// Shows the download progress in logs
print(pack!.progress)
}
}
Related
Overview
I have 2 cycling workouts (recorded using the Workouts app on an Apple Watch SE) that I'm trying to retrieve the location data (GPX samples) from. They were part of the same continuous ride that I recorded using 2 separate workouts due to the original workout being unable to unpause midway through. Ultimately, I'd like to obtain all location samples and merge them into a single file.
Current State
The health app on my iPhone (SE, 2020) correctly shows 2 workouts on the day I recorded them (July 16th), and also contains 4 workout route objects for the 2 workouts: 1 that makes up the entirety of the first workout, and 3 that combine to make up the second workout.
Screenshot showing 2 workouts
Screenshot showing 4 workout routes
When I export the raw health data however, only 3 workout routes show up for July 16th. And they're all for the second workout. No GPX route file is exported for the first workout.
Attempt to Solve
In an attempt to try and access the raw data from HealthKit directly, I found an app on Github, built it, and loaded it onto my iPhone to see what I could get. The app retrieves a list of workouts, and exports a GPX file with all location samples when a workout is selected. This works great with the second of my cycling workouts, but crashes when trying to export the data from the first workout.
Here's the function that actually queries the workout for route information:
public func route(for workout: HKWorkout, completion: #escaping (([CLLocation]?, Error?) -> Swift.Void)) {
let routeType = HKSeriesType.workoutRoute();
let p = HKQuery.predicateForObjects(from: workout)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let q = HKSampleQuery(sampleType: routeType, predicate: p, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor]) {
(query, samples, error) in
if let err = error {
print(err)
return
}
guard let routeSamples: [HKWorkoutRoute] = samples as? [HKWorkoutRoute] else { print("No route samples"); return }
if (routeSamples.count == 0){
completion([CLLocation](), nil)
return;
}
var sampleCounter = 0
var routeLocations:[CLLocation] = []
for routeSample: HKWorkoutRoute in routeSamples {
let locationQuery: HKWorkoutRouteQuery = HKWorkoutRouteQuery(route: routeSample) { _, locationResults, done, error in
guard locationResults != nil else {
print("Error occured while querying for locations: \(error?.localizedDescription ?? "")")
DispatchQueue.main.async {
completion(nil, error)
}
return
}
if done {
sampleCounter += 1
if sampleCounter != routeSamples.count {
if let locations = locationResults {
routeLocations.append(contentsOf: locations)
}
} else {
if let locations = locationResults {
routeLocations.append(contentsOf: locations)
let sortedLocations = routeLocations.sorted(by: {$0.timestamp < $1.timestamp})
DispatchQueue.main.async {
completion(sortedLocations, error)
}
}
}
} else {
if let locations = locationResults {
routeLocations.append(contentsOf: locations)
}
}
}
self.healthStore.execute(locationQuery)
}
}
healthStore.execute(q)
}
After further debugging, it appears that the HKSampleQuery call is returning 0 samples when querying the first cycling workout. It returns 3 samples (each of the workout route objects) when querying the second cycling workout.
So it's as if the HealthKit API can't see the 4th workout route, even though it clearly exists as evidenced by the screenshot from the health app.
Question
What's going on here? Why does the second workout return data just fine but the first doesn't?
Is it possible to query the health database directly to obtain this data? I have a local encrypted iPhone backup with a known passcode I can access as well.
This cycling ride was pretty important to me, so I'm trying all I can to retrieve the location data. Thanks in advance!
Edit: Adding main code where the route() function gets called.
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
print(indexPath);
guard let workouts = self.workouts else {
return;
}
if (indexPath.row >= workouts.count){
return;
}
print(indexPath.row)
let workout = workouts[indexPath.row];
let workout_name: String = {
switch workout.workoutActivityType {
case .cycling: return "Cycle"
case .running: return "Run"
case .walking: return "Walk"
default: return "Workout"
}
}()
let workout_title = "\(workout_name) - \(self.dateFormatter.string(from: workout.startDate))"
let file_name = "\(self.filenameDateFormatter.string(from: workout.startDate)) - \(workout_name)"
let targetURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(file_name)
.appendingPathExtension("gpx")
let file: FileHandle
do {
let manager = FileManager.default;
if manager.fileExists(atPath: targetURL.path){
try manager.removeItem(atPath: targetURL.path)
}
print(manager.createFile(atPath: targetURL.path, contents: Data()))
file = try FileHandle(forWritingTo: targetURL);
} catch let err {
print(err)
return
}
workoutStore.heartRate(for: workouts[indexPath.row]){
(rates, error) in
guard let keyedRates = rates, error == nil else {
print(error as Any);
return
}
let iso_formatter = ISO8601DateFormatter()
var current_heart_rate_index = 0;
var current_hr: Double = -1;
let bpm_unit = HKUnit(from: "count/min")
var hr_string = "";
file.write(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><gpx version=\"1.1\" creator=\"Apple Workouts (via pilif's hack of the week)\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://www.topografix.com/GPX/1/1\" xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd\" xmlns:gpxtpx=\"http://www.garmin.com/xmlschemas/TrackPointExtension/v1\"><trk><name><![CDATA[\(workout_title)]]></name><time>\(iso_formatter.string(from: workout.startDate))</time><trkseg>"
.data(using: .utf8)!
)
self.workoutStore.route(for: workouts[indexPath.row]){
(maybe_locations, error) in
guard let locations = maybe_locations, error == nil else {
print(error as Any);
file.closeFile()
return
}
for location in locations {
while (current_heart_rate_index < keyedRates.count) && (location.timestamp > keyedRates[current_heart_rate_index].startDate) {
current_hr = keyedRates[current_heart_rate_index].quantity.doubleValue(for: bpm_unit)
current_heart_rate_index += 1;
hr_string = "<extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>\(current_hr)</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>"
}
file.write(
"<trkpt lat=\"\(location.coordinate.latitude)\" lon=\"\(location.coordinate.longitude)\"><ele>\(location.altitude.magnitude)</ele><time>\(iso_formatter.string(from: location.timestamp))</time>\(hr_string)</trkpt>"
.data(using: .utf8)!
)
}
file.write("</trkseg></trk></gpx>".data(using: .utf8)!)
file.closeFile()
let activityViewController = UIActivityViewController( activityItems: [targetURL],
applicationActivities: nil)
if let popoverPresentationController = activityViewController.popoverPresentationController {
popoverPresentationController.barButtonItem = nil
}
self.present(activityViewController, animated: true, completion: nil)
}
}
}
I'm trying to save player game data using GKSavedGame from GameKit, but it doesn't work, and I don't get any error. (Is the data not saving? Is the data not loading back?)
One of the problem is that GKSavedGame is not well documented (there are no examples like we can find for other things), so we don't really know how to implement it correctly (e.g. what are the best practices?)
I'm using a simple struct GameData: Codable to store the game data, that I encode using JSONEncoder/JSONDecoder. Here is my GameService class with the part that doesn't work:
class GameService {
// Shared instance
static let shared = GameService()
// Properties
private(set) var isGameLoaded = false
private(set) var gameData: GameData?
// Methods
func loadSavedGame(completionHandler: #escaping () -> Void) {
// Check game is not loaded yet
if isGameLoaded {
completionHandler()
return
}
// Get player
let localPlayer = GKLocalPlayer.local
if localPlayer.isAuthenticated {
localPlayer.fetchSavedGames { games, error in
// Iterate saved games
var game: GKSavedGame?
for currentGame in games ?? [] {
if game == nil || game?.modificationDate ?? Date() < currentGame.modificationDate ?? Date() {
game = currentGame
}
}
// If one found, load its data
if let game = game {
game.loadData { data, error in
if let data = data {
self.gameData = try? JSONDecoder().decode(GameData.self, from: data)
}
self.isGameLoaded = true
self.initGameData()
completionHandler()
}
} else {
self.isGameLoaded = true
self.initGameData()
completionHandler()
}
}
}
}
func saveGame() {
// Get player
let localPlayer = GKLocalPlayer.local
if localPlayer.isAuthenticated, let gameData = gameData, let data = try? JSONEncoder().encode(gameData) {
// Save its game data
localPlayer.saveGameData(data, withName: "data") { savedGame, error in
if let error = error {
print(error.localizedDescription)
}
}
}
}
func initGameData() {
// If game data is undefined, define it
if gameData == nil {
gameData = GameData()
}
}
func gameEnded(level: Level, score: Int64) {
// Here I edit my `gameData` object before saving the changes
// I known that this method is called because the data is up to date in the UI
// ...
saveGame()
}
}
Also, GameCenter is enabled in the app capabilities (and workout for other things like leaderboards)
After restarting the app, and calling loadSavedGame, the gameData property is not restored to its previous state.
What is wrong with this code?
Note: I tested it on both Simulator and on my own iPhone (real daily device where GameCenter and iCloud are working with other apps) and it never saves anything.
After enabling iCloud in Capabilities, iCloud Documents, and creating a container for the app, it now works. There is nothing in the documentation about this.
I am using Firebase as my backend and I am trying to increase a the number that is currently being held in the database by 5. However, when it is called, the database adds 5 over and over again, so the score goes from 5 to 10 to 15... this is repeated until the app crashes.
Why is this happening?
func changeUserRewardsScore() {
let db = Firestore.firestore()
db.collection("\(Auth.auth().currentUser?.uid ?? "default")").document("score")
.addSnapshotListener { (querySnapshot, error) in
if let e = error {
print("There was an issue retrieving data from Firestore. \(e)")
}
else {
if let data = querySnapshot?.data() {
let myArray = Array(data.values)
let userScore = "\(myArray[0])"
print("userScore = \(userScore)")
self.writeUserScore(score: userScore)
}
}
}
}
func writeUserScore(score: String) {
var myScore = 0
if localData.rewards.freeCookieInCart == true && score == "50" {
myScore = 0
}
else {
myScore = Int(score)!+5
}
let db = Firestore.firestore()
db.collection("\(Auth.auth().currentUser?.uid ?? "default")").document("score").setData(["score":myScore]) {
(error) in
if let e = error {
print("There was an issue saving data to firestore, \(e)")
} else {
print("Successfully saved data.")
DispatchQueue.main.async {
}
}
}
}
Your document listener, when triggered, writes back to the same document that triggered it, so it triggers again with the result of that change. Which starts the whole cycle over again.
It's not clear to me what you expect to happen instead, but if you just want to get the value of the document once, then update it, you should use get() instead of onSnapshot() as illustrated in the documentation. Either that, or set up some state in your object that indicates to your listener when it shouldn't update the document again.
With the help of a few tutorials I coded my first iCloud App. It is working well in Xcode simulator and on my iPhone and iPad. But as soon as I upload it for TestFlight testing it isn't working anymore.
Here is the whole code for getting and uploading the data. It is a simple one-ViewController Shopping list App which has two arrays: listItems for the current shopping list and shopItems for all items which are added so far. These arrays are stored as string lists in the iCloud recordZone All data are stored locally on the device and in the cloud.
The App is checking the connectivity, the iCloud availability and the fact, if the shopping list was edited while being offline, before it gets the data from iCloud.
// Init all values
var listItems = [String]()
var shopItems = [String]()
var cloudCheck = true
var onlineCheck = true
// Init the user defaults
let defaults = UserDefaults.standard
let privateDatabase = CKContainer.default().privateCloudDatabase
let recordZone = CKRecordZone(zoneName: "ShopListZone")
let predicate = NSPredicate(value: true)
var editedRecord: CKRecord!
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let indexPath = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: indexPath, animated: true)
}
if (reachability?.isReachableViaWiFi)! || (reachability?.isReachableViaWWAN)! {
if isICloudContainerAvailable() && defaults.bool(forKey: "changed") == false {
getCloudData()
}
else if isICloudContainerAvailable() && defaults.bool(forKey: "changed") {
loadOffline()
}
else {
cloudCheck = false
}
} else {
onlineCheck = false
loadOffline()
}
}
// Get the record from iCloud
func getCloudData() {
// Connect to iCloud and fetch the data
let query = CKQuery(recordType: "ShopListData", predicate: predicate)
let operation = CKQueryOperation(query: query)
var myItems = [String]()
var allItems = [String]()
operation.recordFetchedBlock = { record in
myItems = record["ListItems"] as! [String]
allItems = record["ShopItems"] as! [String]
}
operation.queryCompletionBlock = { [unowned self] (cursor, error) in
DispatchQueue.main.async {
if error == nil {
self.listItems = myItems
self.shopItems = allItems
self.tableView.reloadData()
} else {
self.cloudCheck = false
print("iCloud load error: \(String(describing: error?.localizedDescription))")
}
}
}
privateDatabase.add(operation)
cloudCheck = true
}
// Upload and save the record to iCloud
#IBAction func uploadShopListData(_ sender: UIButton) {
// Save the shop list in the user defaults
defaults.set(listItems, forKey: "myItems")
// Set bool if saving while offline
if (reachability?.isReachableViaWiFi)! == false && (reachability?.isReachableViaWWAN)! == false {
defaults.set(true, forKey: "changed")
}
// Save the record
if cloudCheck && onlineCheck {
defaults.set(false, forKey: "changed")
saveRecord()
// Show a short message if records were saved successfully
self.myAlertView(title: "iCloud online", message: NSLocalizedString("Shop list was saved in iCloud.", comment: ""))
} else {
// Show a short message if iCloud isn't available
myAlertView(title: "iCloud offline", message: NSLocalizedString("Shop list was saved on iPhone.", comment: ""))
defaults.set(true, forKey: "changed")
}
}
// Save the shop lists
func saveRecord() {
// Connect to iCloud and start operation
let query = CKQuery(recordType: "ShopListData", predicate: predicate)
privateDatabase.perform(query, inZoneWith: recordZone.zoneID) {
allRecords, error in
if error != nil {
// The query returned an error
OperationQueue.main.addOperation {
print("iCloud save error: \(String(describing: error?.localizedDescription))")
// If there is now record yet, create a new one
self.createRecord()
}
} else {
// The query returned the records
if (allRecords?.count)! > 0 {
let newLists = allRecords?.first
newLists?["ListItems"] = self.listItems as CKRecordValue
newLists?["ShopItems"] = self.shopItems as CKRecordValue
self.privateDatabase.save(newLists!, completionHandler: { returnRecord, error in
if error != nil {
// Print an error message
OperationQueue.main.addOperation {
print("iCloud save error: \(String(describing: error?.localizedDescription))")
}
} else {
// Print a success message
OperationQueue.main.addOperation {
print("Shop list was saved successfully")
}
}
})
}
}
}
}
// Create a new record
func createRecord() {
let myRecord = CKRecord(recordType: "ShopListData", zoneID: (self.recordZone.zoneID))
let operation = CKModifyRecordsOperation(recordsToSave: [myRecord], recordIDsToDelete: nil)
myRecord.setObject(self.listItems as CKRecordValue?, forKey: "ListItems")
myRecord.setObject(self.shopItems as CKRecordValue?, forKey: "ShopItems")
operation.modifyRecordsCompletionBlock = { records, recordIDs, error in
if let error = error {
print("iCloud create error: \(String(describing: error.localizedDescription))")
} else {
DispatchQueue.main.async {
print("Records are saved successfully")
}
self.editedRecord = myRecord
}
}
self.privateDatabase.add(operation)
// Show a short message if icloud save was successfull
self.myAlertView(title: "iCloud online", message: NSLocalizedString("Shop list was saved in iCloud.", comment: ""))
}
Any idea, what did I wrong? I read in another post that I should change the iCloud dashboard from development to production, but others say that this should be done only when the App is already on the way to the App store ..
When you upload your app to TestFlight, it's done using live configuration and app will try to connect to production container.
You have two options to test on device:
1) deploy your iCloud schema to production. As you have nothing released yet, it will not break anything.
2) after you archive your project, export it for 'Development deployment'. You will be asked what iCloud Container Environment should be used. You can select 'Development' and install app locally using Apple Configurator
It was not clear to me that setting iCloud Dashboard to production means, that the record type is used, but the record zone I created during development not.
So I have to create the custom record zone first in the createRecord() method (see above). That's it.
I'm using the SwiftyDropbox library for an iOS project, getting a list of folders recursively, and checking to see if files are photos or videos.
client.files.getMetadata(path: fileURL, includeMediaInfo: true).response { response, error in
if let file = response as? Files.FileMetadata {
if file.mediaInfo != nil {
// ??? how to get file.mediaInfo.metadata
// specifically, I need the mediaMetadata
}
}
}
I can see file.mediaInfo (which, if it exists, means that metadata exists, but the documentation doesn't show how to get the actual metadata itself (specifically, dimensions for photos or durations for video).
I can get this from the description of file.mediaInfo (and parse the String that is returned from that), but that's hacky and not future-safe. Is there another way to get this data?
This is the class I want to get data from (in Files.swift):
public class MediaMetadata: CustomStringConvertible {
/// Dimension of the photo/video.
public let dimensions : Files.Dimensions?
/// The GPS coordinate of the photo/video.
public let location : Files.GpsCoordinates?
/// The timestamp when the photo/video is taken.
public let timeTaken : NSDate?
public init(dimensions: Files.Dimensions? = nil, location: Files.GpsCoordinates? = nil, timeTaken: NSDate? = nil) {
self.dimensions = dimensions
self.location = location
self.timeTaken = timeTaken
}
public var description : String {
return "\(prepareJSONForSerialization(MediaMetadataSerializer().serialize(self)))"
}
}
Here's a sample:
Dropbox.authorizedClient!.files.getMetadata(path: "/test.jpg", includeMediaInfo: true).response { response, error in
if let result = response as? Files.FileMetadata {
print(result.name)
if result.mediaInfo != nil {
switch result.mediaInfo! as Files.MediaInfo {
case .Pending:
print("Media info is pending...")
case .Metadata(let mediaMetadata):
print(mediaMetadata.dimensions)
print(mediaMetadata.location)
print(mediaMetadata.timeTaken)
}
}
} else {
print(error!)
}
}