Preventing/Detecting iCloud migration of app data in UserDefaults and KeyChain - ios

When a user gets a new iPhone, iCloud can restore app data from a different device, which copies info from UserDefaults and the Keychain.
This presents problems for my app when a user migrates from iPhone A -> iPhone B, because the app stores a device-specific security key that changes irregularly.
The restored security key may be expired (an old backup).
The user may continue using both iPhone A and iPhone B, causing their stored security keys get out-of-sync with rotations.
This would be easy to fix if I could detect the iCloud data restore, or an upgrade to a new device. This would allow me to reset the persisted device identifier and clear out the persisted old security key.
But I can find no way to do so, because Apple blocks accessing any unique device identifier so you can't tell if the app has moved to a new device. It also gives no callbacks about when an iCloud restore happened. I could check the hardware device model for changes, but sometimes a user replaces a phone with identical hardware when a phone is damaged or lost.
Is there any way to detect migration of an app to a new device and/or prevent cloning of iCloud backups of my app data from one device to another?

You can detect if an app is installed from iCloud backup by saving a file in the .applicationSupportDirectory. That directory is not backed up, so if your app crates a file there and doesn't see it, then that means it is (a) the first time your app has run or (b) the app was restored from backup.
You can use this as a flag to perform any special cleanup when a restore is detected.
And if you need to discern between a first time install and a restore, just save a second flag to UserDefaults. If the flag exists in UserDefaults but the flag file does not exist in .applicationSupportDirectory then you know it was an iCloud restore.
This technique has passed App Store review once as of this writing.
class RestoredAppDetector {
func saveInstallationFlagFile() {
if let applicationSupportDirectory = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) {
var flagFile = applicationSupportDirectory.appendingPathComponent("app_installed.txt", isDirectory: false)
if (!FileManager.default.createFile(atPath: flagFile.path, contents: "true".data(using: .utf8)) ) {
NSLog("Filed to create flag file")
}
var values = URLResourceValues()
values.isExcludedFromBackup = true
do {
try flagFile.setResourceValues(values)
}
catch {
NSLog("Failed to set resource value")
}
}
else {
NSLog("Could not create application support directory.")
}
}
func installationFlagFileExists() -> Bool {
if let applicationSupportDirectory = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) {
let flagFile = applicationSupportDirectory.appendingPathComponent("app_installed.txt", isDirectory: false)
if (FileManager.default.fileExists(atPath: flagFile.path)) {
NSLog("Flag file exists")
return true
}
else {
NSLog("Flag file does not exist")
}
}
else {
NSLog("Could not find application support directory.")
}
return false
}
}

As far as I know and tested, .applicationSupportDirectory folder is definitely backed up with no problem including all the folders/files hierarchy unless you explicitly excluded some specific items from the backup.

Related

Monitor file with some kind of persistent id

My app writes files to the users shared Documents directory. I save the files path but this breaks if the user changes the documents name, moves the document to a new location etc. Is there some kind of persistent file id i can use to keep track of these documents?
i've been poking around
FileManager.default.attributesOfItem(atPath: docUrl.path)
and i find attributes like FileAttributeKey.systemFileNumber but this number seems to change every time i run the app. There has to be some way to keep track of files other than their path
You need to use url bookmarks, like the following (sketch)
if let bookmark = try? URL(fileURLWithPath: docUrl.path)
.bookmarkData(includingResourceValuesForKeys: nil,
relativeTo:nil) {
// store somewhere persistent bookmark to your file
}
...
let bookmark = // read bookmark from persisten storage
var stale = false
if let url = try? URL(resolvingBookmarkData:bookmark, bookmarkDataIsStale: &stale) {
// work with resolved url
} else {
// file disappeared - handle the situation
}

How to delete files in iOS Notification Service Extension?

