Error unarchiving NSAttributedString on iOS13 with NSKeyedUnarchiver when NSTextAttachment is used - ios

I archive an NSAttributedString, which contains an image with NSTextAttachment on iOS14 and noticed, that unarchiving it on iOS13 fail. On iOS14 the unarchive is successful.
The error logged is this:
Error decoding string object. error=Error Domain=NSCocoaErrorDomain Code=4864
"value for key 'NS.objects' was of unexpected class 'NSTextAttachment'. Allowed classes are '{(
NSGlyphInfo,
UIColor,
NSURL,
UIFont,
NSParagraphStyle,
NSString,
NSAttributedString,
NSArray,
NSNumber,
NSDictionary
)}'." UserInfo={NSDebugDescription=value for key 'NS.objects' was of unexpected class 'NSTextAttachment'. Allowed classes are '{(
NSGlyphInfo,
UIColor,
NSURL,
UIFont,
NSParagraphStyle,
NSString,
NSAttributedString,
NSArray,
NSNumber,
NSDictionary
)}'.}
Is there an option to make that work, or am I doomed here?
This is how the image is inserted into an NSMutableAttributedString using NSTextAttachment:
// Add an image:
NSTextAttachment *textAttachment = [[NSTextAttachment alloc] init];
textAttachment.image = image;
NSMutableAttributedString *strWithImage = [[NSAttributedString attributedStringWithAttachment:textAttachment] mutableCopy];
[s appendAttributedString:strWithImage];
This is the archiving line:
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:str requiringSecureCoding:YES error:&error];
This is the unarchiving line, which returns an NSError instance:
NSError *error = nil;
NSAttributedString *str = [NSKeyedUnarchiver unarchivedObjectOfClass:NSAttributedString.class fromData:data error:&error];
I create the NSData instance on iOS14, store it to a file and read it in iOS13. This is when it fails.

The error clearly says that NSTextAttachment can not be decoded becuase it is not in the list of classes those are supported.
You have to find a workaround to make it work on iOS 13 or lower.
POSSIBLE SOLUTION
Remove all attachments from the NSMutableAttributedString instance.
archive it the same way that you are doing today.
Encode and store the [TextAttachmentInfo] JSON data paired with above data.
unarchive it the same way that you are doing today.
Decode the paired JSON data as [TextAttachmentInfo].
Insert NSTextAttachments into the unarchived NSMutableAttributedString instance
Here's some helper code that needs to be tuned and tested further according to your own use case.
extension NSMutableAttributedString {
struct TextAttachmentInfo: Codable {
let location: Int
let imageData: Data
}
func removeAllTextAttachments() -> [TextAttachmentInfo] {
guard self.length > 0 else { return [] }
var textAttachmentInfos: [TextAttachmentInfo] = []
for location in (0..<self.length).reversed() {
var effectiveRange = NSRange()
let attributes = self.attributes(at: location, effectiveRange: &effectiveRange)
for (_, value) in attributes {
if let textAttachment = value as? NSTextAttachment,
let data = textAttachment.image?.pngData() {
self.replaceCharacters(in: effectiveRange, with: "")
textAttachmentInfos.append(.init(location: effectiveRange.location, imageData: data))
}
}
}
return textAttachmentInfos.reversed()
}
func insertTextAttachmentInfos(_ infos: [TextAttachmentInfo]) {
for info in infos {
let attachment = NSTextAttachment()
attachment.image = UIImage(data: info.imageData)
self.insert(NSAttributedString(attachment: attachment), at: info.location)
}
}
}

