diff --git a/App/Info.plist b/App/Info.plist index e67f7d5..dbd05c7 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -27,6 +27,25 @@ + + UTTypeConformsTo + + public.data + + UTTypeIdentifier + com.google.android.apk + UTTypeTagSpecification + + public.mime-type + + application/vnd.android.package-archive + + public.filename-extension + + apk + + + diff --git a/QLAppBundle.xcodeproj/project.pbxproj b/QLAppBundle.xcodeproj/project.pbxproj index fe4488b..d6547b3 100644 --- a/QLAppBundle.xcodeproj/project.pbxproj +++ b/QLAppBundle.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ 5405CF5E2EA1199B00613856 /* MetaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF5D2EA1199B00613856 /* MetaInfo.swift */; }; 5405CF652EA1376B00613856 /* Zip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF642EA1376B00613856 /* Zip.swift */; }; + 540B77D92ED79BBD009E030C /* MetaInfo+Apk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540B77D82ED79BB2009E030C /* MetaInfo+Apk.swift */; }; + 540B77DA2ED79C6B009E030C /* MetaInfo+Apk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540B77D82ED79BB2009E030C /* MetaInfo+Apk.swift */; }; + 540B77DC2ED79CC1009E030C /* AndroidXML in Frameworks */ = {isa = PBXBuildFile; productRef = 540B77DB2ED79CC1009E030C /* AndroidXML */; }; + 540B77DE2ED79CC8009E030C /* AndroidXML in Frameworks */ = {isa = PBXBuildFile; productRef = 540B77DD2ED79CC8009E030C /* AndroidXML */; }; 5412DECE2EBC168600F9040D /* Preview+ArchiveInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412DECD2EBC168600F9040D /* Preview+ArchiveInfo.swift */; }; 5412DED02EBC283000F9040D /* RuntimeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5412DECF2EBC283000F9040D /* RuntimeError.swift */; }; 543FE5742EB3BB5E0059F98B /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 543FE5732EB3BB5E0059F98B /* AppIcon.icns */; }; @@ -126,6 +130,7 @@ /* Begin PBXFileReference section */ 5405CF5D2EA1199B00613856 /* MetaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaInfo.swift; sourceTree = ""; }; 5405CF642EA1376B00613856 /* Zip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zip.swift; sourceTree = ""; }; + 540B77D82ED79BB2009E030C /* MetaInfo+Apk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetaInfo+Apk.swift"; sourceTree = ""; }; 5412DECD2EBC168600F9040D /* Preview+ArchiveInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+ArchiveInfo.swift"; sourceTree = ""; }; 5412DECF2EBC283000F9040D /* RuntimeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeError.swift; sourceTree = ""; }; 54352E8A2EB6A79A0082F61D /* AssetCarReader.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AssetCarReader.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -192,6 +197,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 540B77DC2ED79CC1009E030C /* AndroidXML in Frameworks */, 54B6FFF02EB6AA0F007397C0 /* AssetCarReader.framework in Frameworks */, 54D3A6FE2EA465B4001EF4F6 /* CoreGraphics.framework in Frameworks */, 54442C232E378BAF008A870E /* Quartz.framework in Frameworks */, @@ -202,6 +208,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 540B77DE2ED79CC8009E030C /* AndroidXML in Frameworks */, 54B6FFF52EB6AA14007397C0 /* AssetCarReader.framework in Frameworks */, 54581FD12EB29A0B0043A0B3 /* QuickLookThumbnailing.framework in Frameworks */, 54581FD22EB29A0B0043A0B3 /* Quartz.framework in Frameworks */, @@ -215,6 +222,7 @@ isa = PBXGroup; children = ( 5405CF5D2EA1199B00613856 /* MetaInfo.swift */, + 540B77D82ED79BB2009E030C /* MetaInfo+Apk.swift */, 5412DECF2EBC283000F9040D /* RuntimeError.swift */, 54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */, 54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */, @@ -406,6 +414,7 @@ ); name = "QL Preview"; packageProductDependencies = ( + 540B77DB2ED79CC1009E030C /* AndroidXML */, ); productName = QLPreview; productReference = 54442C202E378BAF008A870E /* QLAppBundle Preview Extension.appex */; @@ -427,6 +436,7 @@ ); name = "QL Thumbnail"; packageProductDependencies = ( + 540B77DD2ED79CC8009E030C /* AndroidXML */, ); productName = QLThumbnail; productReference = 54581FCF2EB29A0B0043A0B3 /* QLAppBundle Thumbnail Extension.appex */; @@ -462,6 +472,9 @@ ); mainGroup = 54442BEB2E378B71008A870E; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 54D891112ED7313100BF23C4 /* XCRemoteSwiftPackageReference "AndroidXML" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 54442BF52E378B71008A870E /* Products */; projectDirPath = ""; @@ -549,6 +562,7 @@ 547F52ED2EB2C822002B6D5F /* Preview+AppInfo.swift in Sources */, 549E3BA12EBAE7D300ADFF56 /* URL+File.swift in Sources */, 547F52F42EB2CA05002B6D5F /* Preview+Entitlements.swift in Sources */, + 540B77D92ED79BBD009E030C /* MetaInfo+Apk.swift in Sources */, 5405CF5E2EA1199B00613856 /* MetaInfo.swift in Sources */, 547F52F92EB2CBAB002B6D5F /* Date+Format.swift in Sources */, 54D3A6EC2EA31B52001EF4F6 /* AppCategories.swift in Sources */, @@ -567,6 +581,7 @@ 54AE5BFF2EB3DB1000B4CFC7 /* ThumbnailProvider.swift in Sources */, 549E3BA22EBAECD400ADFF56 /* URL+File.swift in Sources */, 547899752EB38F3D00F96B80 /* AppIcon.swift in Sources */, + 540B77DA2ED79C6B009E030C /* MetaInfo+Apk.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -821,6 +836,7 @@ ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSPrincipalClass = NSApplication; @@ -847,6 +863,7 @@ ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSPrincipalClass = NSApplication; @@ -1014,6 +1031,30 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 54D891112ED7313100BF23C4 /* XCRemoteSwiftPackageReference "AndroidXML" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/relikd/AndroidXML"; + requirement = { + kind = exactVersion; + version = 1.3.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 540B77DB2ED79CC1009E030C /* AndroidXML */ = { + isa = XCSwiftPackageProductDependency; + package = 54D891112ED7313100BF23C4 /* XCRemoteSwiftPackageReference "AndroidXML" */; + productName = AndroidXML; + }; + 540B77DD2ED79CC8009E030C /* AndroidXML */ = { + isa = XCSwiftPackageProductDependency; + package = 54D891112ED7313100BF23C4 /* XCRemoteSwiftPackageReference "AndroidXML" */; + productName = AndroidXML; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 54442BEC2E378B71008A870E /* Project object */; } diff --git a/QLAppBundle.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/QLAppBundle.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..6b4ea56 --- /dev/null +++ b/QLAppBundle.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "c869761611793a3eebb4e2f56e7aebab4faa8db4159e6116b059292c98af7094", + "pins" : [ + { + "identity" : "androidxml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/relikd/AndroidXML", + "state" : { + "revision" : "fd522d612f24ee813c80b1a1b0f6bd311b2735c3", + "version" : "1.3.0" + } + } + ], + "version" : 3 +} diff --git a/QLPreview/Info.plist b/QLPreview/Info.plist index c093c6d..9f056ce 100644 --- a/QLPreview/Info.plist +++ b/QLPreview/Info.plist @@ -15,6 +15,9 @@ com.apple.xcode.archive com.opa334.trollstore.tipa dyn.ah62d4rv4ge81k4puqe + com.google.android.apk + dyn.ah62d4rv4ge80c6dp + public.archive.apk QLSupportsSearchableItems diff --git a/QLThumbnail/Info.plist b/QLThumbnail/Info.plist index 28ba8e7..f97ccc2 100644 --- a/QLThumbnail/Info.plist +++ b/QLThumbnail/Info.plist @@ -13,6 +13,9 @@ com.apple.xcode.archive com.opa334.trollstore.tipa dyn.ah62d4rv4ge81k4puqe + com.google.android.apk + dyn.ah62d4rv4ge80c6dp + public.archive.apk QLThumbnailMinimumDimension 16 diff --git a/QLThumbnail/ThumbnailProvider.swift b/QLThumbnail/ThumbnailProvider.swift index c1d8bc0..a94851c 100644 --- a/QLThumbnail/ThumbnailProvider.swift +++ b/QLThumbnail/ThumbnailProvider.swift @@ -22,7 +22,7 @@ class ThumbnailProvider: QLThumbnailProvider { override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) { let meta = MetaInfo(request.fileURL) - guard let appPlist = meta.readPlistApp() else { + guard let appPlist = meta.readPlistApp(iconOnly: true) else { return } let img = AppIcon(meta).extractImage(from: appPlist).withRoundCorners() diff --git a/resources/template.html b/resources/template.html index 5893a7d..b99c6cf 100644 --- a/resources/template.html +++ b/resources/template.html @@ -73,6 +73,21 @@ __ProvisionDeviceIds__ +
+

Features (required)

+ __ApkFeaturesRequiredList__ +
+ +
+

Features (optional)

+ __ApkFeaturesOptionalList__ +
+ +
+

Permissions

+ __ApkPermissionsList__ +
+

File info

__FileName__
diff --git a/src/AppIcon.swift b/src/AppIcon.swift index 3c28e88..6a6f085 100644 --- a/src/AppIcon.swift +++ b/src/AppIcon.swift @@ -16,6 +16,14 @@ struct AppIcon { /// 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") { @@ -116,6 +124,9 @@ extension AppIcon { } } + case .APK: + return nil // handled in `extractImage()` + case .Archive, .Extension: for iconPath in iconList { let fileName = iconPath.components(separatedBy: "/").last! diff --git a/src/MetaInfo+Apk.swift b/src/MetaInfo+Apk.swift new file mode 100644 index 0000000..7fb10d6 --- /dev/null +++ b/src/MetaInfo+Apk.swift @@ -0,0 +1,255 @@ +import Foundation +import AndroidXML +import os // OSLog + +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "MetaInfo+Apk") + + +// MARK: - Full Manifest + +extension MetaInfo { + /// Extract `AndroidManifest.xml` and parse its content + func readApkManifest() -> PlistDict? { + guard let data = self.readPayloadFile("AndroidManifest.xml", osxSubdir: nil) else { + return nil + } + let storage = ApkXmlManifestParser() + if let xml = try? AndroidXML.init(data: data) { + let ALLOWED_TAGS = [ // keep in sync with `ApkXmlManifestParser` + "manifest", + "application", + "uses-feature", + "uses-permission", + "uses-permission-sdk-23", + "uses-sdk", + ] + let parser = xml.parseXml() + let ignore = XMLParser() + try? parser.iterElements({ startTag, attributes in + if ALLOWED_TAGS.contains(startTag) { + storage.parser(ignore, didStartElement: startTag, namespaceURI: nil, qualifiedName: nil, attributes: try attributes.asDictStr()) + } + }) { endTag in + if ALLOWED_TAGS.contains(endTag) { + storage.parser(ignore, didEndElement: endTag, namespaceURI: nil, qualifiedName: nil) + } + } + } else { + // fallback to xml-string parser + let parser = XMLParser(data: data) + parser.delegate = storage + parser.parse() + } + + 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] + return rv + } +} + +/// 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 } + } + + 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" + // 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 + } + 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) + } + case "uses-feature": + if _scope == ["manifest"], let name = attrs["android:name"] { + let optional = attrs["android:required"] == "false" + optional ? _featOpt.append(name) : _featReq.append(name) + } + case "uses-sdk": + if _scope == ["manifest"] { + _rv["sdkVerMin"] = attrs["android:minSdkVersion"] ?? "1" // "21" + _rv["sdkVerTarget"] = attrs["android:targetSdkVersion"] // "35" + } + default: break // ignore + } + _scope.append(elementName) + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + _scope.removeLast() + } +} + + +// MARK: - Icon only + +extension MetaInfo { + /// Same as `readApkManifest()` but only extract `appIcon`. + func readApkIconOnly() -> PlistDict? { + 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"] + } + }) {_ in} + } else { + // fallback to xml-string parser + icon = ApkXmlIconParser().run(data) + } + if let icon = self.resolveResources([(.Icon, icon)])[0] { + return ["appIcon": icon] + } + return nil + } +} + +/// Shorter form of `ApkXmlManifestParser` to only exctract the icon reference (used for Thumbnail Provider) +private class ApkXmlIconParser: NSObject, XMLParserDelegate { + var result: String? = nil + + func run(_ data: Data) -> String? { + let parser = XMLParser(data: data) + parser.delegate = self + parser.parse() + return result + } + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attrs: [String : String] = [:]) { + if elementName == "application" { + result = attrs["android:icon"] + parser.abortParsing() + } + } +} + + +// 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), + let xml = try? AndroidXML.init(data: data), xml.type == .Table else { + return ids.map { _ in nil } + } + 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) + } + } + } +} + +private extension Tbl_Parser { + func getName(_ ref: TblTableRef) -> String? { + // why the heck are these even allowed? + // apparently there can be references onto references + var ref = ref + while let res = try? self.getResource(ref) { + guard let val = res.entries.first?.entry.value else { + return nil + } + switch val.dataType { + case .Reference: ref = val.asTableRef // and continue + case .String: return val.resolve(self.stringPool) + default: return nil + } + } + return nil + } + + /// If `ref.entry != 0`, lookup resource with matching id + func getIconDirect(_ ref: TblTableRef) -> String? { + guard ref.entry != 0, let res = try? self.getResource(ref) else { + return nil + } + var best: ResValue? = nil + var bestScore: UInt16 = 0 + for e in res.entries { + let density = e.config.screenType.density + if density == .any || density == .None { + continue + } + if let val = e.entry.value { + if density.rawValue > bestScore { + bestScore = density.rawValue + best = val + } + } + } + return best?.resolve(self.stringPool) + } + + /// if `ref.entry == 0`, iterate over all entries and search for attribute name `ic_launcher` + func getIconIndirect(_ ref: TblTableRef) -> String? { + // sadly we cannot just `getResource()` because that can point to an app banner + guard let pkg = try? self.getPackage(ref.package), + var pool = pkg.stringPool(for: .Keys), + let (_, types) = try? pkg.getType(ref.type) else { + return nil + } + var best: ResValue? = nil + var bestScore: UInt16 = 0 + for typ in types { + let density = typ.config.screenType.density + if density == .any || density == .None { + continue + } + try? typ.iterValues { _, entry in + let attrName = pool.getStringCached(entry.key) + guard attrName == "ic_launcher", let val = entry.value else { + return + } + if density.rawValue > bestScore { + bestScore = density.rawValue + best = val + } + } + } + return best?.resolve(self.stringPool) + } +} diff --git a/src/MetaInfo.swift b/src/MetaInfo.swift index 9580b81..7bd4609 100644 --- a/src/MetaInfo.swift +++ b/src/MetaInfo.swift @@ -11,6 +11,7 @@ enum FileType { case IPA case Archive case Extension + case APK } struct MetaInfo { @@ -25,7 +26,7 @@ struct MetaInfo { /// Use file url and UTI type to generate an info object to pass around. init(_ url: URL) { self.url = url - self.UTI = try! url.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier ?? "Unknown" + self.UTI = try! url.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier ?? "Unknown" var isOSX = false var effective: URL? = nil @@ -46,6 +47,10 @@ struct MetaInfo { } case "com.apple.application-and-system-extension": self.type = FileType.Extension + case "com.google.android.apk", "dyn.ah62d4rv4ge80c6dp", "public.archive.apk": + self.type = FileType.APK + zipFile = ZipFile(self.url.path) +// case "com.google.android.apkm", "dyn.ah62d4rv4ge80c6dpry": default: os_log(.error, log: log, "Unsupported file type: %{public}@", self.UTI) fatalError() @@ -58,7 +63,7 @@ struct MetaInfo { /// Evaluate path with `osxSubdir` and `filename` func effectiveUrl(_ osxSubdir: String?, _ filename: String) -> URL { switch self.type { - case .IPA: + case .IPA, .APK: return effectiveUrl case .Archive, .Extension: if isOSX, let osxSubdir { @@ -75,16 +80,20 @@ struct MetaInfo { switch self.type { case .IPA: return zipFile!.unzipFile("Payload/*.app/".appending(filename)) + case .APK: + return zipFile!.unzipFile(filename) case .Archive, .Extension: return try? Data(contentsOf: self.effectiveUrl(osxSubdir, filename)) } } /// Read app default `Info.plist`. (used for both, Preview and Thumbnail) - func readPlistApp() -> PlistDict? { + func readPlistApp(iconOnly: Bool = false) -> PlistDict? { switch self.type { case .IPA, .Archive, .Extension: return self.readPayloadFile("Info.plist", osxSubdir: nil)?.asPlistOrNil() + case .APK: + return iconOnly ? self.readApkIconOnly() : self.readApkManifest() } } } diff --git a/src/Preview+AppInfo.swift b/src/Preview+AppInfo.swift index 056ae3f..5a4a8c2 100644 --- a/src/Preview+AppInfo.swift +++ b/src/Preview+AppInfo.swift @@ -42,4 +42,81 @@ extension PreviewGenerator { "AppMinOS": minVersion, ]) } + + /// 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] + + func asList(_ list: [String]) -> String { + "
\(list.joined(separator: "\n"))
" + } + + func resolveSDK(_ sdk: String?) -> String { + sdk == nil ? "" : "\(sdk!) (Android \(ANDROID_SDK_MAP[sdk!] ?? "?"))" + } + 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 ?? "", + + "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), + + "AppDeviceFamily": "Android", + "AppSDK": resolveSDK(appPlist["sdkVerTarget"] as? String), + "AppMinOS": resolveSDK(appPlist["sdkVerMin"] as? String), + ]) + } } + +private let ANDROID_SDK_MAP: [String: String] = [ + "1": "1.0", + "2": "1.1", + "3": "1.5", + "4": "1.6", + "5": "2.0", + "6": "2.0.1", + "7": "2.1.x", + "8": "2.2.x", + "9": "2.3, 2.3.1, 2.3.2", + "10": "2.3.3, 2.3.4", + "11": "3.0.x", + "12": "3.1.x", + "13": "3.2", + "14": "4.0, 4.0.1, 4.0.2", + "15": "4.0.3, 4.0.4", + "16": "4.1, 4.1.1", + "17": "4.2, 4.2.2", + "18": "4.3", + "19": "4.4", + "20": "4.4W", + "21": "5.0", + "22": "5.1", + "23": "6.0", + "24": "7.0", + "25": "7.1, 7.1.1", + "26": "8.0", + "27": "8.1", + "28": "9", + "29": "10", + "30": "11", + "31": "12", + "32": "12", + "33": "13", + "34": "14", + "35": "15", + "36": "16", + // can we assume new versions will stick to this scheme? + "37": "17", + "38": "18", + "39": "19", + "40": "20", +] diff --git a/src/Preview+ArchiveInfo.swift b/src/Preview+ArchiveInfo.swift index d44861b..a4783de 100644 --- a/src/Preview+ArchiveInfo.swift +++ b/src/Preview+ArchiveInfo.swift @@ -7,7 +7,7 @@ extension MetaInfo { case .Archive: // not `readPayloadFile` because plist is in root dir return try? Data(contentsOf: self.url.appendingPathComponent("Info.plist", isDirectory: false)).asPlistOrNil() - case .IPA, .Extension: + case .IPA, .Extension, .APK: return nil } } diff --git a/src/Preview+Entitlements.swift b/src/Preview+Entitlements.swift index bb2b372..cd3f39b 100644 --- a/src/Preview+Entitlements.swift +++ b/src/Preview+Entitlements.swift @@ -18,6 +18,9 @@ extension PreviewGenerator { return Entitlements(forBinary: tmpPath + "/" + bundleExecutable) case .Archive, .Extension: return Entitlements(forBinary: meta.effectiveUrl("MacOS", bundleExecutable).path) + case .APK: + // not applicable for Android + return Entitlements.withoutBinary() } } diff --git a/src/Preview+iTunesPurchase.swift b/src/Preview+iTunesPurchase.swift index 3997b69..5b10578 100644 --- a/src/Preview+iTunesPurchase.swift +++ b/src/Preview+iTunesPurchase.swift @@ -7,7 +7,7 @@ extension MetaInfo { case .IPA: // not `readPayloadFile` because plist is in root dir return self.zipFile!.unzipFile("iTunesMetadata.plist")?.asPlistOrNil() - case .Archive, .Extension: + case .Archive, .Extension, .APK: return nil } } diff --git a/src/PreviewGenerator.swift b/src/PreviewGenerator.swift index 072f69d..72ace22 100644 --- a/src/PreviewGenerator.swift +++ b/src/PreviewGenerator.swift @@ -15,24 +15,38 @@ struct PreviewGenerator { "EntitlementsHidden": CLASS_HIDDEN, "EntitlementsWarningHidden": CLASS_HIDDEN, "ProvisionHidden": CLASS_HIDDEN, + "ApkFeaturesRequiredHidden": CLASS_HIDDEN, + "ApkFeaturesOptionalHidden": CLASS_HIDDEN, + "ApkPermissionsHidden": CLASS_HIDDEN, ] let meta: MetaInfo init(_ meta: MetaInfo) throws { self.meta = meta guard let plistApp = meta.readPlistApp() else { - throw RuntimeError("Info.plist not found") + let isAndroid = meta.type == .APK + throw RuntimeError(isAndroid ? "AndroidManifest.xml not found" : "Info.plist not found") } - let plistProvision = meta.readPlistProvision() data["QuickLookTitle"] = stringForFileType(meta) - procAppInfo(plistApp, isOSX: meta.isOSX) - procArchiveInfo(meta.readPlistXCArchive()) - procItunesMeta(meta.readPlistItunes()) - procTransportSecurity(plistApp) - procEntitlements(meta, plistApp, plistProvision) - procProvision(plistProvision, isOSX: meta.isOSX) + switch meta.type { + case .IPA, .Archive, .Extension: + procAppInfoApple(plistApp, isOSX: meta.isOSX) + if meta.type == .IPA { + procItunesMeta(meta.readPlistItunes()) + } else if meta.type == .Archive { + procArchiveInfo(meta.readPlistXCArchive()) + } + procTransportSecurity(plistApp) + + let plistProvision = meta.readPlistProvision() + procEntitlements(meta, plistApp, plistProvision) + procProvision(plistProvision, isOSX: meta.isOSX) + + case .APK: + procAppInfoAndroid(plistApp) + } procFileInfo(meta.url) procFooterInfo() // App Icon (last, because the image uses a lot of memory) @@ -46,7 +60,7 @@ struct PreviewGenerator { /// Title of the preview window private func stringForFileType(_ meta: MetaInfo) -> String { switch meta.type { - case .IPA: return "App info" + case .IPA, .APK: return "App info" case .Archive: return "Archive info" case .Extension: return "App extension info" }