Files
Vorrania/ios/Sources/Session.swift
Scarriffle 96172c6fa0 iOS: Ablauf-Benachrichtigungen, je Server einstellbar
Die App zeigte "Bald ablaufend" nur beim Oeffnen. Jetzt erinnert sie aktiv vor
dem Ablauf - mit frei zusammenstellbaren Regeln, pro Server getrennt.

Eine Regel ist "X Tage/Wochen vor Ablauf", optional nur fuer eine Kategorie
(inklusive deren Unterkategorien), einmalig oder taeglich wiederkehrend. Eine
Kategorie-Regel ersetzt fuer ihre Produkte die allgemeine - so kann Molkerei
frueher warnen als Chips. Dazu am Ablauftag eine letzte, deutliche Warnung fuer
alle Produkte (eigener Schalter). Die Meldungen kommen als Tages-Sammelmeldung,
je Server eine, zur pro Server eingestellten Uhrzeit.

Weil Vorrania im Heimnetz laeuft und von unterwegs oft nicht erreichbar ist,
kann die App nicht zuverlaessig im Hintergrund abfragen - und iOS verlangt
ohnehin, dass lokale Benachrichtigungen im Voraus geplant werden. Also: aus den
Ablaufdaten kuenftige Termine berechnen und einplanen; aufgefrischt beim
Oeffnen, im Vordergrund und best-effort per Hintergrund-Aktualisierung. Geplant
wird fuer alle eingerichteten Server, jeder mit seinem Token aus dem Keychain;
unerreichbare werden uebersprungen. iOS erlaubt 64 ausstehende Meldungen je App,
deshalb die naechstliegenden zuerst und bei 60 gekappt.

Einstellungen sitzen hinter der Glocke je Server in der Server-Verwaltung (nicht
admin-beschraenkt, es ist eine Geraete-Einstellung). Getippt wechselt die
Meldung auf den betroffenen Server und oeffnet "Bald ablaufend". Keine
Backend-Aenderung: die Kategorie kommt aus /products, der Ablauf aus /expiring.

