Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/build-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Select iOS Simulator
id: select_sim
run: |
SIMULATOR_ID=$(xcrun simctl list devices available | grep -m1 'iPhone' | awk -F '[()]' '{print $2}')
echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV
- name: Build iOS framework
run: |
set -o pipefail && xcodebuild -project ID3TagEditor.xcodeproj -scheme "ID3TagEditor iOS Tests" clean test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "platform=iOS Simulator,name=iPhone 16,OS=18.1" -skipPackagePluginValidation | xcpretty
set -o pipefail && xcodebuild -project ID3TagEditor.xcodeproj -scheme "ID3TagEditor iOS Tests" clean test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "id=$SIMULATOR_ID" -skipPackagePluginValidation | xcpretty
- name: Build iOS Demo
run: |
set -o pipefail && xcodebuild -project Demo/Demo.xcodeproj -scheme "Demo iOS" clean build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "platform=iOS Simulator,name=iPhone 16,OS=18.1" -skipPackagePluginValidation | xcpretty
run: |
set -o pipefail && xcodebuild -project Demo/Demo.xcodeproj -scheme "Demo iOS" clean build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "id=$SIMULATOR_ID" -skipPackagePluginValidation | xcpretty
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
3 changes: 2 additions & 1 deletion Source/Create/ID3FrameCreatorsFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ class ID3FrameCreatorsFactory {
ID3FrameContentCreator(frameCreator: frameFromStringUTF16ContentAdapter, frameName: .originalFilename, frameType: .originalFilename),
ID3FrameContentCreator(frameCreator: frameFromIntegerContentAdapter, frameName: .lengthInMilliseconds, frameType: .lengthInMilliseconds),
ID3FrameContentCreator(frameCreator: frameFromIntegerContentAdapter, frameName: .sizeInBytes, frameType: .sizeInBytes),
ID3FramesWithLocalizedContentCreatorFactory.make()
ID3FramesWithLocalizedContentCreatorFactory.make(),
ID3FramesWithUserDefinedTextInformationCreatorFactory.make()
]
+ ID3RecordingTimesFrameCreatorsFactory.make()
+ ID3iTunesFrameCreatorsFactory.make()
Expand Down
31 changes: 31 additions & 0 deletions Source/Create/ID3FramesWithUserDefinedTextInformationCreator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// ID3FramesWithUserDefinedTextInformationCreator.swift
// ID3TagEditor
//
// Created by Fabrizio Duroni on 26/07/2025.
// 2025 Fabrizio Duroni.
//

import Foundation

