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"
}