From a28636a7cacfc3524fd766c1a352364bd1b78d17 Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 25 Nov 2025 22:46:14 +0100 Subject: [PATCH] Initial --- .gitignore | 8 + LICENSE | 7 + Package.swift | 23 ++ README.md | 90 +++++ Sources/AndroidXML/AndroidXML.swift | 61 ++++ Sources/AndroidXML/BaseTypes.swift | 48 +++ Sources/AndroidXML/ChunkHeader.swift | 81 +++++ Sources/AndroidXML/ColorComponents.swift | 57 ++++ Sources/AndroidXML/RawBytes.swift | 135 ++++++++ Sources/AndroidXML/ResValue.swift | 198 +++++++++++ Sources/AndroidXML/StringPool.swift | 98 ++++++ Sources/AndroidXML/StringPoolSpan.swift | 22 ++ Sources/AndroidXML/TblEntry.swift | 91 +++++ Sources/AndroidXML/TblEntryMap.swift | 98 ++++++ Sources/AndroidXML/TblPkg.swift | 100 ++++++ Sources/AndroidXML/TblTableRef.swift | 34 ++ Sources/AndroidXML/TblType.swift | 179 ++++++++++ Sources/AndroidXML/TblTypeConfig.swift | 401 +++++++++++++++++++++++ Sources/AndroidXML/TblTypeSpec.swift | 91 +++++ Sources/AndroidXML/Tbl_Parser.swift | 82 +++++ Sources/AndroidXML/XmlAttribute.swift | 22 ++ Sources/AndroidXML/XmlCDATA.swift | 20 ++ Sources/AndroidXML/XmlElement.swift | 79 +++++ Sources/AndroidXML/XmlNamespace.swift | 22 ++ Sources/AndroidXML/XmlNode.swift | 24 ++ Sources/AndroidXML/XmlResourceMap.swift | 20 ++ Sources/AndroidXML/Xml_Parser.swift | 218 ++++++++++++ Tests/AndroidXMLTests/Test.swift | 71 ++++ 28 files changed, 2380 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/AndroidXML/AndroidXML.swift create mode 100644 Sources/AndroidXML/BaseTypes.swift create mode 100644 Sources/AndroidXML/ChunkHeader.swift create mode 100644 Sources/AndroidXML/ColorComponents.swift create mode 100644 Sources/AndroidXML/RawBytes.swift create mode 100644 Sources/AndroidXML/ResValue.swift create mode 100644 Sources/AndroidXML/StringPool.swift create mode 100644 Sources/AndroidXML/StringPoolSpan.swift create mode 100644 Sources/AndroidXML/TblEntry.swift create mode 100644 Sources/AndroidXML/TblEntryMap.swift create mode 100644 Sources/AndroidXML/TblPkg.swift create mode 100644 Sources/AndroidXML/TblTableRef.swift create mode 100644 Sources/AndroidXML/TblType.swift create mode 100644 Sources/AndroidXML/TblTypeConfig.swift create mode 100644 Sources/AndroidXML/TblTypeSpec.swift create mode 100644 Sources/AndroidXML/Tbl_Parser.swift create mode 100644 Sources/AndroidXML/XmlAttribute.swift create mode 100644 Sources/AndroidXML/XmlCDATA.swift create mode 100644 Sources/AndroidXML/XmlElement.swift create mode 100644 Sources/AndroidXML/XmlNamespace.swift create mode 100644 Sources/AndroidXML/XmlNode.swift create mode 100644 Sources/AndroidXML/XmlResourceMap.swift create mode 100644 Sources/AndroidXML/Xml_Parser.swift create mode 100644 Tests/AndroidXMLTests/Test.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..509ad41 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 relikd + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e554ccd --- /dev/null +++ b/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version:5.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AndroidXML", + products: [ + .library( + name: "AndroidXML", + targets: ["AndroidXML"] + ), + ], + targets: [ + .target( + name: "AndroidXML" + ), + .testTarget( + name: "AndroidXMLTests", + dependencies: ["AndroidXML"] + ), + ], +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..033d073 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# AndroidXML + +Pure Swift library for parsing binary XML files such as found in Android apk files. + + +## About + +Written from scratch and closely following the original C++ source code ([ResourceTypes.h], [ResourceTypes.cpp]). +Names have been adapted to a more Swiftly syntax. +Import of `Foundation` is optional which should makes the library cross-platform compatible (though I have not tested other than macOS). + +Since the AXML format is not documented anywhere – apart from the source code – you cannot rely on the structure to work forever. +Especially, pay attention to other binary XML formats which have [been spotted](https://www.cclsolutionsgroup.com/post/android-abx-binary-xml) on Android. +I do not think ABX is currently used for `.apk` files, only system files. +However, If that should change I would need to make some adjustments to this library. + + +## Example Usage + +### Automatic XML String Conversion + +```swift +let parser = try AndroidXML(path: "some/AndroidManifest.xml").parseXml() +print(try parser.xmlString()) +``` + +### Manual XML Parser + +```swift +let xml = try! AndroidXML(data: [...]).parseXml() +try xml.iterElements({ startTag, attributes in + print("<\(startTag)>") + for (name, val) in attributes { + print("\t", name, "=", val.resolve(xml.stringPool)) + } +}) { endTag in + print("") +} +``` + +### Resource Table Lookup + +```swift +let lookupId = 0x7F100000 +let xml = try! AndroidXML(path: "some/resources.arsc").parseTable() +let res = try! xml.getResource(TblTableRef(lookupId))! +let pkgPool = res.package.stringPool(for: .Keys)! +for x in res.entries { + let val = x.entry.value! + print(pkgPool.getString(x.entry.key), val.resolve(xml.stringPool), x.config.screenType.density) +} +``` + +### Manual Table Parsing + +```swift +let xml = try AndroidXML(path: "some/resources.arsc").parseTable() +for pkg in try xml.getPackages() { + let pool = pkg.stringPool(for: .Keys)! + try pkg.iterTypes { spec, types in + //print("----- \(spec.id) ------") + for type in types { + try type.iterValues { idx, entry in + if entry.isComplex { + //print("::.::.complex", pool.getString(entry.key), entry.valueMap!.parent.asHex) + for x in entry.valueMap!.entries { + let _ = x.name.asHex + let _ = x.value.resolve(xml.stringPool) + } + } else { + let _ = pool.getString(entry.key) + let _ = entry.value!.resolve(xml.stringPool) + } + } + } + } +} +``` + + +## Developer Note + +There are a few places I could not properly test becaues none of my test files triggered the condition. +For example, handling dynamic attributes and dynamic references. +I've added some `assert` (ignored in release builds) to trigger as soon as a specific condition is met. +If you encounter a file, which triggers such a condition, reach out so I can finalize the library. + + +[ResourceTypes.h]: https://android.googlesource.com/platform/frameworks/base/+/master/libs/androidfw/include/androidfw/ResourceTypes.h +[ResourceTypes.cpp]: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/libs/androidfw/ResourceTypes.cpp diff --git a/Sources/AndroidXML/AndroidXML.swift b/Sources/AndroidXML/AndroidXML.swift new file mode 100644 index 0000000..46199e2 --- /dev/null +++ b/Sources/AndroidXML/AndroidXML.swift @@ -0,0 +1,61 @@ +#if canImport(Foundation) +import Foundation +#endif + +// see: +// https://android.googlesource.com/platform/frameworks/base/+/master/libs/androidfw/include/androidfw/ResourceTypes.h +// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/libs/androidfw/ResourceTypes.cpp +// or: +// https://github.com/aosp-mirror/platform_frameworks_base/blob/master/libs/androidfw/include/androidfw/ResourceTypes.h +// https://github.com/aosp-mirror/platform_frameworks_base/blob/master/libs/androidfw/ResourceTypes.cpp + +public struct AndroidXML { + private let _bytes: RawBytes + /// Document type. Either `.XmlTree` or `.Table + public let type: DocumentType + public enum DocumentType { + case XmlTree + case Table + } + + #if canImport(Foundation) + /// Convenience init for `init(_: URL)` using `URL(fileURLWithPath: path)` + public init(path: String) throws { + try self.init(url: URL(fileURLWithPath: path)) + } + /// Convenience init for `init(_: RawBytes)` using `Data(contentsOf: url)` + public init(url: URL) throws { + try self.init(data: RawBytes(try Data(contentsOf: url))) + } + #endif + + /// Throws exception if `bytes` does not start with `.XmlTree` or `.Table` header. + public init(data: RawBytes) throws { + // TODO: check if other headers are possible + let firstChunk = try? ChunkHeader(0, data) + switch firstChunk?.type { + case .XmlTree: type = .XmlTree + case .Table: type = .Table + default: + throw AXMLError("Unsupported binary XML format. Document must start with .XmlTree or .Table header.") + } + guard firstChunk!.chunkSize == data.count else { + throw AXMLError("Data length != XML-defined data length") + } + _bytes = data + } + + // MARK: - Public methods + + /// `parseXml()` is only valid for `.XmlTree` documents + public func parseXml() -> Xml_Parser { + precondition(type == .XmlTree, "parseXml() is only valid for .XmlTree documents") + return try! Xml_Parser(_bytes) + } + + /// `parseTable()` is only valid for `.Table` documents + public func parseTable() -> Tbl_Parser { + precondition(type == .Table, "parseTable() is only valid for .Table documents") + return try! Tbl_Parser(_bytes) + } +} diff --git a/Sources/AndroidXML/BaseTypes.swift b/Sources/AndroidXML/BaseTypes.swift new file mode 100644 index 0000000..e306acb --- /dev/null +++ b/Sources/AndroidXML/BaseTypes.swift @@ -0,0 +1,48 @@ +/// Index offset over all data (subscript access on `Data`) +public typealias Index = Int + +struct AXMLError: Error { + let description: String + + init(_ description: String) { + self.description = description + } +} + +#if canImport(Foundation) +import Foundation + +extension AXMLError: LocalizedError { + var errorDescription: String? { description } +} +#endif + + +// MARK: - ChunkType + +/// These are standard types that are shared between multiple specific resource types. +enum ChunkType: UInt16 { + case Null = 0x0000 + case StringPool = 0x0001 + case Table = 0x0002 + case XmlTree = 0x0003 + + // Chunk types of XML + case XmlStartNamespace = 0x0100 + case XmlEndNamespace = 0x0101 + case XmlStartElement = 0x0102 + case XmlEndElement = 0x0103 + case XmlCDATA = 0x0104 + /// This contains a `UInt32` array mapping strings in the string pool back to resource identifiers. + /// It is optional. + case XmlResourceMap = 0x0180 + + // Chunk types of TABLE + case TblPackage = 0x0200 + case TblType = 0x0201 + case TblTypeSpec = 0x0202 + case TblLibrary = 0x0203 + case TblOverlayable = 0x0204 + case TblOverlayablePolicy = 0x0205 + case TblStagedAlias = 0x0206 +} diff --git a/Sources/AndroidXML/ChunkHeader.swift b/Sources/AndroidXML/ChunkHeader.swift new file mode 100644 index 0000000..018d9ca --- /dev/null +++ b/Sources/AndroidXML/ChunkHeader.swift @@ -0,0 +1,81 @@ +/** + * Header that appears at the front of every data chunk in a resource. + */ +/// Header size: `8 Bytes` +struct ChunkHeader { + // INTERNAL USAGE. No correspondence in data: + private let _offset: Index + internal let _bytes: RawBytes + + /// Type identifier for this chunk. The meaning of this value depends on the containing chunk. + let type: ChunkType // UInt16 + /// Size of the chunk header (in bytes). + /// Adding this value to the address of the chunk allows you to find its associated data (if any). + let headerSize: UInt16 + /// Total size of this chunk (in bytes). + /// This is the chunkSize plus the size of any data associated with the chunk. + /// Adding this value to the chunk allows you to completely skip its contents (including any child chunks). + /// If this value is the same as chunkSize, there is no data associated with the chunk. + let chunkSize: UInt32 + + init(_ idx: Index, _ bytes: RawBytes, expect: ChunkType? = nil) throws { + let rawType = bytes.int16(idx) + guard let typ = ChunkType(rawValue: rawType) else { + throw AXMLError("Unexpected chunk type \(rawType)") + } + if let expect, expect != typ { + throw AXMLError("Expected chunk type \(expect) but got \(typ)") + } + _offset = idx + _bytes = bytes + type = typ + headerSize = bytes.int16(idx + 2) + chunkSize = bytes.int32(idx + 4) + } + + /// Go over all child elements by iterating over them one-by-one + func iterChildren(filter: ((ChunkHeader) -> Bool)? = nil, _ block: (_ abort: inout Bool, _ chunk: ChunkHeader) throws -> Void) throws { + var nextIdx = index(.startOfData) + let lastIdx = index(.afterChunk) + while nextIdx < lastIdx { + let child = try ChunkHeader(nextIdx, _bytes) + nextIdx = child.index(.afterChunk) + guard filter?(child) ?? true else { + continue + } + var abort = false + try block(&abort, child) + if abort { + break + } + } + } + + /// Collect all children with `iterChildren` and return full list + func children(filter: ((ChunkHeader) -> Bool)? = nil) throws -> [ChunkHeader] { + var result: [ChunkHeader] = [] + try iterChildren(filter: filter) { result.append($1) } + return result + } + + /// Convert relative offset to absolute offset + func index(_ relative: RelativeIndex) -> Index { + switch relative { + case .startOfChunk: _offset + case .startOfHeader: _offset + 8 // chunk header length + case .startOfData: _offset + Index(headerSize) + case .afterChunk: _offset + Index(chunkSize) + } + } + + enum RelativeIndex { + case startOfChunk + case startOfHeader + case startOfData + case afterChunk + } + + func byteReader(at offset: RelativeIndex, skip: Index = 0) -> RawBytes.Reader { + .init(data: _bytes, index: index(offset) + skip) + } +} diff --git a/Sources/AndroidXML/ColorComponents.swift b/Sources/AndroidXML/ColorComponents.swift new file mode 100644 index 0000000..a3c7eff --- /dev/null +++ b/Sources/AndroidXML/ColorComponents.swift @@ -0,0 +1,57 @@ + +extension UnsignedInteger { + /// `Foundation` independent alternative to `String(format: "%x")`. Default: upper-case digits + func hex(_ padding: Int = 1, upper: Bool = true) -> String { + let rv = String(self, radix: 16, uppercase: upper) + return rv.count >= padding + ? rv + : String(repeating: "0", count: padding - rv.count) + rv + } +} + +public struct ColorComponents { + let raw: UInt32 + public let a: UInt8, r: UInt8, g: UInt8, b: UInt8 + init(_ c: UInt32) { + raw = c + a = UInt8((c & 0xFF000000) >> 24) + r = UInt8((c & 0xFF0000) >> 16) + g = UInt8((c & 0xFF00) >> 8) + b = UInt8((c & 0xFF)) + } + + /// alpha value as float + public var fa: Float { Float(a) / 255 } + /// red value as float + public var fr: Float { Float(r) / 255 } + /// green value as float + public var fg: Float { Float(g) / 255 } + /// blue value as float + public var fb: Float { Float(b) / 255 } + + /// String with format `#AARRGGBB` + public func argb8() -> String { "#" + raw.hex(8) } + /// String with format `#RRGGBBAA` + public func rgba8() -> String { "#" + (raw & 0xFFFFFF).hex(6) + a.hex(2) } + /// String with format `#RRGGBB` + public func rgb8() -> String { "#" + (raw & 0xFFFFFF).hex(6) } + /// String with format `#ARGB` + public func argb4() -> String { "#" + (a & 0xF).hex(1) + short.hex(3) } + /// String with format `#RGBA` + public func rgba4() -> String { "#" + short.hex(3) + (a & 0xF).hex(1) } + /// String with format `#RGB` + public func rgb4() -> String { "#" + short.hex(3) } + + /// If color can be represented as 3-letter string, prefer that over the 6-letter version. + /// Alpha value is always ignored, even if set to transparent values. + public func rgbAuto() -> String { + (r >> 4 == r & 0xF) && (g >> 4 == g & 0xF) && (b >> 4 == b & 0xF) + ? rgb4() : rgb8() + } + + // MARK: - Private methods + + private var short: UInt16 { + UInt16((r & 0xF)) << 8 | UInt16((g & 0xF0) | (b & 0xF)) + } +} diff --git a/Sources/AndroidXML/RawBytes.swift b/Sources/AndroidXML/RawBytes.swift new file mode 100644 index 0000000..3f1a970 --- /dev/null +++ b/Sources/AndroidXML/RawBytes.swift @@ -0,0 +1,135 @@ +// MARK: - RawBytes + +#if canImport(Foundation) +import Foundation +public typealias RawBytes = Data +#else +public typealias RawBytes = [UInt8] +#endif + +extension RawBytes { + /// Read `1 Byte` integer + @inlinable func int8(_ idx: Index) -> UInt8 { + self[idx] + } + + /// Read and convert a `2 Bytes` little-endian integer + @inlinable func int16(_ idx: Index) -> UInt16 { + UInt16(self[idx + 1]) << 8 | UInt16(self[idx]) + } + + /// Read and convert a `4 Bytes` little-endian integer + @inlinable func int32(_ idx: Index) -> UInt32 { + UInt32(self[idx + 3]) << 24 | UInt32(self[idx + 2]) << 16 | UInt32(self[idx + 1]) << 8 | UInt32(self[idx]) + } + + /// Big-endian version of `int32()` + @inlinable func int32be(_ idx: Index) -> UInt32 { + UInt32(self[idx]) << 24 | UInt32(self[idx + 1]) << 16 | UInt32(self[idx + 2]) << 8 | UInt32(self[idx + 3]) + } + + /// Decode chunks of data with UTF16-LE encoding. `len` uses `UInt16` characters (* 2 Bytes) + @inlinable func utf16(_ start: Index, fixedLen len: Int) -> String { + let chars = (0 ..< len).map { i in int16(start + i * 2) } + return String(decoding: chars, as: UTF16.self) +// String(data: self[start ..< start + len * 2], encoding: .utf16LittleEndian)! + } + + /// Decode chunks of data with UTF8 encoding. `len` uses `UInt8` characters (1 Byte) + @inlinable func utf8(_ start: Index, fixedLen len: Int) -> String { + String(decoding: self[start ..< start + len], as: UTF8.self) +// String(data: self[start ..< start + len], encoding: .utf8)! + } + + /// Search for first `\0` character. Upto `maxLen` UInt16 characters. Fallback to `maxLen`. + @inlinable func utf16(_ start: Index, maxLen: Int) -> String { + self.utf16(start, fixedLen: (0.. String { + self.utf8(start, fixedLen: (0.. (Index, Int) { + let len = Int(int16(idx)) + if len & 0x8000 != 0 { // uses two chars for size + return (idx + 4, (len & 0x7FFF) << 16 + Int(int16(idx + 2))) + } + return (idx + 2, len) + } + + /** + * Strings in UTF-8 format have length indicated by a length encoded in the stored data. + * It is either 1 or 2 characters of length data. + * This allows a maximum length of `0x7FFF` (32767 bytes), + * but you should consider storing text in another way if you're using that much data in a single string. + * + * If the high bit is set, then there are two characters or 2 bytes of length data encoded. + * In that case, drop the high bit of the first character and add it together with the next character. + */ + @inline(__always) private func lenStr8(_ idx: Index) -> (Index, Int) { + let len = Int(int8(idx)) + if len & 0x80 != 0 { // uses two chars for size + return (idx + 2, (len & 0x7F) << 8 + Int(int8(idx + 1))) + } + return (idx + 1, len) + } + + /// Read an UTF16 string which has its length encoded just before the string. + func stringUTF16(_ idx: Index) -> String { + let (start, len) = lenStr16(idx) + // validate string + precondition(int16(start + len * 2) == 0, "String does not end with \0 (offset: \(start))") + return self.utf16(start, fixedLen: len) + } + + /// Read an UTF8 string which has its length encoded just before the string. + func stringUTF8(_ idx: Index) -> String { + let (next, _) = lenStr8(idx) // length of string when converted to UTF16 + let (start, len) = lenStr8(next) // length of raw UTF8 string + // validate string + precondition(int8(start + len) == 0, "String does not end with \0 (offset: \(idx))") + return self.utf8(start, fixedLen: len) + } + + /// Keeps an index while progressing over the data + struct Reader { + let data: RawBytes + var index: Index + + /// Reads `1 Byte` integer + mutating func read8() -> UInt8 { data.int8(incr(1)) } + /// Reads `2 Bytes` little-endian integer + mutating func read16() -> UInt16 { data.int16(incr(2)) } + /// Reads `4 Bytes` little-endian integer + mutating func read32() -> UInt32 { data.int32(incr(4)) } + /// Reads `4 Bytes` big-endian integer + mutating func read32_be() -> UInt32 { data.int32be(incr(4)) } + /// Reads `N Bytes` of `UTF8` data, terminated by `\0` (1 Byte) + mutating func readStr8(len: Int) -> String { data.utf8(incr(len), maxLen: len) } + /// Reads `2*N Bytes` of `UTF16` data, terminated by `\0` (2 Bytes) + mutating func readStr16(len: Int) -> String { data.utf16(incr(len * 2), maxLen: len) } + + /// return current index, then increment + @discardableResult + mutating func incr(_ len: Index) -> Index { + let rv = index + index += len + return rv + } + } +} diff --git a/Sources/AndroidXML/ResValue.swift b/Sources/AndroidXML/ResValue.swift new file mode 100644 index 0000000..89df399 --- /dev/null +++ b/Sources/AndroidXML/ResValue.swift @@ -0,0 +1,198 @@ +// MARK: - DataType + +/// Type of the data value. +public enum DataType : UInt8 { + /// `data` is either `0` or `1`, specifying this resource is either undefined or empty, respectively. + case Null = 0x00 + /// `data` holds a `TblTableRef`, a reference to another resource table entry. + case Reference = 0x01 + /// `data` holds an attribute resource identifier. + case Attribute = 0x02 + /// `data` holds an index into the containing resource table's global value string pool. + case String = 0x03 + /// `data` holds a single-precision floating point number. + case Float = 0x04 + /// `data` holds a complex number encoding a dimension value, such as `100in`. + case Dimension = 0x05 + /// `data` holds a complex number encoding a fraction of a container. + case Fraction = 0x06 + /// `data` holds a dynamic `TblTableRef`, which needs to be resolved before it can be used like a `.Reference`. + case DynamicReference = 0x07 + /// `data` holds an attribute resource identifier, which needs to be resolved before it can be used like a `.Attribute`. + case DynamicAttribute = 0x08 + /// `data` is a raw integer value of the form `n..n`. + case IntDec = 0x10 + /// `data` is a raw integer value of the form `0xn..n`. + case IntHex = 0x11 + /// `data` is either `0` or `1`, for input `false` or `true` respectively. + case IntBoolean = 0x12 + /// `data` is a raw integer value of the form `#aarrggbb`. + case IntColorARGB8 = 0x1c + /// `data` is a raw integer value of the form `#rrggbb`. + case IntColorRGB8 = 0x1d + /// `data` is a raw integer value of the form `#argb`. + case IntColorARGB4 = 0x1e + /// `data` is a raw integer value of the form `#rgb`. + case IntColorRGB4 = 0x1f + + var isColor: Bool { + self == .IntColorARGB8 || self == .IntColorRGB8 || self == .IntColorARGB4 || self == .IntColorRGB4 + } +} + + +// MARK: - ResValue + +/** + * Representation of a value in a resource, supplying type information. + */ +/// Size: `8 Bytes` +public struct ResValue { + /// Number of bytes in this structure. + let size: UInt16 + /// Always set to 0. + let res0: UInt8 + /// Type of the data value. + public let dataType: DataType // UInt8 + /// The data for this item, as interpreted according to dataType. + let data: UInt32 + + init(type: DataType, data rawData: UInt32) { + size = 8 + res0 = 0 + dataType = type + data = rawData + } + + init(_ br: inout RawBytes.Reader) throws { + size = br.read16() + res0 = br.read8() + let rawType: UInt8 = br.read8() + guard let typ = DataType(rawValue: rawType) else { + throw AXMLError("Unknown ResValue data-type \(rawType) (offset: \(br.index - 1))") + } + dataType = typ + data = br.read32() + assert(dataType != .DynamicAttribute, "TODO: .DynamicAttribute data type") + assert(dataType != .DynamicReference, "TODO: .DynamicReference data type") + } + + /// Convenience getter for `DataType.String` + public var asStringRef: StringPoolRef { data } + /// Convenience getter for `DataType.Reference` and `DataType.Attribute` + public var asTableRef: TblTableRef { TblTableRef(data) } + /// Convenience getter for `DataType.Float` + public var asFloat: Float { Float(bitPattern: data) } + /// Convenience getter for `DataType.Dimension` + public var asDimension: ComplexType { ComplexType(data, isFraction: false) } + /// Convenience getter for `DataType.Fraction` + public var asFraction: ComplexType { ComplexType(data, isFraction: true) } + /// Convenience getter for `DataType.IntDec` + public var asIntDec: Int { Int(Int32(truncatingIfNeeded: data)) } + /// Convenience getter for `DataType.IntHex` with format `00AA22CC` + public var asIntHex: String { data.hex(8) } + /// Convenience getter for `DataType.IntBoolean` (value `> 0`) + public var asBoolean: Bool { data > 0 } + /// Convenience getter for `DataType.IntColor*` + public var asColor: ColorComponents { ColorComponents(data) } + + // see: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/libs/androidfw/ResourceTypes.cpp#7637 + /// Use meta data to display value according to type. + /// `StringPool` only required for type `.String` + public func resolve(_ pool: StringPool?) -> String { + switch dataType { + case .Null: + // switch data { + // case 0: return "(null)" // not defined + // case 1: return "(null empty)" // explicitly defined as empty + // default: return String(format: "(null) 0x%08x\n", data) // should never happen + // } + return "" + case .DynamicReference: + // TODO: can we treat them as their non-dynamic counterpart? + fallthrough + case .Reference: return ((data >> 24) & 0xFF == 1 ? "@android:" : "@") + data.hex(8) + case .DynamicAttribute: + fallthrough + case .Attribute: return ((data >> 24) & 0xFF == 1 ? "?android:" : "?") + data.hex(8) + case .String: return pool!.getString(data) + case .Float: return "\(asFloat)" + case .Dimension: return asDimension.asString() + case .Fraction: return asFraction.asString() + case .IntDec: return "\(asIntDec)" + case .IntHex: return "0x" + data.hex(8) + case .IntBoolean: return data > 0 ? "true" : "false" + case .IntColorARGB8: return asColor.argb8() + case .IntColorRGB8: return asColor.rgb8() + case .IntColorARGB4: return asColor.argb4() + case .IntColorRGB4: return asColor.rgb4() + } + } +} + + +// MARK: - ComplexType + +/** + * Structure of complex data values (`DataType.Dimension` and `DataType.Fraction`) + * + * `MANTISSA_MASK = 0xFFFFFF00` + * + * Where the actual value is. + * This gives us 23 bits of precision. + * The top bit is the sign. + * + * `RADIX_MASK = 0x00000030` + * + * Where the radix information is, telling where the decimal place appears in the mantissa. + * This give us 4 possible fixed point representations as defined below. + * + * `UNIT_MASK = 0x0000000F` + * + * Where the unit type information is. + * This gives us 16 possible types, as defined below. + */ +public struct ComplexType { + public let value: Float + public let unit: String + + init(_ complex: UInt32, isFraction: Bool) { + // UNIT + var scale: Float = 1.0 + if (isFraction) { + scale = 100 + switch (complex & 0xF) { + case 0: unit = "%" // A basic fraction of the overall size. + case 1: unit = "%p" // A fraction of the parent size. + default: unit = "" + } + } else { + switch (complex & 0xF) { + case 0: unit = "px" // raw pixels + case 1: unit = "dp" // Device Independent Pixels + case 2: unit = "sp" // Scaled device independent Pixels + case 3: unit = "pt" // points + case 4: unit = "in" // inches + case 5: unit = "mm" // millimeters + default: unit = "" + } + } + // VALUE + let neg = (complex & 0x80000000) != 0 + let mantissa = Float((complex & 0x7FFFFF00) >> 8) - (neg ? Float(0x7FFFFF) : 0) + let val = mantissa / (radix[Int(complex & 0x30) >> 4] / scale) + // for `.Fraction`, round to nearest integral number (if equivalent) + let isIntegral = isFraction && abs(val.rounded() - val) < 1e-05 + value = isIntegral ? val.rounded() : val + } + + public func asString() -> String { + value.rounded() == value ? "\(Int(value))\(unit)" : "\(value)\(unit)" + } +} + +// 23p0 - The mantissa is an integral number -- i.e., 0xnnnnnn.0 +// 16p7 - The mantissa magnitude is 16 bits -- i.e, 0xnnnn.nn +// 8p15 - The mantissa magnitude is 8 bits -- i.e, 0xnn.nnnn +// 0p23 - The mantissa magnitude is 0 bits -- i.e, 0x0.nnnnnn +private let radix: [Float] = [1, 0x80, 0x8000, 0x800000] diff --git a/Sources/AndroidXML/StringPool.swift b/Sources/AndroidXML/StringPool.swift new file mode 100644 index 0000000..1b86770 --- /dev/null +++ b/Sources/AndroidXML/StringPool.swift @@ -0,0 +1,98 @@ + +/// Index into the string pool table (`UInt32`-offset from the indices immediately after `StringPool`) +/// at which to find the location of the string data in the pool. +public typealias StringPoolRef = UInt32 + +/** + * Definition for a pool of strings. + * + * The data of this chunk is an array of `UInt32` providing indices into the pool, relative to `stringsStart`. + * At `stringsStart` are all of the UTF-16 strings concatenated together; + * each starts with a `UInt16`of the string's length and each ends with a `0x0000` terminator. + * If a string is `> 32767` characters, the high bit of the length is set meaning to take + * those 15 bits as a high word and it will be followed by another `UInt16`containing the low word. + * + * If `styleCount` is not zero, then immediately following the array of `UInt32` indices into the + * string table is another array of indices into a style table starting at `stylesStart`. + * Each entry in the style table is an array of `StringPoolSpan` structures. + */ +/// Header size: `ChunkHeader` (8B) + `20 Bytes` +public struct StringPool { + let header: ChunkHeader + /// Number of strings in this pool (number of `UInt32` indices that follow in the data). + public let stringCount: UInt32 + /// Number of style span arrays in the pool (number of `UInt32` indices follow the string indices). + public let styleCount: UInt32 + /// Flags. + let flags: Flags // UInt32 + struct Flags: OptionSet { + let rawValue: UInt32 + /// If set, the string index is sorted by the string values (based on `strcmp16()`). + static let Sorted = Flags(rawValue: 1 << 0) + /// String pool is encoded in UTF-8 + static let Utf8 = Flags(rawValue: 1 << 8) + } + /// Index from header of the string data. + let stringsStart: UInt32 + /// Index from header of the style data. (will be `0` if `styleCount == 0`) + let stylesStart: UInt32 + + // then: + // string indices (stringCount * UInt32) + // style indices (styleCount * UInt32) + // strings (variable size) + // styles (styleCount * 16) followed by 0xFFFFFFFF 0xFFFFFFFF + + public var isSorted: Bool { flags.contains(.Sorted) } + + // INTERNAL USAGE. No correspondence in data: + private let _offset: Indices + struct Indices { + let indicesString: Index + let indicesStyle: Index + let dataString: Index + let dataStyle: Index + } + + init(_ idx: Index, _ bytes: RawBytes) throws { + self.init(try ChunkHeader(idx, bytes, expect: .StringPool)) + } + + init(_ chunk: ChunkHeader) { + header = chunk + var br = header.byteReader(at: .startOfHeader) + stringCount = br.read32() + styleCount = br.read32() + flags = Flags(rawValue: br.read32()) + stringsStart = br.read32() + stylesStart = br.read32() + // internal + let idxChunk = header.index(.startOfChunk) + let idxData = header.index(.startOfData) + _offset = Indices( + indicesString: idxData, + indicesStyle: idxData + Index(stringCount) * 4, + dataString: idxChunk + Index(stringsStart), + dataStyle: idxChunk + Index(stylesStart)) + } + + // MARK: - Public methods + + /// Retrieve string for given `StringPoolRef` index + public func getString(_ idx: StringPoolRef) -> String { + precondition(idx < stringCount, "Index out of bounds for string-pool string[\(idx) > \(stringCount)]") + let offset = header._bytes.int32(_offset.indicesString + Index(idx) * 4) + let dataOffset = _offset.dataString + Index(offset) + return flags.contains(.Utf8) + ? header._bytes.stringUTF8(dataOffset) + : header._bytes.stringUTF16(dataOffset) + } + + /// Retrieve styles for given `StringPoolRef` index + public func getStyle(_ idx: StringPoolRef) -> StringPoolSpan { + precondition(idx < styleCount, "Index out of bounds for string-pool style[\(idx) > \(styleCount)]") + let offset = header._bytes.int32(_offset.indicesStyle + Index(idx) * 4) + let dataOffset = _offset.dataStyle + Index(offset) + return StringPoolSpan(dataOffset, header._bytes) + } +} diff --git a/Sources/AndroidXML/StringPoolSpan.swift b/Sources/AndroidXML/StringPoolSpan.swift new file mode 100644 index 0000000..149bd6b --- /dev/null +++ b/Sources/AndroidXML/StringPoolSpan.swift @@ -0,0 +1,22 @@ +/** + * This structure defines a span of style information associated with a string in the pool. + */ +/// Size: `12 Bytes` +public struct StringPoolSpan { + /// This is the name of the span -- that is, the name of the XML tag that defined it. + /// The special value END (`0xFFFFFFFF`) indicates the end of an array of spans. + public let name: StringPoolRef // UInt32 + /// The range of characters in the string that this span applies to. + public let firstChar: UInt32 + public let lastChar: UInt32 + + // then: + // fixed 0xFFFFFFFF ? + + init(_ idx: Index, _ bytes: RawBytes) { + name = bytes.int32(idx) + firstChar = bytes.int32(idx + 4) + lastChar = bytes.int32(idx + 8) + // _ff = bytes.int32(idx + 12) + } +} diff --git a/Sources/AndroidXML/TblEntry.swift b/Sources/AndroidXML/TblEntry.swift new file mode 100644 index 0000000..7335583 --- /dev/null +++ b/Sources/AndroidXML/TblEntry.swift @@ -0,0 +1,91 @@ +/** + * This is the beginning of information about an entry in the resource table. + * + * It holds the reference to the name of this entry, and is immediately followed by one of: + * * A `ResValue` structure, if `Flags.Complex` is -not- set. + * * An array of `ResTable_map` structures, if `Flags.Complex` is set. + * These supply a set of name/value mappings of data. + * * If `Flags.Compact` is set, this entry is a compact entry for simple values only + */ +/// Size: `8 Bytes` +public struct TblEntry { + // Note: `_size` and `_key` are private because `Flags` determine their meaning + + /// Number of bytes in this structure. + var size: UInt16 { flags.contains(.Compact) ? 8 : _size } + /// Reference into `TblPackage::keyStrings` identifying this entry. + public var key: StringPoolRef { flags.contains(.Compact) ? StringPoolRef(_size) : _key } + + // ACTUAL data entry: + + /// Number of bytes in this structure. + private let _size: UInt16 // used as `key` if Flags.Compact + /// Flags. + let flags: Flags // UInt16 + struct Flags: OptionSet { + let rawValue: UInt16 + /// If set, this is a complex entry, holding a set of name/value mappings. + /// It is followed by an array of `ResTable_map` structures. + static let Complex = Flags(rawValue: 0x0001) + /// If set, this resource has been declared public, so libraries are allowed to reference it. + static let Public = Flags(rawValue: 0x0002) + /// If set, this is a weak resource and may be overriden by strong resources of the same name/type. + /// This is only useful during linking with other resource tables. + static let Weak = Flags(rawValue: 0x0004) + /// If set, this is a compact entry with data type and value directly encoded in the this entry. + /// See `ResTable_entry::compact` + static let Compact = Flags(rawValue: 0x0008) + } + /// Reference into `TblPackage::keyStrings` identifying this entry. + private let _key: StringPoolRef // UInt32 // used as `data` if Flags.Compact + + // Interpreted data value + + /// Only set if `Flags.Complex` is `false` + public let value: ResValue? // 8 Bytes + /// Only set if `Flags.Complex` is `true` + public let valueMap: TblEntryMap? + /// `true` if `Flags.Complex` is set + public var isComplex: Bool { flags.contains(.Complex) } + + init(_ br: inout RawBytes.Reader) throws { + _size = br.read16() + flags = Flags(rawValue: br.read16()) + _key = br.read32() + assert(!flags.contains(.Compact), "TODO: TblEntry with Flags.Compact") + /* + * Always verify the memory associated with this entry and its value + * before calling `value()` or `map_entry()` + */ + if flags.contains(.Compact) { + let rawType = UInt8((flags.rawValue & 0xFF00) >> 8) + guard let typ = DataType(rawValue: rawType) else { + throw AXMLError("Unknown ResValue data-type \(rawType)") + } + value = ResValue(type: typ, data: _key) + valueMap = nil + } else if flags.contains(.Complex) { + value = nil + valueMap = try TblEntryMap(&br) + } else { + value = try ResValue(&br) + valueMap = nil + } + } +} + +/** + * A compact entry is indicated by `Flags.Compact`, with flags at the same offset as a normal entry. + * This is only for simple data values where + * + * - size for entry or value can be inferred (both being 8 bytes). + * - key index is encoded in 16-bit + * - dataType is encoded as the higher 8-bit of flags + * - data is encoded directly in this entry + */ +// UNUSED because case handled directly in `TblEntry` +//struct TblEntryCompact { +// let key: UInt16 +// let flags: UInt16 +// let data: UInt32 +//} diff --git a/Sources/AndroidXML/TblEntryMap.swift b/Sources/AndroidXML/TblEntryMap.swift new file mode 100644 index 0000000..eef4d25 --- /dev/null +++ b/Sources/AndroidXML/TblEntryMap.swift @@ -0,0 +1,98 @@ +/** + * Extended form of a `TblEntry` for map entries, defining a parent map resource from which to inherit values. + */ +/// Header size: `8 Bytes` +public struct TblEntryMap { + /// Resource identifier of the parent mapping, or `0` if there is none. + /// This is always treated as a `TYPE_DYNAMIC_REFERENCE`. + public let parent: TblTableRef // UInt32 + /// Number of name/value pairs that follow for `Flags.Complex`. + public let count: UInt32 + + /// Entries immediatelly following this structure + public let entries: [TblEntryMapItem] + + init(_ br: inout RawBytes.Reader) throws { + parent = TblTableRef(br.read32()) + count = br.read32() + entries = try (0.. StringPool? { + let offset = type == .Types ? typeStrings : keyStrings + return offset == 0 ? nil : try! StringPool(header.index(.startOfChunk) + Index(keyStrings), header._bytes) + } + public enum StringPoolType { + case Types + case Keys + } + + /// Go over all type declarations + /// + /// you have to manually init `TblType` in `types`. That is for efficiency. + public func iterTypes(_ block: (_ spec: TblTypeSpec, _ types: [TblType]) throws -> Void) throws { + var spec: TblTypeSpec? = nil + var types: [TblType] = [] + // ignore StringPool because they are accessed directly (see above) + try header.iterChildren(filter: { $0.type != .StringPool }) { + if $1.type == .TblTypeSpec { + if spec != nil { + try block(spec!, types) + types.removeAll() + } + spec = TblTypeSpec($1) + } else if $1.type == .TblType { + types.append(TblType($1)) + } + } + if spec != nil { + try block(spec!, types) + } + } + + /// Lookup specific `TblType` from package. E.g., by using `TblTableRef.type` + public func getType(_ typeId: UInt8) throws -> (TblTypeSpec, [TblType])? { + var spec: TblTypeSpec? = nil + var types: [TblType] = [] + try header.iterChildren { + if spec == nil { + if $1.type == .TblTypeSpec { + let tmp = TblTypeSpec($1) + if tmp.id == typeId { + spec = tmp + } + } // else: ignore all other types + } else if $1.type == .TblType { + let tmp = TblType($1) + assert(tmp.id == typeId, "TblType should have same id as parent TblTypeSpec") + types.append(tmp) + } else { + $0 = true // abort further processing + } + } + if spec == nil { + return nil + } + return (spec!, types) + } +} diff --git a/Sources/AndroidXML/TblTableRef.swift b/Sources/AndroidXML/TblTableRef.swift new file mode 100644 index 0000000..db5f2dc --- /dev/null +++ b/Sources/AndroidXML/TblTableRef.swift @@ -0,0 +1,34 @@ +/** + * This is a reference to a unique entry (a `TblEntry` structure) in a resource table. + * The value is structured as: `0xpptteeee`, where + * `pp` is the package index, + * `tt` is the type index in that package, and + * `eeee` is the entry index in that type. + * The package and type values start at 1 for the first item, to help catch cases where they have not been supplied. + */ +public struct TblTableRef: CustomStringConvertible { + public let ident: UInt32 + + public init(_ ident: UInt32) { + self.ident = ident + } + + /// `ident` as hex string + public var asHex: String { ident.hex(8) } + + /// mask: `0xFF000000` + public var package: UInt8 { UInt8((ident & 0xFF000000) >> 24) } + /// mask: `0x00FF0000` + public var type: UInt8 { UInt8((ident & 0x00FF0000) >> 16) } + /// mask: `0x0000FFFF` + public var entry: UInt16 { UInt16(ident & 0x0000FFFF) } + + /// `package != 0 || type != 0 || entry != 0` + public func isVlaid() -> Bool { ident != 0 } + /// `package != 0 || type != 0` + public func check() -> Bool { (ident & 0xFFFF0000) != 0 } + /// `package != 0 && type == 0` + public func isInternal() -> Bool { (ident & 0xFFFF0000) != 0 && (ident & 0x00FF0000) == 0 } + + public var description: String { "TblTableRef(" + ident.hex(8) + ")" } +} diff --git a/Sources/AndroidXML/TblType.swift b/Sources/AndroidXML/TblType.swift new file mode 100644 index 0000000..236664f --- /dev/null +++ b/Sources/AndroidXML/TblType.swift @@ -0,0 +1,179 @@ + +/// Just a name alias to better understand which index is used for what purpose. +public typealias TblDataIndex = Index + +/** + * A collection of resource entries for a particular resource data type. + * + * If the flag `Flags.Sparse` is not set in `flags`, then this struct is followed by an array of `UInt32` + * defining the resource values, corresponding to the array of type strings in the `TblPackage::typeStrings` string block. + * Each of these hold an index from `entriesStart`. + * A value of `0xFFFFFFFF` (NO_ENTRY) means that entry is not defined. + * + * If the flag `Flags.Sparse` is set in `flags`, then this struct is followed by an array of `TblType.SparseIndex` + * defining only the entries that have values for this type. + * Each entry is sorted by their entry ID such that a binary search can be performed over the entries. + * The ID and offset are encoded in a `UInt32`. + * See `TblType.SparseIndex`. + * + * There may be multiple of these chunks for a particular resource type, + * supply different configuration variations for the resource values of that type. + * + * It would be nice to have an additional ordered index of entries, so + * we can do a binary search if trying to find a resource by string name. + */ +/// Header size: `ChunkHeader` (8B) + `12 Bytes` + `TblTypeConfig` (64B) +public struct TblType { + let header: ChunkHeader + + /// The type identifier this chunk is holding. + /// Type IDs start at 1 (corresponding to the value of the type bits in a resource identifier). + /// `0` is invalid. + public let id: UInt8 + /// Flags. + let flags: Flags // UInt8 + struct Flags: OptionSet { + let rawValue: UInt8 + /// If set, the entry is sparse, and encodes both the entry ID and offset into each entry, and a binary search is used to find the key. + /// Only available on platforms `>= O`. + /// Mark any types that use this with a v26 qualifier to prevent runtime issues on older platforms. + static let Sparse = Flags(rawValue: 0x01) + /// If set, the offsets to the entries are encoded in 16-bit, `real_offset = offset * 4` + /// An 16-bit offset of `0xFFFF` means a NO_ENTRY + static let Offset16 = Flags(rawValue: 0x02) + } + /// Must be `0`. + let reserved: UInt16 + /// Number of `UInt32` entry indices that follow. + public let entryCount: UInt32 + /// Offset from header where `TblEntry` data starts. + let entriesStart: UInt32 + /// Configuration this collection of entries is designed for. + /// This must always be last. + public let config: TblTypeConfig // 64 Bytes + + init(_ chunk: ChunkHeader) { + header = chunk + var br = header.byteReader(at: .startOfHeader) + id = br.read8() + flags = Flags(rawValue: br.read8()) + reserved = br.read16() + entryCount = br.read32() + entriesStart = br.read32() + config = TblTypeConfig(&br) + + // TODO: check indexing defined by flags + assert(flags.rawValue == 0, "TODO: TblType flags encountered \(flags.rawValue)") + } + + + // MARK: - Private methods + + /** + * An entry in a `TblType` with the flag `Flags.Sparse` set. + */ + private struct SparseIndex { + /// The index of the entry. + let idx: UInt16 + /// The offset from `TblType::entriesStart`, divided by 4. + let offset: UInt16 + } + + /// Iterate over all data indices (respecting flags). If `Flags.Sparse` is not set, `idx` is strictly incrementing `0.. [(UInt16, TblDataIndex)] { + let start = header.index(.startOfChunk) + Index(entriesStart) + var br = header.byteReader(at: .startOfData) + let range = (0 ..< UInt16(entryCount)) // downcast should be fine because type indices can only be UInt16 + // more efficient to do flags-then-loop instead of loop-then-flags + if flags.contains(.Sparse) { + return range.map { _ in + let sparse = SparseIndex(idx: br.read16(), offset: br.read16()) + return (sparse.idx, start + Index(sparse.offset) * 4) + } + } else if flags.contains(.Offset16) { + return range.compactMap { + let val = br.read16() + return val == 0xFFFF ? nil : ($0, start + Index(val) * 4) + } + } else { + return range.compactMap { + let val = br.read32() + return val == 0xFFFFFFFF ? nil : ($0, start + Index(val)) + } + } + } + + /// Get data entry index (absolute) by calculating offset from index (relative) + private func entryOffset(for idx: Int) -> TblDataIndex? { + precondition(idx < entryCount, "Index out of bounds for TblType entry[\(idx) > \(entryCount)]") + + if flags.contains(.Sparse) { + return binarySearchSparse(idx) + } + + let offsetIndices = header.index(.startOfData) + + if flags.contains(.Offset16) { + let offset = header._bytes.int16(offsetIndices + idx * 2) + guard offset != 0xFFFF else { // NO_ENTRY + return nil + } + return entryIndex(at: UInt32(offset) * 4) + } + + let offset = header._bytes.int32(offsetIndices + idx * 4) + guard offset != 0xFFFFFFFF else { // NO_ENTRY + return nil + } + return entryIndex(at: offset) + } + + /// Index where data entries start + private func entryIndex(at offset: UInt32) -> TblDataIndex { + let absOffset = header.index(.startOfChunk) + Index(entriesStart) + Index(offset) + assert(absOffset < header.index(.afterChunk), "entryIndex > .afterChunk, \(absOffset) > \(header.index(.afterChunk))") + return absOffset + } + + /// Perform binary search on data indices if `Flags.Sparse` is set. + private func binarySearchSparse(_ needleIdx: Int) -> TblDataIndex? { + let start = header.index(.startOfData) + var low = 0, high = Int(entryCount) - 1 + while low < high { + let mid = (low + high) / 2 + let index = start + mid * 4 + // we could use `TblType.SparseIndex` but that would perform unnecessary reads for offset + let currentIdx = Int(header._bytes.int16(index)) + if currentIdx < needleIdx { + low = mid + 1 + } else if currentIdx > needleIdx { + high = mid - 1 + } else { + let offset = UInt32(header._bytes.int16(index + 2)) * 4 + return entryIndex(at: offset) + } + } + return nil + } + + // MARK: - Public methods + + /// Lookup specific `TblEntry` from type. E.g., by using `TblTableRef.entry` + public func getValue(_ idx: UInt16) throws -> TblEntry? { + guard let offset = entryOffset(for: Int(idx)) else { + return nil + } + var br = RawBytes.Reader(data: header._bytes, index: offset) + return try TblEntry(&br) + } + + /// Iterate over all type entries. + public func iterValues(_ block: (_ idx: UInt16, _ entry: TblEntry) throws -> Void) throws { + var br = RawBytes.Reader(data: header._bytes, index: 0) + for (idx, offset) in entryOffsets() { + br.index = offset + try block(idx, try TblEntry(&br)) + } + } +} + diff --git a/Sources/AndroidXML/TblTypeConfig.swift b/Sources/AndroidXML/TblTypeConfig.swift new file mode 100644 index 0000000..fa6dbbd --- /dev/null +++ b/Sources/AndroidXML/TblTypeConfig.swift @@ -0,0 +1,401 @@ + +// MARK: - TblTypeConfig + +/** + * Describes a particular resource configuration. + */ +/// Size: `64 Bytes` +public struct TblTypeConfig { + /// Number of bytes in this structure. + let size: UInt32 + /// mcc, mnc + public let imsi: TblTypeConfigIMSI // UInt32 + /// language, country + public let locale: TblTypeConfigLocale // UInt32 + /// orientation, touchscreen, density + public let screenType: TblTypeConfigScreenType // UInt32 + /// keyboard, navigation, keysHidden, navHidden, input, grammaticalInflection + public let inputType: TblTypeConfigInput // UInt32 + /// screenWidth, screenHeight + public let screenSize: TblTypeConfigScreenSize // UInt32 + /// sdkVersion, minorVersion + public let version: TblTypeConfigVersionSDK // UInt32 + /// screenLayoutSize, screenLayoutLong, screenLayoutRound, uiModeType, uiModeNight, smallestScreenWidthDp + public let screenConfig: TblTypeConfigScreenConfig // UInt32 + /// screenWidthDp, screenHeightDp + public let screenSizeDp: TblTypeConfigScreenSizeDp // UInt32 + /// The ISO-15924 short name for the script corresponding to this configuration. (eg. Hant, Latn, etc.). + /// Interpreted in conjunction with the locale field. + public let localeScript: String // fixed len: 4 * UInt8 + /// A single BCP-47 variant subtag. + /// Will vary in length between 4 and 8 chars. + /// Interpreted in conjunction with the locale field. + public let localeVariant: String // fixed max-len: 8 * UInt8 + /// screenLayoutRound, colorModeWideGamut, colorModeHDR, screenConfigPad2 + public let screenConfig2: TblTypeConfigScreenConfig2 // UInt32 + /// If `false` and `localeScript` is set, it means that the script of the locale was explicitly provided. + /// If `true`, it means that `localeScript` was automatically computed. + /// `localeScript` may still not be set in this case, which means that we tried but could not compute a script. + public let localeScriptWasComputed: Bool // UInt32?? + /// The value of BCP 47 Unicode extension for key 'nu' (numbering system). + /// Varies in length from 3 to 8 chars. + /// Zero-filled value. + public let localeNumberingSystem: String // fixed max-len: 8 * UInt8 + + init(_ br: inout RawBytes.Reader) { + size = br.read32() + imsi = TblTypeConfigIMSI( + mcc: br.read16(), + mnc: br.read16()) + locale = TblTypeConfigLocale(br.read32_be()) + screenType = TblTypeConfigScreenType( + _orientation: br.read8(), + _touchscreen: br.read8(), + _density: br.read16()) + inputType = TblTypeConfigInput(rawValue: br.read32_be()) + screenSize = TblTypeConfigScreenSize( + screenWidth: br.read16(), + screenHeight: br.read16()) + version = TblTypeConfigVersionSDK( + sdkVersion: br.read16(), + minorVersion: br.read16()) + screenConfig = TblTypeConfigScreenConfig( + screenLayout: br.read8(), + uiMode: br.read8(), + smallestScreenWidthDp: br.read16()) + screenSizeDp = TblTypeConfigScreenSizeDp( + screenWidthDp: br.read16(), + screenHeightDp: br.read16()) + localeScript = br.readStr8(len: 4) + localeVariant = br.readStr8(len: 8) + screenConfig2 = TblTypeConfigScreenConfig2( + screenLayout2: br.read8(), + colorMode: br.read8(), + screenConfigPad2: br.read16()) + localeScriptWasComputed = br.read32() != 0 // ??? really 4 bytes? + localeNumberingSystem = br.readStr8(len: 8) + } +} + + +// Mark: - TblTypeConfigIMSI + +/// mcc (UInt16), mnc (UInt16) +public struct TblTypeConfigIMSI { + /// Mobile country code (from SIM). `0` means "any". + public let mcc: UInt16 + /// Mobile network code (from SIM). `0` means "any". + public let mnc: UInt16 +} + + +// MARK: - TblTypeConfigLocale + +/// language (UInt16), country (UInt16) +public struct TblTypeConfigLocale { + /// This field can take three different forms: + /// - `\0\0` means "any". + /// + /// - Two 7 bit ascii values interpreted as ISO-639-1 language codes ('fr', 'en' etc. etc.). + /// The high bit for both bytes is zero. + /// + /// - A single 16 bit little endian packed value representing an ISO-639-2 3 letter language code. + /// This will be of the form: + /// + /// `{1, t, t, t, t, t, s, s, s, s, s, f, f, f, f, f}` + /// + /// `bit[0, 4]` = first letter of the language code + /// `bit[5, 9]` = second letter of the language code + /// `bit[10, 14]` = third letter of the language code. + /// `bit[15]` = 1 always + /// + /// For backwards compatibility, languages that have unambiguous + /// two letter codes are represented in that format. + /// + /// The layout is always bigendian irrespective of the runtime architecture. + let _language: UInt16 + /// This field can take three different forms: + /// - `\0\0` means "any". + /// + /// - Two 7 bit ascii values interpreted as 2 letter region codes ('US', 'GB' etc.). + /// The high bit for both bytes is zero. + /// + /// - An UN M.49 3 digit region code. + /// For simplicity, these are packed in the same manner as the language codes, + /// though we should need only 10 bits to represent them, instead of the 15. + /// + /// The layout is always bigendian irrespective of the runtime architecture. + let _country: UInt16 + + /// Converts bit-pattern to String. Cache the result somewhere or else it will be regenerated all the time + public var language: String? { convertBits(_language, 97) } + /// Converts bit-pattern to String. Cache the result somewhere or else it will be regenerated all the time + public var country: String? { convertBits(_country, 48) } + + init(_ bidEndian: UInt32) { // big-endian + _language = UInt16((bidEndian & 0xFFFF0000) >> 16) + _country = UInt16(bidEndian & 0xFFFF) + } + + private func convertBits(_ bits: UInt16, _ chrStart: UInt8) -> String? { + if bits == 0 { + return nil // any + } + if bits & 0x8000 == 0 { + let f = (bits & 0xFF00) >> 8 + let s = (bits & 0x00FF) + return String(decoding: [UInt8(f), UInt8(s)], as: UTF8.self) + } + let f = (bits & 0b0111110000000000) >> 10 + let s = (bits & 0b0000001111100000) >> 5 + let t = (bits & 0b0000000000011111) + return String(decoding: [chrStart + UInt8(f), chrStart + UInt8(s), chrStart + UInt8(t)], as: UTF8.self) + } +} + + +// MARK: - TblTypeConfigScreenType + +/// orientation (UInt8), touchscreen (UInt8), density (UInt16) +public struct TblTypeConfigScreenType { + let _orientation: UInt8 + let _touchscreen: UInt8 + let _density: UInt16 + + public var orientation: Orientation { .init(rawValue: _orientation)! } + public var touchscreen: Touchscreen { .init(rawValue: _touchscreen)! } + public var density: Density { .init(rawValue: _density)! } + + public enum Orientation: UInt8 { + case any = 0 + case Port = 1 + case Land = 2 + case Square = 3 + } + + public enum Touchscreen: UInt8 { + case any = 0 + case NoTouch = 1 + case Stylus = 2 + case Finger = 3 + } + + public enum Density: UInt16 { + case Default = 0 + case Low = 120 + case Medium = 160 + case Tv = 213 + case High = 240 + case Xhigh = 320 + case XXhigh = 480 + case XXXhigh = 640 + case any = 0xfffe + case None = 0xffff + } +} + + +// MARK: - TblTypeConfigInput + +// Union of either: +// keyboard (UInt8), navigation (UInt8), inputFlags (UInt8), inputFieldPad0 (UInt8) +// input :24 (UInt32), inputFullPad0 :8 (UInt32) +// grammaticalInflectionPad0[3] (UInt8), grammaticalInflection (UInt8) +public struct TblTypeConfigInput { + let rawValue: UInt32 // big-endian + + public var keyboard: Keyboard { + .init(rawValue: UInt8((rawValue & 0xFF000000) >> 24))! + } + public var navigation: Navigation { + .init(rawValue: UInt8((rawValue & 0x00FF0000) >> 16))! + } + public var navHidden: Navhidden { + .init(rawValue: UInt8((rawValue & 0x00000c00) >> 10))! + } + public var keysHidden: Keyshidden { + .init(rawValue: UInt8((rawValue & 0x00000300) >> 8))! + } + public var input: UInt32 { + UInt32((rawValue & 0xFFFFFF00) >> 8) + } + public var grammaticalInflection: GrammaticalGender { + .init(rawValue: UInt8(rawValue & 0x00000003))! + } + + public enum Keyboard: UInt8 { + case any = 0 + case NoKeys = 1 + case Qwerty = 2 + case Key12 = 3 + } + + public enum Navigation: UInt8 { + case any = 0 + case NoNav = 1 + case Dpad = 2 + case Trackball = 3 + case Wheel = 4 + } + + public enum Navhidden: UInt8 { + case any = 0 + case No = 1 + case Yes = 2 + } + + public enum Keyshidden: UInt8 { + case any = 0 + case No = 1 + case Yes = 2 + case Soft = 3 + } + + public enum GrammaticalGender: UInt8 { + case any = 0 + case Neuter = 1 + case Feminine = 2 + case Masculine = 3 + } +} + + +// Mark: - TblTypeConfigScreenSize + +/// screenWidth (UInt16), screenHeight (UInt16) +public struct TblTypeConfigScreenSize { + /// `0` means any + public let screenWidth: UInt16 + /// `0` means any + public let screenHeight: UInt16 +} + + +// Mark: - TblTypeConfigVersionSDK + +/// sdkVersion (UInt16), minorVersion (UInt16) +public struct TblTypeConfigVersionSDK { + /// `0` means any + public let sdkVersion: UInt16 + /// `0` means any. For now minorVersion must always be 0!!! Its meaning is currently undefined. + public let minorVersion: UInt16 +} + + +// Mark: - TblTypeConfigScreenConfig + +/// screenLayout (UInt8), uiMode (UInt8), smallestScreenWidthDp (UInt16) +public struct TblTypeConfigScreenConfig { + /// direction, long, size + let screenLayout: UInt8 + /// night, type + let uiMode: UInt8 + /// `0` means any + public let smallestScreenWidthDp: UInt16 + + /// screenLayout bits for layout direction. + public var screenLayoutDirection: LayoutDir { + .init(rawValue: (screenLayout & 0xC0) >> 6)! + } + /// screenLayout bits for wide/long screen variation. + public var screenLayoutLong: LayoutLong { + .init(rawValue: (screenLayout & 0x30) >> 4)! + } + /// screenLayout bits for screen size class. + public var screenLayoutSize: LayoutSize { + .init(rawValue: (screenLayout & 0x0F))! + } + /// uiMode bits for the night switch. + public var uiModeNight: UIModeNight { + .init(rawValue: (uiMode & 0x30) >> 4)! + } + /// uiMode bits for the mode type. + public var uiModeType: UIModeType { + .init(rawValue: (uiMode & 0x0F))! + } + + public enum LayoutDir: UInt8 { + case any = 0 + case LTR = 1 + case RTL = 2 + } + + public enum LayoutLong: UInt8 { + case any = 0 + case No = 1 + case Yes = 2 + } + + public enum LayoutSize: UInt8 { + case any = 0 + case Small = 1 + case Normal = 2 + case Large = 3 + case Xlarge = 4 + } + + public enum UIModeNight: UInt8 { + case any = 0 + case No = 1 + case Yes = 2 + } + + public enum UIModeType: UInt8 { + case any = 0 + case Normal = 1 + case Desk = 2 + case Car = 3 + case Television = 4 + case Appliance = 5 + case Watch = 6 + case VrHeadset = 7 + } +} + + +// Mark: - TblTypeConfigScreenSizeDp + +public struct TblTypeConfigScreenSizeDp { + /// `0` means any + public let screenWidthDp: UInt16 + /// `0` means any + public let screenHeightDp: UInt16 +} + + +// MARK: - TblTypeConfigScreenConfig2 + +/// screenLayout2 (UInt8), colorMode (UInt8), screenConfigPad2 (UInt16) +public struct TblTypeConfigScreenConfig2 { + /// Contains round/notround qualifier. + let screenLayout2: UInt8 + /// Wide-gamut, HDR, etc. + let colorMode: UInt8 + /// Reserved padding. + let screenConfigPad2: UInt16 + + public var screenLayoutRound: LayoutRound { .init(rawValue: (screenLayout2 & 0x03))! } + public var colorModeHDR: ColorModeHDR { .init(rawValue: (colorMode & 0x0c) >> 2)! } + public var colorModeWideGamut: ColorModeWideColorGamut { .init(rawValue: (colorMode & 0x03))! } + + /// screenLayout2 bits for round/notround. + public enum LayoutRound: UInt8 { + case any = 0 + case No = 1 + case Yes = 2 + } + + /// colorMode bits for HDR/LDR. + public enum ColorModeHDR: UInt8 { + case any = 0 + case No = 1 + case Yes = 2 + } + + /// colorMode bits for wide-color gamut/narrow-color gamut. + public enum ColorModeWideColorGamut: UInt8 { + case any = 0 + case No = 1 + case Yes = 2 + } +} diff --git a/Sources/AndroidXML/TblTypeSpec.swift b/Sources/AndroidXML/TblTypeSpec.swift new file mode 100644 index 0000000..e56b6f2 --- /dev/null +++ b/Sources/AndroidXML/TblTypeSpec.swift @@ -0,0 +1,91 @@ +/** + * A specification of the resources defined by a particular type. + * + * There should be one of these chunks for each resource type. + * + * This structure is followed by an array of integers providing the set of + * configuration change flags (`TblTypeSpecFlags`) that have multiple + * resources for that configuration. + * In addition, the high bit is set if that resource has been made public. + */ +/// Header size: `8 Bytes` +public struct TblTypeSpec { + let header: ChunkHeader + + /// The type identifier this chunk is holding. + /// Type IDs start at 1 (corresponding to the value of the type bits in a resource identifier). + /// `0` is invalid. + public let id: UInt8 + /// Must be `0`. + let res0: UInt8 + /// Used to be reserved, if `>0` specifies the number of `TblType` entries for this spec. + let typesCount: UInt16 + /// Number of `UInt32` entry configuration masks that follow. + public let entryCount: UInt32 + + // then: + // TblTypeSpecFlags entries: (entryCount * UInt32) + + init(_ chunk: ChunkHeader) { + header = chunk + var br = header.byteReader(at: .startOfHeader) + id = br.read8() + res0 = br.read8() + typesCount = br.read16() + entryCount = br.read32() + } + + // MARK: - Public methods + + /// Return list of configurations masks + public func entries() -> [TblTypeSpecFlags] { + let offset = header.index(.startOfData) + return (0.. TblTypeSpecFlags { + precondition(index < entryCount, "Index out of bounds for TypeSpec entry[\(index) > \(entryCount)]") + let offset = header.index(.startOfData) + index * 4 + return TblTypeSpecFlags(rawValue: header._bytes.int32(offset)) + } +} + +/// Flags indicating a set of config values. +/// These flag constants must match the corresponding ones +/// in `android.content.pm.ActivityInfo` and `attrs_manifest.xml`. +public struct TblTypeSpecFlags: OptionSet, Sendable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let Mcc = Self(rawValue: 0x0001) + public static let Mnc = Self(rawValue: 0x0002) + public static let Locale = Self(rawValue: 0x0004) + public static let Touchscreen = Self(rawValue: 0x0008) + public static let Keyboard = Self(rawValue: 0x0010) + public static let KeyboardHidden = Self(rawValue: 0x0020) + public static let Navigation = Self(rawValue: 0x0040) + public static let Orientation = Self(rawValue: 0x0080) + public static let Density = Self(rawValue: 0x0100) + public static let ScreenSize = Self(rawValue: 0x0200) + public static let Version = Self(rawValue: 0x0400) + public static let ScreenLayout = Self(rawValue: 0x0800) + public static let UiMode = Self(rawValue: 0x1000) + public static let SmallestScreenSize = Self(rawValue: 0x2000) + public static let Layoutdir = Self(rawValue: 0x4000) + public static let ScreenRound = Self(rawValue: 0x8000) + public static let ColorMode = Self(rawValue: 0x10000) + public static let GrammaticalGender = Self(rawValue: 0x20000) + + /// Additional flag indicating an entry is public. + public static let SpecPublic = Self(rawValue: 0x40000000) + /// Additional flag indicating the resource id for this resource may change in a future build. + /// If this flag is set, the `.specPublic` flag is also set since the resource must be public + /// to be exposed as an API to other applications. + public static let SpecStagedApi = Self(rawValue: 0x20000000) +} diff --git a/Sources/AndroidXML/Tbl_Parser.swift b/Sources/AndroidXML/Tbl_Parser.swift new file mode 100644 index 0000000..d613022 --- /dev/null +++ b/Sources/AndroidXML/Tbl_Parser.swift @@ -0,0 +1,82 @@ +/** + * Header for a resource table. + * + * Its data contains a series of additional chunks: + * * A `StringPool` containing all table values. + * This string pool contains all of the string values in the entire resource table + * (not the names of entries or type identifiers however). + * * One or more `TblPackage` chunks. + * + * Specific entries within a resource table can be uniquely identified + * with a single integer as defined by the `TblTableRef` structure. + */ +/// Header size: `ChunkHeader` (8B) + `4 Bytes` +public struct Tbl_Parser { + let header: ChunkHeader + + /// The number of `TblPackage` structures. + public let packageCount: UInt32 + /// Global `StringPool` for `StringPoolRef` lookup. + /// Each `TblPackage` may have addicional `StringPool` for lookup. + public let stringPool: StringPool + + init(_ bytes: RawBytes) throws { + header = try ChunkHeader(0, bytes, expect: .Table) + packageCount = bytes.int32(header.index(.startOfHeader)) + // StringPool must follow first chunk + stringPool = try StringPool(header.index(.startOfData), bytes) + } + + // MARK: - Public methods + + /// List all packages + public func getPackages() throws -> [TblPackage] { + try header.children(filter: { $0.type == .TblPackage }).map { TblPackage($0) } + } + + /// Get a specific package. E.g., by using `TblTableRef.package` + public func getPackage(_ pkgId: UInt8) throws -> TblPackage? { + var rv: TblPackage? = nil + try header.iterChildren { abort, chunk in + guard chunk.type == .TblPackage else { return } + let pkg = TblPackage(chunk) + if pkg.id == pkgId { + abort = true + rv = pkg + } + } + return rv + } + + /// Access specific resource using a full `TblTableRef` reference + public func getResource(_ ref: TblTableRef) throws -> Resource? { + guard let pkg = try getPackage(ref.package) else { + return nil + } + guard let (spec, types) = try pkg.getType(ref.type) else { + return nil + } + let entries = try types.compactMap { + if let val = try $0.getValue(ref.entry) { + return Resource.EntrySkin( + config: $0.config, entry: val) + } + return nil + } + let specFlags = spec.getEntry(Int(ref.entry)) + return Resource(package: pkg, specFlags: specFlags, entries: entries) + } + + /// Structured access to all layers (skins) of a table resource + public struct Resource { + public let package: TblPackage + public let specFlags: TblTypeSpecFlags + public let entries: [EntrySkin] + + public struct EntrySkin { + public let config: TblTypeConfig + public let entry: TblEntry + } + } +} + diff --git a/Sources/AndroidXML/XmlAttribute.swift b/Sources/AndroidXML/XmlAttribute.swift new file mode 100644 index 0000000..e829f88 --- /dev/null +++ b/Sources/AndroidXML/XmlAttribute.swift @@ -0,0 +1,22 @@ +/** + * Individual attributes of an `XmlStartElement` + */ +/// Size: `12 Bytes` + `ResValue` (8B) +public struct XmlAttribute { + /// Namespace of this attribute. + public let ns: StringPoolRef // UInt32 + /// Name of this attribute. + public let name: StringPoolRef // UInt32 + /// The original raw string value of this attribute. + /// Should be the same as `typedValue.data` (will be `0xFFFFFFFF` if unset). + public let rawValue: StringPoolRef // UInt32 + /// Processed typed value of this attribute. + public let typedValue: ResValue // 8 Bytes + + init(_ br: inout RawBytes.Reader) throws { + ns = br.read32() + name = br.read32() + rawValue = br.read32() + typedValue = try ResValue(&br) + } +} diff --git a/Sources/AndroidXML/XmlCDATA.swift b/Sources/AndroidXML/XmlCDATA.swift new file mode 100644 index 0000000..3f16a1e --- /dev/null +++ b/Sources/AndroidXML/XmlCDATA.swift @@ -0,0 +1,20 @@ +/** + * Extended XML tree node for CDATA tags -- includes the CDATA string. + * Appears `header.headerSize` bytes after a `XmlNode`. + */ +/// Header size: `ChunkHeader` (8B) + `XmlNode` (8B) + `4 Bytes` + `ResValue` (8B) +public struct XmlCDATA: XmlNode { + let header: ChunkHeader + + /// The raw CDATA character data. + public let data: StringPoolRef // UInt32 + /// The typed value of the character data if this is a CDATA node. + public let typedData: ResValue // 8 Bytes + + init(_ chunk: ChunkHeader) throws { + header = chunk + var br = header.byteReader(at: .startOfData) // skips XmlNode header + data = br.read32() + typedData = try ResValue(&br) + } +} diff --git a/Sources/AndroidXML/XmlElement.swift b/Sources/AndroidXML/XmlElement.swift new file mode 100644 index 0000000..49e2acb --- /dev/null +++ b/Sources/AndroidXML/XmlElement.swift @@ -0,0 +1,79 @@ + +// MARK: - Start + +/** + * Extended XML tree node for start tags -- includes attribute information. + * Appears `header.headerSize` bytes after a `XmlNode`. + */ +/// Header size: `ChunkHeader` (8B) + `XmlNode` (8B) + `20 Bytes` +public struct XmlStartElement: XmlNode { + let header: ChunkHeader + + /// String of the full namespace of this element. + public let ns: StringPoolRef // UInt32 + /// String name of this node if it is an `ELEMENT`; the raw character data if this is a `CDATA` node. + public let name: StringPoolRef // UInt32 + /// Byte offset from the start of this structure where the attributes start. + let attributeStart: UInt16 + /// Size of the `XmlAttribute` structures that follow. + let attributeSize: UInt16 + /// Number of attributes associated with an `ELEMENT`. + /// These are available as an array of `XmlAttribute` structures immediately following this node. + public let attributeCount: UInt16 + /// Index (1-based) of the "id" attribute. `0` if none. + public let idIndex: UInt16 + /// Index (1-based) of the "class" attribute. `0` if none. + public let classIndex: UInt16 + /// Index (1-based) of the "style" attribute. `0` if none. + public let styleIndex: UInt16 + + init(_ chunk: ChunkHeader) { + header = chunk + var br = header.byteReader(at: .startOfData) // skips XmlNode header + ns = br.read32() + name = br.read32() + attributeStart = br.read16() + attributeSize = br.read16() + attributeCount = br.read16() + idIndex = br.read16() + classIndex = br.read16() + styleIndex = br.read16() + } + + // MARK: - Public methods + + /// Generate and populate all attributes on the fly + public func attributes() throws -> [XmlAttribute] { + var br = header.byteReader(at: .startOfData) + let offset = br.index + Index(attributeStart) + let size = Index(attributeSize) + return try (0 ..< Index(attributeCount)).map { + br.index = offset + $0 * size + return try XmlAttribute(&br) + } + } +} + + +// MARK: - End + +/** + * Extended XML tree node for element start/end nodes. + * Appears `header.headerSize` bytes after a `XmlNode`. + */ +/// Header size: `ChunkHeader` (8B) + `XmlNode` (8B) + `8 Bytes` +public struct XmlEndElement: XmlNode { + let header: ChunkHeader + + /// String of the full namespace of this element. + public let ns: StringPoolRef // UInt32 + /// String name of this node if it is an `ELEMENT`; the raw character data if this is a `CDATA` node. + public let name: StringPoolRef // UInt32 + + init(_ chunk: ChunkHeader) { + header = chunk + var br = header.byteReader(at: .startOfData) // skips XmlNode header + ns = br.read32() + name = br.read32() + } +} diff --git a/Sources/AndroidXML/XmlNamespace.swift b/Sources/AndroidXML/XmlNamespace.swift new file mode 100644 index 0000000..6bf229b --- /dev/null +++ b/Sources/AndroidXML/XmlNamespace.swift @@ -0,0 +1,22 @@ +/** + * Extended XML tree node for namespace start/end nodes. + * Appears `header.headerSize` bytes after a `XmlNode`. + */ +/// Header size: `ChunkHeader` (8B) + `XmlNode` (8B) + `8 Bytes` +public struct XmlNamespace: XmlNode { + let header: ChunkHeader + + /// The prefix of the namespace. + public let prefix: StringPoolRef // UInt32 + /// The URI of the namespace. + public let uri: StringPoolRef // UInt32 + + init(_ chunk: ChunkHeader) { + header = chunk + var br = header.byteReader(at: .startOfData) // skips XmlNode header + prefix = br.read32() + uri = br.read32() + } +} + +// NOTE: start and end nodes are identical diff --git a/Sources/AndroidXML/XmlNode.swift b/Sources/AndroidXML/XmlNode.swift new file mode 100644 index 0000000..ce7298a --- /dev/null +++ b/Sources/AndroidXML/XmlNode.swift @@ -0,0 +1,24 @@ +/** + * Basic XML tree node. A single item in the XML document. + * Extended info about the node can be found after `header.headerSize`. + */ +/// Header size: `ChunkHeader` (8B) + `8 Bytes` +protocol XmlNode { + var header: ChunkHeader { get } +} + +extension XmlNode { + /// Line number in original source file at which this element appeared. + public var lineNumber: UInt32 { + header._bytes.int32(header.index(.startOfHeader) + 0) + } + + /// Optional XML comment that was associated with this element + public var comment: StringPoolRef? { + let val = header._bytes.int32(header.index(.startOfHeader) + 4) + return val == 0xFFFFFFFF ? nil : val // `-1` if none. + } + + /// Absolute offset in data + public var offset: Index { header.index(.startOfChunk) } +} diff --git a/Sources/AndroidXML/XmlResourceMap.swift b/Sources/AndroidXML/XmlResourceMap.swift new file mode 100644 index 0000000..79c8095 --- /dev/null +++ b/Sources/AndroidXML/XmlResourceMap.swift @@ -0,0 +1,20 @@ +/** + * Array mapping strings in the string pool back to resource identifiers. + */ +/// Header size: `ChunkHeader` (8B) + `0 Bytes` +public struct XmlResourceMap { + let header: ChunkHeader + + init(_ chunk: ChunkHeader) { + header = chunk + } + + public var count: Int { + (header.index(.afterChunk) - header.index(.startOfData)) / 4 + } + + public func entries() -> [TblTableRef] { + var br = header.byteReader(at: .startOfData) + return (0.. XmlResourceMap? { + var rv: XmlResourceMap? = nil + try header.iterChildren { abort, chunk in + if chunk.type == .XmlResourceMap { + abort = true + rv = XmlResourceMap(chunk) + } + } + return rv + } + + /// Iterate over whole tree and call `start` and `end` blocks for each element. + /// Each `start` block contains the `tag` name and its attribute `(name, value)` pairs. + public func iterElements(_ start: (_ startTag: String, _ attributes: [(String, ResValue)]) -> Void, _ end: (_ endTag: String) -> Void) throws { + // cache StringPool lookups for faster access + var _lookupTable: [StringPoolRef: String] = [:] + func lookup(_ index: StringPoolRef) -> String { + if let cached = _lookupTable[index] { + return cached + } + let val = stringPool.getString(index) + _lookupTable[index] = val + return val + } + // cache namespace prefix strings + var namespaces: [StringPoolRef: String] = [:] + var nsOnNextTag: StringPoolRef? = nil + + try header.iterChildren { _, chunk in + switch chunk.type { + case .StringPool, .XmlResourceMap: + break // ignore + + case .XmlCDATA: + // let cdata = try XmlCDATA(chunk) + break // ignore + + case .XmlStartNamespace: + let ns = XmlNamespace(chunk) + namespaces[ns.uri] = lookup(ns.prefix) + nsOnNextTag = ns.uri + + case .XmlEndNamespace: + namespaces.removeValue(forKey: XmlNamespace(chunk).uri) + nsOnNextTag = nil + + case .XmlStartElement: + let elem = XmlStartElement(chunk) + var attrs: [(String, ResValue)] = [] + // if tag is preceded by a namespace, apply ns-attribute + if let uri = nsOnNextTag, let prefix = namespaces[uri] { + attrs.append(("xmlns:\(prefix)", ResValue(type: .String, data: uri))) + } + nsOnNextTag = nil + // generate attributes + for attr in try elem.attributes() { + var key = lookup(attr.name) + if let prefix = namespaces[attr.ns] { + key = prefix + ":" + key + } + attrs.append((key, attr.typedValue)) + } + start(lookup(elem.name), attrs) + + case .XmlEndElement: + end(lookup(XmlEndElement(chunk).name)) + + default: + throw AXMLError("Dont know how to handle \(chunk.type) (offset: \(chunk.index(.startOfChunk)))") + } + } + } + + // MARK: XML string + + /// Return data as XML string. + public func xmlString(prettyPrint: Bool = true, collapseEmptyElements: Bool = true, indent: String = " ") throws -> String { + var rv = #""# + func newLine() -> String { + prettyPrint ? "\n" + String(repeating: indent, count: lvl) : "" + } + + var lvl = 0 + var lastOpen = false + + try iterElements({ startTag, attrs in + if lastOpen { + rv.append(">") + } + rv.append(newLine() + "<" + startTag) + for (key, attr) in attrs { + // yes, strings can contain quotes. Thus we must escape them. + // Hint: no `replace` because that is only available with Foundation + let val = attr.dataType == .String + ? stringPool.getString(attr.asStringRef) + .split(separator: #"""#).joined(separator: #"\""#) + : attr.resolve(nil) + rv.append(" \(key)=\"\(val)\"") + } + lvl += 1 + if collapseEmptyElements { + lastOpen = true + } else { + rv.append(">") + } + }) { closeTag in + lvl -= 1 + if lastOpen { + rv.append("/>") + lastOpen = false + } else { + rv.append(newLine() + "") + } + } + return rv + } +} + + +// MARK: SVG converter + +extension Xml_Parser { + /// Same as `xmlString()` but converts tag names and attribute names to SVG standard. + @available(*, deprecated, message: "Much more work needed for a proper SVG parser. This is just a very rudimentary converter for a quick preview.") + public func svgString() throws -> String { + var rv = #""# + func newLine() -> String { + "\n" + String(repeating: " ", count: lvl) + } + func unitless(_ val: ResValue) -> String { + switch val.dataType { + case .Dimension, .Fraction: "\(val.asDimension.value)" + default: val.resolve(stringPool) + } + } + + var lvl = 0 + var lastOpen = false + + try iterElements({ startTag, attrs in + if lastOpen { + rv.append(">") + } + var dict = attrs.reduce(into: [:]) { $0[$1.0] = $1.1 } + if startTag == "vector" { + rv.append(newLine() + #"") + lastOpen = false + } else { + if closeTag == "vector" { + rv.append(newLine() + "") + } else { + rv.append(newLine() + "") + } + } + } + return rv + } +} diff --git a/Tests/AndroidXMLTests/Test.swift b/Tests/AndroidXMLTests/Test.swift new file mode 100644 index 0000000..a3d4dcc --- /dev/null +++ b/Tests/AndroidXMLTests/Test.swift @@ -0,0 +1,71 @@ +import Testing +import Foundation +import AndroidXML + +struct Test { + @Test func foo() async throws { + // code for debugging. Run with Cmd+U +// printXml(.t1, "AndroidManifest.xml") +// printRes(.t1, 0x7F100000) +// try allXmlForFolder(.t1) +// try attrResolveEverything(.t1) + print("done.") + } + + private enum Pth: String { + case t1 = "/path/to/unpacked/apk/dir/" + } + + private func printXml(_ base: Pth, _ path: String) { + print(try! AndroidXML(path: base.rawValue + "/" + path).parseXml().xmlString()) + } + + private func printRes(_ base: Pth, _ id: UInt32) { + let xml = try! AndroidXML(path: base.rawValue + "/resources.arsc").parseTable() + let res = try! xml.getResource(TblTableRef(id))! + let pkgPool = res.package.stringPool(for: .Keys)! + for x in res.entries { + let val = x.entry.value! + print(pkgPool.getString(x.entry.key), val.resolve(xml.stringPool), x.config.screenType.density) + } + } + + private func allXmlForFolder(_ pth: Pth) throws { + let xx = FileManager.default.enumerator(atPath: pth.rawValue + "/res/")! + for x in xx.allObjects as! [String] { + if !x.hasSuffix(".xml") { + continue + } + + // only process files which are binary-xml + if let xml = try? AndroidXML(path: pth.rawValue + "/res/" + x) { + print(x) + let _ = try xml.parseXml().xmlString() + } + } + } + + private func attrResolveEverything(_ pth: Pth) throws { + let xml = try AndroidXML(path: pth.rawValue + "/resources.arsc").parseTable() + for pkg in try xml.getPackages() { + let pool = pkg.stringPool(for: .Keys)! + try pkg.iterTypes { spec, types in + //print("----- \(spec.id) ------") + for type in types { + try type.iterValues { idx, entry in + if entry.isComplex { + //print("::.::.complex", pool.getString(entry.key), entry.valueMap!.parent.asHex) + for x in entry.valueMap!.entries { + let _ = x.name.asHex + let _ = x.value.resolve(xml.stringPool) + } + } else { + let _ = pool.getString(entry.key) + let _ = entry.value!.resolve(xml.stringPool) + } + } + } + } + } + } +}