Add exif support

This commit is contained in:
Marcin Czachursk 2023-01-08 14:50:37 +01:00
parent 660eaa2819
commit 2ebfe762f0
15 changed files with 259 additions and 62 deletions

View File

@ -24,7 +24,7 @@
F8210DE52966E160001D9973 /* Color+SystemColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DE42966E160001D9973 /* Color+SystemColors.swift */; };
F8210DE72966E1D1001D9973 /* Color+Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DE62966E1D1001D9973 /* Color+Assets.swift */; };
F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DE92966E4F9001D9973 /* AnimatePlaceholderModifier.swift */; };
F8341F90295C636C009C8EE6 /* UIImage+Exif.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8341F8F295C636C009C8EE6 /* UIImage+Exif.swift */; };
F8341F90295C636C009C8EE6 /* Data+Exif.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8341F8F295C636C009C8EE6 /* Data+Exif.swift */; };
F8341F92295C63BB009C8EE6 /* ImageStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8341F91295C63BB009C8EE6 /* ImageStatus.swift */; };
F83901A6295D8EC000456AE2 /* LabelIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83901A5295D8EC000456AE2 /* LabelIcon.swift */; };
F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D4970296402DC00751DF7 /* AuthorizationService.swift */; };
@ -75,6 +75,8 @@
F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7D2965FD89001D8331 /* UserProfileView.swift */; };
F8A93D802965FED4001D8331 /* AccountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7F2965FED4001D8331 /* AccountService.swift */; };
F8A93D822965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D812965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift */; };
F8C14392296AF0B3001FE31D /* String+Exif.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C14391296AF0B3001FE31D /* String+Exif.swift */; };
F8C14394296AF21B001FE31D /* Double+Round.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C14393296AF21B001FE31D /* Double+Round.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -92,7 +94,7 @@
F8210DE42966E160001D9973 /* Color+SystemColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+SystemColors.swift"; sourceTree = "<group>"; };
F8210DE62966E1D1001D9973 /* Color+Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Assets.swift"; sourceTree = "<group>"; };
F8210DE92966E4F9001D9973 /* AnimatePlaceholderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatePlaceholderModifier.swift; sourceTree = "<group>"; };
F8341F8F295C636C009C8EE6 /* UIImage+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Exif.swift"; sourceTree = "<group>"; };
F8341F8F295C636C009C8EE6 /* Data+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Exif.swift"; sourceTree = "<group>"; };
F8341F91295C63BB009C8EE6 /* ImageStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageStatus.swift; sourceTree = "<group>"; };
F83901A5295D8EC000456AE2 /* LabelIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelIcon.swift; sourceTree = "<group>"; };
F85D4970296402DC00751DF7 /* AuthorizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationService.swift; sourceTree = "<group>"; };
@ -145,6 +147,8 @@
F8A93D7D2965FD89001D8331 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
F8A93D7F2965FED4001D8331 /* AccountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountService.swift; sourceTree = "<group>"; };
F8A93D812965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonClientAuthenticated+Account.swift"; sourceTree = "<group>"; };
F8C14391296AF0B3001FE31D /* String+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Exif.swift"; sourceTree = "<group>"; };
F8C14393296AF21B001FE31D /* Double+Round.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Round.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -192,13 +196,15 @@
F8341F94295C63FE009C8EE6 /* Extensions */ = {
isa = PBXGroup;
children = (
F8341F8F295C636C009C8EE6 /* UIImage+Exif.swift */,
F8341F8F295C636C009C8EE6 /* Data+Exif.swift */,
F85D4980296417F700751DF7 /* MastodonClientAuthenticated+Context.swift */,
F8A93D812965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift */,
F85D49862964334100751DF7 /* String+Date.swift */,
F8C14391296AF0B3001FE31D /* String+Exif.swift */,
F8210DE22966D256001D9973 /* Status+StatusData.swift */,
F8210DE42966E160001D9973 /* Color+SystemColors.swift */,
F8210DE62966E1D1001D9973 /* Color+Assets.swift */,
F8C14393296AF21B001FE31D /* Double+Round.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -452,7 +458,7 @@
F800480A2961EA1900E6868A /* AttachmentDataHandler.swift in Sources */,
F80048032961850500E6868A /* AttachmentData+CoreDataClass.swift in Sources */,
F897978D2968369600B22335 /* HapticService.swift in Sources */,
F8341F90295C636C009C8EE6 /* UIImage+Exif.swift in Sources */,
F8341F90295C636C009C8EE6 /* Data+Exif.swift in Sources */,
F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */,
F85D4981296417F700751DF7 /* MastodonClientAuthenticated+Context.swift in Sources */,
F88C246E295C37B80006098B /* MainView.swift in Sources */,
@ -469,6 +475,7 @@
F88ABD9429687CA4004EF61E /* ComposeView.swift in Sources */,
F85D497D29640D5900751DF7 /* InteractionRow.swift in Sources */,
F866F6A729604629002E8F88 /* SignInView.swift in Sources */,
F8C14392296AF0B3001FE31D /* String+Exif.swift in Sources */,
F88C246C295C37B80006098B /* VernissageApp.swift in Sources */,
F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */,
F88FAD25295F3FF7009B20C9 /* FederatedFeedView.swift in Sources */,
@ -477,6 +484,7 @@
F897978F29684BCB00B22335 /* LoadingView.swift in Sources */,
F88FAD2D295F4AD7009B20C9 /* ApplicationState.swift in Sources */,
F866F6A1296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift in Sources */,
F8C14394296AF21B001FE31D /* Double+Round.swift in Sources */,
F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */,
F8A93D802965FED4001D8331 /* AccountService.swift in Sources */,
F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */,

View File

@ -3,13 +3,10 @@
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
//
import Foundation
import CoreData
extension AttachmentData {
@nonobjc public class func fetchRequest() -> NSFetchRequest<AttachmentData> {
@ -25,10 +22,13 @@ extension AttachmentData {
@NSManaged public var text: String?
@NSManaged public var type: String
@NSManaged public var url: URL
@NSManaged public var exifCamera: String?
@NSManaged public var exifLens: String?
@NSManaged public var exifExposure: String?
@NSManaged public var exifCreatedDate: String?
@NSManaged public var statusRelation: StatusData?
}
extension AttachmentData : Identifiable {
}

View File

@ -0,0 +1,100 @@
//
// https://mczachurski.dev
// Copyright © 2022 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
import UIKit
public extension Dictionary<String, Any> {
func getExifValue(_ key: String) -> String? {
if let value = self[key] as? String {
return value
}
if let dictionary = self[key] as? [String: Any], let value = dictionary[key] {
return value as? String
}
return nil
}
}
public extension Data {
func getExifData() -> [String: Any]? {
var imageInfo: [String: Any]? = nil
guard let imageSource = CGImageSourceCreateWithData(self as CFData, nil),
let metadata = CGImageSourceCopyMetadataAtIndex(imageSource, 0, nil),
let tags = CGImageMetadataCopyTags(metadata) else {
return nil
}
imageInfo = self.readMetadataTagArr(tagArr: tags)
return imageInfo
}
/// Reads the Arrays of tags and convert them into a dictionary of [String: Any].
private func readMetadataTagArr(tagArr: CFArray) -> [String: Any]? {
var result = [String: Any]()
for (_, tag) in (tagArr as NSArray).enumerated() {
let tagMetadata = tag as! CGImageMetadataTag
if let cfName = CGImageMetadataTagCopyName(tagMetadata) {
let name = String(cfName)
result[name] = self.readMetadataTag(metadataTag: tagMetadata)
}
}
return result
}
/// Convert CGImageMetadataTag to a dictionary of [String: Any].
private func readMetadataTag(metadataTag: CGImageMetadataTag) -> [String: Any] {
var result = [String: Any]()
guard let cfName = CGImageMetadataTagCopyName(metadataTag) else { return result }
let name = String(cfName)
let value = CGImageMetadataTagCopyValue(metadataTag)
/// checking the type of `value` object and then performing respective operation on `value`
if CFGetTypeID(value) == CFStringGetTypeID() {
let valueStr = String(value as! CFString)
result[name] = valueStr
} else if CFGetTypeID(value) == CFDictionaryGetTypeID() {
let nsDict: NSDictionary = value as! CFDictionary
result[name] = self.getDictionary(from: nsDict)
} else if CFGetTypeID(value) == CFArrayGetTypeID() {
let valueArr: NSArray = value as! CFArray
for (_, item) in valueArr.enumerated() {
let tagMetadata = item as! CGImageMetadataTag
result[name] = self.readMetadataTag(metadataTag: tagMetadata)
}
} else {
// when the data was of some other type
let descriptionString: CFString = CFCopyDescription(value);
let str = String(descriptionString)
result[name] = str
}
return result
}
/// Converting CGImage Metadata dictionary to [String: Any]
private func getDictionary(from nsDict: NSDictionary) -> [String: Any] {
var subDictionary = [String: Any]()
for (key, val) in nsDict {
guard let key = key as? String else { continue }
let tempDict: [String: Any] = [key: val]
if JSONSerialization.isValidJSONObject(tempDict) {
subDictionary[key] = val
} else {
let mData = val as! CGImageMetadataTag
let tempDict: [String: Any] = [key: self.readMetadataTag(metadataTag: mData)]
if JSONSerialization.isValidJSONObject(tempDict) {
subDictionary[key] = tempDict
}
}
}
return subDictionary
}
}

View File

@ -0,0 +1,14 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
extension Double {
func rounded(toPlaces places:Int) -> Double {
let divisor = pow(10.0, Double(places))
return (self * divisor).rounded() / divisor
}
}

View File

@ -26,7 +26,28 @@ extension Status {
attachmentData.statusId = statusData.id
attachmentData.data = imageData
// TODO: read exif informatio
// Read exif information.
if let exifProperties = imageData.getExifData() {
if let make = exifProperties.getExifValue("Make"), let model = exifProperties.getExifValue("Model") {
attachmentData.exifCamera = "\(make) \(model)"
}
// "Lens" or "Lens Model"
if let lens = exifProperties.getExifValue("Lens") {
attachmentData.exifLens = lens
}
if let createData = exifProperties.getExifValue("CreateDate") {
attachmentData.exifCreatedDate = createData
}
if let focalLenIn35mmFilm = exifProperties.getExifValue("FocalLenIn35mmFilm"),
let fNumber = exifProperties.getExifValue("FNumber")?.calculateExifNumber(),
let exposureTime = exifProperties.getExifValue("ExposureTime"),
let photographicSensitivity = exifProperties.getExifValue("PhotographicSensitivity") {
attachmentData.exifExposure = "\(focalLenIn35mmFilm)mm, f/\(fNumber), \(exposureTime)s, ISO \(photographicSensitivity)"
}
}
attachmentData.statusRelation = statusData
statusData.addToAttachmentRelation(attachmentData)

View File

@ -0,0 +1,27 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
extension String {
func calculateExifNumber() -> String? {
guard self.contains("/") else {
return self
}
let parts = self.split(separator: "/")
guard parts.count == 2 else {
return nil
}
if let first = Int(parts[0]), let second = Int(parts[1]) {
let calculated = Double(first) / Double(second)
return String(calculated.rounded(toPlaces: 2))
}
return nil
}
}

View File

@ -1,27 +0,0 @@
//
// https://mczachurski.dev
// Copyright © 2022 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
import UIKit
public extension UIImage {
func getExifData() -> CFDictionary? {
var exifData: CFDictionary? = nil
if let data = self.jpegData(compressionQuality: 1.0) {
data.withUnsafeBytes {
let bytes = $0.baseAddress?.assumingMemoryBound(to: UInt8.self)
if let cfData = CFDataCreate(kCFAllocatorDefault, bytes, data.count),
let source = CGImageSourceCreateWithData(cfData, nil) {
exifData = CGImageSourceCopyPropertiesAtIndex(source, 0, nil)
}
}
}
return exifData
}
}

View File

@ -48,7 +48,7 @@ public class AuthorizationService {
// Create application (we will get clientId amd clientSecret).
let oAuthApp = try await client.createApp(
named: "Photofed",
named: "Vernissage",
redirectUri: "oauth-vernissage://oauth-callback/mastodon",
scopes: Scopes(["read", "write", "follow", "push"]),
website: baseUrl)

View File

@ -91,13 +91,34 @@ public class TimelineService {
// Save attachment in database.
let attachmentData = statusData.attachments().first { item in item.id == attachment.id }
?? AttachmentDataHandler.shared.createAttachmnentDataEntity(viewContext: backgroundContext)
?? AttachmentDataHandler.shared.createAttachmnentDataEntity(viewContext: backgroundContext)
attachmentData.copyFrom(attachment)
attachmentData.statusId = statusData.id
attachmentData.data = imageData
// TODO: read exif information
// Read exif information.
if let exifProperties = imageData.getExifData() {
if let make = exifProperties.getExifValue("Make"), let model = exifProperties.getExifValue("Model") {
attachmentData.exifCamera = "\(make) \(model)"
}
// "Lens" or "Lens Model"
if let lens = exifProperties.getExifValue("Lens") {
attachmentData.exifLens = lens
}
if let createData = exifProperties.getExifValue("CreateDate") {
attachmentData.exifCreatedDate = createData
}
if let focalLenIn35mmFilm = exifProperties.getExifValue("FocalLenIn35mmFilm"),
let fNumber = exifProperties.getExifValue("FNumber")?.calculateExifNumber(),
let exposureTime = exifProperties.getExifValue("ExposureTime"),
let photographicSensitivity = exifProperties.getExifValue("PhotographicSensitivity") {
attachmentData.exifExposure = "\(focalLenIn35mmFilm)mm, f/\(fNumber), \(exposureTime)s, ISO \(photographicSensitivity)"
}
}
if attachmentData.isInserted {
attachmentData.statusRelation = statusData

View File

@ -27,6 +27,10 @@
<entity name="AttachmentData" representedClassName="AttachmentData" syncable="YES">
<attribute name="blurhash" optional="YES" attributeType="String"/>
<attribute name="data" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
<attribute name="exifCamera" optional="YES" attributeType="String"/>
<attribute name="exifCreatedDate" optional="YES" attributeType="String"/>
<attribute name="exifExposure" optional="YES" attributeType="String"/>
<attribute name="exifLens" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="previewUrl" optional="YES" attributeType="URI"/>
<attribute name="remoteUrl" optional="YES" attributeType="URI"/>

View File

@ -33,7 +33,7 @@ struct VernissageApp: App {
.environmentObject(applicationState)
}
}
.task {
.task {
await AuthorizationService.shared.verifyAccount({ accountData in
guard let accountData = accountData else {
self.applicationViewMode = .signIn
@ -43,9 +43,6 @@ struct VernissageApp: App {
self.applicationState.accountData = accountData
self.applicationViewMode = .mainView
})
URLCache.shared.memoryCapacity = 10_000_000 // ~10 MB memory space
URLCache.shared.diskCapacity = 1_000_000_000 // ~1GB disk cache space
}
.navigationViewStyle(.stack)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in

View File

@ -30,8 +30,10 @@ struct SignInView: View {
Button("Go") {
Task {
try await AuthorizationService.shared.signIn(serverAddress: serverAddress, { accountData in
self.applicationState.accountData = accountData
onSignInStateChenge(.mainView)
DispatchQueue.main.async {
self.applicationState.accountData = accountData
onSignInStateChenge(.mainView)
}
})
}
}

View File

@ -15,11 +15,18 @@ struct StatusView: View {
@State private var showCompose = false
@State private var statusData: StatusData?
@State private var exifCamera: String?
@State private var exifExposure: String?
@State private var exifCreatedDate: String?
@State private var exifLens: String?
var body: some View {
ScrollView {
if let statusData = self.statusData {
VStack (alignment: .leading) {
ImagesCarousel(attachments: statusData.attachments())
VStack (alignment: .leading) {
ImagesCarousel(attachments: statusData.attachments()) { attachmentData in
self.setAttachment(attachmentData)
}
VStack(alignment: .leading) {
NavigationLink(destination: UserProfileView(
@ -36,10 +43,10 @@ struct StatusView: View {
.padding(.leading, -4)
VStack (alignment: .leading) {
LabelIcon(iconName: "camera", value: "SONY ILCE-7M3")
LabelIcon(iconName: "camera.aperture", value: "Viltrox 24mm F1.8 E")
LabelIcon(iconName: "timelapse", value: "24.0 mm, f/1.8, 1/640s, ISO 100")
LabelIcon(iconName: "calendar", value: "2 Oct 2022")
LabelIcon(iconName: "camera", value: self.exifCamera)
LabelIcon(iconName: "camera.aperture", value: self.exifLens)
LabelIcon(iconName: "timelapse", value: self.exifExposure)
LabelIcon(iconName: "calendar", value: self.exifCreatedDate?.toDate(.isoDateTimeSec)?.formatted())
}
.padding(.bottom, 2)
.foregroundColor(.lightGrayColor)
@ -127,6 +134,13 @@ struct StatusView: View {
}
}
}
private func setAttachment(_ attachmentData: AttachmentData) {
exifCamera = attachmentData.exifCamera
exifExposure = attachmentData.exifExposure
exifCreatedDate = attachmentData.exifCreatedDate
exifLens = attachmentData.exifLens
}
}
struct StatusView_Previews: PreviewProvider {

View File

@ -9,20 +9,30 @@ import SwiftUI
struct ImagesCarousel: View {
@State public var attachments: [AttachmentData]
@State private var height: Double = 0.0
@State private var selectedAttachmentId = ""
var onAttachmentChange: (_ attachmentData: AttachmentData) -> Void?
var body: some View {
TabView {
ForEach(attachments, id: \.self) { attachment in
TabView(selection: $selectedAttachmentId) {
ForEach(attachments, id: \.id) { attachment in
if let image = UIImage(data: attachment.data) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.tag(attachment.id)
}
}
}
.frame(height: CGFloat(self.height))
.tabViewStyle(PageTabViewStyle())
.onChange(of: selectedAttachmentId, perform: { index in
if let attachment = attachments.first(where: { item in item.id == index }) {
onAttachmentChange(attachment)
}
})
.onAppear {
self.selectedAttachmentId = self.attachments.first?.id ?? ""
self.calculateImageHeight()
}
}
@ -47,6 +57,8 @@ struct ImagesCarousel: View {
struct ImagesCarousel_Previews: PreviewProvider {
static var previews: some View {
ImagesCarousel(attachments: [])
ImagesCarousel(attachments: []) { attachmentData in
}
}
}

View File

@ -8,16 +8,20 @@ import SwiftUI
struct LabelIcon: View {
let iconName: String
let value: String
let value: String?
var body: some View {
HStack(alignment: .center) {
Image(systemName: iconName)
.frame(width: 30, alignment: .leading)
Text(value)
.font(.footnote)
if let value {
HStack(alignment: .center) {
Image(systemName: iconName)
.frame(width: 30, alignment: .leading)
Text(value)
.font(.footnote)
}
.padding(.vertical, 2)
} else {
EmptyView()
}
.padding(.vertical, 2)
}
}