feat: support for apk

This commit is contained in:
relikd
2025-11-28 13:21:58 +01:00
parent cde957b01f
commit 591a75dabc
15 changed files with 480 additions and 15 deletions

View File

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

255
src/MetaInfo+Apk.swift Normal file
View File

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

View File

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

View File

@@ -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 {
"<pre>\(list.joined(separator: "\n"))</pre>"
}
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",
]

View File

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

View File

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

View File

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

View File

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