Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,6 @@ KSPlayer is intentionally pinned to `bb6d367c02f3247ef3f328dc0cfea34e77aef7c5` f
- UI tests live in `swiftchanUITests/`. The shared scheme runs `BrowsingUITests` for following/editing generals, link validation, reading restoration, and unread navigation. These tests use a debug-only in-memory favorites store (`--ui-testing`); Thread UI tests opt into a deterministic debug-only fixture with `--ui-thread-fixture`; older live-network UI tests remain excluded.
- Add focused regressions for behavior fixes. Prefer fixtures and injected loaders over live network dependencies.
- GitHub Actions builds and runs unit and focused UI tests for pull requests.
- The build/test step has a 35-minute limit within the 40-minute job. A cold hosted build plus the full UI suite exceeded the earlier 25-minute limit while tests were still passing; keep time for compilation and slower simulator interaction. Gallery paging returns to the post associated with the selected media; test that post after dismissing the gallery instead of expecting the first post to remain onscreen. Favorites export checks must identify the actual `Browse View (Picker)` plus either `DOCPicker.filenameTextField` or Save. Verify the expected backup filename whenever its field is exposed. Captured hosted runs show that Browse can expose the filename without Save, while a folder can expose Save without the filename field.
- The build/test step has a 35-minute limit within the 40-minute job. A cold hosted build plus the full UI suite exceeded the earlier 25-minute limit while tests were still passing; keep time for compilation and slower simulator interaction. Gallery paging returns to the post associated with the selected media; test that post after dismissing the gallery instead of expecting the first post to remain onscreen. Allow up to 60 seconds for the first Files picker on a cold hosted simulator; captured failures showed its remote view empty after the earlier 20-second wait. Favorites export checks must identify the actual `Browse View (Picker)` plus either `DOCPicker.filenameTextField` or Save. Verify the expected backup filename whenever its field is exposed. Captured hosted runs show that Browse can expose the filename without Save, while a folder can expose Save without the filename field.
- `bundle exec fastlane beta` signs and uploads a TestFlight release, then bumps the build number. Run it only when releasing is requested.
- Certificates are managed with Fastlane match (`make renew_certs` / `make get_certs`).
10 changes: 10 additions & 0 deletions docs/ipad-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,13 @@ The shared workspace review is `../iphone-duo-review/2026-10-04-current/review/`
The follow-up catalog grid review is `../iphone-duo-review/2026-10-04-grid/`. Keep its build 4 clips and validation separate from the earlier build 3 Swiftchan footage.

The full-screen navigation review is `../iphone-duo-review/2026-10-05-fullscreen/`, for build 1.3 (5). Its mini portrait and Pro landscape captures verify the actual full-width thread and Back restoring the scrolled catalog.

## Gallery controls

Build 1.3 (6) opens each gallery page without the image/thumbnail search button or media counter. A single media tap reveals both, and another hides them. The optional thumbnail strip follows the same tap. Paging, zooming, and seeking clear the overlays; the close button and long-press media actions remain available when not zoomed. Check preview enabled and disabled, double-tap zoom, vertical paging, and returning to the selected post.

The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation before opening the gallery. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose.

With `xcodebuild`, the `TEST_RUNNER_` prefix is stripped when forwarding these variables to XCTest: the test reads `GALLERY_CAPTURE_ORIENTATION` and `DUO_CAPTURE`. Orientation assertions apply only to gallery captures. Record simulator walkthroughs sequentially; concurrent capture runs against shared test products produced stale display frames and unstable automation sessions. The build 6 gallery review is `../iphone-duo-review/2026-10-05-gallery/`.

