Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
25238ee
Improve VoiceOver navigation and message accessibility
Kostenkov-2021 Jul 22, 2026
6a9b10a
Expand VoiceOver message actions and sharing accessibility
Kostenkov-2021 Jul 24, 2026
452baa3
Improve VoiceOver accessibility for modal interfaces
Kostenkov-2021 Jul 24, 2026
8591801
Improve Dynamic Type support for accessible modals
Kostenkov-2021 Jul 24, 2026
512637e
Improve VoiceOver support for alerts and Voice Control
Kostenkov-2021 Jul 24, 2026
41b9bd3
Improve VoiceOver reply navigation and peer info actions
Kostenkov-2021 Jul 24, 2026
2cff1ba
Improve VoiceOver accessibility for peer info members and header
Kostenkov-2021 Jul 24, 2026
2c9ee56
Improve VoiceOver accessibility for peer info media panes
Kostenkov-2021 Jul 24, 2026
1e328cb
Improve VoiceOver behavior in embedded peer info panes
Kostenkov-2021 Jul 24, 2026
d9f29dd
Improve VoiceOver accessibility in gifts and sharing
Kostenkov-2021 Jul 24, 2026
1864188
Improve VoiceOver accessibility in selection flows
Kostenkov-2021 Jul 24, 2026
047760a
Keep VoiceOver scroll feedback consistently localized
Kostenkov-2021 Jul 25, 2026
ca99e37
Improve VoiceOver selection flows and Share Extension
Kostenkov-2021 Jul 25, 2026
2108986
Improve VoiceOver accessibility for gifts and reply media
Kostenkov-2021 Jul 29, 2026
81e0eb2
Improve VoiceOver focus persistence and modal accessibility
Kostenkov-2021 Jul 29, 2026
e37e5ba
Complete VoiceOver focus persistence and regression gates
Kostenkov-2021 Aug 22, 2026
0f958f6
Expand VoiceOver regression coverage
Kostenkov-2021 Aug 22, 2026
693b4f9
Improve rich text scaling and Voice Control performance coverage
Kostenkov-2021 Aug 22, 2026
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
41 changes: 41 additions & 0 deletions .github/ISSUE_TEMPLATE/voiceover_tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
name: VoiceOver release tracking
about: Track VoiceOver verification, regressions, and release evidence
title: "VoiceOver release gate: <version or commit>"
labels: "a11y-voiceover,a11y-regression"
assignees: ""
---

## Scope

- Commit or tag:
- iOS versions:
- Devices and simulators:
- Locales:

## Automated gates

- [ ] VoiceOver source-contract tests pass.
- [ ] iOS 17+ XCTest accessibility-tree audit passes in required app states.
- [ ] Result logs and `.xcresult` are attached.

## Manual VoiceOver gate

- [ ] Chat history traversal and scrolling
- [ ] Jump to latest and input focus
- [ ] Message information, states, and custom actions
- [ ] Instant video and reply voice messages
- [ ] Selection flows and limit errors
- [ ] Share Extension
- [ ] Peer Info, Gifts, contacts, and searches
- [ ] Modal containment, Escape, and trigger-focus restoration
- [ ] Dynamic Type, Reduce Motion, and Voice Control

## Regressions

Link every regression to a dedicated issue and atomic fix commit. Apply the appropriate `a11y-focus`, `a11y-navigation`, `a11y-semantics`, or `a11y-regression` label.

## Release decision

- [ ] No release-blocking accessibility regressions remain.
- Decision and rationale:
21 changes: 21 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Summary

Describe the user-visible change and link the relevant issue.

## Verification

- [ ] The affected target builds successfully.
- [ ] The change was tested on a supported iOS version.
- [ ] `python3 -m unittest discover -s Tests/VoiceOverContracts -p "test_*.py" -v` passes.

## Accessibility

- [ ] Labels describe content without repeating the control role.
- [ ] Values expose selection, delivery, playback, loading, and disabled states.
- [ ] Hints describe the result of activation.
- [ ] VoiceOver focus survives updates by a stable domain identifier.
- [ ] Modal UI contains traversal, supports Escape, and restores trigger focus.
- [ ] Dynamic Type, Reduce Motion, and Voice Control were checked where applicable.
- [ ] Decorative or hidden views are excluded from the accessibility tree.
- [ ] The iOS 17+ XCTest accessibility-tree audit passes.
- [ ] Manual VoiceOver verification evidence is attached, or the reason it is not applicable is documented.
60 changes: 60 additions & 0 deletions .github/workflows/voiceover-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: VoiceOver accessibility gate

on:
push:
branches:
- "VoiceOver-fixes"
paths:
- "submodules/**/*.swift"
- "Telegram/Tests/Sources/AccessibilityUITests.swift"
- "Tests/VoiceOverContracts/**"
- ".github/workflows/voiceover-gate.yml"
pull_request:
paths:
- "submodules/**/*.swift"
- "Telegram/Tests/Sources/AccessibilityUITests.swift"
- "Tests/VoiceOverContracts/**"
- ".github/workflows/voiceover-gate.yml"
workflow_dispatch:

jobs:
source-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run VoiceOver source contracts
run: python3 -m unittest discover -s Tests/VoiceOverContracts -p "test_*.py" -v

simulator-tree-audit:
if: github.event_name == 'workflow_dispatch'
runs-on: macos-26
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Select repository Xcode version
run: |
XCODE_VERSION=$(python3 -c 'import json; print(json.load(open("versions.json"))["xcode"])')
sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"
- name: Run iOS accessibility tree audit
run: |
python3 build-system/Make/Make.py \
--bazelUserRoot=/private/var/tmp/_bazel_voiceover \
test \
--configurationPath=build-system/appstore-configuration.json \
--disableProvisioningProfiles \
--target=Telegram:iOSAppUITestSuite
- name: Collect XCTest result bundles
if: always()
run: |
mkdir -p voiceover-test-results
find bazel-testlogs -name "*.xcresult" -exec cp -R {} voiceover-test-results/ \; || true
- name: Upload VoiceOver performance evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: voiceover-xcresult
path: voiceover-test-results
if-no-files-found: warn
retention-days: 30
21 changes: 21 additions & 0 deletions ACCESSIBILITY_CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Accessibility changelog

## Unreleased — VoiceOver-fixes

- Added navigable chat history scrolling, localized scroll feedback, and an accessible jump-to-latest control.
- Preserved message focus by stable `MessageId` after history transactions.
- Improved input-field hit testing, frame updates, and focus behavior.
- Unified message renderer labels, values, hints, traits, delivery/playback states, reply semantics, and custom actions.
- Added stable accessibility identifiers for message renderers.
- Improved selection states, disabled-limit explanations, and focus persistence across selection updates.
- Improved Share Extension mode semantics, recipients, search, topics, initial focus, Escape, error handling, and modal containment.
- Improved Peer Info, Gifts, contacts, global search, attachment search, and sticker search semantics and stable focus restoration.
- Added accessible gift context actions and non-drag reorder alternatives.
- Added modal containment, initial focus, Escape, and trigger-focus restoration to shared alerts, action sheets, context, peek, and pinch controllers.
- Added source-contract tests, an iOS accessibility-tree audit, a pull-request checklist, and a documented release gate.
- Added an initial accessibility-tree size budget, XCTest traversal time/memory metrics, and retained `.xcresult` performance evidence.
- Added opt-in populated-chat UI contracts for message names, stable identifiers, frames, and the input field hit target using `VOICEOVER_USE_EXISTING_DATA=1`.
- Expanded source contracts to cover media/reply/delivery/play states, history scrolling, selection limits, Share modes, Gifts actions, Dynamic Type, Reduce Motion, and Reduce Transparency.
- Scaled Rich Message Instant Page typography from the configured chat text size and included that size in the layout cache key.
- Added stable Voice Control targets for interactive Settings disclosure/action rows.
- Added opt-in Settings tree/memory and chat typing accessibility performance scenarios with retained measurements and blocking budgets.
202 changes: 202 additions & 0 deletions Telegram/Tests/Sources/AccessibilityUITests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import Foundation
import XCTest

final class AccessibilityUITests: XCTestCase {
private static let initialTreeElementBudget = 500
private static let traversalSampleCount = 10
private static let averageTraversalBudget: TimeInterval = 2.0
private static let maximumTraversalBudget: TimeInterval = 5.0
private static let settingsTreeElementBudget = 250
private static let typingUpdateAverageBudget: TimeInterval = 3.0
private static let typingUpdateMaximumBudget: TimeInterval = 6.0

private var app: XCUIApplication!

override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
if ProcessInfo.processInfo.environment["VOICEOVER_USE_EXISTING_DATA"] != "1" {
app.launchArguments.append("--ui-test")
}
app.launchArguments.append("--voiceover-release-gate")
}

override func tearDownWithError() throws {
app = nil
}

@available(iOS 17.0, *)
func testInitialAccessibilityTree() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))
try app.performAccessibilityAudit()
}

func testStableMessageIdentifiersAreUnique() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))

let messages = app.descendants(matching: .any).matching(
NSPredicate(format: "identifier BEGINSWITH %@", "message.")
)
var identifiers = Set<String>()
for index in 0 ..< messages.count {
let identifier = messages.element(boundBy: index).identifier
XCTAssertFalse(identifier.isEmpty)
XCTAssertTrue(identifiers.insert(identifier).inserted, "Duplicate message accessibility identifier: \(identifier)")
}
}

func testInitialAccessibilityTreeStaysWithinBudget() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))

let elementCount = app.descendants(matching: .any).count
let attachment = XCTAttachment(string: "Initial accessibility tree elements: \(elementCount)")
attachment.name = "Accessibility tree size"
attachment.lifetime = .keepAlways
add(attachment)

