From 38c861442cc88333b9853df748f64510bd279fb7 Mon Sep 17 00:00:00 2001 From: relikd Date: Mon, 1 Dec 2025 00:44:59 +0100 Subject: [PATCH] ref: split ApkManifest into data parser and content --- Config.xcconfig | 2 +- QLAppBundle.xcodeproj/project.pbxproj | 22 +- src/Apk+Icon.swift | 74 +++++++ src/Apk+Manifest.swift | 123 +++++++++++ src/Apk.swift | 130 ++++++++++++ src/ApkManifest.swift | 288 -------------------------- src/AppIcon.swift | 6 +- src/Preview+AppInfo.swift | 2 +- src/PreviewGenerator.swift | 4 +- 9 files changed, 350 insertions(+), 301 deletions(-) create mode 100644 src/Apk+Icon.swift create mode 100644 src/Apk+Manifest.swift create mode 100644 src/Apk.swift delete mode 100644 src/ApkManifest.swift diff --git a/Config.xcconfig b/Config.xcconfig index e5e9158..65911e7 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -11,4 +11,4 @@ MACOSX_DEPLOYMENT_TARGET = 10.15 MARKETING_VERSION = 1.4.0 PRODUCT_NAME = QLAppBundle PRODUCT_BUNDLE_IDENTIFIER = de.relikd.QLAppBundle -CURRENT_PROJECT_VERSION = 1981 +CURRENT_PROJECT_VERSION = 2015 diff --git a/QLAppBundle.xcodeproj/project.pbxproj b/QLAppBundle.xcodeproj/project.pbxproj index 48bc75b..83f00be 100644 --- a/QLAppBundle.xcodeproj/project.pbxproj +++ b/QLAppBundle.xcodeproj/project.pbxproj @@ -9,12 +9,15 @@ /* 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 /* ApkManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540B77D82ED79BB2009E030C /* ApkManifest.swift */; }; - 540B77DA2ED79C6B009E030C /* ApkManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540B77D82ED79BB2009E030C /* ApkManifest.swift */; }; + 540B77D92ED79BBD009E030C /* Apk+Manifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540B77D82ED79BB2009E030C /* Apk+Manifest.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 */; }; + 543899082EDCA223007C02FC /* Apk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543899072EDCA223007C02FC /* Apk.swift */; }; + 543899092EDCA26D007C02FC /* Apk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543899072EDCA223007C02FC /* Apk.swift */; }; + 5438990B2EDCA27F007C02FC /* Apk+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5438990A2EDCA27F007C02FC /* Apk+Icon.swift */; }; + 5438990E2EDCB126007C02FC /* Apk+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5438990A2EDCA27F007C02FC /* Apk+Icon.swift */; }; 543FE5742EB3BB5E0059F98B /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 543FE5732EB3BB5E0059F98B /* AppIcon.icns */; }; 54442C232E378BAF008A870E /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54442C222E378BAF008A870E /* Quartz.framework */; }; 54442C702E378BDD008A870E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54442C6A2E378BDD008A870E /* AppDelegate.swift */; }; @@ -137,10 +140,12 @@ /* 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 /* ApkManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApkManifest.swift; sourceTree = ""; }; + 540B77D82ED79BB2009E030C /* Apk+Manifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Apk+Manifest.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; }; + 543899072EDCA223007C02FC /* Apk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Apk.swift; sourceTree = ""; }; + 5438990A2EDCA27F007C02FC /* Apk+Icon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Apk+Icon.swift"; sourceTree = ""; }; 543FE5732EB3BB5E0059F98B /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; 543FE5752EB3BC740059F98B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 54442BF42E378B71008A870E /* QLAppBundle (debug).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "QLAppBundle (debug).app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -245,7 +250,9 @@ 5469E11C2EA5930C00D46CE7 /* Entitlements.swift */, 54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */, 54CCF59D2EDC9A6800D766F9 /* AndroidSdkMap.swift */, - 540B77D82ED79BB2009E030C /* ApkManifest.swift */, + 543899072EDCA223007C02FC /* Apk.swift */, + 5438990A2EDCA27F007C02FC /* Apk+Icon.swift */, + 540B77D82ED79BB2009E030C /* Apk+Manifest.swift */, 547F52DC2EB2C15D002B6D5F /* ExpirationStatus.swift */, 547F52E62EB2C41C002B6D5F /* PreviewGenerator.swift */, 547F52EC2EB2C822002B6D5F /* Preview+AppInfo.swift */, @@ -566,6 +573,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 543899082EDCA223007C02FC /* Apk.swift in Sources */, 549E3BA42EBC021500ADFF56 /* Preview+TransportSecurity.swift in Sources */, 5412DECE2EBC168600F9040D /* Preview+ArchiveInfo.swift in Sources */, 54D3A6F02EA3F49F001EF4F6 /* NSBezierPath+RoundedRect.swift in Sources */, @@ -581,12 +589,13 @@ 54993B952EDBC819008B656D /* Plist+MobileProvision.swift in Sources */, 547F52E82EB2C41C002B6D5F /* PreviewGenerator.swift in Sources */, 54993B882EDB9AA1008B656D /* Plist+Info.swift in Sources */, + 5438990B2EDCA27F007C02FC /* Apk+Icon.swift in Sources */, 547F52EB2EB2C672002B6D5F /* Preview+FileInfo.swift in Sources */, 54CCF59E2EDC9A6800D766F9 /* AndroidSdkMap.swift in Sources */, 547F52ED2EB2C822002B6D5F /* Preview+AppInfo.swift in Sources */, 549E3BA12EBAE7D300ADFF56 /* URL+File.swift in Sources */, 547F52F42EB2CA05002B6D5F /* Preview+Entitlements.swift in Sources */, - 540B77D92ED79BBD009E030C /* ApkManifest.swift in Sources */, + 540B77D92ED79BBD009E030C /* Apk+Manifest.swift in Sources */, 5405CF5E2EA1199B00613856 /* MetaInfo.swift in Sources */, 547F52F92EB2CBAB002B6D5F /* Date+Format.swift in Sources */, 54D3A6EC2EA31B52001EF4F6 /* AppCategories.swift in Sources */, @@ -604,10 +613,11 @@ 547899722EB38F3D00F96B80 /* MetaInfo.swift in Sources */, 547899732EB38F3D00F96B80 /* NSBezierPath+RoundedRect.swift in Sources */, 54AE5BFF2EB3DB1000B4CFC7 /* ThumbnailProvider.swift in Sources */, + 543899092EDCA26D007C02FC /* Apk.swift in Sources */, 549E3BA22EBAECD400ADFF56 /* URL+File.swift in Sources */, 547899752EB38F3D00F96B80 /* AppIcon.swift in Sources */, - 540B77DA2ED79C6B009E030C /* ApkManifest.swift in Sources */, 54993B8B2EDBA75A008B656D /* Plist+Icon.swift in Sources */, + 5438990E2EDCB126007C02FC /* Apk+Icon.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/src/Apk+Icon.swift b/src/Apk+Icon.swift new file mode 100644 index 0000000..0ad7be2 --- /dev/null +++ b/src/Apk+Icon.swift @@ -0,0 +1,74 @@ +import Foundation +import AndroidXML + +extension MetaInfo { + /// Read `AndroidManifest.xml` but only extract `appIcon`. + func readApk_Icon() -> Apk_Icon? { + assert(type == .APK) + var apk = Apk(self) + return Apk_Icon(&apk) + } +} + + +// MARK: - Apk_Icon + +/// Representation of `AndroidManifest.xml` (containing only the icon extractor). +/// Seperate from main class because everything else is not needed for `ThumbnailProvider` +struct Apk_Icon { + let path: String + let data: Data + + init?(_ apk: inout Apk, iconRef: String? = nil) { + if apk.isApkm, let iconData = apk.mainZip.unzipFile("icon.png") { + path = "icon.png" + data = iconData + return + } + + guard let manifest = apk.manifest else { + return nil + } + + var ref = iconRef + // no need to parse xml if reference already supplied + if ref == nil { + if let xml = try? AndroidXML.init(data: manifest) { + let parser = xml.parseXml() + try? parser.iterElements({ startTag, attributes in + if startTag == "application" { + ref = try? attributes.get("android:icon")?.resolve(parser.stringPool) + } + }) {_ in} + } else { + // fallback to xml-string parser + ref = ApkXmlIconParser().run(manifest) + } + } + + guard let img = apk.resolveIcon(&ref) else { + return nil + } + path = ref! + data = img + } +} + +/// 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() + } + } +} diff --git a/src/Apk+Manifest.swift b/src/Apk+Manifest.swift new file mode 100644 index 0000000..5e7c95d --- /dev/null +++ b/src/Apk+Manifest.swift @@ -0,0 +1,123 @@ +import Foundation +import AndroidXML + +extension MetaInfo { + /// Read `AndroidManifest.xml` and parse its content + func readApk_Manifest() -> Apk_Manifest? { + assert(type == .APK) + var apk = Apk(self) + return Apk_Manifest.from(&apk) + } +} + + +// MARK: - Apk_Manifest + +/// Representation of `AndroidManifest.xml`. +/// See: +struct Apk_Manifest { + var packageId: String? = nil + var appName: String? = nil + var icon: Apk_Icon? = nil + /// Computed property + var appIconData: Data? = nil + var versionName: String? = nil + var versionCode: String? = nil + var sdkVerMin: Int? = nil + var sdkVerTarget: Int? = nil + + var featuresRequired: [String] = [] + var featuresOptional: [String] = [] + var permissions: [String] = [] + + static func from(_ apk: inout Apk) -> Self? { + guard let manifest = apk.manifest else { + return nil + } + + let storage = ApkXmlManifestParser() + if let xml = try? AndroidXML.init(data: manifest) { + 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: manifest) + parser.delegate = storage + parser.parse() + } + + var rv = storage.result + apk.resolveName(&rv.appName) + rv.icon = Apk_Icon(&apk, iconRef: storage.iconRef) + return rv + } +} + +// keep in sync with `ApkXmlManifestParser` below +private let ALLOWED_TAGS = [ + "manifest", + "application", + "uses-feature", + "uses-permission", + "uses-permission-sdk-23", + "uses-sdk", +] + +/// Wrapper to use same code for binary-xml and string-xml parsing +private class ApkXmlManifestParser: NSObject, XMLParserDelegate { + private var _scope: [String] = [] + var result = Apk_Manifest() + var iconRef: String? = nil + + 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 == [] { + 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"] { + result.appName = attrs["android:label"] // @resource-ref + iconRef = 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"] { + result.permissions.append(name) + } + case "uses-feature": + if _scope == ["manifest"], let name = attrs["android:name"] { + if attrs["android:required"] == "false" { + result.featuresOptional.append(name) + } else { + result.featuresRequired.append(name) + } + } + case "uses-sdk": + if _scope == ["manifest"] { + result.sdkVerMin = Int(attrs["android:minSdkVersion"] ?? "1") // "21" + result.sdkVerTarget = Int(attrs["android:targetSdkVersion"] ?? "-1") // "35" + } + default: break // ignore + } + _scope.append(elementName) + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + _scope.removeLast() + } +} diff --git a/src/Apk.swift b/src/Apk.swift new file mode 100644 index 0000000..12871ed --- /dev/null +++ b/src/Apk.swift @@ -0,0 +1,130 @@ +import Foundation +import AndroidXML + +/// Data structure for processing the content of `.apk` files. +struct Apk { + let isApkm: Bool + let mainZip: ZipFile + + init(_ meta: MetaInfo) { + isApkm = meta.url.pathExtension.lowercased() == "apkm" + mainZip = meta.zipFile! + } + + /// Unzip `AndroidManifest.xml` (once). Data is cached until deconstructor. + lazy var manifest: Data? = { effectiveZip?.unzipFile("AndroidManifest.xml") }() + + /// Select zip-file depending on `.apk` or `.apkm` extension + private lazy var effectiveZip: ZipFile? = { isApkm ? nestedZip : mainZip }() + + /// `.apkm` may contain multiple `.apk` files. (plus "icon.png" and "info.json" files) + private lazy var nestedZip: ZipFile? = { + if isApkm, let pth = try? mainZip.unzipFileToTempDir("base.apk") { + return ZipFile(pth) + } + return nil + }() + + /// Shared instance for resolving resources + private lazy var resourceParser: Tbl_Parser? = { + guard let data = effectiveZip?.unzipFile("resources.arsc"), + let xml = try? AndroidXML.init(data: data), xml.type == .Table else { + return nil + } + return xml.parseTable() + }() + + /// Lookup app bundle name / label + mutating func resolveName(_ name: inout String?) { + if let val = name, let ref = try? TblTableRef(val), let parser = resourceParser { + name = parser.getName(ref) + } + } + + /// Lookup image path and image data + mutating func resolveIcon(_ iconRef: inout String?) -> Data? { + if let val = iconRef, let ref = try? TblTableRef(val), let parser = resourceParser { + if let iconPath = parser.getIconDirect(ref) ?? parser.getIconIndirect(ref) { + iconRef = iconPath + return effectiveZip?.unzipFile(iconPath) + } + } + return nil + } +} + + +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 + } + + /// Lookup resource with matching id. Choose the icon with the highest density. + func getIconDirect(_ ref: TblTableRef) -> String? { + guard let res = try? self.getResource(ref) else { + return nil + } + var best: ResValue? = nil + var bestScore: UInt16 = 0 + for e in res.entries { + switch e.config.screenType.density { + case .Default, .any, .None: continue + case let density: + if density.rawValue > bestScore, let val = e.entry.value { + bestScore = density.rawValue + best = val + } + } + } + return best?.resolve(self.stringPool) + } + + /// Iterate over all entries and choose best-rated icon file. + /// Rating prefers files which have an attribute name `"app_icon"` or `"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 + } + // density is 120-640 + let rates: [String: UInt16] = [ + "app_icon": 1000, + "ic_launcher": 800, + "ic_launcher_foreground": 200, + ] + var best: ResValue? = nil + var bestScore: UInt16 = 0 + for typ in types { + switch typ.config.screenType.density { + case .any, .None: continue + case let density: + try? typ.iterValues { + if let val = $1.value { + let attrName = pool.getStringCached($1.key) + let score = density.rawValue + (rates[attrName] ?? 0) + if score > bestScore { + bestScore = score + best = val + } + } + } + } + } + return best?.resolve(self.stringPool) + } +} diff --git a/src/ApkManifest.swift b/src/ApkManifest.swift deleted file mode 100644 index 35a511b..0000000 --- a/src/ApkManifest.swift +++ /dev/null @@ -1,288 +0,0 @@ -import Foundation -import AndroidXML -import os // OSLog - -private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "ApkManifest") - -/// Representation of `AndroidManifest.xml`. -/// See: -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: Int? = nil - var sdkVerTarget: Int? = nil - - var featuresRequired: [String] = [] - var featuresOptional: [String] = [] - var permissions: [String] = [] -} - - -// MARK: - Full Manifest - -extension MetaInfo { - /// `(true, )` -- if extension is `.apkm`. - /// `(false, zipFile!)` -- if extension is `.apk`. - private func effectiveApk() -> (Bool, ZipFile) { - // .apkm may contain multiple .apk files. (plus "icon.png" and "info.json" files) - if self.url.pathExtension.lowercased() == "apkm" { - if let pth = try? zipFile!.unzipFileToTempDir("base.apk") { - return (true, ZipFile(pth)) - } - } - // .apk (and derivatives) have their contents structured directly in zip - return (false, zipFile!) - } - - /// Extract `AndroidManifest.xml` and parse its content - func readApkManifest() -> ApkManifest? { - assert(type == .APK) - let (isApkm, nestedZip) = effectiveApk() - guard let data = nestedZip.unzipFile("AndroidManifest.xml") 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 - // if apkm can load png, prefer that over xml-parsing - if isApkm, let iconData = zipFile!.unzipFile("icon.png") { - rv.appIcon = nil - rv.appIconData = iconData - } - os_log(.debug, log: log, "[apk] resolving %{public}@", String(describing: rv)) - rv.resolve(nestedZip) - os_log(.debug, log: log, "[apk] resolved name: \"%{public}@\" icon: %{public}@", rv.appName ?? "", rv.appIcon ?? "-") - return rv - } -} - -/// Wrapper to use same code for binary-xml and string-xml parsing -private class ApkXmlManifestParser: NSObject, XMLParserDelegate { - private var _scope: [String] = [] - 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 == [] { - 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"] { - 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"] { - result.permissions.append(name) - } - case "uses-feature": - if _scope == ["manifest"], let name = attrs["android:name"] { - if attrs["android:required"] == "false" { - result.featuresOptional.append(name) - } else { - result.featuresRequired.append(name) - } - } - case "uses-sdk": - if _scope == ["manifest"] { - result.sdkVerMin = Int(attrs["android:minSdkVersion"] ?? "1") // "21" - result.sdkVerTarget = Int(attrs["android:targetSdkVersion"] ?? "-1") // "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() -> ApkManifest? { - assert(type == .APK) - var rv = ApkManifest() - let (isApkm, nestedZip) = effectiveApk() - if isApkm, let iconData = zipFile!.unzipFile("icon.png") { - rv.appIcon = "icon.png" - rv.appIconData = iconData - return rv - } - guard let data = nestedZip.unzipFile("AndroidManifest.xml") else { - return nil - } - if let xml = try? AndroidXML.init(data: data) { - let parser = xml.parseXml() - try? parser.iterElements({ startTag, attributes in - if startTag == "application" { - rv.appIcon = try? attributes.get("android:icon")?.resolve(parser.stringPool) - } - }) {_ in} - } else { - // fallback to xml-string parser - rv.appIcon = ApkXmlIconParser().run(data) - } - rv.resolve(nestedZip) - return rv - } -} - -/// 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 ApkManifest { - /// Reuse `ZipFile` from previous call because that may be an already unpacked `base.apk` - mutating func resolve(_ zip: ZipFile) { - guard let data = zip.unzipFile("resources.arsc"), - let xml = try? AndroidXML.init(data: data), xml.type == .Table else { - return - } - - let parser = xml.parseTable() - 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) - } - } - } -} - -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 - } - - /// Lookup resource with matching id. Choose the icon with the highest density. - func getIconDirect(_ ref: TblTableRef) -> String? { - guard let res = try? self.getResource(ref) else { - return nil - } - var best: ResValue? = nil - var bestScore: UInt16 = 0 - for e in res.entries { - switch e.config.screenType.density { - case .Default, .any, .None: continue - case let density: - if density.rawValue > bestScore, let val = e.entry.value { - bestScore = density.rawValue - best = val - } - } - } - return best?.resolve(self.stringPool) - } - - /// Iterate over all entries and choose best-rated icon file. - /// Rating prefers files which have an attribute name `"app_icon"` or `"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 - } - // density is 120-640 - let rates: [String: UInt16] = [ - "app_icon": 1000, - "ic_launcher": 800, - "ic_launcher_foreground": 200, - ] - var best: ResValue? = nil - var bestScore: UInt16 = 0 - for typ in types { - switch typ.config.screenType.density { - case .any, .None: continue - case let density: - try? typ.iterValues { - if let val = $1.value { - let attrName = pool.getStringCached($1.key) - let score = density.rawValue + (rates[attrName] ?? 0) - if score > bestScore { - bestScore = score - best = val - } - } - } - } - } - return best?.resolve(self.stringPool) - } -} diff --git a/src/AppIcon.swift b/src/AppIcon.swift index 2eaf7ec..eef785d 100644 --- a/src/AppIcon.swift +++ b/src/AppIcon.swift @@ -19,13 +19,13 @@ struct AppIcon { case .IPA, .Archive, .Extension: extractImage(from: meta.readPlist_Icon()?.filenames) case .APK: - extractImage(from: meta.readApkIconOnly()) + extractImage(from: meta.readApk_Icon()) } } /// Extract image from Android app bundle. - func extractImage(from manifest: ApkManifest?) -> NSImage { - if let data = manifest?.appIconData, let img = NSImage(data: data) { + func extractImage(from apkIcon: Apk_Icon?) -> NSImage { + if let data = apkIcon?.data, let img = NSImage(data: data) { return img } return defaultIcon() diff --git a/src/Preview+AppInfo.swift b/src/Preview+AppInfo.swift index bd7d234..1b5471e 100644 --- a/src/Preview+AppInfo.swift +++ b/src/Preview+AppInfo.swift @@ -20,7 +20,7 @@ extension PreviewGenerator { } /// Process info stored in `AndroidManifest.xml` - mutating func procAppInfoAndroid(_ manifest: ApkManifest) { + mutating func procAppInfoAndroid(_ manifest: Apk_Manifest) { let featReq = manifest.featuresRequired let featOpt = manifest.featuresOptional let perms = manifest.permissions diff --git a/src/PreviewGenerator.swift b/src/PreviewGenerator.swift index c28fd76..eb97cb8 100644 --- a/src/PreviewGenerator.swift +++ b/src/PreviewGenerator.swift @@ -44,12 +44,12 @@ struct PreviewGenerator { data["AppIcon"] = AppIcon(meta).extractImage(from: plistApp.icons).withRoundCorners().asBase64() case .APK: - guard let manifest = meta.readApkManifest() else { + guard let manifest = meta.readApk_Manifest() 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["AppIcon"] = AppIcon(meta).extractImage(from: manifest.icon).withRoundCorners().asBase64() } data["QuickLookTitle"] = stringForFileType(meta)