I have a UNNotificationServiceExtension that downloads videos and images to the Documents directory for use by classes that adopt UNNotificationContentExtension. I want to delete the media files that are no longer being used by any notifications. I am not sure how to go about doing this.
I tried to delete the files in my AppDelegate, but I believe the UNNotificationServiceExtension has its own Documents directory per the "Sharing Data With Your Containing App" section of this document: https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html, so I cannot access these files from my main app. They are in a different container.
I don't want to create an App Group to share the data between the app and the extension just so that I can delete the unused files.
I don't want to delete the unused files in the UNNotificationServiceExtension, because the extension has a limited amount of time in which to complete its work, and if I try to download files and delete other files, it may time out.
I think the best option is to check to see which files are needed by any delivered notifications and to delete the unneeded files in the Notification Service Extension's Documents directory. My concern with this is that the UNNotificationServiceExtension is only given a short period of time during which it must complete all of its work, after which it will time out.
So, my question is, "Is this the right way to clean up unused files from a Notification Service Extension, or is there a better way?"
Thanks to manishsharma93, I was able to implement a good solution. I am now storing the files in a directory shared by the main app and the notification service extension. I first had to set up a shared App Group using the information found here: https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html#//apple_ref/doc/uid/TP40011195-CH4-SW19
Then in my AppDelegate, I added this private function, which I call at the end of the applicationDidFinishLaunching(_:) method:
// I call this at the end of the AppDelegate.applicationDidFinishLaunching(_:) method
private func clearNotificationMedia() {
// Check to see if there are any delivered notifications. If there are, don't delete the media yet,
// because the notifications may be using them. If you wanted to be more fine-grained here,
// you could individually check to see which files the notifications are using, and delete everything else.
UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in
guard notifications.isEmpty else { return }
let fileManager = FileManager.default
guard let mediaCacheUrl = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourGroupHere")?.appendingPathComponent("media_cache", isDirectory: true) else { return }
// Check to see if the directory exists. If it doesn't, we have nothing to do here.
var isDirectory: ObjCBool = false
let directoryExists = FileManager.default.fileExists(atPath: mediaCacheUrl.path, isDirectory: &isDirectory)
guard directoryExists && isDirectory.boolValue else {
print("No media_cache directory to delete.", terminator: "\n")
return
}
// The directory exists and there aren't any notifications using media stored there,
// so go ahead and delete it. Use a lock to make sure that there isn't data corruption,
// since the directory is shared.
let lock = NSLock()
lock.lock()
do {
try FileManager.default.removeItem(at: mediaCacheUrl)
DebugLog("Successfully deleted media_cache directory.")
} catch let error as NSError {
DebugLog("Error: \(error.localizedDescription). Failed to delete media_cache directory.")
}
lock.unlock()
}
}
It works like a charm. Thanks again for pointing me in the right direction manishsharma93.

Detect if app starts after backup restore

Is there a way in swift to detect if the app starts after restoring a backup?. Let's say make a backup on a device and restore it into another device.
I tried a solution which seems to work after some testing. I tried several backup restores and it worked fine for me.
The first step would be to save the vendor identifier at the first run of the app:
if UserDefaults.standard.value(forKey: "vendorID") == nil {
if let vendorID = myDevice.identifierForVendor?.description {
UserDefaults.standard.set(vendorID, forKey: "vendorID")
}
}
The second step is to check if the saved vendor id matches the current device's vendor identifier.
Because the vendor id changes after a backup or restoring a backup to a device, I can do other action if the two vendor IDs don't match:
let savedVendorID = UserDefaults.standard.value(forKey: "vendorID") as! String
if savedVendorID == myDevice.identifierForVendor?.description {
//The app can start normally
} else {
//Do action needed after backup restore
}
The vendor id changes under other conditions as well but for my app those changes doesn't matter.

Turning off iCloud and remove items from the ubiquitous container

