Skip to content
Open
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
18 changes: 15 additions & 3 deletions .github/workflows/xcode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ jobs:
test:
name: Run Tests
runs-on: xcode-27
timeout-minutes: 30
needs: [build]

if: github.event.pull_request.draft == false
Expand Down Expand Up @@ -103,6 +104,7 @@ jobs:
run: |
mkdir server/data
./server/occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-host=127.0.0.1 --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
./server/occ config:system:set force_language --value=en
./server/occ config:system:set hashing_default_password --value=true --type=boolean
./server/occ config:system:set auth.bruteforce.protection.enabled --value false --type bool
./server/occ config:system:set ratelimit.protection.enabled --value false --type bool
Expand All @@ -116,12 +118,17 @@ jobs:
name: Nextcloud for iOS

- name: Assert Nextcloud Server Status
run: curl -s --retry 5 --retry-delay 60 --retry-all-errors http://localhost:8080/status.php || true
run: curl --fail --show-error --silent --max-time 5 --retry 5 --retry-delay 2 --retry-max-time 30 --retry-all-errors http://localhost:8080/status.php

- name: Boot iOS Simulator
run: |
xcrun simctl boot "iPhone 17"
xcrun simctl bootstatus "iPhone 17" || echo "Simulator booted"
xcrun simctl bootstatus "iPhone 17" -b
xcrun simctl spawn "iPhone 17" defaults write NSGlobalDomain AppleLanguages -array en
xcrun simctl spawn "iPhone 17" defaults write NSGlobalDomain AppleLocale -string en_US
xcrun simctl shutdown "iPhone 17"
xcrun simctl boot "iPhone 17"
xcrun simctl bootstatus "iPhone 17" -b

- name: Check if Safari is installed
run: |
Expand All @@ -133,19 +140,24 @@ jobs:
fi

- name: Run Xcode Test
timeout-minutes: 25
run: |
set -o pipefail && \
xcodebuild test-without-building \
-xctestrun $(find . -type f -name "*.xctestrun") \
-destination "${{ env.DESTINATION }}" \
-derivedDataPath "DerivedData" \
-skip-testing:NextcloudUITests/PasscodeUITests \
-test-iterations 3 \
-retry-tests-on-failure \
-test-timeouts-enabled YES \
-default-test-execution-time-allowance 360 \
-maximum-test-execution-time-allowance 360 \
-resultBundlePath "TestResult.xcresult"

- name: Upload Xcode Test Results
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
if: ${{ always() }}
with:
name: TestResult.xcresult
path: "TestResult.xcresult"
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Other platforms like Android are irrelevant for this project.
|-----------------|-----------------------------------------------------|----------------------|
| `.github` | GitHub CI workflows (build & test, SwiftLint, extension-target builds) and issue templates. | Keep all three checks green: `xcode.yml` (builds the `Nextcloud` scheme and runs its tests against a Nextcloud server provisioned in CI), `lint.yml` (SwiftLint), `additional-targets.yml` (build-checks the extension schemes). CI skips draft PRs. |
| `iOSClient` | Main iOS client source code. | — |
| `iOSClient/Utility/Passcode` | Shared SwiftUI passcode UI and authentication model for the app and Share Extension; the mandatory app lock uses a dedicated window per scene. | Preserve existing credentials, lockout rules and app/extension reset behavior. |
| `iOSClient/Supporting Files` | Translation files synced with Transifex (see `iOSClient/.tx/config`). | Only add new strings to `en.lproj` (`Localizable.strings`, plurals in `Localizable.stringsdict`); never edit the other `*.lproj` dirs — Transifex overwrites them. |
| `Brand` | Branding layer: `NCBrand.swift` (app constants, colors), `Database.swift` (Realm database name and schema version), Info.plists and entitlements for all targets, launch screen and intro screens. | Bump `databaseSchemaVersion` in `Brand/Database.swift` when changing Realm models. |
| `File Provider Extension` | Contains business logic for the iOS File Provider extension. | — |
Expand Down
232 changes: 28 additions & 204 deletions Nextcloud.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
</BuildActionEntries>
</BuildAction>
<TestAction
language = "en"
region = "US"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
Expand Down
6 changes: 0 additions & 6 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,6 @@ precedence = "aggregate"
SPDX-FileCopyrightText = "Nextcloud GmbH"
SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks"

