mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
The iOS client is now a screen-for-screen port of the Android app over the same pstr-android UniFFI surface, with no Rust changes: Library, title page, History, Shares (public links and Drive folders), Downloads and Settings, with the same strings, undo snackbars and two-pane layout on wide screens. - Theme from the engine's palette (Material role names, Inter, the same type ramp and corners), repainted live from the appearance settings. - Account sign-in with two-factor code, mailbox password and the Proton CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and watch-history sync every five minutes, after sign-in, on player close and when the app leaves the screen. - Player over libmpv: resume, progress saved every 5 s, autoplay with the up-next card, skip and auto-skip from the chapter plan, track, speed, volume and episode pickers, landscape lock, mini transport, and lock-screen controls with interruption and route-change handling. - Offline downloads with the Android record format, free-space floor, retries, transfer rate and the Wi-Fi-only policy; interrupted transfers resume at next launch. - Config, data and download state are excluded from backup, as Android's allowBackup=false: a restore would bring shares back without their Keychain secrets and give two devices one sync identity. Picture-in-picture, the share sheet and background downloads are not possible the same way on iOS; docs/IOS.md lists why and what replaces them. Adds XCTest ports of the Android unit tests, a `test` command to scripts/build-ios.sh, the app icon and its render script, and an iOS workflow on macos-15 that runs the tests and builds the unsigned .ipa.
188 lines
6.6 KiB
Swift
188 lines
6.6 KiB
Swift
@testable import ProtonStream
|
|
import XCTest
|
|
|
|
final class DownloadStateStoreTests: XCTestCase {
|
|
private var file: URL!
|
|
private var store: DownloadStateStore!
|
|
|
|
override func setUp() {
|
|
file = FileManager.default.temporaryDirectory.appendingPathComponent("downloads-\(UUID().uuidString).json")
|
|
store = DownloadStateStore(file: file)
|
|
}
|
|
|
|
override func tearDown() {
|
|
try? FileManager.default.removeItem(at: file)
|
|
}
|
|
|
|
private func record(
|
|
shareId: String = "share-1",
|
|
linkId: String = "link-1",
|
|
label: String = "S01E01.mkv",
|
|
status: String = RetainedDownload.statusRunning,
|
|
error: String? = nil
|
|
) -> RetainedDownload {
|
|
RetainedDownload(
|
|
shareId: shareId,
|
|
volumeId: "volume-1",
|
|
linkId: linkId,
|
|
label: label,
|
|
downloaded: 4 * 1024 * 1024,
|
|
total: 761 * 1024 * 1024,
|
|
status: status,
|
|
error: error
|
|
)
|
|
}
|
|
|
|
private func baseJson() -> [String: Any] {
|
|
[
|
|
"share": "share-1",
|
|
"volume": "volume-1",
|
|
"link": "link-1",
|
|
"label": "S01E01.mkv",
|
|
"downloaded": 0,
|
|
"total": 0,
|
|
"status": RetainedDownload.statusQueued,
|
|
]
|
|
}
|
|
|
|
/// A row as an older or another writer left it, read by a fresh store.
|
|
private func writeRaw(_ rows: [String: String]) throws {
|
|
try JSONEncoder().encode(rows).write(to: file)
|
|
store = DownloadStateStore(file: file)
|
|
}
|
|
|
|
private func encoded(_ json: [String: Any]) throws -> String {
|
|
String(decoding: try JSONSerialization.data(withJSONObject: json), as: UTF8.self)
|
|
}
|
|
|
|
func testARecordSurvivesARoundTripUnchanged() {
|
|
let original = record()
|
|
store.put(original)
|
|
XCTAssertEqual(store.get("share-1", "link-1"), original)
|
|
}
|
|
|
|
func testARecordSurvivesARestartUnchanged() {
|
|
let original = record()
|
|
store.put(original)
|
|
XCTAssertEqual(DownloadStateStore(file: file).get("share-1", "link-1"), original)
|
|
}
|
|
|
|
func testAnAbsentRecordReadsAsNilRatherThanADefault() {
|
|
XCTAssertNil(store.get("share-1", "link-1"))
|
|
}
|
|
|
|
func testAnErrorMessageRoundTripsAndANilOneStaysNil() {
|
|
store.put(record(error: "no space left on device"))
|
|
XCTAssertEqual(store.get("share-1", "link-1")?.error, "no space left on device")
|
|
store.put(record(error: nil))
|
|
XCTAssertNil(store.get("share-1", "link-1")?.error)
|
|
}
|
|
|
|
func testABlankErrorIsNotReadAsAFailureMessage() throws {
|
|
var json = baseJson()
|
|
json["error"] = ""
|
|
try writeRaw([downloadKey("share-1", "link-1"): encoded(json)])
|
|
XCTAssertNil(store.get("share-1", "link-1")?.error)
|
|
}
|
|
|
|
func testARecordWithNoLabelIsCaptionedWithItsLinkId() throws {
|
|
var json = baseJson()
|
|
json["label"] = nil
|
|
try writeRaw([downloadKey("share-1", "link-1"): encoded(json)])
|
|
XCTAssertEqual(store.get("share-1", "link-1")?.label, "link-1")
|
|
}
|
|
|
|
func testACorruptRowIsSkippedRatherThanFailingTheWholeList() throws {
|
|
try writeRaw([
|
|
downloadKey("share-1", "link-1"): DownloadStateStore.encode(record(label: "good.mkv")),
|
|
downloadKey("share-1", "link-broken"): "{ not json",
|
|
])
|
|
XCTAssertEqual(store.records().map(\.label), ["good.mkv"])
|
|
}
|
|
|
|
func testRecordsAreOrderedByLabelWithoutRegardToCase() {
|
|
store.put(record(linkId: "a", label: "beta.mkv"))
|
|
store.put(record(linkId: "b", label: "Alpha.mkv"))
|
|
XCTAssertEqual(store.records().map(\.label), ["Alpha.mkv", "beta.mkv"])
|
|
}
|
|
|
|
func testRemovingOneShareLeavesTheOthersAlone() {
|
|
store.put(record(shareId: "share-1", linkId: "a"))
|
|
store.put(record(shareId: "share-2", linkId: "b"))
|
|
store.removeShare("share-1")
|
|
XCTAssertEqual(store.records().map(\.shareId), ["share-2"])
|
|
}
|
|
|
|
func testClearForgetsEverything() {
|
|
store.put(record(linkId: "a"))
|
|
store.put(record(linkId: "b"))
|
|
store.clear()
|
|
XCTAssertTrue(store.records().isEmpty)
|
|
}
|
|
|
|
func testTheKeySeparatesShareFromLinkSoIdsCannotRunTogether() {
|
|
store.put(record(shareId: "a", linkId: "b-c"))
|
|
store.put(record(shareId: "a-b", linkId: "c"))
|
|
XCTAssertEqual(store.records().count, 2)
|
|
}
|
|
|
|
func testAnUpdateThatReturnsNilLeavesTheRecordAlone() {
|
|
store.put(record(status: RetainedDownload.statusPaused))
|
|
store.update("share-1", "link-1") { _ in nil }
|
|
XCTAssertEqual(store.get("share-1", "link-1")?.status, RetainedDownload.statusPaused)
|
|
}
|
|
}
|
|
|
|
final class TransferRateTests: XCTestCase {
|
|
func testTheFirstSampleHasNoRateToReport() {
|
|
var rate = TransferRate()
|
|
XCTAssertEqual(rate.sample(4 * 1024 * 1024, now: 1000), 0)
|
|
}
|
|
|
|
func testASteadyTransferReportsItsRate() {
|
|
var rate = TransferRate()
|
|
_ = rate.sample(0, now: 0)
|
|
XCTAssertEqual(rate.sample(1_000_000, now: 1000), 1_000_000)
|
|
XCTAssertEqual(rate.sample(2_000_000, now: 2000), 1_000_000)
|
|
}
|
|
|
|
func testOneSlowBlockMovesTheRateWithoutHalvingIt() {
|
|
var rate = TransferRate()
|
|
_ = rate.sample(0, now: 0)
|
|
_ = rate.sample(1_000_000, now: 1000)
|
|
let after = rate.sample(2_000_000, now: 3000)
|
|
XCTAssertTrue((600_000 ... 999_999).contains(after))
|
|
}
|
|
|
|
func testAResumedDownloadStartingBelowTheLastSampleStartsOver() {
|
|
var rate = TransferRate()
|
|
_ = rate.sample(0, now: 0)
|
|
_ = rate.sample(1_000_000, now: 1000)
|
|
XCTAssertEqual(rate.sample(0, now: 2000), 1_000_000)
|
|
}
|
|
}
|
|
|
|
final class DownloadStatusLineTests: XCTestCase {
|
|
func testARunningDownloadSaysHowFarAndHowFast() {
|
|
let download = RetainedDownload(
|
|
shareId: "s", volumeId: "v", linkId: "l", label: "E01",
|
|
downloaded: 120 * 1024 * 1024, total: 1288 * 1024 * 1024,
|
|
status: RetainedDownload.statusRunning, bytesPerSecond: 2_516_582
|
|
)
|
|
XCTAssertEqual(downloadStatusLine(download), "Running · 120.0 MiB of 1.3 GiB · 2.4 MiB/s")
|
|
}
|
|
|
|
func testAQueuedDownloadSaysOnlyThat() {
|
|
let download = RetainedDownload(shareId: "s", volumeId: "v", linkId: "l", label: "E01")
|
|
XCTAssertEqual(downloadStatusLine(download), "Queued")
|
|
}
|
|
|
|
func testAPausedDownloadGivesNoRate() {
|
|
let download = RetainedDownload(
|
|
shareId: "s", volumeId: "v", linkId: "l", label: "E01",
|
|
downloaded: 1024, total: 2048, status: RetainedDownload.statusPaused, bytesPerSecond: 500
|
|
)
|
|
XCTAssertEqual(downloadStatusLine(download), "Paused · 1.0 KiB of 2.0 KiB")
|
|
}
|
|
}
|