I have a UISwitch in my app for users to switch on/off iCloud. I want to achieve the following: When a user turns off iCloud, all contents in the ubiquitous container will be removed and copied to a local directory as backups. However, as soon as the files have been removed from the ubiquitous container, copies on the iCloud server are also removed. This basically clear everything on iCloud.
I have the following questions:
How can files on the ubiquitous container be removed without affecting copies on the iCloud server?
What is the best or standard practice to remove files from the ubiquitous container before disabling iCloud?
Can iCloud be disabled at all after it has been initialised/enabled?
Thank you.
After reading Apple's documents and others suggestions, here is my understanding. I am not 100% sure if they are correct. Comments and corrections are most welcome:
Anything added to or removed from the ubiquity container will be synced with the iCloud server. The app has no control of this.
Once iCloud document storage has been enabled in Settings app by the user, it cannot be disabled by the app. The app's responsibility is to provide UI (assuming a UISwitch) to let user indicate where they want their documents synced with the iCloud for the app.
If the user turns off iCloud by turning off the UISwitch in the app (not in Settings), what the app should do is to stop querying metadata, stop listening to NSMetadataQueryDidUpdateNotification, and stop accessing files in the ubiquity container (as mentioned by crizzis above). If later the user turns iCloud on again, files already in the ubiquity container will be synced with iCloud automatically, and no manual merging should be needed unless unresolved conflicts occur.
Using evictUbiquitousItem(at url:)
See point #1. I'm not sure why you would want to do that, though. Can't you just stop accessing the local copies the second the switch is off?
I don't think it can be disabled programmatically. On a positive side, if you want to avail the users of a possibility to disable iCloud, it's already there. iCloud is supposed to be disabled via the Settings app, and all you really need to do is handle that fact within the app by listening to NSUbiquityIdentityDidChangeNotification
UPDATE
Amin Negm-Awad suggested that evictUbiquitousItem(at url:) forces a reload, and so the local copy is not permanently deleted. However, I've done a little testing just out of curiosity, and haven't found that to be the case. The following test:
func runTest(ubiURL: URL) {
self.query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
self.query.predicate = NSPredicate(format: "%K like '*'", NSMetadataItemFSNameKey)
NotificationCenter.default.addObserver(self, selector: #selector(self.metadataQueryDidUpdate(_:)), name: NSNotification.Name.NSMetadataQueryDidUpdate, object: self.query)
self.query.start()
self.query.enableUpdates()
let fileURL = ubiURL.appendingPathComponent("Documents/file.txt")
FileManager.default.createFile(atPath: fileURL.path, contents: "Hello".data(using: .utf8))
do {
try FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
} catch {
print("startDownloadingUbiquitousItem: \(error.localizedDescription)")
}
}
var updateCount = 0
func metadataQueryDidUpdate(_ notification: Notification) {
print("######################")
print("update #\(updateCount)")
for file in query.results as! [NSMetadataItem] {
guard let fileURL = file.value(forAttribute: NSMetadataItemURLKey) as? URL, let fileStatus = file.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String else {
print("Invalid item!")
return
}
if fileStatus == NSMetadataUbiquitousItemDownloadingStatusCurrent {
print("URL to evict: \(fileURL)")
do {
try FileManager.default.evictUbiquitousItem(at: fileURL)
print("Eviction result: successful")
} catch {
print("evictUbiquitousItem: \(error.localizedDescription)")
}
}
print("File exists at URL: \(FileManager.default.fileExists(atPath: fileURL.path))")
}
updateCount = updateCount + 1
}
Yielded:
ubiURL is file:///private/var/mobile/Library/Mobile%20Documents/iCloud~com~example~blabla3/
######################
update #0
URL to evict: file:///private/var/mobile/Library/Mobile%20Documents/iCloud~com~example~blabla3/Documents/file.txt
evictUbiquitousItem: The file “file.txt” couldn’t be saved in the folder “blabla”.
File exists at URL: true
######################
...
update #3
URL to evict: file:///private/var/mobile/Library/Mobile%20Documents/iCloud~com~example~blabla3/Documents/file.txt
Eviction result: successful
File exists at URL: true
######################
update #4
File exists at URL: false
(no further updates followed)
I don't think the remote file will end up on the device unless startDownloadingUbiquitousItemAtUrl: is called with the evicted file's URL. Not sure if this behavior can be relied upon, though.

How to find Apple App Group shared directory

We are currently developing an iOS10 app, including "Messages Extension".
To share CoreDatas persistant store.sqlite inbetween App and Extension, we are using a shared "Apple App Group" directory, which is working fine.
Now we have to get our hands on the store for debug reasons and are unable to find the directory. The Apps container directories are completely empty, which makes sense. But how to download our database? Do we have to somehow copy it programmatically to a reachable place?
To sum it up:
We already use CoreData which stores model.sqlite in our shared directory.
Everything is up and running.
What we want to archive is to download the database to our computer.
Without a shared directory we can simply download the App container from the device, using Xcode->Devices. But as we do use a shared directory, the .sqlite database is not within the container.
Question:
How can we download the .sqlite database from the device to our computer?
EDIT on 2018-10-12: Updated code for Swift 4.x (Xcode 10). (Older version retained for reference.)
In Swift 4.x:
let sharedContainerURL :URL? = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.etc.etc")
// replace "group.etc.etc" above with your App Group's identifier
NSLog("sharedContainerURL = \(String(describing: sharedContainerURL))")
if let sourceURL :URL = sharedContainerURL?.appendingPathComponent("store.sqlite") {
if let destinationURL :URL = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("copyOfStore.sqlite") {
try! FileManager().copyItem(at: sourceURL, to: destinationURL)
}
}
In older version of Swift (probably Swift 2.x):
let sharedContainerURL :NSURL? = NSFileManager.defaultManager().containerURLForSecurityApplicationGroupIdentifier("group.etc.etc") // replace "group.etc.etc" with your App Group's identifier
NSLog("sharedContainerURL = \(sharedContainerURL)")
if let sourceURL :NSURL = sharedContainerURL?.URLByAppendingPathComponent("store.sqlite")
{
if let destinationURL :NSURL = NSFileManager().URLsForDirectory(.DocumentDirectory, inDomains: .UserDomainMask)[0].URLByAppendingPathComponent("copyOfStore.sqlite")
{
try! NSFileManager().copyItemAtURL(sourceURL, toURL: destinationURL)
}
}
Something like the above will get a file from the app group's shared container to the app's Documents directory. From there, you could use Xcode > Window > Devices to get it to your computer.
You could also use iTunes file sharing to retrieve the file from the app's Documents directory after setting UIFileSharingEnabled to YES in the Info.plist file, but bear in mind that this will expose the directory's contents to the user as well. Should be okay for development/debugging purposes, though.

Resources