[[annotations]]
path = ["iOSClient/Utility/TOPasscodeViewController/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2017 Timothy Oliver"
SPDX-License-Identifier = "MIT"

[[annotations]]
path = ["iOSClient/Utility/NYMnemonic/**"]
precedence = "aggregate"
Expand Down
44 changes: 21 additions & 23 deletions Share/NCShareExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ class NCShareExtension: UIViewController {
var banner: LucidBanner?
var sceneIdentifier: String = UUID().uuidString
private var isPresentingNoAccountAlert = false
private var isPresentingPasscode = false
private var isPasscodeAuthenticated = false

// MARK: - View Life Cycle

Expand Down Expand Up @@ -110,11 +112,8 @@ class NCShareExtension: UIViewController {
return
}

if NCPreferences().presentPasscode {
NCPasscode.shared.presentPasscode(viewController: self, delegate: self) {
NCPasscode.shared.enableTouchFaceID()
}
}
self.isPasscodeAuthenticated = false
self.requestPasscodeIfNeeded()
}

// Verify version
Expand Down Expand Up @@ -165,11 +164,7 @@ class NCShareExtension: UIViewController {
}
}

if NCPreferences().presentPasscode {
NCPasscode.shared.presentPasscode(viewController: self, delegate: self) {
NCPasscode.shared.enableTouchFaceID()
}
}
self.requestPasscodeIfNeeded()

self.collectionView.reloadData()
}
Expand All @@ -189,7 +184,7 @@ class NCShareExtension: UIViewController {
}

private func validateAccount() -> Bool {
guard !NCPasscode.shared.isPasscodeReset else {
guard !NCPasscodeModel.isPasscodeReset else {
return showNoAccountAlert()
}

Expand Down Expand Up @@ -575,18 +570,21 @@ extension NCShareExtension {
}
}

extension NCShareExtension: NCPasscodeDelegate {
func passcodeReset(_ passcodeViewController: TOPasscodeViewController) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
passcodeViewController.dismiss(animated: false)
self.cancel(with: .noAccount)
}
}

