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
1 change: 1 addition & 0 deletions NativeAppTemplate.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions NativeAppTemplate/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions NativeAppTemplate/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
22 changes: 21 additions & 1 deletion NativeAppTemplate/Data/Repositories/ItemTagRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -76,6 +95,7 @@ import SwiftUI
.fetch(from: Self.self, reason: error.codedDescription)
.log()
}
guard generation == listGeneration else { return }

isLoadingMore = false
}
Expand Down
9 changes: 5 additions & 4 deletions NativeAppTemplate/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>Connects to a NativeAppTemplate API server running on your local network during development.</string>
<key>NativeAppTemplateAPIDomain</key>
<string>$(NATIVEAPPTEMPLATE_API_DOMAIN)</string>
<key>NativeAppTemplateAPIPort</key>
Expand All @@ -38,10 +40,7 @@
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>backgroundColor</string>
</dict>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
Expand All @@ -57,5 +56,7 @@
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>
18 changes: 18 additions & 0 deletions NativeAppTemplate/InfoPlist.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions NativeAppTemplate/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,16 @@
}
}
},
"Session Expired" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "セッションの有効期限切れ"
}
}
}
},
"Settings" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -1428,6 +1438,16 @@
}
}
}
},
"Your session has expired. Please sign in again." : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "セッションの有効期限が切れました。もう一度サインインしてください。"
}
}
}
}
},
"version" : "1.0"
Expand Down
37 changes: 26 additions & 11 deletions NativeAppTemplate/Networking/Services/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
}
63 changes: 59 additions & 4 deletions NativeAppTemplate/Sessions/SessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -94,6 +128,7 @@ import Observation

do {
shopkeeper = try await loginRepository.login(email: email, password: password)
didExpireSession = false
Event
.login(from: Self.self)
.log()
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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

Expand All @@ -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
}
}
Expand Down
3 changes: 3 additions & 0 deletions NativeAppTemplate/Sessions/SessionControllerProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading
Loading