A solution for me was to provide a list of acceptable classes in the initWithCoder: method of the class which contains the NSAttributedString. (Credits go to a solution from Markus Spoettl in the cocoa-dev mailing list from a couple of years ago.)
-(instancetype)initWithCoder:(NSCoder *)coder {
self = [super init];
if (self) {
NSAttributedString *str = nil;
// This fails on iOS13:
// str = [coder decodeObjectOfClass:NSAttributedString.class forKey:#"attrStr"];
// Provide a set of classes to make NSTextAttachment accepted:
str = [coder decodeObjectOfClasses:[NSSet setWithObjects:
NSAttributedString.class,
NSTextAttachment.class, nil] forKey:#"attrStr"];
self.attrStr = str;
}
return self;
}
This seems to be an issue with other classes used with NSAttributedString too like NSTextTab etc. so depending on the use case, one can extend the list of acceptable classes.

Related

How to get VCF data with contact images using CNContactVCardSerialization dataWithContacts: method?

I'm using CNContacts and CNContactUI framework and picking a contact via this
CNContactPickerViewController *contactPicker = [CNContactPickerViewController new];
contactPicker.delegate = self;
[self presentViewController:contactPicker animated:YES completion:nil];
and
-(void)contactPicker:(CNContactPickerViewController *)picker didSelectContact:(CNContact *)contact
{
NSArray *array = [[NSArray alloc] initWithObjects:contact, nil];
NSError *error;
NSData *data = [CNContactVCardSerialization dataWithContacts:array error:&error];
NSLog(#"ERROR_IF_ANY :: %#",error.description);
}
This contact object have contact.imageData and coming in logs. But when I tried to cross check this data by
NSArray *contactList = [NSArray arrayWithArray:[CNContactVCardSerialization contactsWithData:data error:nil]];
CNContact *contactObject = [contactList objectAtIndex:0];
This is getting null:
//contactObject.imageData
Why am I getting this null and this contact has image when check in contacts?
I'd like to improve upon and modernise for Swift 3 the excellent answer by kudinovdenis.
Just put the following extension into your project
import Foundation
import Contacts
extension CNContactVCardSerialization {
internal class func vcardDataAppendingPhoto(vcard: Data, photoAsBase64String photo: String) -> Data? {
let vcardAsString = String(data: vcard, encoding: .utf8)
let vcardPhoto = "PHOTO;TYPE=JPEG;ENCODING=BASE64:".appending(photo)
let vcardPhotoThenEnd = vcardPhoto.appending("\nEND:VCARD")
if let vcardPhotoAppended = vcardAsString?.replacingOccurrences(of: "END:VCARD", with: vcardPhotoThenEnd) {
return vcardPhotoAppended.data(using: .utf8)
}
return nil
}
class func data(jpegPhotoContacts: [CNContact]) throws -> Data {
var overallData = Data()
for contact in jpegPhotoContacts {
let data = try CNContactVCardSerialization.data(with: [contact])
if contact.imageDataAvailable {
if let base64imageString = contact.imageData?.base64EncodedString(),
let updatedData = vcardDataAppendingPhoto(vcard: data, photoAsBase64String: base64imageString) {
overallData.append(updatedData)
}
} else {
overallData.append(data)
}
}
return overallData
}
}
and then you can use it similarly to the existing serialisation method:
CNContactVCardSerialization.data(jpegPhotoContacts: [contact1, contact2])
Note that this takes care of serialisation, you'll need to write a similar method for deserialisation if you are also importing.
As a workaround you can create PHOTO field inside of VCard.
NSError* error = nil;
NSData* vCardData = [CNContactVCardSerialization dataWithContacts:#[contact] error:&error];
NSString* vcString = [[NSString alloc] initWithData:vCardData encoding:NSUTF8StringEncoding];
NSString* base64Image = contact.imageData.base64Encoding;
NSString* vcardImageString = [[#"PHOTO;TYPE=JPEG;ENCODING=BASE64:" stringByAppendingString:base64Image] stringByAppendingString:#"\n"];
vcString = [vcString stringByReplacingOccurrencesOfString:#"END:VCARD" withString:[vcardImageString stringByAppendingString:#"END:VCARD"]];
vCardData = [vcString dataUsingEncoding:NSUTF8StringEncoding];
For some reasons CNContactVCardSerialization does not use any photo of contact. VCard after serialization is looks like:
BEGIN:VCARD
VERSION:3.0
PRODID:-//Apple Inc.//iPhone OS 9.3.2//EN
N:Contact;Test;;;
FN: Test Contact
END:VCARD
After insertion the PHOTO field inside VCard you will get
BEGIN:VCARD
VERSION:3.0
PRODID:-//Apple Inc.//iPhone OS 9.3.2//EN
N:Contact;Test;;;
FN: Test Contact
PHOTO;TYPE=JPEG;ENCODING=BASE64:<photo base64 string>
END:VCARD
After this insertion contact will looks fine in CNContactViewController
For N number of contacts, you can add image data into VCF by using simple method as below.
-(NSData*)getVCFDataWithImagesFromContacts:(NSArray*)arrContacts
{
//---- Convert contacts array into VCF data.
NSError *error;
NSData *vcfData = [CNContactVCardSerialization dataWithContacts:arrContacts error:&error];
//--- Convert VCF data into string.
NSString *strVCF = [[NSString alloc] initWithData:vcfData encoding:NSUTF8StringEncoding];
//--- Split contacts from VCF.
NSMutableArray *arrSplit = (NSMutableArray*)[strVCF componentsSeparatedByString:#"END:VCARD"];
[arrSplit removeLastObject];//-- if object is "\r\n" otherwise comment this line.
//--- Validate array count
if (arrSplit.count == arrContacts.count)
{
for (int index=0;index<arrContacts.count;index++)
{
//--- Get current contact and VCF contact string.
CNContact *contact = arrContacts[index];
NSString *strContact = arrSplit[index];
//--- Get base64 string of image.
NSString* base64Image = [UIImagePNGRepresentation([ViewController imageWithImage:[UIImage imageWithData:contact.imageData] scaledToSize:CGSizeMake(50,50)]) base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithCarriageReturn];
//--- Append image tag into contact string.
NSString* vcardImageString = [[#"PHOTO;ENCODING=BASE64;JPEG:" stringByAppendingString:base64Image] stringByAppendingString:#"\r\n"];
strContact = [strContact stringByAppendingString:[NSString stringWithFormat:#"%#%#",vcardImageString,#"END:VCARD\r\n"]];
//--- Update contact string from array.
[arrSplit replaceObjectAtIndex:index withObject:strContact];
NSLog(#"strContact :%#",strContact);
}
}
//--- Combine all contacts together in VCF.
vcfData = [[arrSplit componentsJoinedByString:#""] dataUsingEncoding:NSUTF8StringEncoding];
strVCF = [[NSString alloc] initWithData:vcfData encoding:NSUTF8StringEncoding];//--- VCF Data
NSLog(#"Contact VCF error :%#",error.localizedDescription);
return vcfData;
}
+(UIImage *)imageWithImage:(UIImage *)image scaledToSize:(CGSize)newSize
{
UIGraphicsBeginImageContextWithOptions(newSize, NO, 0.0);
[image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)];
UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return newImage;
}

UIImage encoded to NSData in ObjectiveC and then decoded in Swift

I have an app that was originally created in objective C (lets call this version 1) and I have now converted my app to Swift 2.0 (version 2). One of the main functions of the app is the ability to send images and text from one device to another. Images and text are stored in a NSMutableDictionary and then encoded to NSData and the sent / stored on the Parse backend server. The design of my app also has the ability to email an image from one device to another.
This is working well for both versions of my app – Objective C and Swift. Great !
My problem is when a user sends NSData from version 1 of my app to a device with version 2 (basically an image encoded in objective C and then decoded in Swift) !! Encoded text decodes fine but not the image (saved as objectForKey("data")). See below example. quizData is an array the holds dictionary (keyValue items) that have been sent from another device. This array works with all items except for objectForKey("data"). This object is the encoded image.
var imageData = NSData()
imageData = quizData.objectAtIndex(currentQuestionNumber).objectForKey("data") as! NSData
// the following always prints out lots of info to confirm the imageData has the encoded image
print("imageData.length = \(imageData.length)")
print("imageData.description = \(imageData.description)")
// decoding
photoImageView.image = UIImage(data:imageData)
ok, so the above works when the image was created on another device using Swift. But if the image created and sent from version 1 (objective c) the photoImageView is blank (no errors) yet the imageData is huge (the printout shows that imageDate does hold the users image).
Surley if an NSdata object has the data for a UIImage it should be able to be decoded in ObjC or Swift ?? No problem sending more code if required
Question amended as follows :
Not sure if this really helps but heres objC code for sending a NSData via email (all app data is saved a pList)
// emailArray to be populated with selected data from plist
NSMutableArray *emailArray = [NSMutableArray arrayWithContentsOfFile:path];
MFMailComposeViewController *emailPicker = [[MFMailComposeViewController alloc]init];
emailPicker.mailComposeDelegate =self;
/// NSdata from emailArray
NSData *emailQuizData = [NSKeyedArchiver archivedDataWithRootObject:emailArray];
[emailPicker addAttachmentData:emailQuizData mimeType:#"application/quizApp" fileName:_quizNameLabel.text];
if you use base64 encoding this issue shouldn't arise.
here is the implementation in swift:
import UIKit
func base64StringForImage(image: UIImage) -> String? {
guard let data = UIImagePNGRepresentation(image) else { return nil }
return data.base64EncodedStringWithOptions([])
}
func imageFromBase64String(string: String) -> UIImage? {
guard let data = NSData(base64EncodedString: string, options: []) else { return nil }
return UIImage(data: data)
}
here is the implementation in objc:
#import <UIKit/UIKit.h>
NS_INLINE NSString * base64StringForImage_objc(UIImage *image) {
NSData *imageData = UIImagePNGRepresentation(image);
return [imageData base64EncodedStringWithOptions:0];
}
NS_INLINE UIImage * imageFromBase64String_objc(NSString *string) {
NSData *imageData = [[NSData alloc] initWithBase64EncodedString: string options: 0];
return [[UIImage alloc] initWithData:imageData];
}

How to set a textview's text to a string created by receiving text from an app action extension

I am playing around with action extensions and looked at Apple's documents and found this code.
NSExtensionContext *myExtensionContext = self.extensionContext;
NSArray *inputItems = myExtensionContext.inputItems;
Then I change the array to a string.
NSString * resultString = [inputItems componentsJoinedByString:#""];
Then, I set the text view to the resultString string.
textView.text = resultString;
What I have been getting is
<NSExtensionItem: 0x174002840> - userInfo: {NSExtensionItemAttachmentsKey = ("<NSItemProvider: 0x17424c900> {types = (\n \"public.plain-text\"\n)}");}
that appears in my text view.
Code snippet from viewDidLoad:
[super viewDidLoad];
NSExtensionContext *myExtensionContext = self.extensionContext;
NSArray *inputItems = myExtensionContext.inputItems;
NSString * resultString = [inputItems componentsJoinedByString:#""];
textView.text = resultString;
Actually following code will return array of NSExtensionItem not a NSString type so you can not parse directly using
NSString * resultString = [inputItems componentsJoinedByString:#""];
To Parse NSArray of NSExtensionItems, You need to do following things. Here I assume that 'NSDictionary' as input type.
for (NSExtensionItem *item in self.extensionContext.inputItems) {
for (NSItemProvider *itemProvider in item.attachments) {
if ([itemProvider hasItemConformingToTypeIdentifier:#"typeIdentifier"]) {
// This is an image. We'll load it, then place it in our image view.
[itemProvider loadItemForTypeIdentifier:#"typeIdentifier" options:nil completionHandler:^(id<NSSecureCoding> item, NSError *error) {
NSDictionary* tempDict = (NSDictionary*)item;
NSLog(#"Dectionary : %#",item);
}];
}
}
}
For More details Action Extension tutorial may help you.

How to get RTF contents from UIPasteboard after UIWebView copy?

I have a UIWebView showing some custom HTML content. If I tap and hold, then select some text and tap the Copy option, the text gets added to UIPasteboard with the key "com.apple.rtfd". My problem now is that I can't find any way of extracting the actual textual contents of what I just copied. If I use this code:
NSString *contents = [NSString stringWithUTF8String:[data bytes]];
it returns the literal string "rtfd", regardless of what text I actually copied from the UIWebView. If I use this code:
NSString *contents = [[NSString alloc] initWithData:data
encoding:NSUTF8StringEncoding];
I get a blank string. How can I get the actual text contents of what I just copied into the pasteboard?
I have learned that when you copy selected text from a UIWebView into UIPasteboard, it actually puts multiple keyed values into the dictionary returned by UIPasteboard, of which "com.apple.rtfd" is only the first key. The actual text value of the copied element is also included under the key "public.text". This code can be used to extract the value:
UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
NSArray *dataArray = [pasteboard items];
NSDictionary *dict = (NSDictionary *)[dataArray objectAtIndex:0];
NSString *pastedText;
if ([dict objectForKey:#"public.text"]) {
// this is a text paste
pastedText = (NSString *)[dict objectForKey:#"public.text"];
}
A correct Swift solution for extract rft text content what copied from Safari:
guard let rtf = textItem["public.rtf"] as? String,
let rtfData = rtf.data(using: .utf8) else {
return
}
do {
let attr = try NSAttributedString(data: rtfData,
options: [NSDocumentTypeDocumentAttribute: NSRTFTextDocumentType],
documentAttributes: nil)
//DO SOMETHING ...
}
catch (let exc) {
print(exc.localizedDescription)
}

Copy NSAttributedString in UIPasteBoard

How do you copy an NSAttributedString in the pasteboard, to allow the user to paste, or to paste programmatically (with - (void)paste:(id)sender, from UIResponderStandardEditActions protocol).
I tried:
UIPasteboard *pasteBoard = [UIPasteboard generalPasteboard];
[pasteBoard setValue:attributedString forPasteboardType:(NSString *)kUTTypeRTF];
but this crash with:
-[UIPasteboard setValue:forPasteboardType:]: value is not a valid property list type'
which is to be expected, because NSAttributedString is not a property list value.
If the user paste the content of the pasteboard in my app, I would like to keep all the standards and custom attributes of the attributed string.
I have found that when I (as a user of the application) copy rich text from a UITextView into the pasteboard, the pasteboard contains two types:
"public.text",
"Apple Web Archive pasteboard type
Based on that, I created a convenient category on UIPasteboard.
(With heavy use of code from this answer).
It works, but:
The conversion to html format means I will lose custom attributes. Any clean solution will be gladly accepted.
File UIPasteboard+AttributedString.h:
#interface UIPasteboard (AttributedString)
- (void) setAttributedString:(NSAttributedString *)attributedString;
#end
File UIPasteboard+AttributedString.m:
#import <MobileCoreServices/UTCoreTypes.h>
#import "UIPasteboard+AttributedString.h"
#implementation UIPasteboard (AttributedString)
- (void) setAttributedString:(NSAttributedString *)attributedString {
NSString *htmlString = [attributedString htmlString]; // This uses DTCoreText category NSAttributedString+HTML - https://github.com/Cocoanetics/DTCoreText
NSDictionary *resourceDictionary = #{ #"WebResourceData" : [htmlString dataUsingEncoding:NSUTF8StringEncoding],
#"WebResourceFrameName": #"",
#"WebResourceMIMEType" : #"text/html",
#"WebResourceTextEncodingName" : #"UTF-8",
#"WebResourceURL" : #"about:blank" };
NSDictionary *htmlItem = #{ (NSString *)kUTTypeText : [attributedString string],
#"Apple Web Archive pasteboard type" : #{ #"WebMainResource" : resourceDictionary } };
[self setItems:#[ htmlItem ]];
}
#end
Only implemented setter. If you want to write the getter, and/or put it on GitHub, be my guest :)
Instead of involving HTML, the clean solution is to insert NSAttributedString as RTF (plus plaintext fallback) into the paste board:
- (void)setAttributedString:(NSAttributedString *)attributedString {
NSData *rtf = [attributedString dataFromRange:NSMakeRange(0, attributedString.length)
documentAttributes:#{NSDocumentTypeDocumentAttribute: NSRTFTextDocumentType}
error:nil];
self.items = #[#{(id)kUTTypeRTF: [[NSString alloc] initWithData:rtf encoding:NSUTF8StringEncoding],
(id)kUTTypeUTF8PlainText: attributedString.string}];
}
Swift 5
import MobileCoreServices
public extension UIPasteboard {
func set(attributedString: NSAttributedString) {
do {
let rtf = try attributedString.data(from: NSMakeRange(0, attributedString.length), documentAttributes: [NSAttributedString.DocumentAttributeKey.documentType: NSAttributedString.DocumentType.rtf])
items = [[kUTTypeRTF as String: NSString(data: rtf, encoding: String.Encoding.utf8.rawValue)!, kUTTypeUTF8PlainText as String: attributedString.string]]
} catch {
}
}
}
It is quite simple:
#import <MobileCoreServices/UTCoreTypes.h>
NSMutableDictionary *item = [[NSMutableDictionary alloc] init];
NSData *rtf = [attributedString dataFromRange:NSMakeRange(0, attributedString.length)
documentAttributes:#{NSDocumentTypeDocumentAttribute: NSRTFDTextDocumentType}
error:nil];
if (rtf) {
[item setObject:rtf forKey:(id)kUTTypeFlatRTFD];
}
[item setObject:attributedString.string forKey:(id)kUTTypeUTF8PlainText];
UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
pasteboard.items = #[item];
The pasteboard manager in OSX can auto convert between a lot of textual and image types.
For rich text types, you'd usually place RTF into the pasteboard. You can create RTF representation from an attributed string, and vice versa. See the "NSAttributedString Application Kit Additions Reference".
If you have images included as well, then use the RTFd instead of RTF flavor.
I don't know the MIME types for these (I'm used to the Carbon Pasteboard API, not the Cocoa one), but you can convert between UTIs, Pboard and MIME Types using the UTType API.
UTI for RTF is "public.rtf", for RTFd it's "com.apple.flat-rtfd".

Resources