func evaluatePolicy(_ passcodeViewController: TOPasscodeViewController, isCorrectCode: Bool) {
if !isCorrectCode {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
passcodeViewController.dismiss(animated: false)
extension NCShareExtension {
/// Require authentication once per foreground session without reopening after dismissal.
private func requestPasscodeIfNeeded() {
guard NCPreferences().presentPasscode,
!isPasscodeAuthenticated,
!isPresentingPasscode else { return }
isPresentingPasscode = true
NCPasscodeView.present(from: self) { [weak self] result in
guard let self else { return }
self.isPresentingPasscode = false
switch result {
case .authenticated:
self.isPasscodeAuthenticated = true
_ = self.validateAccount()
case .cancelled, .reset, .configured, .disabled:
self.cancel(with: .noAccount)
}
}
Expand Down
1 change: 0 additions & 1 deletion Share/Share-Bridging-Header.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,3 @@

#import "NCEndToEndEncryption.h"
#import "UIImage+animatedGIF.h"
#import "TOPasscodeViewController.h"
187 changes: 116 additions & 71 deletions Tests/NextcloudUITests/BaseUIXCTestCase.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-FileCopyrightText: Nextcloud GmbH
// SPDX-FileCopyrightText: 2025 Milen Pivchev
// SPDX-FileCopyrightText: 2026 Marino Faggiana
// SPDX-License-Identifier: GPL-3.0-or-later

import Foundation
Expand Down Expand Up @@ -58,92 +59,136 @@ class BaseUIXCTestCase: XCTestCase {
try await Task.sleep(for: .seconds(TestConstants.controlExistenceTimeout))
}

///
/// Automation of the sign-in, if required.
///
///
/// Sign in through the app's authentication flow, supporting system authentication and the embedded fallback.
func logIn() async throws {
guard app.buttons["login"].exists else {
return
let login = app.buttons["login"]
let deadline = Date().addingTimeInterval(30)
// Startup can restore the main interface after the first accessibility snapshot.
// Wait for either destination instead of assuming that the login screen will appear.
repeat {
if app.buttons["openSidebar"].exists || app.buttons["accountSwitcher"].exists {
return
}
if login.exists && login.isHittable {
break
}
try await Task.sleep(for: .milliseconds(250))
} while Date() < deadline
guard login.exists else {
attachAuthenticationDiagnostics()
throw UITestError.waitForExistence(login)
}
login.tap()

app.buttons["login"].tap()

let serverAddressTextField = app.textFields["serverAddress"].firstMatch
guard serverAddressTextField.await() else { return }

try await aSmallMoment()

serverAddressTextField.tap()
serverAddressTextField.typeText(TestConstants.server)

let serverAddress = app.textFields["serverAddress"].firstMatch
guard serverAddress.await() else { throw UITestError.waitForExistence(serverAddress) }
serverAddress.tap()
serverAddress.typeText(TestConstants.server)
app.buttons["submitServerAddress"].tap()

try await aSmallMoment()

let webView = app.webViews.firstMatch

guard webView.await() else {
throw UITestError.waitForExistence(webView)
let webView = try await authenticationWebView()
let username = webView.textFields.firstMatch
let loginButton = webView.buttons["Log in"].firstMatch
if !username.exists, loginButton.await(timeout: 5) {
loginButton.tap()
}

// try await aSmallMoment()

let loginButton = webView.buttons["Log in"]

// try await aSmallMoment()

if loginButton.await() {
if username.await() {
username.tap()
username.typeText(TestConstants.username)
let password = webView.secureTextFields.firstMatch
guard password.await() else { throw UITestError.waitForExistence(password) }
password.tap()
password.typeText(TestConstants.password)
guard loginButton.await() else { throw UITestError.waitForExistence(loginButton) }
loginButton.tap()
}

// try await aSmallMoment()

let usernameTextField = webView.textFields.firstMatch

if usernameTextField.await() {

try await aSmallMoment()

guard usernameTextField.await() else { return }
usernameTextField.tap()

try await aSmallMoment()

usernameTextField.typeText(TestConstants.username)

try await aSmallMoment()

let passwordSecureTextField = webView.secureTextFields.firstMatch

try await aSmallMoment()

passwordSecureTextField.tap()


try await aSmallMoment()

passwordSecureTextField.typeText(TestConstants.password)

try await aSmallMoment()

webView.buttons.firstMatch.tap()
let grant = webView.buttons["Grant access"]
guard try await waitForAuthenticationConsent() else {
attachAuthenticationDiagnostics()
throw UITestError.waitForExistence(grant)
}

try await aSmallMoment()

let grantButton = webView.buttons["Grant access"]

guard grantButton.await() else {
throw UITestError.waitForExistence(grantButton)
let sidebar = app.buttons["openSidebar"].firstMatch
guard sidebar.await(timeout: 30) || app.buttons["accountSwitcher"].exists else {
attachAuthenticationDiagnostics()
throw UITestError.waitForExistence(sidebar)
}
}

/// Waiting alone does not trigger interruption monitors; dismiss password prompts in their owning process.
private func waitForAuthenticationConsent() async throws -> Bool {
let browsers = [app!, XCUIApplication(bundleIdentifier: "com.apple.SafariViewService")]
let hosts = browsers + [XCUIApplication(bundleIdentifier: "com.apple.springboard")]
let deadline = Date().addingTimeInterval(30)
var passwordPromptDismissed = false
repeat {
promptSearch: for host in hosts where !passwordPromptDismissed && host.state != .notRunning {
for label in ["Not Now", "Non ora", "Ora no"] {
let button = host.buttons[label].firstMatch
if button.exists && button.isHittable {
button.tap()
passwordPromptDismissed = true
_ = button.waitForNonExistence(timeout: 5)
break promptSearch
}
}
}
for browser in browsers where browser.state != .notRunning {
let grant = browser.webViews.buttons["Grant access"].firstMatch
if grant.exists && grant.isHittable {
grant.tap()
return true
}
}
try await Task.sleep(for: .milliseconds(250))
} while Date() < deadline
return false
}

grantButton.tap()
grantButton.awaitInexistence()
/// ASWebAuthenticationSession may expose its UI in SafariViewService rather than in the app hierarchy.
private func authenticationWebView() async throws -> XCUIElement {
let browsers = [app!, XCUIApplication(bundleIdentifier: "com.apple.SafariViewService")]
let promptHosts = browsers + [XCUIApplication(bundleIdentifier: "com.apple.springboard")]
// The app tries HTTPS first, even for the HTTP-only localhost test server.
// Allow the initial URLSession request to time out before its HTTP fallback starts.
let timeout = URLSessionConfiguration.default.timeoutIntervalForRequest + 30
let deadline = Date().addingTimeInterval(timeout)
repeat {
for host in promptHosts where host.state != .notRunning {
for label in ["Continue", "Allow"] {
let button = host.alerts.buttons[label].firstMatch
if button.exists && button.isHittable {
button.tap()
}
}
}
for browser in browsers where browser.state != .notRunning {
let webView = browser.webViews.firstMatch
if webView.exists {
return webView
}
}
try await Task.sleep(for: .milliseconds(250))
} while Date() < deadline

app.buttons["accountSwitcher"].await()
attachAuthenticationDiagnostics()
throw UITestError.waitForExistence(app.webViews.firstMatch)
}

try await aSmallMoment()
/// Keep login diagnostics in the xcresult instead of losing the context on a CI failure.
private func attachAuthenticationDiagnostics() {
let screenshot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
screenshot.name = "Authentication screen"
screenshot.lifetime = .keepAlways
add(screenshot)
for host in [app!, XCUIApplication(bundleIdentifier: "com.apple.SafariViewService"), XCUIApplication(bundleIdentifier: "com.apple.springboard")]
where host.state == .runningForeground {
let hierarchy = XCTAttachment(string: host.debugDescription)
hierarchy.name = "Authentication UI hierarchy"
hierarchy.lifetime = .keepAlways
add(hierarchy)
}
}

///
Expand Down
Loading
Loading