ref: ApkManifest

This commit is contained in:
relikd
2025-11-29 20:24:01 +01:00
parent f233d0e4a2
commit 6bb62bdf82
8 changed files with 108 additions and 100 deletions

View File

@@ -1038,7 +1038,7 @@
repositoryURL = "https://github.com/relikd/AndroidXML"; repositoryURL = "https://github.com/relikd/AndroidXML";
requirement = { requirement = {
kind = exactVersion; kind = exactVersion;
version = 1.3.0; version = 1.4.0;
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */

View File

@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/relikd/AndroidXML", "location" : "https://github.com/relikd/AndroidXML",
"state" : { "state" : {
"revision" : "fd522d612f24ee813c80b1a1b0f6bd311b2735c3", "revision" : "d9fe646bcc3b05548aebbd20b4eee0af675c129f",
"version" : "1.3.0" "version" : "1.4.0"
} }
} }
], ],

View File

@@ -16,16 +16,9 @@ extension QLThumbnailReply {
} }
class ThumbnailProvider: QLThumbnailProvider { 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) { override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) {
let meta = MetaInfo(request.fileURL) let meta = MetaInfo(request.fileURL)
guard let appPlist = meta.readPlistApp(iconOnly: true) else { let img = AppIcon(meta).extractImageForThumbnail().withRoundCorners()
return
}
let img = AppIcon(meta).extractImage(from: appPlist).withRoundCorners()
// First way: Draw the thumbnail into the current context, set up with UIKit's coordinate system. // 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 let reply = QLThumbnailReply(contextSize: request.maximumSize, currentContextDrawing: { () -> Bool in

View File

@@ -13,17 +13,27 @@ struct AppIcon {
self.meta = meta 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. /// 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. /// 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 { 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 // no need to unwrap the plist, and most .ipa should include the Artwork anyway
if meta.type == .IPA { if meta.type == .IPA {
if let data = meta.zipFile!.unzipFile("iTunesArtwork") { if let data = meta.zipFile!.unzipFile("iTunesArtwork") {

View File

@@ -4,12 +4,30 @@ import os // OSLog
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "MetaInfo+Apk") 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 // MARK: - Full Manifest
extension MetaInfo { extension MetaInfo {
/// Extract `AndroidManifest.xml` and parse its content /// 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 { guard let data = self.readPayloadFile("AndroidManifest.xml", osxSubdir: nil) else {
return nil return nil
} }
@@ -42,14 +60,9 @@ extension MetaInfo {
} }
var rv = storage.result var rv = storage.result
guard let _ = rv["packageId"] else { os_log(.debug, log: log, "[apk] resolving %{public}@", String(describing: rv))
return nil rv.resolve(zipFile!)
} os_log(.debug, log: log, "[apk] resolved name: \"%{public}@\" icon: %{public}@", rv.appName ?? "", rv.appIcon ?? "-")
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 return rv
} }
} }
@@ -57,48 +70,41 @@ extension MetaInfo {
/// Wrapper to use same code for binary-xml and string-xml parsing /// Wrapper to use same code for binary-xml and string-xml parsing
private class ApkXmlManifestParser: NSObject, XMLParserDelegate { private class ApkXmlManifestParser: NSObject, XMLParserDelegate {
private var _scope: [String] = [] private var _scope: [String] = []
private var _rv: [String: String] = [:] var result = ApkManifest()
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] = [:]) { func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attrs: [String : String] = [:]) {
// keep in sync with ALLOWED_TAGS above // keep in sync with ALLOWED_TAGS above
switch elementName { switch elementName {
case "manifest": case "manifest":
if _scope == [] { if _scope == [] {
_rv["packageId"] = attrs["package"] // "org.bundle.id" result.packageId = attrs["package"] // "org.bundle.id"
_rv["versionName"] = attrs["android:versionName"] // "7.62.3" result.versionName = attrs["android:versionName"] // "7.62.3"
_rv["versionCode"] = attrs["android:versionCode"] // "160700" result.versionCode = attrs["android:versionCode"] // "160700"
// attrs["platformBuildVersionCode"] // "35" // attrs["platformBuildVersionCode"] // "35"
// attrs["platformBuildVersionName"] // "15" // attrs["platformBuildVersionName"] // "15"
} }
case "application": case "application":
if _scope == ["manifest"] { if _scope == ["manifest"] {
_rv["appName"] = attrs["android:label"] // @resource-ref result.appName = attrs["android:label"] // @resource-ref
_rv["appIcon"] = attrs["android:icon"] // @resource-ref result.appIcon = attrs["android:icon"] // @resource-ref
} }
case "uses-permission", "uses-permission-sdk-23": case "uses-permission", "uses-permission-sdk-23":
// no "permission" because that will produce duplicates with "uses-permission" // no "permission" because that will produce duplicates with "uses-permission"
if _scope == ["manifest"], let name = attrs["android:name"] { if _scope == ["manifest"], let name = attrs["android:name"] {
_perm.append(name) result.permissions.append(name)
} }
case "uses-feature": case "uses-feature":
if _scope == ["manifest"], let name = attrs["android:name"] { if _scope == ["manifest"], let name = attrs["android:name"] {
let optional = attrs["android:required"] == "false" if attrs["android:required"] == "false" {
optional ? _featOpt.append(name) : _featReq.append(name) result.featuresOptional.append(name)
} else {
result.featuresRequired.append(name)
}
} }
case "uses-sdk": case "uses-sdk":
if _scope == ["manifest"] { if _scope == ["manifest"] {
_rv["sdkVerMin"] = attrs["android:minSdkVersion"] ?? "1" // "21" result.sdkVerMin = attrs["android:minSdkVersion"] ?? "1" // "21"
_rv["sdkVerTarget"] = attrs["android:targetSdkVersion"] // "35" result.sdkVerTarget = attrs["android:targetSdkVersion"] // "35"
} }
default: break // ignore default: break // ignore
} }
@@ -115,26 +121,25 @@ private class ApkXmlManifestParser: NSObject, XMLParserDelegate {
extension MetaInfo { extension MetaInfo {
/// Same as `readApkManifest()` but only extract `appIcon`. /// 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 { guard let data = self.readPayloadFile("AndroidManifest.xml", osxSubdir: nil) else {
return nil return nil
} }
var icon: String? = nil
if let xml = try? AndroidXML.init(data: data) { if let xml = try? AndroidXML.init(data: data) {
let parser = xml.parseXml() let parser = xml.parseXml()
try? parser.iterElements({ startTag, attributes in try? parser.iterElements({ startTag, attributes in
if startTag == "application" { if startTag == "application" {
icon = try? attributes.asDictStr()["android:icon"] rv.appIcon = try? attributes.get("android:icon")?.resolve(parser.stringPool)
} }
}) {_ in} }) {_ in}
} else { } else {
// fallback to xml-string parser // fallback to xml-string parser
icon = ApkXmlIconParser().run(data) rv.appIcon = ApkXmlIconParser().run(data)
} }
if let icon = self.resolveResources([(.Icon, icon)])[0] { rv.resolve(zipFile!)
return ["appIcon": icon] return rv
}
return nil
} }
} }
@@ -160,25 +165,21 @@ private class ApkXmlIconParser: NSObject, XMLParserDelegate {
// MARK: - Resolve resource // MARK: - Resolve resource
private extension MetaInfo { private extension ApkManifest {
// currently there are only two types of resources, "android:icon" and "android:label" mutating func resolve(_ zip: ZipFile) {
enum ResourceType { guard let data = zip.unzipFile("resources.arsc"),
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 { let xml = try? AndroidXML.init(data: data), xml.type == .Table else {
return ids.map { _ in nil } return
} }
let parser = xml.parseTable() let parser = xml.parseTable()
return ids.map { typ, val in if let val = appName, let ref = try? TblTableRef(val) {
guard let val, let ref = try? TblTableRef(val) else { appName = parser.getName(ref)
return nil }
} if let val = appIcon, let ref = try? TblTableRef(val) {
switch typ { if let iconPath = parser.getIconDirect(ref) ?? parser.getIconIndirect(ref) {
case .Name: return parser.getName(ref) appIcon = iconPath
case .Icon: return parser.getIconDirect(ref) ?? parser.getIconIndirect(ref) appIconData = zip.unzipFile(iconPath)
} }
} }
} }

View File

@@ -88,12 +88,12 @@ struct MetaInfo {
} }
/// Read app default `Info.plist`. (used for both, Preview and Thumbnail) /// Read app default `Info.plist`. (used for both, Preview and Thumbnail)
func readPlistApp(iconOnly: Bool = false) -> PlistDict? { func readPlistApp() -> PlistDict? {
switch self.type { switch self.type {
case .IPA, .Archive, .Extension: case .IPA, .Archive, .Extension:
return self.readPayloadFile("Info.plist", osxSubdir: nil)?.asPlistOrNil() return self.readPayloadFile("Info.plist", osxSubdir: nil)?.asPlistOrNil()
case .APK: case .APK:
return iconOnly ? self.readApkIconOnly() : self.readApkManifest() return nil // not applicable for Android
} }
} }
} }