class ID3FramesWithUserDefinedTextInformationCreator: ID3FrameCreator {
private let userDefinedTextInformationFrameCreator: UserDefinedTextInformationFrameCreator

init(userDefinedTextInformationFrameCreator: UserDefinedTextInformationFrameCreator) {
self.userDefinedTextInformationFrameCreator = userDefinedTextInformationFrameCreator
}

func createFrames(id3Tag: ID3Tag) -> [UInt8] {
return id3Tag.frames.reduce([], { accumulator, frameElement in
if case .userDefinedTextInformation(_) = frameElement.key,
let frame = frameElement.value as? ID3FrameUserDefinedTextInformation {
return accumulator + userDefinedTextInformationFrameCreator.createFrame(
using: frame,
version: id3Tag.properties.version,
frameType: .userDefinedTextInformation
)
}
return accumulator
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// ID3FramesWithUserDefinedTextInformationCreatorFactory.swift
// ID3TagEditor
//
// Created by Fabrizio Duroni on 26/07/2025.
// 2025 Fabrizio Duroni.
//

import Foundation

class ID3FramesWithUserDefinedTextInformationCreatorFactory {
static func make() -> ID3FramesWithUserDefinedTextInformationCreator {
let frameConfiguration = ID3FrameConfiguration()
let paddingAdder = PaddingAdderToEndOfContentUsingNullChar()

return ID3FramesWithUserDefinedTextInformationCreator(
userDefinedTextInformationFrameCreator: ID3UserDefinedTextInformationFrameCreator(
id3FrameConfiguration: frameConfiguration,
frameHeaderCreator: ID3FrameHeaderCreatorFactory.make(),
paddingAdder: paddingAdder
)
)
}
}
59 changes: 59 additions & 0 deletions Source/Create/ID3UserDefinedTextInformationFrameCreator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// ID3UserDefinedTextInformationFrameCreator.swift
// ID3TagEditor
//
// Created by Fabrizio Duroni on 26/07/2025.
// 2025 Fabrizio Duroni.
//

import Foundation

protocol UserDefinedTextInformationFrameCreator {
func createFrame(using frame: ID3FrameUserDefinedTextInformation, version: ID3Version, frameType: FrameType) -> [UInt8]
}

class ID3UserDefinedTextInformationFrameCreator: UserDefinedTextInformationFrameCreator {
private let id3FrameConfiguration: ID3FrameConfiguration
private let frameHeaderCreator: FrameHeaderCreator
private let paddingAdder: PaddingAdder

init(id3FrameConfiguration: ID3FrameConfiguration,
frameHeaderCreator: FrameHeaderCreator,
paddingAdder: PaddingAdder) {
self.id3FrameConfiguration = id3FrameConfiguration
self.frameHeaderCreator = frameHeaderCreator
self.paddingAdder = paddingAdder
}

func createFrame(using frame: ID3FrameUserDefinedTextInformation, version: ID3Version, frameType: FrameType) -> [UInt8] {
let encoding = selectEncoding(for: version)
let stringEncoding = convertToStringEncoding(encoding, version: version)
let encodingByte = id3FrameConfiguration.encodingByteFor(version: version, encoding: encoding)

let descriptionBytes = [UInt8](frame.description.data(using: stringEncoding) ?? Data())
let description = paddingAdder.addTo(content: descriptionBytes, numberOfByte: getTerminatorSize(for: stringEncoding))
let content = [UInt8](frame.content.data(using: stringEncoding) ?? Data())

let body = encodingByte + description + content
let header = frameHeaderCreator.createUsing(version: version, frameType: frameType, frameBody: body)

return header + body
}

private func selectEncoding(for version: ID3Version) -> ID3StringEncoding {
return version == .version4 ? .UTF8 : .UTF16
}

private func convertToStringEncoding(_ id3Encoding: ID3StringEncoding, version: ID3Version) -> String.Encoding {
let encodingMap: [ID3Version: [ID3StringEncoding: String.Encoding]] = [
.version2: [.ISO88591: .isoLatin1, .UTF16: .utf16],
.version3: [.ISO88591: .isoLatin1, .UTF16: .utf16],
.version4: [.ISO88591: .isoLatin1, .UTF16: .utf16, .UTF8: .utf8]
]
return encodingMap[version]?[id3Encoding] ?? .utf16
}

private func getTerminatorSize(for encoding: String.Encoding) -> Int {
return encoding == .utf16 ? 2 : 1
}
}
3 changes: 3 additions & 0 deletions Source/Frame/FrameName.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,7 @@ public enum FrameName: Equatable, Hashable, CaseIterable {
case iTunesPodcastID
/// Podcast keywords frame name, Version 2.3 and 2.4 only.
case iTunesPodcastKeywords
/// User defined text information frame name (TXXX in v2.3/v2.4, TXX in v2.2).
/// - description: the description that identifies the type of text information.
case userDefinedTextInformation(_ description: String)
}
1 change: 1 addition & 0 deletions Source/Frame/FrameType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ enum FrameType: String, Equatable {
case iTunesPodcastDescription = "iTunesPodcastDescription"
case iTunesPodcastID = "iTunesPodcastID"
case iTunesPodcastKeywords = "iTunesPodcastKeywords"
case userDefinedTextInformation = "userDefinedTextInformation"
case invalid = ""

static func == (lhs: FrameType, rhs: FrameType) -> Bool {
Expand Down
12 changes: 8 additions & 4 deletions Source/Frame/ID3FrameConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ class ID3FrameConfiguration {
.iTunesPodcastCategory: [UInt8]("TCAT".utf8),
.iTunesPodcastDescription: [UInt8]("TDES".utf8),
.iTunesPodcastID: [UInt8]("TGID".utf8),
.iTunesPodcastKeywords: [UInt8]("TKWD".utf8)
.iTunesPodcastKeywords: [UInt8]("TKWD".utf8),
.userDefinedTextInformation: [UInt8]("TXXX".utf8)
]
private var identifiers: [ID3Version: [FrameType: [UInt8]]] = [
.version2: [
Expand Down Expand Up @@ -92,7 +93,8 @@ class ID3FrameConfiguration {
.lengthInMilliseconds: [UInt8]("TLE".utf8),
.sizeInBytes: [UInt8]("TSI".utf8),
.unsyncronisedLyrics: [UInt8]("ULT".utf8),
.comment: [UInt8]("COM".utf8)
.comment: [UInt8]("COM".utf8),
.userDefinedTextInformation: [UInt8]("TXX".utf8)
],
.version3: [
.recordingDayMonth: [UInt8]("TDAT".utf8),
Expand Down Expand Up @@ -137,7 +139,8 @@ class ID3FrameConfiguration {
"TBPM": .beatsPerMinute,
"TOFN": .originalFilename,
"TLEN": .lengthInMilliseconds,
"TKEY": .initialKey
"TKEY": .initialKey,
"TXXX": .userDefinedTextInformation
]
private var nameForIdentifier: [ID3Version: [String: FrameType]] = [
.version2: [
Expand Down Expand Up @@ -168,7 +171,8 @@ class ID3FrameConfiguration {
"TOF": .originalFilename,
"TLE": .lengthInMilliseconds,
"TSI": .sizeInBytes,
"TKE": .initialKey
"TKE": .initialKey,
"TXX": .userDefinedTextInformation
],
.version3: [
"TDAT": .recordingDayMonth,
Expand Down
38 changes: 38 additions & 0 deletions Source/Frame/ID3FrameUserDefinedTextInformation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// ID3FrameUserDefinedTextInformation.swift
// ID3TagEditor
//
// Created by Fabrizio Duroni on 26/07/2025.
// 2025 Fabrizio Duroni.
//

import Foundation

/**
A class used to represent an ID3 User Defined Text Information frame (TXXX in v2.3/v2.4, TXX in v2.2).
This frame is intended for one-string text information concerning the audio file.
The frame contains a description field that identifies the type of text and the actual text value.
Multiple TXXX frames are allowed in a tag, but only one with the same description.
*/
public class ID3FrameUserDefinedTextInformation: ID3FrameWithStringContent, CustomDebugStringConvertible {
/// A description that identifies the type of text information
public let description: String
/// ID3FrameUserDefinedTextInformation debug description.
public var debugDescription: String {
return """
description: \(description)
content: \(content)
"""
}

/**
Init an ID3 User Defined Text Information frame.

- parameter description: a description that identifies the type of text information.
- parameter content: the actual text content.
*/
public init(description: String, content: String) {
self.description = description
super.init(content: content)
}
}
3 changes: 2 additions & 1 deletion Source/Parse/ID3FrameContentParsingOperationFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ class ID3FrameContentParsingOperationFactory {
.iTunesGrouping: ID3ParsingOperationForID3FrameWithStringFactory.make(frameName: .iTunesGrouping),
.iTunesMovementIndex: ID3ParsingOperationForID3FrameWithIntegerFactory.make(frameName: .iTunesMovementIndex),
.iTunesMovementCount: ID3ParsingOperationForID3FrameWithIntegerFactory.make(frameName: .iTunesMovementCount),
.iTunesMovementName: ID3ParsingOperationForID3FrameWithStringFactory.make(frameName: .iTunesMovementName)
.iTunesMovementName: ID3ParsingOperationForID3FrameWithStringFactory.make(frameName: .iTunesMovementName),
.userDefinedTextInformation: ID3UserDefinedTextInformationFrameContentParsingOperationFactory.make()
]
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//
// ID3UserDefinedTextInformationFrameContentParsingOperation.swift
// ID3TagEditor
//
// Created by Fabrizio Duroni on 26/07/2025.
// 2025 Fabrizio Duroni.
//

import Foundation

class ID3UserDefinedTextInformationFrameContentParsingOperation: FrameContentParsingOperation {
private let id3FrameConfiguration: ID3FrameConfiguration
private let stringEncodingDetector: ID3FrameStringEncodingDetector
private let paddingRemover: PaddingRemover

init(id3FrameConfiguration: ID3FrameConfiguration,
paddingRemover: PaddingRemover,
stringEncodingDetector: ID3FrameStringEncodingDetector) {
self.id3FrameConfiguration = id3FrameConfiguration
self.stringEncodingDetector = stringEncodingDetector
self.paddingRemover = paddingRemover
}

func parse(frame: Data, version: ID3Version, completed: (FrameName, ID3Frame) -> Void) {
let headerSize = id3FrameConfiguration.headerSizeFor(version: version)
let encoding = stringEncodingDetector.detect(frame: frame, version: version)
let (description, content) = parseBodyFrom(frame: frame, using: headerSize, and: encoding)
let userDefinedFrame = ID3FrameUserDefinedTextInformation(description: description, content: content)
completed(.userDefinedTextInformation(description), userDefinedFrame)
}

private func parseBodyFrom(frame: Data, using headerSize: Int, and encoding: String.Encoding) -> (String, String) {
let encodingSize = id3FrameConfiguration.encodingSize()
let frameContentStartIndex = headerSize + encodingSize
let allContent = frame.subdata(in: frameContentStartIndex..<frame.count)

if encoding == .utf16 {
return parseUTF16Content(allContent)
} else {
return parseNonUTF16Content(allContent, encoding: encoding)
}
}

private func parseUTF16Content(_ data: Data) -> (String, String) {
// For UTF-16, we need to handle the BOM and find the actual null terminator
var currentIndex = 0

// Skip BOM if present
if data.count >= 2 && data[0] == 0xFF && data[1] == 0xFE {
currentIndex = 2
}

// Find the null terminator (00 00)
var descriptionEnd = currentIndex
while descriptionEnd < data.count - 1 {
if data[descriptionEnd] == 0x00 && data[descriptionEnd + 1] == 0x00 {
break
}
descriptionEnd += 2 // UTF-16 uses 2 bytes per character
}

// Extract description (including BOM if present)
let descriptionData = data.subdata(in: 0..<descriptionEnd)
let description = String(data: descriptionData, encoding: .utf16) ?? ""

// Skip past the null terminator
let contentStart = descriptionEnd + 2

// Extract content (rest of the data)
let contentData = data.subdata(in: contentStart..<data.count)
let content = String(data: contentData, encoding: .utf16) ?? ""

return (paddingRemover.removeFrom(string: description), paddingRemover.removeFrom(string: content))
}

private func parseNonUTF16Content(_ data: Data, encoding: String.Encoding) -> (String, String) {
let separatorBytes = Data([0x00])
guard let separatorRange = data.range(of: separatorBytes) else {
// If no separator found, treat all as description with empty content
let description = String(data: data, encoding: encoding) ?? ""
return (paddingRemover.removeFrom(string: description), "")
}

let description = String(
data: data.subdata(in: 0..<separatorRange.lowerBound),
encoding: encoding
) ?? ""

let valueStartIndex = separatorRange.upperBound
let content = String(
data: data.subdata(in: valueStartIndex..<data.count),
encoding: encoding
) ?? ""

return (paddingRemover.removeFrom(string: description), paddingRemover.removeFrom(string: content))
}

private func getSeparatorUsing(encoding: String.Encoding) -> [UInt8] {
return encoding == String.Encoding.utf16 ? [0x00, 0x00] : [0x00]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// ID3UserDefinedTextInformationFrameContentParsingOperationFactory.swift
// ID3TagEditor
//
// Created by Fabrizio Duroni on 26/07/2025.
// 2025 Fabrizio Duroni.
//

import Foundation

class ID3UserDefinedTextInformationFrameContentParsingOperationFactory {
static func make() -> ID3UserDefinedTextInformationFrameContentParsingOperation {
let id3FrameConfiguration = ID3FrameConfiguration()
return ID3UserDefinedTextInformationFrameContentParsingOperation(
id3FrameConfiguration: id3FrameConfiguration,
paddingRemover: PaddingRemoverUsingTrimming(),
stringEncodingDetector: ID3FrameStringEncodingDetector(
id3FrameConfiguration: id3FrameConfiguration,
id3StringEncodingConverter: ID3StringEncodingConverter()
)
)
}
}
Loading
Loading