ref: split ApkManifest into data parser and content

This commit is contained in:
relikd
2025-12-01 00:44:59 +01:00
parent 1ce5f3e069
commit 38c861442c
9 changed files with 350 additions and 301 deletions

74
src/Apk+Icon.swift Normal file
View File

@@ -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()
}
}
}

123
src/Apk+Manifest.swift Normal file
View File

@@ -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: <https://developer.android.com/guide/topics/manifest/manifest-element>
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()
}
}

130
src/Apk.swift Normal file
View File

@@ -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)
}
}

View File

@@ -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: <https://developer.android.com/guide/topics/manifest/manifest-element>
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, <nested-apk>)` -- 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)
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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)