diff --git a/BitTorrent.xcodeproj/project.pbxproj b/BitTorrent.xcodeproj/project.pbxproj index 7ff3323..609fd7c 100644 --- a/BitTorrent.xcodeproj/project.pbxproj +++ b/BitTorrent.xcodeproj/project.pbxproj @@ -21,6 +21,11 @@ B514DD8F1F40C53C00C932F8 /* NetworkSpeedTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B514DD8E1F40C53C00C932F8 /* NetworkSpeedTrackerTests.swift */; }; B514DD911F40D15C00C932F8 /* StringUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B514DD901F40D15C00C932F8 /* StringUtilities.swift */; }; B514DD931F40D27500C932F8 /* TorrentInfoRowData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B514DD921F40D27500C932F8 /* TorrentInfoRowData.swift */; }; + B514DD951F40DB3100C932F8 /* TorrentFileManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B514DD941F40DB3100C932F8 /* TorrentFileManagerTests.swift */; }; + B514DD9A1F40DCCA00C932F8 /* BigTorrentTest.torrent in Resources */ = {isa = PBXBuildFile; fileRef = B514DD991F40DCCA00C932F8 /* BigTorrentTest.torrent */; }; + B514DD9C1F40DD1B00C932F8 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B514DD9B1F40DD1B00C932F8 /* URL.swift */; }; + B514DDA01F40DFC000C932F8 /* Data.bin in Resources */ = {isa = PBXBuildFile; fileRef = B514DD9F1F40DFC000C932F8 /* Data.bin */; }; + B514DDA41F40E96100C932F8 /* CombinedNetworkSpeedTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B514DDA31F40E96100C932F8 /* CombinedNetworkSpeedTracker.swift */; }; B51638931F0EE9B6009E563E /* TCPConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51638921F0EE9B6009E563E /* TCPConnection.swift */; }; B51638951F0EEAA4009E563E /* TCPConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51638941F0EEAA4009E563E /* TCPConnectionTests.swift */; }; B51638971F0EEC2B009E563E /* GCDAsyncSocketStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51638961F0EEC2B009E563E /* GCDAsyncSocketStub.swift */; }; @@ -173,6 +178,12 @@ B514DD8E1F40C53C00C932F8 /* NetworkSpeedTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSpeedTrackerTests.swift; sourceTree = ""; }; B514DD901F40D15C00C932F8 /* StringUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringUtilities.swift; sourceTree = ""; }; B514DD921F40D27500C932F8 /* TorrentInfoRowData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentInfoRowData.swift; sourceTree = ""; }; + B514DD941F40DB3100C932F8 /* TorrentFileManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentFileManagerTests.swift; sourceTree = ""; }; + B514DD961F40DC8300C932F8 /* LancasterPics.torrent */ = {isa = PBXFileReference; lastKnownFileType = file; path = LancasterPics.torrent; sourceTree = ""; }; + B514DD991F40DCCA00C932F8 /* BigTorrentTest.torrent */ = {isa = PBXFileReference; lastKnownFileType = file; path = BigTorrentTest.torrent; sourceTree = ""; }; + B514DD9B1F40DD1B00C932F8 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; + B514DD9F1F40DFC000C932F8 /* Data.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; name = Data.bin; path = "../../../../../../Library/Developer/CoreSimulator/Devices/BB5761CA-9F0E-4CAF-97A8-22BA0F3CA49D/data/Documents/Data.bin"; sourceTree = ""; }; + B514DDA31F40E96100C932F8 /* CombinedNetworkSpeedTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedNetworkSpeedTracker.swift; sourceTree = ""; }; B51638921F0EE9B6009E563E /* TCPConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPConnection.swift; sourceTree = ""; }; B51638941F0EEAA4009E563E /* TCPConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPConnectionTests.swift; sourceTree = ""; }; B51638961F0EEC2B009E563E /* GCDAsyncSocketStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GCDAsyncSocketStub.swift; sourceTree = ""; }; @@ -393,7 +404,9 @@ children = ( B54D0C2B1CA5787E004343BD /* Data+sha1.swift */, B514DD8A1F40C40500C932F8 /* NetworkSpeedTracker.swift */, + B514DDA31F40E96100C932F8 /* CombinedNetworkSpeedTracker.swift */, B514DD8E1F40C53C00C932F8 /* NetworkSpeedTrackerTests.swift */, + B514DD9B1F40DD1B00C932F8 /* URL.swift */, ); path = Utilities; sourceTree = ""; @@ -507,6 +520,9 @@ isa = PBXGroup; children = ( B5C5F9661F250CC4007623B2 /* TorrentFileManager.swift */, + B514DD941F40DB3100C932F8 /* TorrentFileManagerTests.swift */, + B514DD9F1F40DFC000C932F8 /* Data.bin */, + B514DD991F40DCCA00C932F8 /* BigTorrentTest.torrent */, B5AF7AA51F252A66003FD66F /* MultiFileHandle.swift */, B5AF7AA81F252A70003FD66F /* MultFileHandleTests.swift */, B5AF7AAC1F253055003FD66F /* FileHandleFake.swift */, @@ -799,7 +815,9 @@ files = ( B5E9B0E51F02FAC600EF58E3 /* TestText.torrent in Resources */, B514DD891F40B58200C932F8 /* text.txt in Resources */, + B514DD9A1F40DCCA00C932F8 /* BigTorrentTest.torrent in Resources */, B514DD831F40A2B800C932F8 /* TrackerManagerTests.torrent in Resources */, + B514DDA01F40DFC000C932F8 /* Data.bin in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -953,10 +971,12 @@ B5C5F9671F250CC4007623B2 /* TorrentFileManager.swift in Sources */, B5F81E491F0436D600B25C70 /* TorrentPeerInfo.swift in Sources */, B527F6311F1BF30E001F06AF /* TorrentClient.swift in Sources */, + B514DD9C1F40DD1B00C932F8 /* URL.swift in Sources */, B5BFD9D01F3FB13E00CE0186 /* TorrentTracker.swift in Sources */, B55317DC1F02FC4D00909ADF /* TorrentHTTPTracker.swift in Sources */, B52EE3401F129CA200AC22D6 /* TorrentPeerHandshakeMessageBuffer.swift in Sources */, B5F27C621F3F93AC0040589C /* TorrentProgressManager.swift in Sources */, + B514DDA41F40E96100C932F8 /* CombinedNetworkSpeedTracker.swift in Sources */, B514DD8B1F40C40500C932F8 /* NetworkSpeedTracker.swift in Sources */, B5E977961CAFB46B0038EBE7 /* String+URLEncode.swift in Sources */, B558F4831F0A647D00438BB4 /* InternetProtocol.swift in Sources */, @@ -1002,6 +1022,7 @@ B535534C1F125DB500A6DCBE /* TorrentPeerMessageBufferTests.swift in Sources */, B527F6271F1BBC6B001F06AF /* TorrentPeerCommunicatorStub.swift in Sources */, B5BFD9CE1F3FAFA500CE0186 /* TorrentTrackerManagerTests.swift in Sources */, + B514DD951F40DB3100C932F8 /* TorrentFileManagerTests.swift in Sources */, B56A8A071C83539300426AC8 /* TestHelpers.swift in Sources */, B53553401F112BDB00A6DCBE /* BitFieldTests.swift in Sources */, B514DD881F40B2EC00C932F8 /* TorrentClientManagerStubs.swift in Sources */, @@ -1324,7 +1345,7 @@ DEVELOPMENT_TEAM = HT9AS2MWK9; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BitTorrentExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "uk.co.-bendavisapps.BitTorrentExample"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1348,7 +1369,7 @@ DEVELOPMENT_TEAM = HT9AS2MWK9; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BitTorrentExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "uk.co.-bendavisapps.BitTorrentExample"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/BitTorrent/File Manager/BigTorrentTest.torrent b/BitTorrent/File Manager/BigTorrentTest.torrent new file mode 100644 index 0000000..1e48ddd Binary files /dev/null and b/BitTorrent/File Manager/BigTorrentTest.torrent differ diff --git a/BitTorrent/File Manager/Data.bin b/BitTorrent/File Manager/Data.bin new file mode 100644 index 0000000..43544ea Binary files /dev/null and b/BitTorrent/File Manager/Data.bin differ diff --git a/BitTorrent/File Manager/MultFileHandleTests.swift b/BitTorrent/File Manager/MultFileHandleTests.swift index d22678c..5856a72 100644 --- a/BitTorrent/File Manager/MultFileHandleTests.swift +++ b/BitTorrent/File Manager/MultFileHandleTests.swift @@ -82,4 +82,10 @@ class MultFileHandleTests: XCTestCase { XCTAssert(fileHandle.synchronizeFileCalled) } } + + func test_canReadLastBytes() { + sut.seek(toFileOffset: 8) + let data = sut.readData(ofLength: 2) + XCTAssertEqualData(data, Data(bytes: [9,10])) + } } diff --git a/BitTorrent/File Manager/MultiFileHandle.swift b/BitTorrent/File Manager/MultiFileHandle.swift index d79c20b..f64edce 100644 --- a/BitTorrent/File Manager/MultiFileHandle.swift +++ b/BitTorrent/File Manager/MultiFileHandle.swift @@ -98,6 +98,7 @@ class MultiFileHandle: FileHandleProtocol { } private func incrementCurrentFile() { + guard (fileIndex + 1) < files.count else { return } fileIndex += 1 currentFile.handle.seek(toFileOffset: 0) } diff --git a/BitTorrent/File Manager/TorrentFileManager.swift b/BitTorrent/File Manager/TorrentFileManager.swift index 7cc3c43..0d88096 100644 --- a/BitTorrent/File Manager/TorrentFileManager.swift +++ b/BitTorrent/File Manager/TorrentFileManager.swift @@ -17,7 +17,7 @@ public class TorrentFileManager { let metaInfo: TorrentMetaInfo let rootDirectory: String - fileprivate let fileHandle: MultiFileHandle + fileprivate let fileHandle: FileHandleProtocol convenience init(metaInfo: TorrentMetaInfo, rootDirectory: String) { let fileHandles = TorrentFileManager.createFileHandles(for: metaInfo, in: rootDirectory) @@ -54,6 +54,22 @@ public class TorrentFileManager { fileHandle.seek(toFileOffset: UInt64(byteIndex)) return fileHandle.readData(ofLength: length) } + + // TODO: Multi-threaded check + func reCheckProgress() -> BitField { + var result = BitField(size: metaInfo.info.pieces.count) + for (pieceIndex, _) in result { + autoreleasepool { + let correctSha1 = metaInfo.info.pieces[pieceIndex] + let piece = getPiece(at: pieceIndex) + let sha1 = piece.sha1() + if sha1 == correctSha1 { + result.set(at: pieceIndex) + } + } + } + return result + } } // MARK: - Prepare directory @@ -100,3 +116,30 @@ extension TorrentFileManager { close(fileDescriptor) // Now we have a file of the correct size we close it } } + +// Save/Load progress +extension TorrentFileManager { + + static func saveProgressBitfield(_ bitfield: BitField, infoHash: Data) { + let fileName = String(asciiData: infoHash.base64EncodedData())! + let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, + .userDomainMask, + true)[0] as String + let documentsUrl = URL(fileURLWithPath: documentsPath, isDirectory: true) + let fileURL = documentsUrl.appendingPathComponent("torrent_progress.bin", isDirectory: false) + try? bitfield.toData().write(to: fileURL) + } + + static func loadSavedProgressBitfield(infoHash: Data) -> BitField? { + let fileName = String(asciiData: infoHash.base64EncodedData())! + let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, + .userDomainMask, + true)[0] as String + let documentsUrl = URL(fileURLWithPath: documentsPath, isDirectory: true) + let fileURL = documentsUrl.appendingPathComponent("torrent_progress.bin", isDirectory: false) + if let data = try? Data(contentsOf: fileURL) { + return BitField(data: data) + } + return nil + } +} diff --git a/BitTorrent/File Manager/TorrentFileManagerTests.swift b/BitTorrent/File Manager/TorrentFileManagerTests.swift new file mode 100644 index 0000000..6f0967e --- /dev/null +++ b/BitTorrent/File Manager/TorrentFileManagerTests.swift @@ -0,0 +1,72 @@ +// +// TorrentFileManagerTests.swift +// BitTorrentTests +// +// Created by Ben Davis on 13/08/2017. +// Copyright © 2017 Ben Davis. All rights reserved. +// + +import XCTest +@testable import BitTorrent + +class TorrentFileManagerTests: XCTestCase { + + let metaInfo: TorrentMetaInfo = { + let path = Bundle(for: TorrentProgressManagerTests.self).path(forResource: "BigTorrentTest", + ofType: "torrent") + let data = try! Data(contentsOf: URL(fileURLWithPath: path!)) + return TorrentMetaInfo(data: data)! + }() + + let piece1: Data = { + let path = Bundle(for: TorrentProgressManagerTests.self).path(forResource: "Data", + ofType: "bin") + return try! Data(contentsOf: URL(fileURLWithPath: path!)) + }() + + var fileHandle: FileHandleFake! + var sut: TorrentFileManager! + + override func setUp() { + super.setUp() + + fileHandle = FileHandleFake(data: Data(repeating: 0, count: metaInfo.info.length)) + sut = TorrentFileManager(metaInfo: metaInfo, rootDirectory: "/", fileHandles: [fileHandle]) + } + + func test_canSetPiece() { + // Given + let pieceLength = metaInfo.info.pieceLength + + // When + sut.setPiece(at: 1, data: piece1) + + // Then + XCTAssertEqualData(fileHandle.data[pieceLength.. BitField func torrentPeerManager(_ sender: TorrentPeerManager, @@ -32,7 +34,8 @@ class TorrentPeerManager { } } - var maximumNumberOfConnectedPeers = 20 + var maximumNumberOfConnectedPeers = 20 // variable for testing + var minimumNumberOfConnectedPeers = 5 // variable for testing let maximumNumberOfPiecesPerPeer = 2 weak var delegate: TorrentPeerManagerDelegate? @@ -49,12 +52,15 @@ class TorrentPeerManager { return peers.filter({ $0.connected && $0.currentProgress.complete }).count } - private(set) var downloadSpeedTracker = NetworkSpeedTracker () + private(set) var downloadSpeedTracker: NetworkSpeedTrackable! init(clientId: Data, infoHash: Data, bitFieldSize: Int) { self.clientId = clientId self.infoHash = infoHash self.bitFieldSize = bitFieldSize + self.downloadSpeedTracker = CombinedNetworkSpeedTracker { [unowned self] in + return self.peers.map { $0.downloadSpeedTracker } + } } // Exposed for testing @@ -80,8 +86,12 @@ class TorrentPeerManager { let bitField = delegate.torrentPeerManagerCurrentBitfieldForHandshake(self) let peersToConnectTo = peers[0 ..< numberToConnectTo] connectToPeers(peersToConnectTo, bitField: bitField) + if numberOfConnectedPeers < minimumNumberOfConnectedPeers { + delegate.torrentPeerManagerNeedsMorePeers(self) + } } + // Using sequence to allow array slices private func connectToPeers(_ peers: T, bitField: BitField) where T.Element == TorrentPeer { for peer in peers { peer.delegate = self @@ -118,7 +128,6 @@ extension TorrentPeerManager: TorrentPeerDelegate { } func peer(_ sender: TorrentPeer, gotPieceAtIndex index: Int, piece: Data) { - downloadSpeedTracker.increase(by: piece.count) delegate?.torrentPeerManager(self, downloadedPieceAtIndex: index, piece: piece) requestNextPiece(from: sender) // TODO: send have to peers @@ -133,8 +142,8 @@ extension TorrentPeerManager: TorrentPeerDelegate { private func requestNextPiece(from peer: TorrentPeer) { if let pieceRequest = delegate?.torrentPeerManager(self, nextPieceFromAvailable: peer.currentProgress) { peer.downloadPiece(atIndex: pieceRequest.pieceIndex, size: pieceRequest.size) - } else { - print("No available pieces for peer \(peer.peerInfo.ip):\(peer.peerInfo.port) to download") + } else if enableLogging { + print("No available pieces for peer \(peer.peerInfo.ip):\(peer.peerInfo.port) to download") } } } diff --git a/BitTorrent/Peer Manager/TorrentPeerManagerTests.swift b/BitTorrent/Peer Manager/TorrentPeerManagerTests.swift index 90f2b5c..53e30ad 100644 --- a/BitTorrent/Peer Manager/TorrentPeerManagerTests.swift +++ b/BitTorrent/Peer Manager/TorrentPeerManagerTests.swift @@ -11,6 +11,11 @@ import XCTest class TorrentPeerFake: TorrentPeer { + var testDownloadSpeedTracker = NetworkSpeedTracker() + override var downloadSpeedTracker: NetworkSpeedTracker { + return testDownloadSpeedTracker + } + var connectCalled = false var connectHandshakeData: (clientId: Data, bitField: BitField)? override func connect(withHandshakeData handshakeData: (clientId: Data, bitField: BitField)) throws { @@ -34,6 +39,14 @@ class TorrentPeerFake: TorrentPeer { class TorrentPeerManagerDelegateStub: TorrentPeerManagerDelegate { + var torrentPeerManagerNeedsMorePeersCalled = false + var torrentPeerManagerNeedsMorePeersParameter: TorrentPeerManager? + func torrentPeerManagerNeedsMorePeers(_ sender: TorrentPeerManager) { + torrentPeerManagerNeedsMorePeersCalled = true + torrentPeerManagerNeedsMorePeersParameter = sender + } + + var downloadedPieceAtIndexCalled = false var downloadedPieceAtIndexParameters: (sender: TorrentPeerManager, pieceIndex: Int, piece: Data)? func torrentPeerManager(_ sender: TorrentPeerManager, @@ -283,7 +296,7 @@ class TorrentPeerManagerTests: XCTestCase { XCTAssertFalse(peer.downloadPieceCalled) } - func test_downloadSpeedRecordedOnGettingPiece() { + func test_downloadSpeedSumsPeerDownloadSpeeds() { // Given let peerInfo = TorrentPeerInfo(ip: "127.0.0.1", port: 123, peerId: nil) @@ -293,9 +306,23 @@ class TorrentPeerManagerTests: XCTestCase { let pieceSize = 100 // When - sut.peer(peer, gotPieceAtIndex: 0, piece: Data(repeating: 0, count: pieceSize)) + peer.testDownloadSpeedTracker.increase(by: pieceSize) // Then XCTAssertEqual(sut.downloadSpeedTracker.totalNumberOfBytes, pieceSize) } + + func test_morePeersRequestedWhenNumberDropsBelowMin() { + + // Given + sut.minimumNumberOfConnectedPeers = 2 + let peerInfo = TorrentPeerInfo(ip: "127.0.0.1", port: 123, peerId: nil) + + // When + sut.addPeers(withInfo: [peerInfo]) + + // Then + XCTAssert(delegate.torrentPeerManagerNeedsMorePeersCalled) + XCTAssert(delegate.torrentPeerManagerNeedsMorePeersParameter === sut) + } } diff --git a/BitTorrent/Peer/TorrentPeer.swift b/BitTorrent/Peer/TorrentPeer.swift index 9b83387..5a1d86c 100644 --- a/BitTorrent/Peer/TorrentPeer.swift +++ b/BitTorrent/Peer/TorrentPeer.swift @@ -109,6 +109,14 @@ class TorrentPeer { } downloadPieceRequests.removeAll() } + + private func onConnectionDropped() { + keepAliveTimer?.invalidate() + keepAliveTimer = nil + killAllDownloads() + connected = false + delegate?.peerLost(self) + } } // Keep alive @@ -142,8 +150,7 @@ extension TorrentPeer { } @objc private func didntReceiveKeepAlive() { - keepAliveTimer = nil - delegate?.peerLost(self) + onConnectionDropped() } } @@ -161,9 +168,7 @@ extension TorrentPeer: TorrentPeerCommunicatorDelegate { } func peerLost(_ sender: TorrentPeerCommunicator) { - killAllDownloads() - delegate?.peerLost(self) - connected = false + onConnectionDropped() } func peerSentHandshake(_ sender: TorrentPeerCommunicator, sentHandshakeWithPeerId peerId: Data, onDHT: Bool) { diff --git a/BitTorrent/Peer/TorrentPeerTests.swift b/BitTorrent/Peer/TorrentPeerTests.swift index 479fc63..d00f0a2 100644 --- a/BitTorrent/Peer/TorrentPeerTests.swift +++ b/BitTorrent/Peer/TorrentPeerTests.swift @@ -329,6 +329,7 @@ class TorrentPeerTests: XCTestCase { let e = expectation(description: "Keep alive sent") DispatchQueue.main.async { XCTAssert(self.delegate.peerLostCalled) + XCTAssertFalse(self.sut.connected) e.fulfill() } waitForExpectations(timeout: 0.1) diff --git a/BitTorrent/Progress Manager/TorrentProgressManager.swift b/BitTorrent/Progress Manager/TorrentProgressManager.swift index f5992d3..ce30341 100644 --- a/BitTorrent/Progress Manager/TorrentProgressManager.swift +++ b/BitTorrent/Progress Manager/TorrentProgressManager.swift @@ -22,7 +22,13 @@ class TorrentProgressManager { convenience init(metaInfo: TorrentMetaInfo, rootDirectory: String) { let downloadDirectory = rootDirectory + "/" + metaInfo.sensibleDownloadDirectoryName() let fileManager = TorrentFileManager(metaInfo: metaInfo, rootDirectory: downloadDirectory) - let progress = TorrentProgress(size: metaInfo.info.pieces.count) + + let progress: TorrentProgress + if let bitField = TorrentFileManager.loadSavedProgressBitfield(infoHash: metaInfo.infoHash) { + progress = TorrentProgress(bitField: bitField) + } else { + progress = TorrentProgress(size: metaInfo.info.pieces.count) + } self.init(fileManager: fileManager, progress: progress) } @@ -31,6 +37,11 @@ class TorrentProgressManager { self.progress = progress } + public func forceReCheck() { + let bitField = fileManager.reCheckProgress() + progress = TorrentProgress(bitField: bitField) + } + func getNextPieceToDownload(from availablePieces: BitField) -> TorrentPieceRequest? { for (i, isSet) in availablePieces where isSet { if !progress.hasPiece(i) && !progress.isCurrentlyDownloading(piece: i) { @@ -44,6 +55,7 @@ class TorrentProgressManager { func setDownloadedPiece(_ piece: Data, pieceIndex: Int) { progress.finishedDownloading(piece: pieceIndex) fileManager.setPiece(at: pieceIndex, data: piece) + TorrentFileManager.saveProgressBitfield(progress.bitField, infoHash: metaInfo.infoHash) } func setLostPiece(at index: Int) { diff --git a/BitTorrent/Progress Manager/TorrentProgressManagerTests.swift b/BitTorrent/Progress Manager/TorrentProgressManagerTests.swift index 9823e30..47e868b 100644 --- a/BitTorrent/Progress Manager/TorrentProgressManagerTests.swift +++ b/BitTorrent/Progress Manager/TorrentProgressManagerTests.swift @@ -21,6 +21,11 @@ class TorrentProgressManagerTests: XCTestCase { return TorrentMetaInfo(data: data)! }() + let finalData: Data = { + let path = Bundle(for: TorrentProgressManagerTests.self).path(forResource: "text", ofType: "txt") + return try! Data(contentsOf: URL(fileURLWithPath: path!)) + }() + let completeBitField: BitField = { var result = BitField(size: 1) result.set(at: 0) @@ -42,6 +47,18 @@ class TorrentProgressManagerTests: XCTestCase { sut = TorrentProgressManager(fileManager: fileManager, progress: progress) } + func test_canForceReCheck() { + + // Given + fileHandle.data = finalData + + // When + sut.forceReCheck() + + // Then + XCTAssert(sut.progress.complete) + } + func test_exampleMetaInfoOnlyHas1Piece() { XCTAssertEqual(metaInfo.info.pieces.count, 1) } diff --git a/BitTorrent/Torrent Client/TorrentClient.swift b/BitTorrent/Torrent Client/TorrentClient.swift index 5cd457f..4f37955 100644 --- a/BitTorrent/Torrent Client/TorrentClient.swift +++ b/BitTorrent/Torrent Client/TorrentClient.swift @@ -35,7 +35,7 @@ public class TorrentClient { public var progress: TorrentProgress { return progressManager.progress } public var numberOfConnectedPeers: Int { return peerManager.numberOfConnectedPeers } public var numberOfConnectedSeeds: Int { return peerManager.numberOfConnectedPeers } - public var downloadSpeedTracker: NetworkSpeedTracker { return peerManager.downloadSpeedTracker } + public var downloadSpeedTracker: NetworkSpeedTrackable { return peerManager.downloadSpeedTracker } let progressManager: TorrentProgressManager let peerManager: TorrentPeerManager @@ -55,7 +55,6 @@ public class TorrentClient { trackerManager.delegate = self peerManager.delegate = self - peerManager.enableLogging = true } // For testing @@ -69,6 +68,10 @@ public class TorrentClient { self.trackerManager = trackerManager } + public func forceReCheck() { + progressManager.forceReCheck() + } + public func start() { trackerManager.start() status = .started @@ -85,9 +88,9 @@ extension TorrentClient: TorrentTrackerManagerDelegate { return TorrentTrackerManagerAnnonuceInfo( numberOfBytesRemaining: progress.remaining * metaInfo.info.pieceLength, - numberOfBytesUploaded: progress.uploaded * metaInfo.info.pieceLength, + numberOfBytesUploaded: 0, numberOfBytesDownloaded: progress.downloaded * metaInfo.info.pieceLength, - numberOfPeersToFetch: 20) + numberOfPeersToFetch: 50) } } @@ -105,6 +108,10 @@ extension TorrentClient: TorrentPeerManagerDelegate { progressManager.setLostPiece(at: index) } + func torrentPeerManagerNeedsMorePeers(_ sender: TorrentPeerManager) { + trackerManager.forceRestart() + } + func torrentPeerManagerCurrentBitfieldForHandshake(_ sender: TorrentPeerManager) -> BitField { return progressManager.progress.bitField } diff --git a/BitTorrent/Torrent Progress/TorrentProgress.swift b/BitTorrent/Torrent Progress/TorrentProgress.swift index d53219a..1d7686e 100644 --- a/BitTorrent/Torrent Progress/TorrentProgress.swift +++ b/BitTorrent/Torrent Progress/TorrentProgress.swift @@ -13,7 +13,6 @@ public struct TorrentProgress { public private(set) var bitField: BitField private var piecesBeingDownloaded: [Int] = [] - public private(set) var uploaded: Int = 0 public private(set) var downloaded: Int = 0 public var remaining: Int { @@ -32,6 +31,13 @@ public struct TorrentProgress { self.bitField = BitField(size: size) } + init(bitField: BitField) { + self.bitField = bitField + for (_, isSet) in bitField where isSet { + downloaded += 1 + } + } + func isCurrentlyDownloading(piece: Int) -> Bool { return piecesBeingDownloaded.contains(piece) } diff --git a/BitTorrent/Torrent Progress/TorrentProgressTests.swift b/BitTorrent/Torrent Progress/TorrentProgressTests.swift index 3dacd92..74d9dbd 100644 --- a/BitTorrent/Torrent Progress/TorrentProgressTests.swift +++ b/BitTorrent/Torrent Progress/TorrentProgressTests.swift @@ -14,7 +14,6 @@ class TorrentProgressTests: XCTestCase { func test_torrentProgressIsInitialisedAsNoneDownloaded() { let result = TorrentProgress(size: 10) XCTAssertEqual(result.downloaded, 0) - XCTAssertEqual(result.uploaded, 0) } func test_canMarkPieceAsDownloading() { diff --git a/BitTorrent/Tracker Manager/TorrentTrackerManager.swift b/BitTorrent/Tracker Manager/TorrentTrackerManager.swift index da1a622..2d50357 100644 --- a/BitTorrent/Tracker Manager/TorrentTrackerManager.swift +++ b/BitTorrent/Tracker Manager/TorrentTrackerManager.swift @@ -30,6 +30,15 @@ class TorrentTrackerManager { let clientId: String let port: Int + var announceTimeInterval: TimeInterval = 600 + private lazy var announceTimer: Timer = { + return Timer.scheduledTimer(timeInterval: self.announceTimeInterval, + target: self, + selector: #selector(announce), + userInfo: nil, + repeats: true) + }() + init(metaInfo: TorrentMetaInfo, clientId: Data, port: Int) { self.metaInfo = metaInfo self.clientId = String(data: clientId, encoding: .utf8)! @@ -51,16 +60,17 @@ class TorrentTrackerManager { private static func createTrackers(from metaInfo: TorrentMetaInfo) -> [TorrentTracker] { - let announceList = metaInfo.announceList?.first ?? [metaInfo.announce] + let announceList = metaInfo.announceList ?? [[metaInfo.announce]] + let flatAnnounceList = announceList.flatMap { return $0 } var lastPortNumberUsed: UInt16 = 3475 var result: [TorrentTracker] = [] - for url in announceList { + for url in flatAnnounceList { - if url.scheme == "http" { + if url.scheme == "http" || url.scheme == "https" { - let tracker = TorrentHTTPTracker(announceURL: url) + let tracker = TorrentHTTPTracker(announceURL: url.bySettingScheme(to: "https")) result.append(tracker) } else if url.scheme == "udp" { @@ -76,10 +86,14 @@ class TorrentTrackerManager { } func start() { - announce() + forceRestart() } - private func announce() { + func forceRestart() { + announceTimer.fire() + } + + @objc private func announce() { guard let delegate = delegate else { return } diff --git a/BitTorrent/Tracker Manager/TorrentTrackerManagerTests.swift b/BitTorrent/Tracker Manager/TorrentTrackerManagerTests.swift index 1250863..a53523d 100644 --- a/BitTorrent/Tracker Manager/TorrentTrackerManagerTests.swift +++ b/BitTorrent/Tracker Manager/TorrentTrackerManagerTests.swift @@ -23,6 +23,7 @@ class TorrentTrackerStub: TorrentTracker { numberOfBytesDownloaded: Int, numberOfPeersToFetch: Int)? + var onAnnounceClient: (()->Void)? func announceClient(with peerId: String, port: Int, event: TorrentTrackerEvent, @@ -40,6 +41,7 @@ class TorrentTrackerStub: TorrentTracker { numberOfBytesUploaded, numberOfBytesDownloaded, numberOfPeersToFetch) + onAnnounceClient?() } } @@ -73,6 +75,12 @@ class TorrentTrackerManagerTests: XCTestCase { let clientIdData = "-BD0000-bxa]N#IRKqv`".data(using: .ascii)! let listeningPort = 123 + + let announceInfo = TorrentTrackerManagerAnnonuceInfo(numberOfBytesRemaining: 1, + numberOfBytesUploaded: 2, + numberOfBytesDownloaded: 3, + numberOfPeersToFetch: 4) + func test_createsTrackers() { let sut = TorrentTrackerManager(metaInfo: metaInfo, clientId: clientIdData, port: listeningPort) @@ -89,7 +97,8 @@ class TorrentTrackerManagerTests: XCTestCase { return } - XCTAssertEqual(httpTracker.announceURL, metaInfo.announceList![0][0]) + let httpsScheme = metaInfo.announceList![0][0].bySettingScheme(to: "https") + XCTAssertEqual(httpTracker.announceURL, httpsScheme) XCTAssertEqual(udpTracker.announceURL, metaInfo.announceList![0][1]) XCTAssert(httpTracker.delegate === sut) @@ -101,18 +110,14 @@ class TorrentTrackerManagerTests: XCTestCase { // Given let tracker = TorrentTrackerStub() let delegate = TorrentTrackerManagerDelegateStub() - let announceInfo = TorrentTrackerManagerAnnonuceInfo(numberOfBytesRemaining: 1, - numberOfBytesUploaded: 2, - numberOfBytesDownloaded: 3, - numberOfPeersToFetch: 4) let sut = TorrentTrackerManager(metaInfo: metaInfo, clientId: clientIdData, port: listeningPort, trackers: [tracker]) - sut.delegate = delegate delegate.torrentTrackerManagerAnnonuceInfoResult = announceInfo + sut.delegate = delegate // When sut.start() @@ -133,4 +138,56 @@ class TorrentTrackerManagerTests: XCTestCase { XCTAssertEqual(announceClientParameters.numberOfBytesDownloaded, announceInfo.numberOfBytesDownloaded) XCTAssertEqual(announceClientParameters.numberOfPeersToFetch, announceInfo.numberOfPeersToFetch) } + + func test_announceRepeats() { + + // Given + let tracker = TorrentTrackerStub() + let delegate = TorrentTrackerManagerDelegateStub() + + let sut = TorrentTrackerManager(metaInfo: metaInfo, + clientId: clientIdData, + port: listeningPort, + trackers: [tracker]) + + sut.delegate = delegate + sut.announceTimeInterval = 0 + + // When + sut.start() + + // Then + let expectation = self.expectation(description: "Announce is repeatedly called") + tracker.onAnnounceClient = { + tracker.onAnnounceClient = nil + expectation.fulfill() + } + waitForExpectations(timeout: 0.1) + } + + func test_canForceReAnnounce_resetsAnnounceTimer() { + + // Given + let tracker = TorrentTrackerStub() + let delegate = TorrentTrackerManagerDelegateStub() + let sut = TorrentTrackerManager(metaInfo: metaInfo, + clientId: clientIdData, + port: listeningPort, + trackers: [tracker]) + + sut.delegate = delegate + sut.announceTimeInterval = 600 + sut.start() + + // Then + let expectation = self.expectation(description: "Announce is repeatedly called") + tracker.onAnnounceClient = { + tracker.onAnnounceClient = nil + expectation.fulfill() + } + + // When + sut.forceRestart() + waitForExpectations(timeout: 0.1) + } } diff --git a/BitTorrent/Utilities/CombinedNetworkSpeedTracker.swift b/BitTorrent/Utilities/CombinedNetworkSpeedTracker.swift new file mode 100644 index 0000000..aba02a8 --- /dev/null +++ b/BitTorrent/Utilities/CombinedNetworkSpeedTracker.swift @@ -0,0 +1,44 @@ +// +// CombinedNetworkSpeedTracker.swift +// BitTorrent +// +// Created by Ben Davis on 13/08/2017. +// Copyright © 2017 Ben Davis. All rights reserved. +// + +import Foundation + +class CombinedNetworkSpeedTracker: NetworkSpeedTrackable { + + let trackers: () -> [NetworkSpeedTracker] + + init(trackers: @escaping () -> [NetworkSpeedTracker]) { + self.trackers = trackers + } + + // MARK: - NetworkSpeedTrackable + + var totalNumberOfBytes: Int { + var result = 0 + for tracker in trackers() { + result += tracker.totalNumberOfBytes + } + return result + } + + func numberOfBytesDownloaded(since date: Date) -> Int { + var result = 0 + for tracker in trackers() { + result += tracker.numberOfBytesDownloaded(since: date) + } + return result + } + + func numberOfBytesDownloaded(over timeInterval: TimeInterval) -> Int { + var result = 0 + for tracker in trackers() { + result += tracker.numberOfBytesDownloaded(over: timeInterval) + } + return result + } +} diff --git a/BitTorrent/Utilities/NetworkSpeedTracker.swift b/BitTorrent/Utilities/NetworkSpeedTracker.swift index be3192a..92b1dc6 100644 --- a/BitTorrent/Utilities/NetworkSpeedTracker.swift +++ b/BitTorrent/Utilities/NetworkSpeedTracker.swift @@ -8,9 +8,37 @@ import Foundation -extension Collection { - func firstWhere(_ predicate: (Element) -> Bool) -> Element? { - return first(where: predicate) +public protocol NetworkSpeedTrackable { + var totalNumberOfBytes: Int { get } + func numberOfBytesDownloaded(since date: Date) -> Int + func numberOfBytesDownloaded(over timeInterval: TimeInterval) -> Int +} + +public struct NetworkSpeedTracker: NetworkSpeedTrackable { + public var totalNumberOfBytes: Int = 0 + + // TODO: limit number of dataPoints stored + private var dataPoints: [NetworkSpeedDataPoint] = [NetworkSpeedDataPoint(0)] + + mutating func increase(by bytes: Int) { + totalNumberOfBytes += bytes + addDataPoint(NetworkSpeedDataPoint(totalNumberOfBytes)) + } + + private mutating func addDataPoint(_ dataPoint: NetworkSpeedDataPoint) { + dataPoints = [dataPoint] + dataPoints + } + + public func numberOfBytesDownloaded(since date: Date) -> Int { + + guard let previouslyDataPoint = dataPoints.firstWhere({ $0.dateRecorded < date }) else { + return totalNumberOfBytes + } + return totalNumberOfBytes - previouslyDataPoint.numberOfBytes + } + + public func numberOfBytesDownloaded(over timeInterval: TimeInterval) -> Int { + return numberOfBytesDownloaded(since: Date(timeIntervalSinceNow: -timeInterval)) } } @@ -27,7 +55,7 @@ public struct NetworkSpeedDataPoint: Equatable, Comparable { public static func ==(_ lhs: NetworkSpeedDataPoint, _ rhs: NetworkSpeedDataPoint) -> Bool { return ( lhs.numberOfBytes == rhs.numberOfBytes && - lhs.dateRecorded == rhs.dateRecorded + lhs.dateRecorded == rhs.dateRecorded ) } @@ -35,28 +63,9 @@ public struct NetworkSpeedDataPoint: Equatable, Comparable { return lhs.dateRecorded.compare(rhs.dateRecorded) == .orderedAscending } } - -public struct NetworkSpeedTracker { - var totalNumberOfBytes: Int = 0 - private var dataPoints: [NetworkSpeedDataPoint] = [NetworkSpeedDataPoint(0)] - - mutating func increase(by bytes: Int) { - totalNumberOfBytes += bytes - addDataPoint(NetworkSpeedDataPoint(totalNumberOfBytes)) - } - - private mutating func addDataPoint(_ dataPoint: NetworkSpeedDataPoint) { - dataPoints = [dataPoint] + dataPoints - } - - public func numberOfBytesDownloaded(since date: Date) -> Int { - guard let previouslyDataPoint = dataPoints.firstWhere({ $0.dateRecorded < date }) else { - return totalNumberOfBytes - } - return totalNumberOfBytes - previouslyDataPoint.numberOfBytes - } - - public func numberOfBytesDownloaded(over timeInterval: TimeInterval) -> Int { - return numberOfBytesDownloaded(since: Date(timeIntervalSinceNow: -timeInterval)) +// Not sure why I need this 🤷‍♂️ +extension Collection { + func firstWhere(_ predicate: (Element) -> Bool) -> Element? { + return first(where: predicate) } } diff --git a/BitTorrent/Utilities/URL.swift b/BitTorrent/Utilities/URL.swift new file mode 100644 index 0000000..6796709 --- /dev/null +++ b/BitTorrent/Utilities/URL.swift @@ -0,0 +1,17 @@ +// +// URL.swift +// BitTorrent +// +// Created by Ben Davis on 13/08/2017. +// Copyright © 2017 Ben Davis. All rights reserved. +// + +import Foundation + +extension URL { + public func bySettingScheme(to scheme: String) -> URL { + var components = URLComponents(url: self, resolvingAgainstBaseURL: false)! + components.scheme = scheme + return components.url! + } +} diff --git a/BitTorrentExample/Info.plist b/BitTorrentExample/Info.plist index 4222ac2..f1ad792 100644 --- a/BitTorrentExample/Info.plist +++ b/BitTorrentExample/Info.plist @@ -2,6 +2,8 @@ + UIFileSharingEnabled + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/BitTorrentExample/StringUtilities.swift b/BitTorrentExample/StringUtilities.swift index d670a36..6cf279c 100644 --- a/BitTorrentExample/StringUtilities.swift +++ b/BitTorrentExample/StringUtilities.swift @@ -10,16 +10,14 @@ import Foundation extension TimeInterval { - var milliseconds: Int { - return Int((self.truncatingRemainder(dividingBy: 1)) * 1000) - } - var seconds: Int { - return Int(self.remainder(dividingBy: 60)) + let remaining = self - Double(hours*60*60) - Double(minutes*60) + return Int(remaining / 60) } var minutes: Int { - return Int((self/60).remainder(dividingBy: 60)) + let remaining = self - Double(hours*60*60) + return Int(remaining / 60) } var hours: Int { @@ -31,8 +29,6 @@ extension TimeInterval { return "\(self.hours)h \(self.minutes)m \(self.seconds)s" } else if self.minutes != 0 { return "\(self.minutes)m \(self.seconds)s" - } else if self.milliseconds != 0 { - return "\(self.seconds)s \(self.milliseconds)ms" } else { return "\(self.seconds)s" } diff --git a/BitTorrentExample/TorrentInfoRowData.swift b/BitTorrentExample/TorrentInfoRowData.swift index c700175..a5d3c41 100644 --- a/BitTorrentExample/TorrentInfoRowData.swift +++ b/BitTorrentExample/TorrentInfoRowData.swift @@ -74,15 +74,16 @@ enum TorrentInfoRowData: Int { case .eta: let speed = client.downloadSpeedTracker.numberOfBytesDownloaded(over: speedSampleSize) + let speedPerSecond = Double(speed) / speedSampleSize guard speed > 0 else { return "∞" } let remaining = client.progress.remaining * client.metaInfo.info.pieceLength - guard speed > 0 else { return "n/a" } + guard remaining > 0 else { return "n/a" } - return TimeInterval(remaining / speed).stringTime + return (Double(remaining) / speedPerSecond).stringTime case .uploaded: - return bytesToString(client.progress.uploaded * client.metaInfo.info.pieceLength) + return "????" } } } diff --git a/BitTorrentExample/TorrentViewController.swift b/BitTorrentExample/TorrentViewController.swift index 145b26d..a2de7c8 100644 --- a/BitTorrentExample/TorrentViewController.swift +++ b/BitTorrentExample/TorrentViewController.swift @@ -11,15 +11,15 @@ import BitTorrent class TorrentViewController: UIViewController { - static let refreshRate: TimeInterval = 1 + let refreshRate: TimeInterval = 1 let tableView = UITableView() let torrentClient: TorrentClient lazy var refreshTimer: Timer = { - return Timer.scheduledTimer(timeInterval: TorrentViewController.refreshRate, + return Timer.scheduledTimer(timeInterval: self.refreshRate, target: self, - selector: #selector(TorrentViewController.timerFired), + selector: #selector(timerFired), userInfo: nil, repeats: true) }() @@ -44,7 +44,14 @@ class TorrentViewController: UIViewController { super.viewDidLayoutSubviews() tableView.frame = view.bounds if tableView.contentInset == .zero { - tableView.contentInset = view.safeAreaInsets + if #available(iOS 11, *) { + tableView.contentInset = view.safeAreaInsets + } else { + tableView.contentInset = UIEdgeInsetsMake(topLayoutGuide.length, + 0, + bottomLayoutGuide.length, + 0) + } tableView.contentOffset = .zero } } @@ -69,7 +76,7 @@ extension TorrentViewController: UITableViewDataSource { if section == 0 { return TorrentInfoRowData.numberOfRows } else { - return 1 + return 2 } } @@ -77,11 +84,19 @@ extension TorrentViewController: UITableViewDataSource { if indexPath.section == 0 { return cellForTorrentInfoSection(at: indexPath, tableView: tableView) } else { - let startCell = UITableViewCell(style: .default, reuseIdentifier: nil) - startCell.textLabel?.text = "Start" - startCell.textLabel?.textAlignment = .center - startCell.textLabel?.textColor = .blue - return startCell + if indexPath.row == 0 { + let startCell = UITableViewCell(style: .default, reuseIdentifier: nil) + startCell.textLabel?.text = "Force re-check" + startCell.textLabel?.textAlignment = .center + startCell.textLabel?.textColor = .blue + return startCell + } else { + let reCheckCell = UITableViewCell(style: .default, reuseIdentifier: nil) + startCell.textLabel?.text = "Start" + startCell.textLabel?.textAlignment = .center + startCell.textLabel?.textColor = .blue + return startCell + } } } @@ -121,10 +136,14 @@ extension TorrentViewController: UITableViewDelegate { guard let cell = tableView.cellForRow(at: indexPath) else { return } cell.setSelected(false, animated: false) - if indexPath.section == 1 { + guard indexPath.section == 1 else { return } + if indexPath.row == 0 { + torrentClient.forceReCheck() + } else { torrentClient.start() - tableView.reloadData() } + tableView.reloadData() + } }