Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f846ddc
Release 1.12.0: portal parity + native SwiftUI iOS (#16)
decoder-dev Aug 2, 2026
663f790
Record published v1.12.0 release commit and artifact checksums
decoder-dev Aug 2, 2026
37f8a29
docs: record v1.12.0 release artifacts (#17)
decoder-dev Aug 2, 2026
df77ca2
Bring Android portal parity to native SwiftUI iOS
decoder-dev Aug 2, 2026
d0f3361
Fix campus navigator URL and SF Symbol for iOS portal guides
decoder-dev Aug 2, 2026
e19e523
Bump in-app browser user agent to MAUverse 1.12.1
decoder-dev Aug 2, 2026
2374989
iOS 1.12.1: portal parity with Android (#18)
decoder-dev Aug 2, 2026
9c8e9e7
Record published v1.12.1 release commit and artifact checksums
decoder-dev Aug 2, 2026
e67864c
docs: record v1.12.1 release artifacts (#19)
decoder-dev Aug 2, 2026
d19a9cf
Update release records after author history rewrite
decoder-dev Aug 2, 2026
c4fb7e8
docs: update release SHAs after author rewrite (#20)
decoder-dev Aug 2, 2026
c727705
Fix news HTML entities and Murmansk phone extensions
decoder-dev Aug 2, 2026
75e8d58
ci: retrigger checks for release 1.12.2
decoder-dev Aug 2, 2026
aa04ea5
Merge pull request #21 from decoder-dev/cursor/fix-news-phone-010d
cursor[bot] Aug 2, 2026
cf18407
docs: add 1.12.2 release record (artifacts pending CI)
decoder-dev Aug 2, 2026
8e497b9
ci: trigger 1.12.2 release artifact build
decoder-dev Aug 2, 2026
b4876ba
fix Android CA1822 on department CallAsync for Release build
decoder-dev Aug 2, 2026
e1c315d
Merge pull request #22 from decoder-dev/cursor/release-1.12.2-010d
cursor[bot] Aug 2, 2026
9560fcb
docs: record published v1.12.2 release artifacts
decoder-dev Aug 2, 2026
540a013
Merge pull request #23 from decoder-dev/cursor/release-1.12.2-record-…
cursor[bot] Aug 2, 2026
0eccd0f
UX audit 1.12.3: dumb-user bug/UX fixes + Android signing setup
decoder-dev Aug 3, 2026
acfa977
Merge pull request #24 from decoder-dev/cursor/ux-audit-fixes-010d
cursor[bot] Aug 3, 2026
8562471
docs: record published v1.12.3 release artifacts
decoder-dev Aug 3, 2026
92638c7
Merge pull request #25 from decoder-dev/cursor/release-1.12.3-record-…
cursor[bot] Aug 3, 2026
5cef875
iOS 1.12.4: production Liquid Glass materials and motion
decoder-dev Aug 3, 2026
c652ae2
Merge pull request #26 from decoder-dev/cursor/ios-liquid-glass-010d
cursor[bot] Aug 3, 2026
bb270a4
docs: record published v1.12.4 release artifacts
decoder-dev Aug 3, 2026
5efcfc3
Merge pull request #27 from decoder-dev/cursor/release-1.12.4-record-…
cursor[bot] Aug 3, 2026
4fdc072
build(deps): bump fastapi from 0.139.0 to 0.141.1 in /mauverse-api
dependabot[bot] Aug 4, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
*.p12
*.pfx
*.mobileprovision
**/Directory.Build.signing.props

# Generated artifacts
.artifacts/
Expand Down
2 changes: 2 additions & 0 deletions MauverseIOS/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>MAUverseScheduleToken</key>
<string>$(MAUVERSE_SCHEDULE_TOKEN)</string>
<key>MAUVERSE_API_BASE_URL</key>
<string>$(MAUVERSE_API_BASE_URL)</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
Expand Down
8 changes: 7 additions & 1 deletion MauverseIOS/Sources/Core/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,16 @@ extension Notification.Name {

final class APIClient {
static let shared = APIClient()
private let baseURL = URL(string: "https://app.mauniver.ru/dev/mauverse/")!
private let baseURL: URL
private let session: URLSession

private init() {
if let configured = Bundle.main.object(forInfoDictionaryKey: "MAUVERSE_API_BASE_URL") as? String,
let url = URL(string: configured), !configured.isEmpty {
baseURL = url
} else {
baseURL = URL(string: "https://app.mauniver.ru/dev/mauverse/")!
}
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.waitsForConnectivity = true
Expand Down
75 changes: 75 additions & 0 deletions MauverseIOS/Sources/Core/HTMLTextCleaning.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Foundation

enum HTMLTextCleaning {
/// Decodes HTML entities (`&thinsp;`, `&#160;`, …) and strips tags for RSS titles/bodies.
static func cleanRSS(_ value: String) -> String {
let decoded = decodeEntities(value)
return decoded
.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
.replacingOccurrences(of: "[\\u00A0\\u2000-\\u200B\\u202F\\u205F\\u3000]+", with: " ", options: .regularExpression)
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}

static func decodeEntities(_ value: String) -> String {
guard !value.isEmpty else { return value }

var result = value
let named: [(String, String)] = [
("&nbsp;", " "),
("&thinsp;", " "),
("&ensp;", " "),
("&emsp;", " "),
("&mdash;", "—"),
("&ndash;", "–"),
("&laquo;", "«"),
("&raquo;", "»"),
("&quot;", "\""),
("&apos;", "'"),
("&#39;", "'"),
("&lt;", "<"),
("&gt;", ">"),
("&amp;", "&")
]
// Decode named entities first; &amp; last among pairs that can nest.
for (entity, replacement) in named where entity != "&amp;" {
result = result.replacingOccurrences(of: entity, with: replacement, options: .caseInsensitive)
}

// Numeric decimal &#160; and hex &#x2009;
result = replaceNumericEntities(result)
result = result.replacingOccurrences(of: "&amp;", with: "&", options: .caseInsensitive)
return result
}

private static func replaceNumericEntities(_ value: String) -> String {
var output = value
let patterns: [(String, (String) -> String?)] = [
(#"&#(\d+);"#, { Int($0).flatMap(unicodeScalarString) }),
(#"&#x([0-9a-fA-F]+);"#, { UInt32($0, radix: 16).flatMap(unicodeScalarString) })
]
for (pattern, decoder) in patterns {
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
let range = NSRange(output.startIndex..., in: output)
let matches = regex.matches(in: output, range: range).reversed()
for match in matches {
guard match.numberOfRanges >= 2,
let full = Range(match.range(at: 0), in: output),
let body = Range(match.range(at: 1), in: output),
let replacement = decoder(String(output[body])) else { continue }
output.replaceSubrange(full, with: replacement)
}
}
return output
}

private static func unicodeScalarString(_ value: Int) -> String? {
guard let scalar = UnicodeScalar(value) else { return nil }
return String(Character(scalar))
}

private static func unicodeScalarString(_ value: UInt32) -> String? {
guard let scalar = UnicodeScalar(value) else { return nil }
return String(Character(scalar))
}
}
15 changes: 13 additions & 2 deletions MauverseIOS/Sources/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ struct UserDTO: Codable, Equatable {
subGroupId = values.decodeLossyStringIfPresent(forKey: .subGroupId)
?? values.decodeLossyStringIfPresent(forKey: .legacySubGroupId)
scheduleGroupUID = try values.decodeIfPresent(String.self, forKey: .scheduleGroupUID)
?? values.decodeLossyStringIfPresent(forKey: .apiGroupId)
groupName = try values.decodeIfPresent(String.self, forKey: .groupName)
?? values.decodeLossyStringIfPresent(forKey: .legacyGroupName)
speciality = try values.decodeIfPresent(String.self, forKey: .speciality)
Expand Down Expand Up @@ -493,7 +492,17 @@ struct APIMessage: Codable {
}

enum NewsFilter: Int, CaseIterable, Identifiable {
case all = 0, departments, sport, students, science, international, events, other
case all = 0
case departments = 1
case sport = 2
case students = 3
case science = 4
case international = 5
case events = 6
case other = 7
case applicant = 8
case calendar = 9

var id: Int { rawValue }
var title: String {
return switch self {
Expand All @@ -505,6 +514,8 @@ enum NewsFilter: Int, CaseIterable, Identifiable {
case .international: "Международное"
case .events: "События"
case .other: "Другое"
case .applicant: "Абитуриент"
case .calendar: "Календарь"
}
}
}
Expand Down
22 changes: 5 additions & 17 deletions MauverseIOS/Sources/Core/OfficialNewsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ actor OfficialNewsService {
case .international: path = "press/inter/rss/"
case .events: path = "press/events/rss/"
case .other: path = "press/information/rss/"
case .applicant: path = "abit/news/rss/"
case .calendar: path = "press/calendar/rss/"
}

guard let url = URL(string: "https://mauniver.ru/\(path)") else {
Expand Down Expand Up @@ -56,7 +58,7 @@ actor OfficialNewsService {
private func fetch(url: URL, cachePolicy: URLRequest.CachePolicy) async throws -> [NewsItem] {
var request = URLRequest(url: url, cachePolicy: cachePolicy, timeoutInterval: 30)
request.timeoutInterval = 30
request.setValue("MAUverse/1.11.0 (iOS)", forHTTPHeaderField: "User-Agent")
request.setValue("MAUverse/1.12.4 (iOS)", forHTTPHeaderField: "User-Agent")
request.setValue("application/rss+xml, application/xml, text/xml", forHTTPHeaderField: "Accept")

let (data, response) = try await URLSession.shared.data(for: request)
Expand Down Expand Up @@ -150,10 +152,10 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate {
guard current != nil else { return }
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
switch elementName {
case "title": current?.title = value.cleanedRSS
case "title": current?.title = HTMLTextCleaning.cleanRSS(value)
case "link": current?.link = Self.absoluteURL(value)
case "description":
current?.description = value.cleanedRSS
current?.description = HTMLTextCleaning.cleanRSS(value)
if current?.image == nil {
current?.image = Self.firstImageURL(in: value)
}
Expand Down Expand Up @@ -203,20 +205,6 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate {
}
}

private extension String {
var cleanedRSS: String {
replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&mdash;", with: "—")
.replacingOccurrences(of: "&ndash;", with: "–")
.replacingOccurrences(of: "&laquo;", with: "«")
.replacingOccurrences(of: "&raquo;", with: "»")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

private extension Array {
func uniqued<Key: Hashable>(by key: (Element) -> Key) -> [Element] {
var seen = Set<Key>()
Expand Down
57 changes: 57 additions & 0 deletions MauverseIOS/Sources/Core/PhoneNumberFormatting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

enum PhoneNumberFormatting {
/// Murmansk city code used for local 6-digit university PBX numbers.
static let murmanskAreaCode = "8152"

/// Builds a `tel:` URL for Russian / Murmansk numbers.
/// Local numbers like `21-38-81 (3045)` become `tel:+78152213881;ext=3045`.
static func telURL(from raw: String) -> URL? {
let parsed = parse(raw)
guard let e164 = parsed.e164 else { return nil }
if let extensionCode = parsed.extensionCode, !extensionCode.isEmpty {
return URL(string: "tel:\(e164);ext=\(extensionCode)")
}
return URL(string: "tel:\(e164)")
}

static func parse(_ raw: String) -> (e164: String?, extensionCode: String?) {
var working = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !working.isEmpty else { return (nil, nil) }

var extensionCode: String?
if let match = working.range(of: #"\(\d{2,6}\)\s*$"#, options: .regularExpression) {
extensionCode = String(working[match]).filter(\.isNumber)
working.removeSubrange(match)
} else if let match = working.range(
of: #"(?i)(?:доб\.?|ext\.?|extension)\s*\d{2,6}\s*$"#,
options: .regularExpression
) {
extensionCode = String(working[match]).filter(\.isNumber)
working.removeSubrange(match)
}

let digits = working.filter(\.isNumber)
guard !digits.isEmpty else { return (nil, nil) }

let e164: String?
if digits.count == 6 {
// Local Murmansk landline: 21-38-81 → +7 8152 213881
e164 = "+7\(murmanskAreaCode)\(digits)"
} else if digits.count == 10, digits.hasPrefix(murmanskAreaCode) || digits.hasPrefix("800") {
e164 = "+7\(digits)"
} else if digits.count == 11, digits.hasPrefix("8") {
e164 = "+7\(digits.dropFirst())"
} else if digits.count == 11, digits.hasPrefix("7") {
e164 = "+\(digits)"
} else if working.contains("+"), digits.count >= 11 {
e164 = "+\(digits)"
} else if digits.count >= 10 {
e164 = digits.hasPrefix("7") ? "+\(digits)" : "+7\(digits)"
} else {
e164 = nil
}

return (e164, extensionCode)
}
}
20 changes: 17 additions & 3 deletions MauverseIOS/Sources/Core/SessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class SessionStore: ObservableObject {
UserDefaults.standard.removeObject(forKey: storageKey)
}
}
if var current = user {
Self.clearMistakenScheduleUID(&current)
user = current
}
sessionObserver = NotificationCenter.default.addObserver(
forName: .mauverseSessionExpired,
object: nil,
Expand Down Expand Up @@ -63,9 +67,7 @@ final class SessionStore: ObservableObject {
)
}
authenticated.username = authenticated.username ?? normalizedUsername
if authenticated.scheduleGroupUID?.isEmpty != false {
authenticated.scheduleGroupUID = authenticated.groupId
}
Self.clearMistakenScheduleUID(&authenticated)
user = authenticated
persist()
await refreshProfile()
Expand All @@ -83,6 +85,7 @@ final class SessionStore: ObservableObject {

func signOut() {
user = nil
errorMessage = nil
KeychainStore.delete(account: storageKey)
UserDefaults.standard.removeObject(forKey: storageKey)
}
Expand Down Expand Up @@ -118,4 +121,15 @@ final class SessionStore: ObservableObject {
guard let user, let data = try? JSONEncoder().encode(user) else { return }
KeychainStore.write(data, account: storageKey)
}

/// Moodle numeric group ids were previously copied into scheduleGroupUID and broke lookups.
private static func clearMistakenScheduleUID(_ user: inout UserDTO) {
guard let uid = user.scheduleGroupUID, !uid.isEmpty else {
user.scheduleGroupUID = nil
return
}
if let groupId = user.groupId, uid == groupId {
user.scheduleGroupUID = nil
}
}
}
Loading
Loading