diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj
index d52b23d..638b575 100644
--- a/NativeAppTemplate.xcodeproj/project.pbxproj
+++ b/NativeAppTemplate.xcodeproj/project.pbxproj
@@ -1196,6 +1196,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_ASSET_PATHS = "\"NativeAppTemplate/Preview Content\"";
+ ENABLE_DEBUG_DYLIB = NO;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = NativeAppTemplate/Info.plist;
diff --git a/NativeAppTemplate/App.swift b/NativeAppTemplate/App.swift
index 572ae70..0fdcb9e 100644
--- a/NativeAppTemplate/App.swift
+++ b/NativeAppTemplate/App.swift
@@ -25,6 +25,7 @@ private final class NullSessionController: SessionControllerProtocol {
}
var shouldPopToRootView: Bool = false
+ var didExpireSession: Bool = false
var shouldUpdateApp: Bool = false
var shouldUpdatePrivacy: Bool = false
var shouldUpdateTerms: Bool = false
diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift
index f9e4d48..ce72d37 100644
--- a/NativeAppTemplate/Constants.swift
+++ b/NativeAppTemplate/Constants.swift
@@ -297,6 +297,8 @@ enum Strings {
static let signInToYourAccount = String(localized: "Sign In to Your Account")
static let email = String(localized: "Email")
static let password = String(localized: "Password")
+ static let sessionExpired = String(localized: "Session Expired")
+ static let sessionExpiredDescription = String(localized: "Your session has expired. Please sign in again.")
static var welcomeToApp: String {
String(localized: "Welcome to \(appName)")
diff --git a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift
index 025c3f5..16c2801 100644
--- a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift
+++ b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift
@@ -12,6 +12,11 @@ import SwiftUI
var state: DataState = .initial
var paginationMeta: PaginationMeta?
var isLoadingMore = false
+ /// The shop whose pages are on screen or loading
+ private var listedShopId: String?
+ /// Bumped by every reloadPage, so a page that arrives after another one was
+ /// asked for (the user opened another shop meanwhile) is dropped
+ private var listGeneration = 0
required init(itemTagsService: ItemTagsService) {
self.itemTagsService = itemTagsService
@@ -26,7 +31,8 @@ import SwiftUI
return
}
- if state == .loading {
+ // A reload of this shop is already on its way
+ if state == .loading, listedShopId == shopId {
return
}
@@ -37,15 +43,24 @@ import SwiftUI
paginationMeta = nil
}
+ // A next page still loading belongs to the old list and will be dropped
+ isLoadingMore = false
+ listedShopId = shopId
+ listGeneration += 1
+ let generation = listGeneration
state = .loading
Task { @MainActor in
do {
let response = try await itemTagsService.allItemTags(shopId: shopId, page: page)
+ guard generation == listGeneration else { return }
+
itemTags = response.itemTags
paginationMeta = response.paginationMeta
state = .hasData
} catch {
+ guard generation == listGeneration else { return }
+
state = .failed
Failure
.fetch(from: Self.self, reason: error.codedDescription)
@@ -62,10 +77,14 @@ import SwiftUI
}
isLoadingMore = true
+ let generation = listGeneration
Task { @MainActor in
do {
let response = try await itemTagsService.allItemTags(shopId: shopId, page: meta.currentPage + 1)
+ // The list was reloaded (or another shop opened) meanwhile
+ guard generation == listGeneration else { return }
+
// A list that shifted since the last page can repeat tags already
// listed; keep the first occurrence so each id appears once, in order
let listedIds = Set(itemTags.map(\.id))
@@ -76,6 +95,7 @@ import SwiftUI
.fetch(from: Self.self, reason: error.codedDescription)
.log()
}
+ guard generation == listGeneration else { return }
isLoadingMore = false
}
diff --git a/NativeAppTemplate/Info.plist b/NativeAppTemplate/Info.plist
index 673fc62..61d817f 100644
--- a/NativeAppTemplate/Info.plist
+++ b/NativeAppTemplate/Info.plist
@@ -24,6 +24,8 @@
LSRequiresIPhoneOS
+ NSLocalNetworkUsageDescription
+ Connects to a NativeAppTemplate API server running on your local network during development.
NativeAppTemplateAPIDomain
$(NATIVEAPPTEMPLATE_API_DOMAIN)
NativeAppTemplateAPIPort
@@ -38,10 +40,7 @@
UIApplicationSupportsIndirectInputEvents
UILaunchScreen
-
- UIColorName
- backgroundColor
-
+
UIRequiredDeviceCapabilities
arm64
@@ -57,5 +56,7 @@
UIInterfaceOrientationPortrait
UIInterfaceOrientationPortraitUpsideDown
+ UIUserInterfaceStyle
+ Dark
diff --git a/NativeAppTemplate/InfoPlist.xcstrings b/NativeAppTemplate/InfoPlist.xcstrings
index 520d07c..4ef09fd 100644
--- a/NativeAppTemplate/InfoPlist.xcstrings
+++ b/NativeAppTemplate/InfoPlist.xcstrings
@@ -36,6 +36,24 @@
}
}
}
+ },
+ "NSLocalNetworkUsageDescription" : {
+ "comment" : "Privacy - Local Network Usage Description",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Connects to a NativeAppTemplate API server running on your local network during development."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "開発中に、ローカルネットワーク上で動作している NativeAppTemplate API サーバーに接続します。"
+ }
+ }
+ }
}
},
"version" : "1.0"
diff --git a/NativeAppTemplate/Localizable.xcstrings b/NativeAppTemplate/Localizable.xcstrings
index 71658da..d936b0b 100644
--- a/NativeAppTemplate/Localizable.xcstrings
+++ b/NativeAppTemplate/Localizable.xcstrings
@@ -1019,6 +1019,16 @@
}
}
},
+ "Session Expired" : {
+ "localizations" : {
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "セッションの有効期限切れ"
+ }
+ }
+ }
+ },
"Settings" : {
"localizations" : {
"ja" : {
@@ -1428,6 +1438,16 @@
}
}
}
+ },
+ "Your session has expired. Please sign in again." : {
+ "localizations" : {
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "セッションの有効期限が切れました。もう一度サインインしてください。"
+ }
+ }
+ }
}
},
"version" : "1.0"
diff --git a/NativeAppTemplate/Networking/Services/Service.swift b/NativeAppTemplate/Networking/Services/Service.swift
index 97b679d..417dfd6 100644
--- a/NativeAppTemplate/Networking/Services/Service.swift
+++ b/NativeAppTemplate/Networking/Services/Service.swift
@@ -62,17 +62,20 @@ extension Service {
let statusCode = (response as? HTTPURLResponse)?.statusCode
guard statusCode.map((200 ..< 300).contains) == true
else {
- var errorMessage: String?
-
- do {
- if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
- let theErrorMessage = json["error_message"] as? String {
- errorMessage = theErrorMessage
- }
- } catch {
- // Not JSON (e.g. a proxy's HTML error page): no message to show,
- // so the error falls back to its generic description
- throw NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, nil)
+ // Not JSON (e.g. a proxy's HTML error page) or no error_message: no message
+ // to show, so the error falls back to its generic description
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let errorMessage = json?["error_message"] as? String
+
+ // devise_token_auth rejects a missing, wrong or expired token with a 401
+ // whose body has no error_message. The API's own 401 (a Pundit denial)
+ // has one and keeps the session.
+ if statusCode == 401, errorMessage == nil, !networkClient.authToken.isEmpty {
+ NotificationCenter.default.post(
+ name: .apiSessionDidExpire,
+ object: nil,
+ userInfo: [Notification.authTokenKey: networkClient.authToken]
+ )
}
throw NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, errorMessage)
@@ -81,3 +84,15 @@ extension Service {
return try request.handle(response: data)
}
}
+
+extension Notification.Name {
+ /// Posted on the main actor when the API rejects a request's access token: a 401
+ /// from devise_token_auth, e.g. once the token's lifespan is over. The rejected
+ /// token is in `userInfo[Notification.authTokenKey]`, so a session that has
+ /// since signed in again can tell the notice is not about it.
+ static let apiSessionDidExpire = Notification.Name("NativeAppTemplate.apiSessionDidExpire")
+}
+
+extension Notification {
+ static let authTokenKey = "authToken"
+}
diff --git a/NativeAppTemplate/Sessions/SessionController.swift b/NativeAppTemplate/Sessions/SessionController.swift
index db8eca5..c3ae4a3 100644
--- a/NativeAppTemplate/Sessions/SessionController.swift
+++ b/NativeAppTemplate/Sessions/SessionController.swift
@@ -14,6 +14,8 @@ import Observation
private(set) var permissionState: PermissionState = .notLoaded
private(set) var didFetchPermissions = false
var shouldPopToRootView = false
+ /// Set when the API rejected the session's token and the app signed out
+ var didExpireSession = false
var shouldUpdateApp = false
var shouldUpdatePrivacy = false
@@ -36,6 +38,13 @@ import Observation
} else {
userState = .notLoggedIn
permissionState = .notLoaded
+ // These were the signed-out shopkeeper's; the next one's are fetched
+ // on sign-in, so none of them may show meanwhile
+ didFetchPermissions = false
+ shouldUpdateApp = false
+ shouldUpdatePrivacy = false
+ shouldUpdateTerms = false
+ shopLimitCount = 0
// URLSession.api keeps no cache, but versions before it used
// URLCache.shared, which holds their API responses on disk
URLCache.shared.removeAllCachedResponses()
@@ -50,6 +59,9 @@ import Observation
private let connectionMonitor = NWPathMonitor()
private(set) var permissionsService: PermissionsService
private(set) var meService: MeService
+ private let notificationCenter: NotificationCenter
+ /// Removed in deinit, which is nonisolated
+ @ObservationIgnored private nonisolated(unsafe) var sessionExpiryObserver: (any NSObjectProtocol)?
var isLoggedIn: Bool {
userState == .loggedIn
@@ -68,13 +80,16 @@ import Observation
/// - monitorsConnection: Starts `NWPathMonitor`; tests pass `false` and call
/// `connectionChanged(to:)` themselves, so no permission requests go out on their own.
/// - environment: The API the client talks to. Tests point it at a local server.
+ /// - notificationCenter: Where `Service` announces `.apiSessionDidExpire`
init(
loginRepository: LoginRepositoryProtocol,
monitorsConnection: Bool = true,
- environment: NativeAppTemplateEnvironment = .prod
+ environment: NativeAppTemplateEnvironment = .prod,
+ notificationCenter: NotificationCenter = .default
) {
self.loginRepository = loginRepository
self.environment = environment
+ self.notificationCenter = notificationCenter
let shopkeeper = loginRepository.currentShopkeeper
let client = Self.makeClient(shopkeeper: shopkeeper, environment: environment)
@@ -85,6 +100,25 @@ import Observation
if monitorsConnection {
prepareConnectionMonitor()
}
+
+ // Service posts on the main actor, and a nil queue runs this at once, so the
+ // session is gone before the failed request's caller sees its error
+ sessionExpiryObserver = notificationCenter.addObserver(
+ forName: .apiSessionDidExpire,
+ object: nil,
+ queue: nil
+ ) { [weak self] notification in
+ let authToken = notification.userInfo?[Notification.authTokenKey] as? String
+ MainActor.assumeIsolated {
+ self?.sessionDidExpire(authToken: authToken)
+ }
+ }
+ }
+
+ deinit {
+ if let sessionExpiryObserver {
+ notificationCenter.removeObserver(sessionExpiryObserver)
+ }
}
func login(email: String, password: String) async throws {
@@ -94,6 +128,7 @@ import Observation
do {
shopkeeper = try await loginRepository.login(email: email, password: password)
+ didExpireSession = false
Event
.login(from: Self.self)
.log()
@@ -131,6 +166,22 @@ import Observation
}
}
+ /// The API rejected `authToken` (see `.apiSessionDidExpire`). When that is this
+ /// session's token, every further request would fail too, so sign out here: the
+ /// server has no session left to destroy, and the user signs in again.
+ func sessionDidExpire(authToken: String?) {
+ guard isLoggedIn, let authToken, authToken == client.authToken else { return }
+
+ Failure
+ .logout(from: Self.self, reason: "The API rejected the session's token")
+ .log()
+
+ shouldPopToRootView = true
+ try? loginRepository.updateShopkeeper(shopkeeper: nil)
+ shopkeeper = nil
+ didExpireSession = true
+ }
+
func fetchPermissionsIfNeeded() {
guard !hasPermissions else { return }
@@ -159,15 +210,16 @@ import Observation
// Check that we have a logged in user. Otherwise this is pointless
guard let shopkeeper = self.shopkeeper else { return }
- // Update the user
- self.shopkeeper = shopkeeper
// Ensure loginRepository is aware, and hence the keychain is updated
- try self.loginRepository.updateShopkeeper(shopkeeper: self.shopkeeper)
+ try self.loginRepository.updateShopkeeper(shopkeeper: shopkeeper)
shouldUpdateApp = Int(Bundle.main.appBuild)! < permissionsResponse.iosAppVersion
shouldUpdatePrivacy = permissionsResponse.shouldUpdatePrivacy
shouldUpdateTerms = permissionsResponse.shouldUpdateTerms
shopLimitCount = permissionsResponse.shopLimitCount
+ // Set here, not by reassigning shopkeeper: its didSet saw the old
+ // shopLimitCount (0 on the first fetch) and requested them again
+ permissionState = .loaded
didFetchPermissions = true
@@ -178,6 +230,9 @@ import Observation
.fetch(from: Permissions.self, reason: error.codedDescription)
.log()
+ // An expired session has signed out already; that is not a permissions error
+ guard isLoggedIn else { return }
+
self.permissionState = .error
}
}
diff --git a/NativeAppTemplate/Sessions/SessionControllerProtocol.swift b/NativeAppTemplate/Sessions/SessionControllerProtocol.swift
index d36dd1a..d75296e 100644
--- a/NativeAppTemplate/Sessions/SessionControllerProtocol.swift
+++ b/NativeAppTemplate/Sessions/SessionControllerProtocol.swift
@@ -32,6 +32,9 @@ protocol SessionControllerProtocol: AnyObject, Observable, Sendable {
var permissionState: PermissionState { get }
var shouldPopToRootView: Bool { get set }
+ /// True once the API rejected the session's token and the app signed out;
+ /// MainView tells the user and resets it
+ var didExpireSession: Bool { get set }
var shouldUpdateApp: Bool { get set }
var shouldUpdatePrivacy: Bool { get set }
diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift
index d51909b..74d0dfe 100644
--- a/NativeAppTemplate/UI/App Root/MainView.swift
+++ b/NativeAppTemplate/UI/App Root/MainView.swift
@@ -19,6 +19,18 @@ struct MainView: View {
contentView
.background(Color.backgroundColor)
.overlay(MessageBarView(messageBus: messageBus), alignment: .bottom)
+ .alert(Strings.sessionExpired, isPresented: sessionExpiredAlertBinding) {
+ Button(Strings.ok) {}
+ } message: {
+ Text(Strings.sessionExpiredDescription)
+ }
+ .onChange(of: sessionController.didExpireSession) {
+ // The alert says why; the failed request's own error would only
+ // repeat it as a status code
+ if sessionController.didExpireSession {
+ messageBus.dismiss()
+ }
+ }
.onAppear {
if viewModel == nil {
viewModel = MainViewModel(
@@ -34,6 +46,13 @@ struct MainView: View {
// MARK: - private
private extension MainView {
+ var sessionExpiredAlertBinding: Binding {
+ Binding(
+ get: { sessionController.didExpireSession },
+ set: { sessionController.didExpireSession = $0 }
+ )
+ }
+
@ViewBuilder var contentView: some View {
switch MainViewModel.content(
sessionController: sessionController,
diff --git a/NativeAppTemplate/UI/App Root/SignUpView.swift b/NativeAppTemplate/UI/App Root/SignUpView.swift
index b4e522f..fb69f5f 100644
--- a/NativeAppTemplate/UI/App Root/SignUpView.swift
+++ b/NativeAppTemplate/UI/App Root/SignUpView.swift
@@ -77,7 +77,7 @@ private extension SignUpView {
Section {
SecureField(Strings.placeholderPassword, text: $viewModel.password)
- .textContentType(.password)
+ .textContentType(SignUpViewModel.passwordContentType)
.autocapitalization(.none)
.autocorrectionDisabled(true)
} header: {
diff --git a/NativeAppTemplate/UI/App Root/SignUpViewModel.swift b/NativeAppTemplate/UI/App Root/SignUpViewModel.swift
index aae2116..aa536f2 100644
--- a/NativeAppTemplate/UI/App Root/SignUpViewModel.swift
+++ b/NativeAppTemplate/UI/App Root/SignUpViewModel.swift
@@ -9,6 +9,10 @@ import SwiftUI
@Observable
@MainActor
final class SignUpViewModel {
+ /// The password field's content type. `.newPassword` lets iOS suggest a strong
+ /// password and save it with the email; `.password` offers only saved passwords.
+ static let passwordContentType: UITextContentType = .newPassword
+
var name = ""
var email = ""
var password = ""
diff --git a/NativeAppTemplate/UI/Settings/PasswordEditView.swift b/NativeAppTemplate/UI/Settings/PasswordEditView.swift
index e8125e0..3c058e9 100644
--- a/NativeAppTemplate/UI/Settings/PasswordEditView.swift
+++ b/NativeAppTemplate/UI/Settings/PasswordEditView.swift
@@ -42,7 +42,7 @@ private extension PasswordEditView {
Form {
Section {
SecureField(Strings.currentPassword, text: $viewModel.currentPassword)
- .textContentType(.password)
+ .textContentType(PasswordEditViewModel.currentPasswordContentType)
.autocapitalization(.none)
.autocorrectionDisabled(true)
} header: {
@@ -58,7 +58,7 @@ private extension PasswordEditView {
}
Section {
SecureField(Strings.newPassword, text: $viewModel.password)
- .textContentType(.password)
+ .textContentType(PasswordEditViewModel.newPasswordContentType)
.autocapitalization(.none)
.autocorrectionDisabled(true)
} header: {
@@ -81,7 +81,7 @@ private extension PasswordEditView {
}
Section {
SecureField(Strings.confirmNewPassword, text: $viewModel.passwordConfirmation)
- .textContentType(.password)
+ .textContentType(PasswordEditViewModel.newPasswordContentType)
.autocapitalization(.none)
.autocorrectionDisabled(true)
} header: {
diff --git a/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift b/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift
index 842cd48..f6e7e75 100644
--- a/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift
+++ b/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift
@@ -9,6 +9,12 @@ import SwiftUI
@Observable
@MainActor
final class PasswordEditViewModel {
+ /// The current password is one iOS may have saved; the new password and its
+ /// confirmation are `.newPassword`, so iOS suggests a strong one and updates the
+ /// saved password instead of autofilling the old one into them
+ static let currentPasswordContentType: UITextContentType = .password
+ static let newPasswordContentType: UITextContentType = .newPassword
+
var currentPassword = ""
var password = ""
var passwordConfirmation = ""
diff --git a/NativeAppTemplate/Utilities/Utility.swift b/NativeAppTemplate/Utilities/Utility.swift
index a7ad2cc..61f7fbb 100644
--- a/NativeAppTemplate/Utilities/Utility.swift
+++ b/NativeAppTemplate/Utilities/Utility.swift
@@ -8,7 +8,8 @@ import os
enum Utility {
/// The zone (a key of `timeZones`) to preselect for this device: the one
- /// named after the identifier's city, else the first with its standard
+ /// named exactly after the identifier's city, else one at the device's
+ /// standard offset whose name contains the city, else the first at that
/// offset. The table labels zones by standard offset, so summer time must
/// not move the match.
static func currentTimeZone(_ timeZone: TimeZone = .current, date: Date = .now) -> String {
@@ -22,7 +23,17 @@ enum Utility {
let city = (timeZone.identifier.components(separatedBy: "/").last ?? "")
.replacingOccurrences(of: "_", with: " ")
- if !city.isEmpty, let zone = timeZones.first(where: { $0.key.contains(city) }) {
+ guard !city.isEmpty else { return firstZoneWithOffset.key }
+
+ // The whole name, at any offset: the table's offset for a city can differ
+ // from the system's (Dublin, Casablanca), and the city still names the zone
+ if let zone = timeZones.first(where: { $0.key == city }) {
+ return zone.key
+ }
+
+ // Part of a name only among zones at this offset: "West" (Australia/West,
+ // +08:00) is in "International Date Line West" (-12:00)
+ if let zone = zonesWithOffset.first(where: { $0.key.contains(city) }) {
return zone.key
}
diff --git a/NativeAppTemplateTests/App/InfoPlistTest.swift b/NativeAppTemplateTests/App/InfoPlistTest.swift
new file mode 100644
index 0000000..090b154
--- /dev/null
+++ b/NativeAppTemplateTests/App/InfoPlistTest.swift
@@ -0,0 +1,63 @@
+//
+// InfoPlistTest.swift
+// NativeAppTemplate
+//
+
+import Foundation
+@testable import NativeAppTemplate
+import Testing
+import UIKit
+
+/// Checks the built app's Info.plist (Bundle.main is the app the tests are hosted in)
+@Suite
+struct InfoPlistTest {
+ /// Without NSLocalNetworkUsageDescription, iOS denied the app local network
+ /// access to a development API server on the LAN (NSURLErrorNotConnectedToInternet,
+ /// "Local network prohibited") without ever showing the permission prompt.
+ @Test
+ func declaresTheLocalNetworkUsageDescription() throws {
+ let info = try #require(Bundle.main.infoDictionary)
+ let description = try #require(info["NSLocalNetworkUsageDescription"] as? String)
+
+ #expect(!description.isEmpty)
+ }
+
+ /// UILaunchScreen's UIColorName must name a color set in the asset catalog
+ /// (Apple's UILaunchScreen reference); a name that is not there is ignored
+ /// silently and the launch screen falls back to the system background.
+ /// The app defines its colors in Color+Extensions.swift, not in the asset catalog.
+ @Test
+ func everyLaunchScreenColorNameResolves() throws {
+ let info = try #require(Bundle.main.infoDictionary)
+ let launchScreen = try #require(info["UILaunchScreen"] as? [String: Any])
+
+ if let colorName = launchScreen["UIColorName"] as? String {
+ #expect(UIColor(named: colorName, in: .main, compatibleWith: nil) != nil, "\(colorName) is not in the asset catalog")
+ }
+ }
+
+ /// The app is dark mode only (App.swift applies .preferredColorScheme(.dark)), but
+ /// that only takes effect once SwiftUI is on screen. UIUserInterfaceStyle = Dark
+ /// keeps the launch screen's system background dark in light mode too.
+ @Test
+ func launchesInDarkMode() throws {
+ let info = try #require(Bundle.main.infoDictionary)
+
+ #expect(info["UIUserInterfaceStyle"] as? String == "Dark")
+ }
+
+ /// iOS local network privacy identifies an app by its main executable's
+ /// LC_UUID (Apple TN3179). With ENABLE_DEBUG_DYLIB the Debug main executable
+ /// is a tiny stub that depends only on the product name, so this app and the
+ /// paid NativeAppTemplate app (same product name) had the same UUID: the Free
+ /// app was silently denied local network access and never prompted because
+ /// the paid app already held the entry. Debug builds must link the code into
+ /// the main executable, so no .debug.dylib may be in the bundle.
+ @Test
+ func mainExecutableIsNotADebugDylibStub() throws {
+ let executableName = try #require(Bundle.main.executableURL?.lastPathComponent)
+ let debugDylib = Bundle.main.bundleURL.appendingPathComponent("\(executableName).debug.dylib")
+
+ #expect(!FileManager.default.fileExists(atPath: debugDylib.path), "\(debugDylib.lastPathComponent) is in the app bundle")
+ }
+}
diff --git a/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift b/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift
index 919e26f..3ccc798 100644
--- a/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift
+++ b/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift
@@ -126,4 +126,85 @@ struct ItemTagRepositoryPaginationTest {
#expect(repository.state == .failed)
#expect(repository.itemTags.isEmpty)
}
+
+ // MARK: - Switching shops while a page is loading
+
+ /// Holds shop-A's requests until `releaseA` is signalled; shop-B's are answered
+ /// at once. Each shop's page lists its own tags (a1, a2 then a3 / b1).
+ private nonisolated static func shopServer(releaseA: DispatchSemaphore) async throws -> TestHTTPServer {
+ try await TestHTTPServer.start { request in
+ if request.path.contains("/shops/shop-A/") {
+ releaseA.wait()
+ let page = request.queryItems.first { $0.name == "page" }?.value ?? "1"
+ return TestHTTPServer.json(JSONAPIFixtures.itemTagPage(
+ ids: page == "1" ? ["a1", "a2"] : ["a3"],
+ currentPage: Int(page) ?? 1,
+ totalPages: 2,
+ shopId: "shop-A"
+ ))
+ }
+ return TestHTTPServer.json(JSONAPIFixtures.itemTagPage(
+ ids: ["b1"],
+ currentPage: 1,
+ totalPages: 1,
+ shopId: "shop-B"
+ ))
+ }
+ }
+
+ /// Opening shop B while shop A's first page is still loading: B's list must
+ /// load B's tags, and A's late page must not replace them
+ @Test
+ func reloadingAnotherShopWhileOneIsLoadingShowsThatShop() async throws {
+ let releaseA = DispatchSemaphore(value: 0)
+ let server = try await Self.shopServer(releaseA: releaseA)
+ defer { server.stop() }
+ let repository = makeRepository(server)
+
+ repository.reloadPage(shopId: "shop-A", page: 1)
+ await waitUntil { server.requests.count == 1 }
+ repository.reloadPage(shopId: "shop-B", page: 1)
+ await waitUntil(timeout: .seconds(10)) { repository.state == .hasData }
+
+ #expect(repository.itemTags.map(\.id) == ["b1"])
+
+ // A's answer arrives late. Nothing on screen shows it was handled, so give it time
+ releaseA.signal()
+ try await Task.sleep(for: .milliseconds(500))
+
+ #expect(repository.itemTags.map(\.id) == ["b1"])
+ #expect(repository.itemTags.allSatisfy { $0.shopId == "shop-B" })
+ #expect(repository.state == .hasData)
+ }
+
+ /// Shop A's next page arriving after the user opened shop B must not be
+ /// appended to B's list
+ @Test
+ func nextPageOfThePreviousShopIsNotAppendedToAnotherShop() async throws {
+ // Starts at 0 and ends at 0: libdispatch crashes when a semaphore is freed
+ // below its starting value
+ let releaseA = DispatchSemaphore(value: 0)
+ let server = try await Self.shopServer(releaseA: releaseA)
+ defer { server.stop() }
+ let repository = makeRepository(server)
+ // Let page 1 through; page 2 waits
+ releaseA.signal()
+ repository.reloadPage(shopId: "shop-A", page: 1)
+ await waitUntil { repository.state == .hasData }
+ repository.loadNextPage(shopId: "shop-A")
+ await waitUntil { server.requests.count == 2 }
+
+ repository.reloadPage(shopId: "shop-B", page: 1)
+ await waitUntil { repository.state == .hasData && repository.itemTags.contains { $0.id == "b1" } }
+ releaseA.signal()
+ // Nothing on screen shows A's late page was handled, so give it time
+ try await Task.sleep(for: .milliseconds(500))
+
+ #expect(repository.itemTags.map(\.id) == ["b1"])
+ // B's own meta (1 of 1), not A's page 2
+ #expect(repository.paginationMeta?.currentPage == 1)
+ #expect(repository.paginationMeta?.totalPages == 1)
+ // B's list can load its own next pages
+ #expect(!repository.isLoadingMore)
+ }
}
diff --git a/NativeAppTemplateTests/Networking/ServiceTest.swift b/NativeAppTemplateTests/Networking/ServiceTest.swift
index 67bdf4c..39adb5d 100644
--- a/NativeAppTemplateTests/Networking/ServiceTest.swift
+++ b/NativeAppTemplateTests/Networking/ServiceTest.swift
@@ -180,6 +180,63 @@ struct ServiceTest {
#expect(error.codedDescription ==
"[NATIVEAPPTEMPLATE-2001] NativeAppTemplateAPIError::RequestFailed[Status: 502 | Error: UNKNOWN]")
}
+
+ // MARK: - Expired session
+
+ /// devise_token_auth's authenticate_shopkeeper! answers a missing, wrong or expired
+ /// token with 401 and this body; the local API sends it for
+ /// `curl -H "access-token: bogus" .../api/v1/shopkeeper/permissions`
+ private nonisolated(unsafe) static let deviseUnauthenticatedBody: [String: Any] = [
+ "errors": ["You need to sign in or sign up before continuing."]
+ ]
+
+ @Test
+ func rejectedTokenIsAnnouncedAsAnExpiredSession() async throws {
+ let server = try await TestHTTPServer.start { _ in
+ TestHTTPServer.json(status: 401, Self.deviseUnauthenticatedBody)
+ }
+ defer { server.stop() }
+ // Unique, so another test's 401 can't be mistaken for this one's
+ let token = "expired-\(UUID().uuidString)"
+
+ var error: NativeAppTemplateAPIError?
+ let tokens = await expiredSessionTokens {
+ error = try? await requestFailed {
+ _ = try await ItemTagsService(networkClient: server.client(authToken: token)).itemTagDetail(id: "t1")
+ }
+ }
+
+ #expect(tokens.filter { $0 == token }.count == 1)
+ // The request still fails as before, for its caller to handle
+ guard case let .requestFailed(_, statusCode, _) = error else {
+ Issue.record("Expected requestFailed, got \(String(describing: error))")
+ return
+ }
+ #expect(statusCode == 401)
+ }
+
+ /// The API's own 401 (BaseController#user_not_authorized for a Pundit denial)
+ /// carries an error_message and comes with a valid session; it must not sign
+ /// the user out
+ @Test
+ func authorizationFailureWithAnErrorMessageIsNotAnExpiredSession() async throws {
+ let server = try await TestHTTPServer.start { _ in
+ TestHTTPServer.json(
+ status: 401,
+ ["code": 401, "error_message": "You are not authorized to perform this action."]
+ )
+ }
+ defer { server.stop() }
+ let token = "valid-\(UUID().uuidString)"
+
+ let tokens = await expiredSessionTokens {
+ _ = try? await requestFailed {
+ _ = try await ItemTagsService(networkClient: server.client(authToken: token)).itemTagDetail(id: "t1")
+ }
+ }
+
+ #expect(!tokens.contains(token))
+ }
}
/// Runs `body` and returns the NativeAppTemplateAPIError it throws
@@ -193,3 +250,26 @@ func requestFailed(_ body: () async throws -> Void) async throws -> NativeAppTem
Issue.record("Expected a NativeAppTemplateAPIError")
throw CancellationError()
}
+
+/// The tokens posted with `.apiSessionDidExpire` on NotificationCenter.default while `body` runs.
+/// Service posts on the main actor and the observer has no queue, so it runs synchronously.
+@MainActor
+func expiredSessionTokens(_ body: () async -> Void) async -> [String] {
+ final class Tokens: @unchecked Sendable {
+ var values: [String] = []
+ }
+ let tokens = Tokens()
+ let observer = NotificationCenter.default.addObserver(
+ forName: .apiSessionDidExpire,
+ object: nil,
+ queue: nil
+ ) { notification in
+ if let token = notification.userInfo?[Notification.authTokenKey] as? String {
+ tokens.values.append(token)
+ }
+ }
+ defer { NotificationCenter.default.removeObserver(observer) }
+
+ await body()
+ return tokens.values
+}
diff --git a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift
index 9196de1..e878c9a 100644
--- a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift
+++ b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift
@@ -58,8 +58,6 @@ struct SessionControllerPermissionsTest {
let sessionController = makeSessionController(server: server)
sessionController.connectionChanged(to: .online)
- // The first response reassigns shopkeeper before shopLimitCount is set, so its
- // didSet starts a second request; the state is .loaded once that one lands
await waitUntil { sessionController.didFetchPermissions && sessionController.hasPermissions }
let request = try #require(server.requests.first)
@@ -74,6 +72,83 @@ struct SessionControllerPermissionsTest {
#expect(sessionController.shopLimitCount == 3)
}
+ /// Going online asks for the permissions once. The response used to be applied
+ /// by reassigning shopkeeper, whose didSet still saw shopLimitCount 0 and sent
+ /// a second request (and synced the locale twice).
+ @Test
+ func permissionsAreFetchedOnce() async throws {
+ let server = try await TestHTTPServer.start { request in
+ request.path.hasSuffix("/shopkeeper/permissions")
+ ? TestHTTPServer.json(["data": [], "meta": Self.meta])
+ : TestHTTPServer.json([String: Any]())
+ }
+ defer { server.stop() }
+ let sessionController = makeSessionController(server: server)
+
+ sessionController.connectionChanged(to: .online)
+ await waitUntil { sessionController.hasPermissions }
+ // A second request would be sent as the first response is applied; give it time to arrive
+ try await Task.sleep(for: .milliseconds(500))
+
+ #expect(server.requests.filter { $0.path.hasSuffix("/shopkeeper/permissions") }.count == 1)
+ }
+
+ /// Signing out forgets the permissions of the shopkeeper who left. Otherwise the
+ /// next sign-in starts with them: its didSet saw shopLimitCount 3 and marked the
+ /// permissions loaded, so the previous shopkeeper's privacy prompt showed.
+ @Test
+ func signingOutForgetsThePermissions() async throws {
+ let server = try await TestHTTPServer.start { request in
+ request.path.hasSuffix("/shopkeeper/permissions")
+ ? TestHTTPServer.json(["data": [], "meta": Self.meta])
+ : TestHTTPServer.json([String: Any]())
+ }
+ defer { server.stop() }
+ let sessionController = makeSessionController(server: server)
+ sessionController.connectionChanged(to: .online)
+ await waitUntil { sessionController.hasPermissions }
+ // From meta above
+ try #require(sessionController.shouldUpdatePrivacy)
+
+ try await sessionController.logout()
+
+ // Nothing is known without a session: the properties' initial values
+ #expect(!sessionController.shouldUpdatePrivacy)
+ #expect(!sessionController.shouldUpdateTerms)
+ #expect(!sessionController.shouldUpdateApp)
+ #expect(sessionController.shopLimitCount == 0)
+ #expect(!sessionController.didFetchPermissions)
+ }
+
+ /// End to end: launching with an expired token. The permissions request gets
+ /// devise_token_auth's 401, and the app signs out instead of showing
+ /// MainView's permissions error screen.
+ @Test
+ func expiredTokenAtLaunchSignsOut() async throws {
+ let server = try await TestHTTPServer.start { _ in
+ TestHTTPServer.json(status: 401, ["errors": ["You need to sign in or sign up before continuing."]])
+ }
+ defer { server.stop() }
+ var shopkeeper = Self.shopkeeper
+ // Unique, so the notice can't sign out another test's SessionController
+ shopkeeper.token = "expired-\(UUID().uuidString)"
+ loginRepository.currentShopkeeper = shopkeeper
+ let sessionController = SessionController(
+ loginRepository: loginRepository,
+ monitorsConnection: false,
+ environment: server.client().environment
+ )
+
+ sessionController.connectionChanged(to: .online)
+ await waitUntil { !sessionController.isLoggedIn }
+ // The failed fetch's catch runs after the sign-out; give it time to land
+ try await Task.sleep(for: .milliseconds(200))
+
+ #expect(sessionController.didExpireSession)
+ #expect(loginRepository.currentShopkeeper == nil)
+ #expect(sessionController.permissionState == .notLoaded)
+ }
+
@Test
func failedFetchIsAPermissionError() async throws {
let server = try await TestHTTPServer.start { _ in
diff --git a/NativeAppTemplateTests/Sessions/SessionControllerTest.swift b/NativeAppTemplateTests/Sessions/SessionControllerTest.swift
index e1cfde5..b0181cd 100644
--- a/NativeAppTemplateTests/Sessions/SessionControllerTest.swift
+++ b/NativeAppTemplateTests/Sessions/SessionControllerTest.swift
@@ -78,4 +78,81 @@ struct SessionControllerTest {
#expect(URLCache.shared.cachedResponse(for: request) == nil)
}
+
+ // MARK: - Expired session
+
+ /// A signed-in SessionController listening on its own NotificationCenter, so
+ /// notices from other tests can't reach it
+ private func signedInSessionController(
+ token: String,
+ notificationCenter: NotificationCenter
+ ) throws -> (SessionController, TestLoginRepository) {
+ let loginRepository = TestLoginRepository()
+ loginRepository.currentShopkeeper = try #require(Shopkeeper(
+ id: "shopkeeper-1",
+ accountId: "account-1",
+ personalAccountId: "account-1",
+ accountOwnerId: "shopkeeper-1",
+ accountName: "Account",
+ email: "test@example.com",
+ name: "Test User",
+ timeZone: "Tokyo",
+ locale: "en",
+ uid: "test@example.com",
+ token: token,
+ client: "client-1",
+ expiry: "expiry-1"
+ ))
+ let sessionController = SessionController(
+ loginRepository: loginRepository,
+ monitorsConnection: false,
+ notificationCenter: notificationCenter
+ )
+ return (sessionController, loginRepository)
+ }
+
+ /// Every request fails once the token has expired, so the app signs out
+ /// locally and says why
+ @Test
+ func expiredTokenSignsOut() throws {
+ let notificationCenter = NotificationCenter()
+ let (sessionController, loginRepository) = try signedInSessionController(
+ token: "token-1",
+ notificationCenter: notificationCenter
+ )
+ #expect(sessionController.isLoggedIn)
+
+ notificationCenter.post(
+ name: .apiSessionDidExpire,
+ object: nil,
+ userInfo: [Notification.authTokenKey: "token-1"]
+ )
+
+ #expect(!sessionController.isLoggedIn)
+ #expect(sessionController.shopkeeper == nil)
+ // The saved session is gone too, so the next launch starts signed out
+ #expect(loginRepository.currentShopkeeper == nil)
+ #expect(sessionController.didExpireSession)
+ }
+
+ /// A 401 for a token this session no longer uses (a request sent before the
+ /// user signed in again) is not about the current session
+ @Test
+ func expiryOfAnotherTokenKeepsTheSession() throws {
+ let notificationCenter = NotificationCenter()
+ let (sessionController, loginRepository) = try signedInSessionController(
+ token: "token-2",
+ notificationCenter: notificationCenter
+ )
+
+ notificationCenter.post(
+ name: .apiSessionDidExpire,
+ object: nil,
+ userInfo: [Notification.authTokenKey: "old-token"]
+ )
+
+ #expect(sessionController.isLoggedIn)
+ #expect(loginRepository.currentShopkeeper != nil)
+ #expect(!sessionController.didExpireSession)
+ }
}
diff --git a/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift b/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift
index 11442ba..9749b15 100644
--- a/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift
+++ b/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift
@@ -34,10 +34,11 @@ enum JSONAPIFixtures {
currentPage: Int,
totalPages: Int,
totalCount: Int? = nil,
- limit: Int = 20
+ limit: Int = 20,
+ shopId: String = "shop-1"
) -> [String: Any] {
[
- "data": ids.map { itemTag(id: $0) },
+ "data": ids.map { itemTag(id: $0, shopId: shopId) },
"meta": [
"current_page": currentPage,
"total_pages": totalPages,
diff --git a/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift b/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift
index 456f1a0..66bcdc3 100644
--- a/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift
+++ b/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift
@@ -14,6 +14,7 @@ import Foundation
public var permissionState: PermissionState = .notLoaded
public var shouldPopToRootView: Bool = false
+ public var didExpireSession: Bool = false
public var shouldUpdateApp: Bool = false
public var shouldUpdatePrivacy: Bool = false
diff --git a/NativeAppTemplateTests/Testing/TestHTTPServer.swift b/NativeAppTemplateTests/Testing/TestHTTPServer.swift
index 3f7831e..c9ea8f4 100644
--- a/NativeAppTemplateTests/Testing/TestHTTPServer.swift
+++ b/NativeAppTemplateTests/Testing/TestHTTPServer.swift
@@ -83,12 +83,12 @@ final class TestHTTPServer: @unchecked Sendable {
}
/// An API client whose requests go to this server
- func client(accountId: String = "account-id") -> NativeAppTemplateAPI {
+ func client(accountId: String = "account-id", authToken: String = "token") -> NativeAppTemplateAPI {
NativeAppTemplateAPI(
// NativeAppTemplateEnvironment.swift is also compiled into the test target
environment: NativeAppTemplate
.NativeAppTemplateEnvironment(baseURL: URL(string: "http://127.0.0.1:\(port)")!),
- authToken: "token",
+ authToken: authToken,
client: "client",
expiry: "expiry",
uid: "uid",
diff --git a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift
index 1d601a3..e2f9d49 100644
--- a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift
+++ b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift
@@ -6,6 +6,7 @@
import Foundation
@testable import NativeAppTemplate
import Testing
+import UIKit
@MainActor
@Suite
@@ -13,6 +14,14 @@ struct SignUpViewModelTest {
let signUpRepository = TestSignUpRepository()
let messageBus = MessageBus()
+ /// Sign-up creates a password, so the field is .newPassword (UITextContentType docs:
+ /// "the new password ... when creating an account or changing a password"), which
+ /// lets iOS suggest a strong one and save it
+ @Test
+ func passwordFieldIsANewPassword() {
+ #expect(SignUpViewModel.passwordContentType == .newPassword)
+ }
+
@Test
func initialState() {
let viewModel = SignUpViewModel(
diff --git a/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift
index 01b7ea4..659f9f6 100644
--- a/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift
+++ b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift
@@ -6,6 +6,7 @@
import Foundation
@testable import NativeAppTemplate
import Testing
+import UIKit
@MainActor
@Suite
@@ -15,6 +16,15 @@ struct PasswordEditViewModelTest {
)
let messageBus = MessageBus()
+ /// The current password is an existing one (.password); the new password and its
+ /// confirmation are .newPassword (UITextContentType docs: "when creating an
+ /// account or changing a password")
+ @Test
+ func passwordFieldContentTypes() {
+ #expect(PasswordEditViewModel.currentPasswordContentType == .password)
+ #expect(PasswordEditViewModel.newPasswordContentType == .newPassword)
+ }
+
@Test
func initializesCorrectly() {
let viewModel = PasswordEditViewModel(
diff --git a/NativeAppTemplateTests/Utilities/UtilityTest.swift b/NativeAppTemplateTests/Utilities/UtilityTest.swift
index fac44fc..bd529f5 100644
--- a/NativeAppTemplateTests/Utilities/UtilityTest.swift
+++ b/NativeAppTemplateTests/Utilities/UtilityTest.swift
@@ -34,6 +34,33 @@ struct UtilityTest {
#expect(Utility.currentTimeZone(timeZone, date: date) == expected)
}
+ /// A city that is only part of a zone's name at another offset must not pick
+ /// that zone. The expected key is the first at the device's standard offset in
+ /// TimeZoneData.swift, as none at that offset contains the city:
+ /// - Australia/West is +08:00 ("West" is in International Date Line West, -12:00);
+ /// the first +08:00 zone is Beijing
+ /// - Pacific/Easter (Easter Island) is -06:00 ("Easter" is in Eastern Time, -05:00);
+ /// the first -06:00 zone is Central America
+ /// - America/Indiana/Petersburg is -05:00 ("Petersburg" is in St. Petersburg, +03:00);
+ /// the first -05:00 zone is Bogota
+ @Test(arguments: [
+ ("Australia/West", "Beijing"),
+ ("Pacific/Easter", "Central America"),
+ ("America/Indiana/Petersburg", "Bogota")
+ ])
+ func currentTimeZoneIgnoresACityInAZoneAtAnotherOffset(identifier: String, expected: String) throws {
+ let timeZone = try #require(TimeZone(identifier: identifier))
+ #expect(Utility.currentTimeZone(timeZone, date: Self.winter) == expected)
+ }
+
+ /// Part of a name still picks the zone when it is at the device's offset:
+ /// Atlantic/Cape_Verde (-01:00) is "Cape Verde Is." (-01:00) in the table
+ @Test
+ func currentTimeZoneMatchesPartOfANameAtTheSameOffset() throws {
+ let timeZone = try #require(TimeZone(identifier: "Atlantic/Cape_Verde"))
+ #expect(Utility.currentTimeZone(timeZone, date: Self.winter) == "Cape Verde Is.")
+ }
+
/// New York is not in the table, so the zone falls back to the offset.
/// That must be its standard offset, -05:00 (America/New_York's base UTC
/// offset), not the summer offset -04:00, which is Atlantic Time.