View File

@@ -44,10 +44,10 @@ extension PreviewGenerator {
} }
/// Process info stored in `Info.plist` /// Process info stored in `Info.plist`
mutating func procAppInfoAndroid(_ appPlist: PlistDict) { mutating func procAppInfoAndroid(_ manifest: ApkManifest) {
let featuresRequired = appPlist["featuresRequired"] as! [String] let featReq = manifest.featuresRequired
let featuresOptional = appPlist["featuresOptional"] as! [String] let featOpt = manifest.featuresOptional
let permissions = appPlist["permissions"] as! [String] let perms = manifest.permissions
func asList(_ list: [String]) -> String { func asList(_ list: [String]) -> String {
"<pre>\(list.joined(separator: "\n"))</pre>" "<pre>\(list.joined(separator: "\n"))</pre>"
@@ -58,21 +58,21 @@ extension PreviewGenerator {
} }
self.apply([ self.apply([
"AppInfoHidden": CLASS_VISIBLE, "AppInfoHidden": CLASS_VISIBLE,
"AppName": appPlist["appName"] as? String ?? "", "AppName": manifest.appName ?? "",
"AppVersion": appPlist["versionName"] as? String ?? "", "AppVersion": manifest.versionName ?? "",
"AppBuildVer": appPlist["versionCode"] as? String ?? "", "AppBuildVer": manifest.versionCode ?? "",
"AppId": appPlist["packageId"] as? String ?? "", "AppId": manifest.packageId ?? "",
"ApkFeaturesRequiredHidden": featuresRequired.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE, "ApkFeaturesRequiredHidden": featReq.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE,
"ApkFeaturesRequiredList": asList(featuresRequired), "ApkFeaturesRequiredList": asList(featReq),
"ApkFeaturesOptionalHidden": featuresOptional.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE, "ApkFeaturesOptionalHidden": featOpt.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE,
"ApkFeaturesOptionalList": asList(featuresOptional), "ApkFeaturesOptionalList": asList(featOpt),
"ApkPermissionsHidden": permissions.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE, "ApkPermissionsHidden": perms.isEmpty ? CLASS_HIDDEN : CLASS_VISIBLE,
"ApkPermissionsList": asList(permissions), "ApkPermissionsList": asList(perms),
"AppDeviceFamily": "Android", "AppDeviceFamily": "Android",
"AppSDK": resolveSDK(appPlist["sdkVerTarget"] as? String), "AppSDK": resolveSDK(manifest.sdkVerTarget),
"AppMinOS": resolveSDK(appPlist["sdkVerMin"] as? String), "AppMinOS": resolveSDK(manifest.sdkVerMin),
]) ])
} }
} }

View File

@@ -23,15 +23,12 @@ struct PreviewGenerator {
init(_ meta: MetaInfo) throws { init(_ meta: MetaInfo) throws {
self.meta = meta 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 { switch meta.type {
case .IPA, .Archive, .Extension: case .IPA, .Archive, .Extension:
guard let plistApp = meta.readPlistApp() else {
throw RuntimeError("Info.plist not found")
}
procAppInfoApple(plistApp, isOSX: meta.isOSX) procAppInfoApple(plistApp, isOSX: meta.isOSX)
if meta.type == .IPA { if meta.type == .IPA {
procItunesMeta(meta.readPlistItunes()) procItunesMeta(meta.readPlistItunes())
@@ -43,14 +40,21 @@ struct PreviewGenerator {
let plistProvision = meta.readPlistProvision() let plistProvision = meta.readPlistProvision()
procEntitlements(meta, plistApp, plistProvision) procEntitlements(meta, plistApp, plistProvision)
procProvision(plistProvision, isOSX: meta.isOSX) 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: 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) procFileInfo(meta.url)
procFooterInfo() 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]) { mutating func apply(_ values: [String: String]) {