During iPad video presentation, XCTest can report a zero-sized accessibility window while the gallery controls remain present. Check the real app window before opening media, then verify every native PNG's orientation in the capture harness. Do not treat a zero accessibility frame as the video display's geometry.
10 changes: 10 additions & 0 deletions docs/iphone-duo-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,13 @@ Read the complete relevant Apple documents and upstream tool instructions when c
- [Apple: Interacting with your app in the iOS or iPadOS Simulator](https://developer.apple.com/documentation/xcode/interacting-with-your-app-in-the-ios-or-ipados-simulator)
- [MobileBuildMCP 2.7.0](https://github.com/getsentry/MobileBuildMCP/releases/tag/v2.7.0) and [2.7.1](https://github.com/getsentry/MobileBuildMCP/releases/tag/v2.7.1)
- [Agent Device foldable-panel ADR](https://github.com/callstack/agent-device/blob/main/docs/adr/0025-foldable-apple-panels.md), especially the 2026-09-22 headless amendment and the accepted quarter-turn evidence gap

## Gallery controls

Build 1.3 (6) opens each gallery page without the image/thumbnail search button or media counter. A single media tap reveals both, and another hides them. The optional thumbnail strip follows the same tap. Paging, zooming, and seeking clear the overlays; the close button and long-press media actions remain available when not zoomed. Check preview enabled and disabled, double-tap zoom, vertical paging, and returning to the selected post.

The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation before opening the gallery. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose.

With `xcodebuild`, the `TEST_RUNNER_` prefix is stripped when forwarding these variables to XCTest: the test reads `GALLERY_CAPTURE_ORIENTATION` and `DUO_CAPTURE`. Orientation assertions apply only to gallery captures. Record simulator walkthroughs sequentially; concurrent capture runs against shared test products produced stale display frames and unstable automation sessions. The build 6 gallery review is `../iphone-duo-review/2026-10-05-gallery/`.

During iPad video presentation, XCTest can report a zero-sized accessibility window while the gallery controls remain present. Check the real app window before opening media, then verify every native PNG's orientation in the capture harness. Do not treat a zero accessibility frame as the video display's geometry.
4 changes: 2 additions & 2 deletions swiftchan.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1101,7 +1101,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\"";
DEVELOPMENT_TEAM = 8Q3RG3ULSU;
ENABLE_BITCODE = NO;
Expand Down Expand Up @@ -1133,7 +1133,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\"";
DEVELOPMENT_TEAM = 8Q3RG3ULSU;
ENABLE_BITCODE = NO;
Expand Down
29 changes: 28 additions & 1 deletion swiftchan/Services/ThreadUITestFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@
let timestamp = 9_000_000_000 + number
post["tim"] = timestamp
post["filename"] = "collection-study-\(number - id + 1)"
post["ext"] = ".png"
let isVideo = arguments.contains("--ui-video-media-fixture") && number == id + 2
post["ext"] = isVideo ? ".webm" : ".png"
post["w"] = 1200
post["h"] = 900
post["tn_w"] = 250
post["tn_h"] = 188
post["fsize"] = 100_000
seedMedia(timestamp: timestamp, index: number - id)
if isVideo { seedVideo(timestamp: timestamp) }
}
if arguments.contains("--ui-quote-fixture"), (103...105).contains(number) {
post["com"] = "<a class=\"quotelink\" href=\"#p\(number - 1)\">&gt;&gt;\(number - 1)</a><br>Quoted reply \(number)"
Expand Down Expand Up @@ -69,6 +71,31 @@
ImageCache.default.store(image, forKey: "https://i.4cdn.org/biz/\(timestamp)s.jpg", toDisk: false)
}

/// Two seconds of silent teal WebM; avoids network-dependent video gallery tests.
private static func seedVideo(timestamp: Int) {
let encoded = """
GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAJ+EU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZU
rmtTrIHWTbuMU6uEElTDZ1OsggEyTbuMU6uEHFO7a1OsggJo7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmsCrXsYMPQkBNgIxM
YXZmNjMuMS4xMDFXQYxMYXZmNjMuMS4xMDFEiYhAn0AAAAAAABZUrmvXrgEAAAAAAABO14EBc8WI5n7UnqvrF9WcgQAitZyDdW5k
iIEAhoVWX1ZQOIOBASPjg4QdzWUA4JCwgaC6gXiagQJVsIRVuYEBVe6BAOwBAAAAAAAAAgAAElTDZ/pzc59jwIBnyJlFo4dFTkNP
REVSRIeMTGF2ZjYzLjEuMTAxc3PVY8CLY8WI5n7UnqvrF9VnyKBFo4dFTkNPREVSRIeTTGF2YzYzLjEuMTAxIGxpYnZweGfIoUWj
iERVUkFUSU9ORIeTMDA6MDA6MDIuMDAwMDAwMDAwAB9DtnVAseeBAKPegQAAgBAHAJ0BKqAAeAAARwiFhYiFhIgCAgJ1qgP4AgaT
kRV2lQnFLSoTilpUJxS0qE4paVCcUtKhOKWlQnFLSoTilpSgAP77aJf/PTNeYP8nP/25H+3I/25H/7bCAKOYgQH0ABECAAEQEAAY
ABhYL/QACICBDLAAo5iBA+gAEQIAARAQABgAGFgv9AAIgIEMsACjmIEF3AARAgABEBAAGAAYWC/0AAiAgQywABxTu2uRu4+zgQC3
iveBAfGCAbHwgQM=
"""
guard let data = Data(base64Encoded: encoded, options: .ignoreUnknownCharacters),
let url = URL(string: "https://i.4cdn.org/biz/\(timestamp).webm") else { return }
let file = CacheManager.shared.cacheURL(url)
do {
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: file, options: .atomic)
} catch {
assertionFailure("Unable to seed video fixture: \(error)")
}
}

static func catalog(board: String) throws -> Catalog? {
let arguments = ProcessInfo.processInfo.arguments
guard board == "biz" else { return nil }
Expand Down
35 changes: 21 additions & 14 deletions swiftchan/Views/Media/Gallery/GalleryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ struct GalleryView: View {

@State private var selection: Int
@State private var canPage = true
@State private var canShowPreview = true
@State private var showPreview = false
@State private var showControls = false
@State private var canShowContextMenu = true
@State private var isSeeking = false
@State private var isZoomed = false
Expand All @@ -43,17 +42,24 @@ struct GalleryView: View {
.onDisappear {
restorePagerScrolling()
}
.gesture(canShowPreview && showGalleryPreview ? showPreviewTap() : nil)
// Block the zoom transition's pan-to-dismiss while pinch-zoomed into
// media or scrubbing video, so those gestures keep priority.
.interactiveDismissDisabled(isZoomed || isSeeking)
.statusBar(hidden: true)
}

private var gallerySurface: some View {
ZStack { galleryPager; galleryChrome }
ZStack {
galleryPager
.simultaneousGesture(galleryControlsTap())
galleryChrome
}
}

private var canShowControls: Bool { !isZoomed && !isSeeking }

private var showPreview: Bool { showControls && showGalleryPreview }

private var galleryPager: some View {
VerticalPagerView(
selection: $selection,
Expand Down Expand Up @@ -119,6 +125,7 @@ struct GalleryView: View {
VStack {
Spacer()
GalleryPreviewView(selection: $state.galleryIndex)
.accessibilityIdentifier("Gallery Preview")
.transition(.opacity)
.padding(.bottom, 60)
}
Expand All @@ -127,19 +134,17 @@ struct GalleryView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .topLeading) { closeButton }
.overlay(alignment: .topTrailing) {
if viewModel.media.indices.contains(selection) {
if showControls && canShowControls && viewModel.media.indices.contains(selection) {
ReverseImageSearchMenu(media: viewModel.media[selection], identifier: "Gallery Image Search")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.padding(12)
.background(.black.opacity(0.65), in: Capsule())
.padding(16)
.opacity(isZoomed || isSeeking ? 0 : 1)
.allowsHitTesting(!isZoomed && !isSeeking)
}
}
.overlay(alignment: .bottomTrailing) {
if viewModel.media.indices.contains(selection) {
if showControls && canShowControls && viewModel.media.indices.contains(selection) {
Text("\(selection + 1) of \(viewModel.media.count)")
.font(.caption.monospacedDigit().weight(.semibold))
.foregroundStyle(.white)
Expand All @@ -148,7 +153,6 @@ struct GalleryView: View {
.background(.black.opacity(0.65), in: Capsule())
.padding(16)
.padding(.bottom, positionBottomPadding)
.opacity(isZoomed || isSeeking ? 0 : 1)
.allowsHitTesting(false)
.accessibilityLabel("Media \(selection + 1) of \(viewModel.media.count)")
.accessibilityIdentifier("Gallery Position")
Expand Down Expand Up @@ -188,18 +192,19 @@ struct GalleryView: View {
.onMediaChanged { zoomed in
isZoomed = zoomed
refreshPagingState()
canShowPreview = !zoomed
canShowContextMenu = !zoomed
if zoomed {
showPreview = false
showControls = false
}
onMediaChanged?(zoomed)
}
.onSeekChanged { seeking in
isSeeking = seeking
refreshPagingState()
canShowPreview = !seeking
canShowContextMenu = !seeking
if seeking {
showControls = false
}
}
.mediaDownloadMenu(url: media.url, thumbnailURL: media.thumbnailUrl, canShowContextMenu: $canShowContextMenu)
.accessibilityIdentifier(
Expand All @@ -215,6 +220,7 @@ struct GalleryView: View {

onPageDragChanged?(.zero)
canShowContextMenu = true
showControls = false
isZoomed = false
isSeeking = false
refreshPagingState()
Expand All @@ -240,11 +246,12 @@ struct GalleryView: View {
viewModel.prefetch(currentIndex: index)
}

func showPreviewTap() -> some Gesture {
private func galleryControlsTap() -> some Gesture {
TapGesture()
.onEnded {
guard canShowControls else { return }
withAnimation(.linear(duration: 0.2)) {
showPreview.toggle()
showControls.toggle()
}
}
}
Expand Down
Loading
Loading