XCTAssertLessThanOrEqual(
elementCount,
Self.initialTreeElementBudget,
"Initial accessibility tree exceeded its budget; inspect hidden or decorative duplicate elements"
)
}

func testAccessibilityTreeTraversalPerformance() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))

_ = app.descendants(matching: .any).count

var samples: [TimeInterval] = []
for _ in 0 ..< Self.traversalSampleCount {
let startTime = CFAbsoluteTimeGetCurrent()
_ = app.descendants(matching: .any).count
samples.append(CFAbsoluteTimeGetCurrent() - startTime)
}

let average = samples.reduce(0.0, +) / Double(samples.count)
let maximum = samples.max() ?? 0.0
let attachment = XCTAttachment(
string: "Traversal samples: \(samples)\nAverage: \(average)\nMaximum: \(maximum)"
)
attachment.name = "Accessibility traversal performance"
attachment.lifetime = .keepAlways
add(attachment)

XCTAssertLessThanOrEqual(average, Self.averageTraversalBudget)
XCTAssertLessThanOrEqual(maximum, Self.maximumTraversalBudget)

measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
_ = app.descendants(matching: .any).count
}
}

func testPopulatedChatMessageContractWhenFixtureIsAvailable() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))

let messages = app.descendants(matching: .any).matching(
NSPredicate(format: "identifier BEGINSWITH %@", "message.")
)
guard messages.count != 0 else {
throw XCTSkip("Run with VOICEOVER_USE_EXISTING_DATA=1 and open a populated chat before launching the test")
}

var identifiers = Set<String>()
for index in 0 ..< messages.count {
let message = messages.element(boundBy: index)
XCTAssertFalse(message.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
XCTAssertFalse(message.identifier.isEmpty)
XCTAssertTrue(identifiers.insert(message.identifier).inserted)
XCTAssertGreaterThan(message.frame.width, 0.0)
XCTAssertGreaterThan(message.frame.height, 0.0)
}
}

func testChatInputHitTargetWhenFixtureIsAvailable() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))

let input = app.descendants(matching: .any)["chat.input"]
guard input.waitForExistence(timeout: 2.0) else {
throw XCTSkip("Run with VOICEOVER_USE_EXISTING_DATA=1 and open a writable chat before launching the test")
}
XCTAssertGreaterThanOrEqual(input.frame.width, 44.0)
XCTAssertGreaterThanOrEqual(input.frame.height, 44.0)
XCTAssertTrue(input.isHittable)
input.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 2.0))
}

func testSettingsVoiceControlContractAndPerformanceWhenFixtureIsAvailable() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))

let settingsItems = app.descendants(matching: .any).matching(
NSPredicate(
format: "identifier BEGINSWITH %@ OR identifier BEGINSWITH %@",
"peerInfo.disclosure.",
"peerInfo.action."
)
)
guard settingsItems.count >= 5 else {
throw XCTSkip("Run with VOICEOVER_USE_EXISTING_DATA=1 and leave the main Settings tab visible")
}

var identifiers = Set<String>()
for index in 0 ..< settingsItems.count {
let item = settingsItems.element(boundBy: index)
XCTAssertFalse(item.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
XCTAssertTrue(identifiers.insert(item.identifier).inserted)
XCTAssertGreaterThan(item.frame.width, 0.0)
XCTAssertGreaterThan(item.frame.height, 0.0)
}

let elementCount = app.descendants(matching: .any).count
XCTAssertLessThanOrEqual(elementCount, Self.settingsTreeElementBudget)
let attachment = XCTAttachment(string: "Settings accessibility tree elements: \(elementCount)")
attachment.name = "Settings accessibility performance"
attachment.lifetime = .keepAlways
add(attachment)

measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
_ = app.descendants(matching: .any).count
}
}

func testChatTypingAccessibilityPerformanceWhenFixtureIsAvailable() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))

let input = app.descendants(matching: .any)["chat.input"]
guard input.waitForExistence(timeout: 2.0) else {
throw XCTSkip("Run with VOICEOVER_USE_EXISTING_DATA=1 and open a writable chat before launching the test")
}
input.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 2.0))

var samples: [TimeInterval] = []
for _ in 0 ..< Self.traversalSampleCount {
let startTime = CFAbsoluteTimeGetCurrent()
input.typeText("a")
_ = app.descendants(matching: .any).count
input.typeText(XCUIKeyboardKey.delete.rawValue)
samples.append(CFAbsoluteTimeGetCurrent() - startTime)
}

let average = samples.reduce(0.0, +) / Double(samples.count)
let maximum = samples.max() ?? 0.0
let attachment = XCTAttachment(
string: "Typing accessibility samples: \(samples)\nAverage: \(average)\nMaximum: \(maximum)"
)
attachment.name = "Voice Control typing performance"
attachment.lifetime = .keepAlways
add(attachment)

XCTAssertLessThanOrEqual(average, Self.typingUpdateAverageBudget)
XCTAssertLessThanOrEqual(maximum, Self.typingUpdateMaximumBudget)
}
}
Loading