Files
nvrl ed05a1e723 feat(ios): port the Android client to SwiftUI
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.
2026-10-07 00:32:03 +02:00

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")
}
}