Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5cea285
feat: add shared SwiftUI passcode component
marinofaggiana Oct 22, 2026
78bc603
feat: migrate settings to SwiftUI passcode flow
marinofaggiana Oct 22, 2026
90dc3e1
fix: authenticate E2EE actions with the shared passcode view
marinofaggiana Oct 22, 2026
f33758a
refactor: migrate app passcode flows to NCPasscodeView
marinofaggiana Oct 22, 2026
5597c60
Merge branch 'master' into passcode
marinofaggiana Oct 23, 2026
09786db
refactor: migrate passcode flows to shared SwiftUI components
marinofaggiana Oct 23, 2026
41feee0
Acknowledgements update
marinofaggiana Oct 23, 2026
9429331
fix
marinofaggiana Oct 23, 2026
6f23c17
fix: preserve passcode reset threshold across lockouts
marinofaggiana Oct 23, 2026
c6c833e
fix: keep app passcode lock above sheets and alerts
marinofaggiana Oct 24, 2026
5e15d9a
fix: wait for HTTP fallback in UI test login
marinofaggiana Oct 24, 2026
14de69c
CI
marinofaggiana Oct 24, 2026
87bc765
Merge branch 'passcode' of https://github.com/nextcloud/ios into pass…
marinofaggiana Oct 24, 2026
ebcd3a6
CI
marinofaggiana Oct 24, 2026
a58fce3
CI
marinofaggiana Oct 24, 2026
aab9355
CI
marinofaggiana Oct 24, 2026
162c76c
Merge branch 'master' into passcode
marinofaggiana Oct 24, 2026
c10aef5
size button
marinofaggiana Oct 26, 2026
227f79f
fix: avoid redundant transfer and auto-upload state updates
marinofaggiana Oct 26, 2026
b3ac1d4
added NCPasscodeView.embed
marinofaggiana Oct 26, 2026
a9e021b
fix: correct share extension passcode layout
marinofaggiana Oct 26, 2026
d5e0f35
fix: enforce app lock after failed passcode attempts
marinofaggiana Oct 27, 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
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
45 changes: 22 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,22 @@ 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
// Embed in the content controller; the helper hides the outer navigation bar.
NCPasscodeView.embed(in: 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