diff --git a/BitTorrent.xcodeproj/project.pbxproj b/BitTorrent.xcodeproj/project.pbxproj index 41deff0..adedeb5 100644 --- a/BitTorrent.xcodeproj/project.pbxproj +++ b/BitTorrent.xcodeproj/project.pbxproj @@ -35,6 +35,8 @@ B5E9B0DA1F02E6F800EF58E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5E9B0D91F02E6F800EF58E3 /* Assets.xcassets */; }; B5E9B0DD1F02E6F800EF58E3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B5E9B0DB1F02E6F800EF58E3 /* LaunchScreen.storyboard */; }; B5E9B0E51F02FAC600EF58E3 /* TestText.torrent in Resources */ = {isa = PBXBuildFile; fileRef = B5E9B0E31F02F9E700EF58E3 /* TestText.torrent */; }; + B5F81E491F0436D600B25C70 /* TorrentPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F81E481F0436D600B25C70 /* TorrentPeer.swift */; }; + B5F81E4B1F04399800B25C70 /* TorrentTrackerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F81E4A1F04399800B25C70 /* TorrentTrackerResponse.swift */; }; F2A74A776590BB3F37116B02 /* Pods_BitTorrent.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9B95A5AC293A32B10DB4660E /* Pods_BitTorrent.framework */; }; /* End PBXBuildFile section */ @@ -145,6 +147,8 @@ B5E9B0DE1F02E6F800EF58E3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B5E9B0E31F02F9E700EF58E3 /* TestText.torrent */ = {isa = PBXFileReference; lastKnownFileType = file; path = TestText.torrent; sourceTree = ""; }; B5E9B0E41F02F9E700EF58E3 /* text.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = text.txt; sourceTree = ""; }; + B5F81E481F0436D600B25C70 /* TorrentPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentPeer.swift; sourceTree = ""; }; + B5F81E4A1F04399800B25C70 /* TorrentTrackerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TorrentTrackerResponse.swift; path = Tracker/TorrentTrackerResponse.swift; sourceTree = ""; }; BDA22A3943D47D6B2152CB15 /* Pods-BitTorrent.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BitTorrent.debug.xcconfig"; path = "Pods/Target Support Files/Pods-BitTorrent/Pods-BitTorrent.debug.xcconfig"; sourceTree = ""; }; BFDACB202E071FF8247EC868 /* Pods-BitTorrentExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BitTorrentExample.debug.xcconfig"; path = "Pods/Target Support Files/Pods-BitTorrentExample/Pods-BitTorrentExample.debug.xcconfig"; sourceTree = ""; }; C79B494173728B25AA5E2B62 /* Pods-BitTorrent-BitTorrentExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BitTorrent-BitTorrentExample.release.xcconfig"; path = "Pods/Target Support Files/Pods-BitTorrent-BitTorrentExample/Pods-BitTorrent-BitTorrentExample.release.xcconfig"; sourceTree = ""; }; @@ -269,6 +273,7 @@ isa = PBXGroup; children = ( B55317DB1F02FC4D00909ADF /* TorrentHTTPTracker.swift */, + B5F81E4A1F04399800B25C70 /* TorrentTrackerResponse.swift */, B5BD7FD51F03032400621BC2 /* TorrentHTTPTrackerTests.swift */, ); name = Tracker; @@ -304,6 +309,7 @@ children = ( B585AB771C3833450093FA41 /* BitTorrent.h */, B585AB831C3833450093FA41 /* BitTorrentTests.swift */, + B5F81E461F0436CC00B25C70 /* Peer */, B55317DA1F02FC3000909ADF /* Tracker */, B54D0C251CA56A25004343BD /* Models */, B54D0C291CA5785F004343BD /* Utilities */, @@ -354,6 +360,14 @@ path = "Single text file"; sourceTree = ""; }; + B5F81E461F0436CC00B25C70 /* Peer */ = { + isa = PBXGroup; + children = ( + B5F81E481F0436D600B25C70 /* TorrentPeer.swift */, + ); + path = Peer; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -702,9 +716,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B5F81E491F0436D600B25C70 /* TorrentPeer.swift in Sources */, B55317DC1F02FC4D00909ADF /* TorrentHTTPTracker.swift in Sources */, B5E977961CAFB46B0038EBE7 /* String+URLEncode.swift in Sources */, B54D0C7A1CA69FAD004343BD /* TorrentMetaInfo.swift in Sources */, + B5F81E4B1F04399800B25C70 /* TorrentTrackerResponse.swift in Sources */, B54D0C7B1CA69FD8004343BD /* Data+sha1.swift in Sources */, B5530DB21F03063300F71CCD /* HTTPConnection.swift in Sources */, ); diff --git a/BitTorrent/Peer/TorrentPeer.swift b/BitTorrent/Peer/TorrentPeer.swift new file mode 100644 index 0000000..23791a9 --- /dev/null +++ b/BitTorrent/Peer/TorrentPeer.swift @@ -0,0 +1,57 @@ +// +// TorrentPeer.swift +// BitTorrent +// +// Created by Ben Davis on 28/06/2017. +// Copyright © 2017 Ben Davis. All rights reserved. +// + +import Foundation + +struct TorrentPeerInfo { + let ip: String + let port: Int + let peerId: Data? + + init(ip: String, port: Int, peerId: Data?) { + self.ip = ip + self.port = port + self.peerId = peerId + } + + init?(dictionary: [String: Any]) { + + guard let ipData = dictionary["ip"] as? Data, + let ip = String(asciiData: ipData), + let port = dictionary["port"] as? Int else { + return nil + } + + self.ip = ip + self.port = port + self.peerId = dictionary["peer id"] as? Data + } + + static func peersInfoFromBinaryModel(_ data: Data) -> [TorrentPeerInfo] { + let numberOfPeers = data.count / 6 + var result: [TorrentPeerInfo] = [] + for i in 0.. String { // var peerId = "-BD0000-" @@ -30,43 +93,3 @@ import Foundation // // return peerId //} - -class TorrentHTTPTracker { - - let metaInfo: TorrentMetaInfo - let connection: BasicHTTPConnection - - init(metaInfo: TorrentMetaInfo, connection: BasicHTTPConnection = HTTPConnection()) { - self.metaInfo = metaInfo - self.connection = connection - } - - func announceClient(with peerId: String, - port: Int, - numberOfBytesRemaining: Int, - infoHash: Data, - numberOfPeersToFetch: Int, - peerKey: String) { - - let urlParameters = [ - "info_hash" : String(urlEncodingData: metaInfo.infoHash), - "peer_id" : "\(peerId)", - "port" : "\(port)", - "uploaded" : "0", - "downloaded" : "0", - "left" : "\(numberOfBytesRemaining)", - "compact" : "1", - "event" : "started", - "numwant" : "\(numberOfPeersToFetch)", - "key" : peerKey, - ] - - connection.makeRequest(url: metaInfo.announce, urlParameters: urlParameters) { response in - - if let data = response.responseData, let utf8Text = String(data: data, encoding: .utf8) { - print("Data: \(utf8Text)") - } - } - } - -} diff --git a/BitTorrent/Tracker/TorrentHTTPTrackerTests.swift b/BitTorrent/Tracker/TorrentHTTPTrackerTests.swift index c9d846b..883182a 100644 --- a/BitTorrent/Tracker/TorrentHTTPTrackerTests.swift +++ b/BitTorrent/Tracker/TorrentHTTPTrackerTests.swift @@ -9,11 +9,46 @@ import XCTest @testable import BitTorrent +class TorrentTrackerDelegateSpy: TorrentTrackerDelegate { + + var receivedResponseCalled = false + var receivedResponseParameter: TorrentHTTPTrackerResponse? = nil + + var receivedErrorMessageCalled = false + var receivedErrorMessageParameter: String? = nil + + func torrentTracker(_ sender: TorrentHTTPTracker, receivedResponse response: TorrentHTTPTrackerResponse) { + receivedResponseCalled = true + receivedResponseParameter = response + } + + func torrentTracker(_ sender: TorrentHTTPTracker, receivedErrorMessage errorMessage: String) { + receivedErrorMessageCalled = true + receivedErrorMessageParameter = errorMessage + } +} + class TorrentHTTPTrackerTests: XCTestCase { var connectionStub: HTTPConnectionStub! var sut: TorrentHTTPTracker! + var delegateSpy: TorrentTrackerDelegateSpy! + + let expectedURLParameters: [String: String] = [ + "info_hash": "%F0%B8q%98%99S%97%3F%BF%A9M%C8%14%98%EE%8D%20%5B%B2%23", + "peer_id" : "peerId", + "port" : "123", + "uploaded" : "1234", + "downloaded" : "4321", + "left" : "456", + "compact" : "1", + "event" : "started", + "numwant" : "321", + ] + + let basicResponseData = "d8:completei1e10:incompletei2e8:intervali600e5:peers0:e".data(using: .ascii)! + override func setUp() { super.setUp() @@ -23,34 +58,162 @@ class TorrentHTTPTrackerTests: XCTestCase { connectionStub = HTTPConnectionStub() sut = TorrentHTTPTracker(metaInfo: metaInfo, connection: connectionStub) + + delegateSpy = TorrentTrackerDelegateSpy() + sut.delegate = delegateSpy + } + + func performAnnounce(withEvent event: TorrentHTTPTrackerEvent) { + sut.announceClient(with: "peerId", + port: 123, + event: event, + infoHash: Data(bytes: [7,8,9]), + numberOfBytesRemaining: 456, + numberOfBytesUploaded: 1234, + numberOfBytesDownloaded: 4321, + numberOfPeersToFetch: 321) } func test_announce() { - - sut.announceClient(with: "peerId", - port: 123, - numberOfBytesRemaining: 456, - infoHash: Data(bytes: [7,8,9]), - numberOfPeersToFetch: 321, - peerKey: "key") - + performAnnounce(withEvent: .started) let request = connectionStub.lastRequest - - let expectedURLParameters = [ - "info_hash": "%F0%B8q%98%99S%97%3F%BF%A9M%C8%14%98%EE%8D%20%5B%B2%23", - "peer_id" : "peerId", - "port" : "123", - "uploaded" : "0", - "downloaded" : "0", - "left" : "456", - "compact" : "1", - "event" : "started", - "numwant" : "321", - "key" : "key", - ] - XCTAssertEqual(request.url.absoluteString, "http://127.0.0.1:53420/announce") XCTAssertEqual(request.urlParameters!, expectedURLParameters) } + func test_sendStoppedEvent() { + performAnnounce(withEvent: .stopped) + let request = connectionStub.lastRequest + XCTAssertEqual(request.urlParameters!["event"], "stopped") + } + + func test_sendCompletedEvent() { + performAnnounce(withEvent: .completed) + let request = connectionStub.lastRequest + XCTAssertEqual(request.urlParameters!["event"], "completed") + } + + func test_delegateNotifiedOnTrackerResponse() { + + performAnnounce(withEvent: .started) + + connectionStub.completeLastRequest(with: HTTPResponse(completed: true, + responseData: basicResponseData, + statusCode: 200)) + + XCTAssert(delegateSpy.receivedResponseCalled) + } + + func test_basicResponseParsing() { + + performAnnounce(withEvent: .started) + + connectionStub.completeLastRequest(with: HTTPResponse(completed: true, + responseData: basicResponseData, + statusCode: 200)) + + let response = delegateSpy.receivedResponseParameter! + + XCTAssertEqual(response.numberOfPeersComplete, 1) + XCTAssertEqual(response.numberOfPeersIncomplete, 2) + XCTAssertNil(response.trackerId) + XCTAssertEqual(response.interval, 600) + XCTAssertEqual(response.minimumInterval, 0) + XCTAssertNil(response.warning) + } + + func test_optionalResponseFieldsParsing() { + performAnnounce(withEvent: .started) + + let completeResponse = "d15:warning message7:warning10:tracker id9:trackerId12:min intervali60e8:completei1e10:incompletei2e8:intervali600e5:peers0:e".data(using: .ascii)! + + connectionStub.completeLastRequest(with: HTTPResponse(completed: true, + responseData: completeResponse, + statusCode: 200)) + + let response = delegateSpy.receivedResponseParameter! + + XCTAssertEqual(response.trackerId, "trackerId".data(using: .ascii)) + XCTAssertEqual(response.minimumInterval, 60) + XCTAssertEqual(response.warning, "warning") + } + + func test_binaryPeersFormat() { + performAnnounce(withEvent: .started) + + let peersBinary = Data(bytes: [0x7f, 0x00, 0x00, 0x01, 0x3c, 0x17, 0x7f, 0x00, 0x00, 0x01, 0x1a, 0xe1]) + + let responseData = "d8:completei1e10:incompletei2e8:intervali600e5:peers12:".data(using: .ascii)! + + peersBinary + + "e".data(using: .ascii)! + + connectionStub.completeLastRequest(with: HTTPResponse(completed: true, + responseData: responseData, + statusCode: 200)) + + let response = delegateSpy.receivedResponseParameter! + + XCTAssertEqual(response.peers.count, 2) + XCTAssertEqual(response.peers.first!.ip, "127.0.0.1") + XCTAssertEqual(response.peers.first!.port, 15383) + XCTAssertEqual(response.peers.last!.ip, "127.0.0.1") + XCTAssertEqual(response.peers.last!.port, 6881) + } + + func test_dictionaryPeersFormat() { + performAnnounce(withEvent: .started) + + let peer1Id = "peerId1-------------" + let peer1IP = "127.0.0.1" + let peer1Port = 15383 + let peer2Id = "peerId2-------------" + let peer2IP = "127.0.0.1" + let peer2Port = 6881 + + let peer1 = "d7:peer id20:\(peer1Id)2:ip9:\(peer1IP)4:porti\(peer1Port)ee" + let peer2 = "d7:peer id20:\(peer2Id)2:ip9:\(peer2IP)4:porti\(peer2Port)ee" + + let responseData = "d8:completei1e10:incompletei2e8:intervali600e5:peersl\(peer1)\(peer2)ee".data(using: .ascii)! + + connectionStub.completeLastRequest(with: HTTPResponse(completed: true, + responseData: responseData, + statusCode: 200)) + + let response = delegateSpy.receivedResponseParameter! + + XCTAssertEqual(response.peers.count, 2) + + XCTAssertEqual(response.peers.first!.peerId!, peer1Id.data(using: .ascii)) + XCTAssertEqual(response.peers.first!.ip, peer1IP) + XCTAssertEqual(response.peers.first!.port, peer1Port) + + XCTAssertEqual(response.peers.last!.peerId!, peer2Id.data(using: .ascii)) + XCTAssertEqual(response.peers.last!.ip, peer2IP) + XCTAssertEqual(response.peers.last!.port, peer2Port) + } + + func test_failResponseDoesNotCallDelegate() { + performAnnounce(withEvent: .started) + + let responseData = "d14:failure reason45:invalid info_hash (not 20 chars):123length: 3e".data(using: .ascii) + + connectionStub.completeLastRequest(with: HTTPResponse(completed: true, + responseData: responseData, + statusCode: 200)) + + XCTAssertFalse(delegateSpy.receivedResponseCalled) + XCTAssert(delegateSpy.receivedErrorMessageCalled) + XCTAssertEqual(delegateSpy.receivedErrorMessageParameter, "invalid info_hash (not 20 chars):123length: 3") + } + + func test_invalidTrackerResponse() { + performAnnounce(withEvent: .started) + + connectionStub.completeLastRequest(with: HTTPResponse(completed: true, + responseData: Data(), + statusCode: 500)) + + XCTAssertFalse(delegateSpy.receivedResponseCalled) + XCTAssertFalse(delegateSpy.receivedErrorMessageCalled) + } } diff --git a/BitTorrent/Tracker/TorrentTrackerResponse.swift b/BitTorrent/Tracker/TorrentTrackerResponse.swift new file mode 100644 index 0000000..5653798 --- /dev/null +++ b/BitTorrent/Tracker/TorrentTrackerResponse.swift @@ -0,0 +1,95 @@ +// +// TorrentTrackerResponse.swift +// BitTorrent +// +// Created by Ben Davis on 28/06/2017. +// Copyright © 2017 Ben Davis. All rights reserved. +// + +import Foundation +import BEncode + +struct TorrentHTTPTrackerResponse { + + let peers: [TorrentPeerInfo] + let numberOfPeersComplete: Int // Seeders + let numberOfPeersIncomplete: Int // Leechers + + let trackerId: Data? + + let interval: Int + let minimumInterval: Int + + let warning: String? + + init(peers: [TorrentPeerInfo], + numberOfPeersComplete: Int = 0, + numberOfPeersIncomplete: Int = 0, + trackerId: Data? = nil, + interval: Int = 60, + minimumInterval: Int = 0, + warning: String? = nil) { + + self.peers = peers + self.numberOfPeersComplete = numberOfPeersComplete + self.numberOfPeersIncomplete = numberOfPeersIncomplete + self.trackerId = trackerId + self.interval = interval + self.minimumInterval = minimumInterval + self.warning = warning + } +} + +extension TorrentHTTPTrackerResponse { + + init?(data: Data) { + let bencode: [String: Any] + do { + bencode = try BEncoder.decodeStringKeyedDictionary(data) + } catch { + return nil + } + + guard let numberOfPeersComplete = bencode["complete"] as? Int, + let numberOfPeersIncomplete = bencode["incomplete"] as? Int, + let interval = bencode["interval"] as? Int, + let peersObject = bencode["peers"] else { + return nil + } + + if let binaryData = peersObject as? Data { + self.peers = TorrentPeerInfo.peersInfoFromBinaryModel(binaryData) + } else { + guard let peersDictionaries = peersObject as? [[String: Any]] else { + return nil + } + self.peers = peersDictionaries.map(TorrentPeerInfo.init(dictionary:)).flatMap({ $0 }) + } + self.numberOfPeersComplete = numberOfPeersComplete + self.numberOfPeersIncomplete = numberOfPeersIncomplete + self.trackerId = bencode["tracker id"] as? Data + self.interval = interval + self.minimumInterval = bencode["min interval"] as? Int ?? 0 + + if let warningData = bencode["warning message"] as? Data { + self.warning = String(data: warningData, encoding: .utf8) + } else { + self.warning = nil + } + } +} + +extension TorrentHTTPTrackerResponse { + + static func errorMessage(fromResponseData data: Data) -> String? { + + let bencode: [String: Any] + do { + bencode = try BEncoder.decodeStringKeyedDictionary(data) + } catch { + return nil + } + + return String(asciiData: bencode["failure reason"] as? Data) + } +} diff --git a/BitTorrentExample/AppDelegate.swift b/BitTorrentExample/AppDelegate.swift index e67c815..c538ab1 100644 --- a/BitTorrentExample/AppDelegate.swift +++ b/BitTorrentExample/AppDelegate.swift @@ -31,11 +31,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { tracker.announceClient(with: "-BD0000-bxa]N#IRKqv`", port: 6881, - numberOfBytesRemaining: 117, - infoHash: Data(bytes:[ 0xf0, 0xb8, 0x71, 0x98, 0x99, 0x53, 0x97, 0x3f, 0xbf, 0xa9, - 0x4d, 0xc8, 0x14, 0x98, 0xee, 0x8d, 0x20, 0x5b, 0xb2, 0x23]), - numberOfPeersToFetch: 50, - peerKey: "key") + event: .started, + infoHash: metaInfo.infoHash, + numberOfBytesRemaining: metaInfo.info.length, + numberOfBytesUploaded: 0, + numberOfBytesDownloaded: 0, + numberOfPeersToFetch: 50) return true } diff --git a/BitTorrentExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/BitTorrentExample/Assets.xcassets/AppIcon.appiconset/Contents.json index 1d060ed..d8db8d6 100644 --- a/BitTorrentExample/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/BitTorrentExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -84,6 +84,11 @@ "idiom" : "ipad", "size" : "83.5x83.5", "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" } ], "info" : {