Die Rechenlogik (ExpiryPlanner) ist bewusst netz- und iOS-frei und mit einem
eigenstaendigen Swift-Harnisch geprueft: Kategorie-Vorrang, Unterkategorien,
Rueckfall auf die allgemeine Regel, einmalig vs. wiederkehrend und die letzte
Warnung. Modelle gegen echte /expiring- und /products-Antworten abgeglichen,
App startet ohne Absturz (auch die BGTask-Registrierung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:15:09 +02:00

246 lines
9.4 KiB
Swift

import Foundation
import Security
/// Haelt die bekannten Server und die Anmeldung am gerade aktiven.
///
/// Jedes Profil hat ein eigenes Token im Keychain. Dadurch bleibt man an
/// mehreren Servern gleichzeitig angemeldet und der Wechsel ist ein Tipp.
/// Die Adressen liegen in den UserDefaults (nicht geheim), die Token im
/// Keychain.
final class Session: ObservableObject {
static let shared = Session()
private let stayKey = "stay_logged_in"
// Schluessel aus der Zeit mit genau einem Server. Werden beim ersten Start
// nach dem Update in ein Profil ueberfuehrt und danach entfernt.
private let legacyURLKey = "server_url"
private let legacyUsernameKey = "username"
private let legacyAccount = "vorrania-token"
@Published private(set) var profiles: [ServerProfile] = []
@Published private(set) var activeProfileID: UUID?
@Published private(set) var token: String?
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
@Published private(set) var stayLoggedIn: Bool = true
var activeProfile: ServerProfile? {
profiles.first { $0.id == activeProfileID }
}
var baseURL: URL? { activeProfile?.url }
var username: String { activeProfile?.username ?? "" }
var isAdmin: Bool { activeProfile?.isAdmin ?? false }
var isLoggedIn: Bool { token != nil && baseURL != nil }
private init() {
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
profiles = ProfileStore.load()
migrateSingleServerIfNeeded()
activeProfileID = ProfileStore.loadActiveID() ?? profiles.first?.id
loadTokenForActiveProfile()
}
/// Uebernimmt eine Anmeldung aus der Zeit vor den Profilen. Ohne das waere
/// man nach dem Update abgemeldet und muesste die Serveradresse neu tippen.
private func migrateSingleServerIfNeeded() {
guard profiles.isEmpty,
let stored = UserDefaults.standard.string(forKey: legacyURLKey),
!stored.isEmpty
else { return }
let name = ServerProfile.suggestedName(for: stored)
let profile = ServerProfile(
name: name,
urlString: stored,
username: UserDefaults.standard.string(forKey: legacyUsernameKey) ?? ""
)
profiles = [profile]
ProfileStore.save(profiles)
ProfileStore.saveActiveID(profile.id)
// Token auf das profilbezogene Konto umziehen.
if let old = Keychain.read(account: legacyAccount) {
Keychain.write(old, account: Session.keychainAccount(for: profile.id))
Keychain.delete(account: legacyAccount)
}
UserDefaults.standard.removeObject(forKey: legacyURLKey)
UserDefaults.standard.removeObject(forKey: legacyUsernameKey)
}
static func keychainAccount(for id: UUID) -> String {
"vorrania-token-\(id.uuidString)"
}
private func loadTokenForActiveProfile() {
guard let id = activeProfileID else { token = nil; return }
token = Keychain.read(account: Session.keychainAccount(for: id))
}
/// Sorgt für eine URL mit Schema und abschließendem "/", damit relative
/// Pfade ("api/...") korrekt aufgelöst werden.
static func normalize(_ raw: String) -> URL? {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if !text.contains("://") { text = "http://" + text }
if !text.hasSuffix("/") { text += "/" }
return URL(string: text)
}
// MARK: - Profile
/// Legt ein Profil an und macht es zum aktiven. Ohne Namen wird der
/// Rechnername genommen.
@discardableResult
func addProfile(name: String = "", urlString: String) -> ServerProfile? {
guard Session.normalize(urlString) != nil else { return nil }
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
let profile = ServerProfile(
name: title.isEmpty ? ServerProfile.suggestedName(for: urlString) : title,
urlString: urlString
)
profiles.append(profile)
ProfileStore.save(profiles)
switchTo(profile.id)
return profile
}
func updateProfile(id: UUID, name: String? = nil, urlString: String? = nil) {
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
if let name {
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
if !title.isEmpty { profiles[index].name = title }
}
if let urlString, Session.normalize(urlString) != nil {
profiles[index].urlString = urlString
}
ProfileStore.save(profiles)
}
/// Entfernt das Profil samt Token. Das Geheimnis darf nicht zurueckbleiben,
/// wenn der Server aus der Liste verschwindet.
func removeProfile(id: UUID) {
Keychain.delete(account: Session.keychainAccount(for: id))
NotificationStore.remove(for: id)
profiles.removeAll { $0.id == id }
ProfileStore.save(profiles)
if activeProfileID == id {
switchTo(profiles.first?.id)
}
}
/// Wechselt den Server. Ist fuer das Ziel ein Token hinterlegt, ist man
/// sofort angemeldet, sonst erscheint die Anmeldung fuer dieses Profil.
func switchTo(_ id: UUID?) {
activeProfileID = id
ProfileStore.saveActiveID(id)
loadTokenForActiveProfile()
}
// MARK: - Anmeldung
/// Legt die Adresse fuer die Anmeldung fest: aktualisiert das aktive Profil
/// oder legt das erste an, wenn die App noch keinen Server kennt.
func setServer(_ raw: String) {
guard Session.normalize(raw) != nil else { return }
if let id = activeProfileID, profiles.contains(where: { $0.id == id }) {
updateProfile(id: id, urlString: raw)
} else {
addProfile(urlString: raw)
}
}
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
/// Auf dem Geraet bleibt dann nichts zurueck.
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
token = newToken
stayLoggedIn = persist
UserDefaults.standard.set(persist, forKey: stayKey)
guard let id = activeProfileID,
let index = profiles.firstIndex(where: { $0.id == id }) else { return }
profiles[index].isAdmin = admin
profiles[index].username = persist ? name : ""
ProfileStore.save(profiles)
if persist {
Keychain.write(newToken, account: Session.keychainAccount(for: id))
} else {
Keychain.delete(account: Session.keychainAccount(for: id))
}
}
/// Meldet nur vom aktiven Server ab. Andere Profile behalten ihr Token.
func logout() {
token = nil
guard let id = activeProfileID else { return }
Keychain.delete(account: Session.keychainAccount(for: id))
if let index = profiles.firstIndex(where: { $0.id == id }) {
profiles[index].username = ""
profiles[index].isAdmin = false
ProfileStore.save(profiles)
}
}
/// Prueft nach einem Wechsel, ob das hinterlegte Token noch gilt, und holt
/// die aktuelle Rolle. Ein abgelaufenes Token fuehrt zur Anmeldung.
@MainActor
func refreshMe() async {
guard isLoggedIn, let id = activeProfileID else { return }
do {
let me = try await APIClient.shared.me()
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
profiles[index].username = me.username
profiles[index].isAdmin = me.role == "admin"
ProfileStore.save(profiles)
} catch APIError.unauthorized {
logout()
} catch {
// Server gerade nicht erreichbar: angemeldet bleiben, damit ein
// kurzer Netzausfall einen nicht aus der App wirft.
}
}
}
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
enum Keychain {
private static let service = "com.scarriffle.vorrania"
static func write(_ value: String, account: String) {
delete(account: account)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: Data(value.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
SecItemAdd(query as CFDictionary, nil)
}
static func read(account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func delete(account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}