diff --git a/QLAppBundle.xcodeproj/project.pbxproj b/QLAppBundle.xcodeproj/project.pbxproj index d6547b3..db50a8a 100644 --- a/QLAppBundle.xcodeproj/project.pbxproj +++ b/QLAppBundle.xcodeproj/project.pbxproj @@ -1038,7 +1038,7 @@ repositoryURL = "https://github.com/relikd/AndroidXML"; requirement = { kind = exactVersion; - version = 1.3.0; + version = 1.4.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/QLAppBundle.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/QLAppBundle.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6b4ea56..85f64f5 100644 --- a/QLAppBundle.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/QLAppBundle.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/relikd/AndroidXML", "state" : { - "revision" : "fd522d612f24ee813c80b1a1b0f6bd311b2735c3", - "version" : "1.3.0" + "revision" : "d9fe646bcc3b05548aebbd20b4eee0af675c129f", + "version" : "1.4.0" } } ], diff --git a/QLThumbnail/ThumbnailProvider.swift b/QLThumbnail/ThumbnailProvider.swift index a94851c..172f73d 100644 --- a/QLThumbnail/ThumbnailProvider.swift +++ b/QLThumbnail/ThumbnailProvider.swift @@ -16,16 +16,9 @@ extension QLThumbnailReply { } class ThumbnailProvider: QLThumbnailProvider { - - // TODO: sadly, this does not seem to work for .xcarchive and .appex - // Probably overwritten by Apple somehow - override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) { let meta = MetaInfo(request.fileURL) - guard let appPlist = meta.readPlistApp(iconOnly: true) else { - return - } - let img = AppIcon(meta).extractImage(from: appPlist).withRoundCorners() + let img = AppIcon(meta).extractImageForThumbnail().withRoundCorners() // First way: Draw the thumbnail into the current context, set up with UIKit's coordinate system. let reply = QLThumbnailReply(contextSize: request.maximumSize, currentContextDrawing: { () -> Bool in diff --git a/src/AppIcon.swift b/src/AppIcon.swift index 6a6f085..75bc773 100644 --- a/src/AppIcon.swift +++ b/src/AppIcon.swift @@ -13,17 +13,27 @@ struct AppIcon { self.meta = meta } + /// Convenience getter to extract app icon regardless of bundle-type. + func extractImageForThumbnail() -> NSImage { + switch meta.type { + case .IPA, .Archive, .Extension: + extractImage(from: meta.readPlistApp()) + case .APK: + extractImage(from: meta.readApkIconOnly()) + } + } + + /// Extract image from Android app bundle. + func extractImage(from manifest: ApkManifest?) -> NSImage { + if let data = manifest?.appIconData, let img = NSImage(data: data) { + return img + } + return defaultIcon() + } + /// Try multiple methods to extract image. /// This method will always return an image even if none is found, in which case it returns the default image. func extractImage(from appPlist: PlistDict?) -> NSImage { - if meta.type == .APK { - if let iconPath = appPlist?["appIcon"] as? String, - let data = meta.zipFile!.unzipFile(iconPath), - let img = NSImage(data: data) { - return img - } - return defaultIcon() - } // no need to unwrap the plist, and most .ipa should include the Artwork anyway if meta.type == .IPA { if let data = meta.zipFile!.unzipFile("iTunesArtwork") { diff --git a/src/MetaInfo+Apk.swift b/src/MetaInfo+Apk.swift index 885fafd..7629dd3 100644 --- a/src/MetaInfo+Apk.swift +++ b/src/MetaInfo+Apk.swift @@ -4,12 +4,30 @@ import os // OSLog private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "MetaInfo+Apk") +/// Representation of `AndroidManifest.xml` +struct ApkManifest { + var packageId: String? = nil + var appName: String? = nil + var appIcon: String? = nil + /// Computed property + var appIconData: Data? = nil + var versionName: String? = nil + var versionCode: String? = nil + var sdkVerMin: String? = nil + var sdkVerTarget: String? = nil + + var featuresRequired: [String] = [] + var featuresOptional: [String] = [] + var permissions: [String] = [] +} + // MARK: - Full Manifest extension MetaInfo { /// Extract `AndroidManifest.xml` and parse its content - func readApkManifest() -> PlistDict? { + func readApkManifest() -> ApkManifest? { + assert(type == .APK) guard let data = self.readPayloadFile("AndroidManifest.xml", osxSubdir: nil) else { return nil } @@ -42,14 +60,9 @@ extension MetaInfo { } var rv = storage.result - guard let _ = rv["packageId"] else { - return nil - } - os_log(.debug, log: log, "[apk] resolving resources name: %{public}@ icon: %{public}@", String(describing: rv["appName"]), String(describing: rv["appIcon"])) - let resolved = self.resolveResources([(ResourceType.Name, rv["appName"] as? String), (ResourceType.Icon, rv["appIcon"] as? String)]) - os_log(.debug, log: log, "[apk] resolved %{public}@", String(describing: resolved)) - rv["appName"] = resolved[0] - rv["appIcon"] = resolved[1] + os_log(.debug, log: log, "[apk] resolving %{public}@", String(describing: rv)) + rv.resolve(zipFile!) + os_log(.debug, log: log, "[apk] resolved name: \"%{public}@\" icon: %{public}@", rv.appName ?? "", rv.appIcon ?? "-") return rv } } @@ -57,48 +70,41 @@ extension MetaInfo { /// Wrapper to use same code for binary-xml and string-xml parsing private class ApkXmlManifestParser: NSObject, XMLParserDelegate { private var _scope: [String] = [] - private var _rv: [String: String] = [:] - private var _perm: [String] = [] - private var _featOpt: [String] = [] - private var _featReq: [String] = [] - var result: PlistDict { - (_rv as PlistDict).merging([ - "permissions": _perm, - "featuresOptional": _featOpt, - "featuresRequired": _featReq, - ]) { $1 } - } + var result = ApkManifest() func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attrs: [String : String] = [:]) { // keep in sync with ALLOWED_TAGS above switch elementName { case "manifest": if _scope == [] { - _rv["packageId"] = attrs["package"] // "org.bundle.id" - _rv["versionName"] = attrs["android:versionName"] // "7.62.3" - _rv["versionCode"] = attrs["android:versionCode"] // "160700" + result.packageId = attrs["package"] // "org.bundle.id" + result.versionName = attrs["android:versionName"] // "7.62.3" + result.versionCode = attrs["android:versionCode"] // "160700" // attrs["platformBuildVersionCode"] // "35" // attrs["platformBuildVersionName"] // "15" } case "application": if _scope == ["manifest"] { - _rv["appName"] = attrs["android:label"] // @resource-ref - _rv["appIcon"] = attrs["android:icon"] // @resource-ref + result.appName = attrs["android:label"] // @resource-ref + result.appIcon = attrs["android:icon"] // @resource-ref } case "uses-permission", "uses-permission-sdk-23": // no "permission" because that will produce duplicates with "uses-permission" if _scope == ["manifest"], let name = attrs["android:name"] { - _perm.append(name) + result.permissions.append(name) } case "uses-feature": if _scope == ["manifest"], let name = attrs["android:name"] { - let optional = attrs["android:required"] == "false" - optional ? _featOpt.append(name) : _featReq.append(name) + if attrs["android:required"] == "false" { + result.featuresOptional.append(name) + } else { + result.featuresRequired.append(name) + } } case "uses-sdk": if _scope == ["manifest"] { - _rv["sdkVerMin"] = attrs["android:minSdkVersion"] ?? "1" // "21" - _rv["sdkVerTarget"] = attrs["android:targetSdkVersion"] // "35" + result.sdkVerMin = attrs["android:minSdkVersion"] ?? "1" // "21" + result.sdkVerTarget = attrs["android:targetSdkVersion"] // "35" } default: break // ignore } @@ -115,26 +121,25 @@ private class ApkXmlManifestParser: NSObject, XMLParserDelegate { extension MetaInfo { /// Same as `readApkManifest()` but only extract `appIcon`. - func readApkIconOnly() -> PlistDict? { + func readApkIconOnly() -> ApkManifest? { + assert(type == .APK) + var rv = ApkManifest() guard let data = self.readPayloadFile("AndroidManifest.xml", osxSubdir: nil) else { return nil } - var icon: String? = nil if let xml = try? AndroidXML.init(data: data) { let parser = xml.parseXml() try? parser.iterElements({ startTag, attributes in if startTag == "application" { - icon = try? attributes.asDictStr()["android:icon"] + rv.appIcon = try? attributes.get("android:icon")?.resolve(parser.stringPool) } }) {_ in} } else { // fallback to xml-string parser - icon = ApkXmlIconParser().run(data) + rv.appIcon = ApkXmlIconParser().run(data) } - if let icon = self.resolveResources([(.Icon, icon)])[0] { - return ["appIcon": icon] - } - return nil + rv.resolve(zipFile!) + return rv } } @@ -160,25 +165,21 @@ private class ApkXmlIconParser: NSObject, XMLParserDelegate { // MARK: - Resolve resource -private extension MetaInfo { - // currently there are only two types of resources, "android:icon" and "android:label" - enum ResourceType { - case Name - case Icon - } - func resolveResources(_ ids: [(ResourceType, String?)]) -> [String?] { - guard let data = self.readPayloadFile("resources.arsc", osxSubdir: nil), +private extension ApkManifest { + mutating func resolve(_ zip: ZipFile) { + guard let data = zip.unzipFile("resources.arsc"), let xml = try? AndroidXML.init(data: data), xml.type == .Table else { - return ids.map { _ in nil } + return } + let parser = xml.parseTable() - return ids.map { typ, val in - guard let val, let ref = try? TblTableRef(val) else { - return nil - } - switch typ { - case .Name: return parser.getName(ref) - case .Icon: return parser.getIconDirect(ref) ?? parser.getIconIndirect(ref) + if let val = appName, let ref = try? TblTableRef(val) { + appName = parser.getName(ref) + } + if let val = appIcon, let ref = try? TblTableRef(val) { + if let iconPath = parser.getIconDirect(ref) ?? parser.getIconIndirect(ref) { + appIcon = iconPath + appIconData = zip.unzipFile(iconPath) } } } diff --git a/src/MetaInfo.swift b/src/MetaInfo.swift index 7bd4609..918b696 100644 --- a/src/MetaInfo.swift +++ b/src/MetaInfo.swift @@ -88,12 +88,12 @@ struct MetaInfo { } /// Read app default `Info.plist`. (used for both, Preview and Thumbnail) - func readPlistApp(iconOnly: Bool = false) -> PlistDict? { + func readPlistApp() -> PlistDict? { switch self.type { case .IPA, .Archive, .Extension: return self.readPayloadFile("Info.plist", osxSubdir: nil)?.asPlistOrNil() case .APK: - return iconOnly ? self.readApkIconOnly() : self.readApkManifest() + return nil // not applicable for Android } } } diff --git a/src/Preview+AppInfo.swift b/src/Preview+AppInfo.swift index 5a4a8c2..99cbff5 100644 --- a/src/Preview+AppInfo.swift +++ b/src/Preview+AppInfo.swift @@ -44,10 +44,10 @@ extension PreviewGenerator { } /// Process info stored in `Info.plist` - mutating func procAppInfoAndroid(_ appPlist: PlistDict) { - let featuresRequired = appPlist["featuresRequired"] as! [String] - let featuresOptional = appPlist["featuresOptional"] as! [String] - let permissions = appPlist["permissions"] as! [String] + mutating func procAppInfoAndroid(_ manifest: ApkManifest) { + let featReq = manifest.featuresRequired + let featOpt = manifest.featuresOptional + let perms = manifest.permissions func asList(_ list: [String]) -> String { "
\(list.joined(separator: "\n"))
" @@ -58,21 +58,21 @@ extension PreviewGenerator { } self.apply([ "AppInfoHidden": CLASS_VISIBLE, - "AppName": appPlist["appName"] as? String ?? "", - "AppVersion": appPlist["versionName"] as? String ?? "", - "AppBuildVer": appPlist["versionCode"] as? String ?? "", - "AppId": appPlist["packageId"] as? String ?? "", + "AppName": manifest.appName ?? "", + "AppVersion": manifest.versionName ?? "", + "AppBuildVer": manifest.versionCode ?? "", + "AppId": manifest.packageId ?? "", - "ApkFeaturesRequiredHidden": featuresRequired.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE, - "ApkFeaturesRequiredList": asList(featuresRequired), - "ApkFeaturesOptionalHidden": featuresOptional.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE, - "ApkFeaturesOptionalList": asList(featuresOptional), - "ApkPermissionsHidden": permissions.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE, - "ApkPermissionsList": asList(permissions), + "ApkFeaturesRequiredHidden": featReq.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE, + "ApkFeaturesRequiredList": asList(featReq), + "ApkFeaturesOptionalHidden": featOpt.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE, + "ApkFeaturesOptionalList": asList(featOpt), + "ApkPermissionsHidden": perms.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE, + "ApkPermissionsList": asList(perms), "AppDeviceFamily": "Android", - "AppSDK": resolveSDK(appPlist["sdkVerTarget"] as? String), - "AppMinOS": resolveSDK(appPlist["sdkVerMin"] as? String), + "AppSDK": resolveSDK(manifest.sdkVerTarget), + "AppMinOS": resolveSDK(manifest.sdkVerMin), ]) } } diff --git a/src/PreviewGenerator.swift b/src/PreviewGenerator.swift index 72ace22..a3666cc 100644 --- a/src/PreviewGenerator.swift +++ b/src/PreviewGenerator.swift @@ -23,15 +23,12 @@ struct PreviewGenerator { init(_ meta: MetaInfo) throws { self.meta = meta - guard let plistApp = meta.readPlistApp() else { - let isAndroid = meta.type == .APK - throw RuntimeError(isAndroid ? "AndroidManifest.xml not found" : "Info.plist not found") - } - - data["QuickLookTitle"] = stringForFileType(meta) switch meta.type { case .IPA, .Archive, .Extension: + guard let plistApp = meta.readPlistApp() else { + throw RuntimeError("Info.plist not found") + } procAppInfoApple(plistApp, isOSX: meta.isOSX) if meta.type == .IPA { procItunesMeta(meta.readPlistItunes()) @@ -43,14 +40,21 @@ struct PreviewGenerator { let plistProvision = meta.readPlistProvision() procEntitlements(meta, plistApp, plistProvision) procProvision(plistProvision, isOSX: meta.isOSX) + // App Icon (last, because the image uses a lot of memory) + data["AppIcon"] = AppIcon(meta).extractImage(from: plistApp).withRoundCorners().asBase64() case .APK: - procAppInfoAndroid(plistApp) + guard let manifest = meta.readApkManifest() else { + throw RuntimeError("AndroidManifest.xml not found") + } + procAppInfoAndroid(manifest) + // App Icon (last, because the image uses a lot of memory) + data["AppIcon"] = AppIcon(meta).extractImage(from: manifest).withRoundCorners().asBase64() } + + data["QuickLookTitle"] = stringForFileType(meta) procFileInfo(meta.url) procFooterInfo() - // App Icon (last, because the image uses a lot of memory) - data["AppIcon"] = AppIcon(meta).extractImage(from: plistApp).withRoundCorners().asBase64() } mutating func apply(_ values: [String: String]) {