~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1

Builds older than the server's minimum ask to be updated

Merged chadtmiller.com asked to merge into main from grain.social/grain-ios:update-gate
Grain/Services/AppUpdateGate.swiftnew+150−0
@@ -0,0 +1,150 @@
import Foundation
import os
private let gateLogger = Logger(subsystem: "social.grain.grain", category: "AppUpdateGate")
/// What `/app-version.json` says about iOS: the oldest build the server still
/// supports, and where to send someone running one older than that.
struct AppVersionPolicy: Equatable {
static let fallbackStoreURL = URL(string: "https://apps.apple.com/app/id6747730230")!
/// `nil` means the server sets no minimum.
let minimumBuild: Int?
let storeURL: URL
init(minimumBuild: Int?, storeURL: URL = Self.fallbackStoreURL) {
self.minimumBuild = minimumBuild
self.storeURL = storeURL
}
private struct Document: Decodable {
struct Platform: Decodable {
let minimumBuild: Int?
let storeUrl: String?
}
let ios: Platform?
}
/// Reads the document, or returns `nil` when it isn't one: not JSON, not an
/// object, or a field of the wrong type. A missing `ios` key or
/// `minimumBuild` is a valid document with no minimum.
static func parse(_ data: Data) -> AppVersionPolicy? {
guard let document = try? JSONDecoder().decode(Document.self, from: data) else {
return nil
}
let storeURL = document.ios?.storeUrl.flatMap { URL(string: $0) }.flatMap { url in
url.scheme == "https" || url.scheme == "itms-apps" ? url : nil
}
return AppVersionPolicy(
minimumBuild: document.ios?.minimumBuild,
storeURL: storeURL ?? fallbackStoreURL
)
}
/// Whether a build is too old to use. A build number that couldn't be read
/// never blocks: the gate exists to stop old builds, not to strand one it
/// can't place.
func blocks(build: Int?) -> Bool {
guard let build, let minimumBuild else { return false }
return build < minimumBuild
}
}
/// The last policy fetched, so a blocked build stays blocked when it launches
/// offline.
enum AppVersionPolicyCache {
private static let minimumBuildKey = "appVersion.ios.minimumBuild"
private static let storeURLKey = "appVersion.ios.storeUrl"
static func load(from defaults: UserDefaults = StorageEnvironment.defaults) -> AppVersionPolicy? {
guard let minimumBuild = defaults.object(forKey: minimumBuildKey) as? Int else { return nil }
let storeURL = defaults.string(forKey: storeURLKey).flatMap { URL(string: $0) }
return AppVersionPolicy(minimumBuild: minimumBuild, storeURL: storeURL ?? AppVersionPolicy.fallbackStoreURL)
}
static func store(_ policy: AppVersionPolicy, in defaults: UserDefaults = StorageEnvironment.defaults) {
if let minimumBuild = policy.minimumBuild {
defaults.set(minimumBuild, forKey: minimumBuildKey)
defaults.set(policy.storeURL.absoluteString, forKey: storeURLKey)
} else {
defaults.removeObject(forKey: minimumBuildKey)
defaults.removeObject(forKey: storeURLKey)
}
}
}
/// Decides whether this build may run, from the server's minimum when it can
/// be fetched and from the last one fetched when it can't.
///
/// Starts from the cached policy so the decision is made before any request,
/// and never waits on the network to let the app open.
@Observable
@MainActor
final class AppUpdateGate {
private(set) var isUpdateRequired: Bool
private(set) var storeURL: URL
private let currentBuild: Int?
private let endpoint: URL
private let session: URLSession
private var isChecking = false
nonisolated static let timeout: TimeInterval = 5
/// `CFBundleVersion`, which Xcode Cloud sets to its build counter.
nonisolated static var runningBuild: Int? {
(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String).flatMap { Int($0) }
}
init(
currentBuild: Int? = AppUpdateGate.runningBuild,
serverURL: URL = AuthManager.serverURL,
session: URLSession = NetworkEnvironment.session
) {
self.currentBuild = currentBuild
endpoint = serverURL.appendingPathComponent("app-version.json")
self.session = session
let cached = AppVersionPolicyCache.load()
isUpdateRequired = cached?.blocks(build: currentBuild) ?? false
storeURL = cached?.storeURL ?? AppVersionPolicy.fallbackStoreURL
}
/// Fetches the policy and applies it. A failed request or an unreadable
/// document leaves the current decision, which came from the cache, alone.
func check() async {
guard !isChecking else { return }
isChecking = true
defer { isChecking = false }
guard let policy = await fetch() else { return }
AppVersionPolicyCache.store(policy)
apply(policy)
}
func apply(_ policy: AppVersionPolicy) {
isUpdateRequired = policy.blocks(build: currentBuild)
storeURL = policy.storeURL
}
private func fetch() async -> AppVersionPolicy? {
var request = URLRequest(url: endpoint)
request.timeoutInterval = Self.timeout
request.cachePolicy = .reloadIgnoringLocalCacheData
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
gateLogger.info("[check] unexpected response")
return nil
}
guard let policy = AppVersionPolicy.parse(data) else {
gateLogger.info("[check] unreadable document")
return nil
}
return policy
} catch {
gateLogger.info("[check] failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}
}
Grain/Views/UpdateRequiredView.swiftnew+73−0
@@ -0,0 +1,73 @@
import SwiftUI
/// Shown in place of the whole app when this build is older than the server
/// supports. There is nothing to dismiss: the only way on is the App Store.
struct UpdateRequiredView: View {
let storeURL: URL
@Environment(\.openURL) private var openURL
var body: some View {
GeometryReader { geo in
let fullHeight = geo.size.height + geo.safeAreaInsets.top + geo.safeAreaInsets.bottom
ZStack {
Image("login-bg")
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: fullHeight)
.clipped()
.ignoresSafeArea()
.allowsHitTesting(false)
Color.black.opacity(0.4)
.ignoresSafeArea()
.allowsHitTesting(false)
VStack(spacing: 0) {
Spacer()
Text("grain")
.font(.custom("Syne", size: 44).weight(.heavy))
.foregroundStyle(.white)
.padding(.bottom, 60)
.accessibilityHidden(true)
Text("Update Grain")
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.padding(.bottom, 8)
.accessibilityAddTraits(.isHeader)
Text("This version of Grain is no longer supported. Update to keep using it.")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.bottom, 24)
Button {
openURL(storeURL)
} label: {
Text("Update")
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.foregroundStyle(.black)
Spacer()
}
.padding(24)
.frame(maxWidth: 440)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.ignoresSafeArea(.keyboard)
.interactiveDismissDisabled()
}
}
#Preview {
UpdateRequiredView(storeURL: AppVersionPolicy.fallbackStoreURL)
}
Grain/GrainApp.swift+15−1
@@ -46,6 +46,8 @@ struct GrainApp: App {
@State private var labelDefsCache = LabelDefinitionsCache()
@State private var uploadCenter = GalleryUploadCenter()
@State private var pendingDeepLink: DeepLink?
@State private var updateGate = AppUpdateGate()
@Environment(\.scenePhase) private var scenePhase
@AppStorage("appearance") private var appearance: String = "auto"
private var colorScheme: ColorScheme? {
@@ -62,7 +64,11 @@ struct GrainApp: App {
let isAuthed = authManager.isAuthenticated
let _ = appSignposter.emitEvent("AuthGateResolved")
Group {
if isAuthed {
if updateGate.isUpdateRequired {
// Replaces the tree rather than covering it, so no sheet or
// cover the app had open can sit above it.
UpdateRequiredView(storeURL: updateGate.storeURL)
} else if isAuthed {
// Keyed on the account: switching rebuilds the whole tree so
// no view model, scroll position, or half-loaded feed
// carries over from the account being left.
@@ -119,6 +125,14 @@ struct GrainApp: App {
.task(priority: .background) {
GrainShortcuts.updateAppShortcutParameters()
}
.task {
await updateGate.check()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
Task { await updateGate.check() }
}
}
.preferredColorScheme(colorScheme)
}
.environment(authManager)
GrainTests/AppUpdateGateTests.swiftnew+188−0
@@ -0,0 +1,188 @@
@testable import Grain
import XCTest
/// The force-update gate: whether a build is blocked, from what the server says
/// and, when the server can't be reached or makes no sense, from what it last
/// said. Getting it wrong either strands a supported build or lets an
/// unsupported one through to errors.
@MainActor
final class AppUpdateGateTests: GrainTestCase {
private let server = URL(string: "https://grain.test")!
override func tearDown() async throws {
MockURLProtocol.handler = nil
try await super.tearDown()
}
private func document(minimumBuild: Int, storeUrl: String = "https://apps.apple.com/app/id1") -> String {
"""
{
"ios": { "minimumBuild": \(minimumBuild), "storeUrl": "\(storeUrl)" },
"android": { "minimumVersionCode": 0, "storeUrl": "https://play.google.com/store/apps/details?id=social.grain" }
}
"""
}
private func makeGate(build: Int?) -> AppUpdateGate {
AppUpdateGate(currentBuild: build, serverURL: server, session: MockURLProtocol.mockSession())
}
private func failRequests() {
MockURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) }
}
// MARK: - Build against minimum
func testABuildBelowTheMinimumIsBlocked() {
XCTAssertTrue(AppVersionPolicy(minimumBuild: 60).blocks(build: 59))
}
func testABuildAtOrAboveTheMinimumIsNot() {
XCTAssertFalse(AppVersionPolicy(minimumBuild: 59).blocks(build: 59))
XCTAssertFalse(AppVersionPolicy(minimumBuild: 59).blocks(build: 60))
}
func testAnUnreadableBuildNumberIsNeverBlocked() {
XCTAssertFalse(AppVersionPolicy(minimumBuild: 60).blocks(build: nil))
}
// MARK: - Parsing
func testParsesTheIOSMinimumAndStoreURL() throws {
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(document(minimumBuild: 61).utf8)))
XCTAssertEqual(policy.minimumBuild, 61)
XCTAssertEqual(policy.storeURL, URL(string: "https://apps.apple.com/app/id1"))
}
func testAMissingIOSKeyMeansNoMinimum() throws {
let json = #"{ "android": { "minimumVersionCode": 99 } }"#
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(json.utf8)))
XCTAssertNil(policy.minimumBuild)
XCTAssertFalse(policy.blocks(build: 1))
}
func testAMissingMinimumBuildMeansNoMinimum() throws {
let json = #"{ "ios": { "storeUrl": "https://apps.apple.com/app/id1" } }"#
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(json.utf8)))
XCTAssertNil(policy.minimumBuild)
XCTAssertFalse(policy.blocks(build: 1))
}
func testAMissingStoreURLFallsBackToTheAppStoreListing() throws {
let json = #"{ "ios": { "minimumBuild": 60 } }"#
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(json.utf8)))
XCTAssertEqual(policy.storeURL, URL(string: "https://apps.apple.com/app/id6747730230"))
}
func testMalformedDocumentsAreRejected() {
for body in ["", "not json", "[]", #"{ "ios": "60" }"#, #"{ "ios": { "minimumBuild": "60" } }"#] {
XCTAssertNil(AppVersionPolicy.parse(Data(body.utf8)), "Should reject \(body.debugDescription)")
}
}
// MARK: - Fetching and the cache
func testNothingCachedAndNoFetchMeansNotBlocked() {
XCTAssertFalse(makeGate(build: 59).isUpdateRequired)
}
func testAFetchedMinimumAboveTheBuildBlocksIt() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60, storeUrl: "https://apps.apple.com/app/id2"))
let gate = makeGate(build: 59)
await gate.check()
XCTAssertTrue(gate.isUpdateRequired)
XCTAssertEqual(gate.storeURL, URL(string: "https://apps.apple.com/app/id2"))
}
func testAFailedFetchWithNothingCachedDoesNotBlock() async {
failRequests()
let gate = makeGate(build: 59)
await gate.check()
XCTAssertFalse(gate.isUpdateRequired)
}
func testAMalformedResponseWithNothingCachedDoesNotBlock() async {
MockURLProtocol.respondWithJSON("<html>oops</html>")
let gate = makeGate(build: 59)
await gate.check()
XCTAssertFalse(gate.isUpdateRequired)
}
func testAnErrorStatusWithNothingCachedDoesNotBlock() async {
MockURLProtocol.respondWithError(statusCode: 503)
let gate = makeGate(build: 59)
await gate.check()
XCTAssertFalse(gate.isUpdateRequired)
}
/// The case the cache exists for: a blocked build opened offline.
func testTheCachedMinimumBlocksAtLaunchBeforeAnyFetch() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60, storeUrl: "https://apps.apple.com/app/id3"))
await makeGate(build: 59).check()
let relaunched = makeGate(build: 59)
XCTAssertTrue(relaunched.isUpdateRequired)
XCTAssertEqual(relaunched.storeURL, URL(string: "https://apps.apple.com/app/id3"))
}
func testTheCachedMinimumStillBlocksWhenTheFetchFails() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60))
await makeGate(build: 59).check()
failRequests()
let relaunched = makeGate(build: 59)
await relaunched.check()
XCTAssertTrue(relaunched.isUpdateRequired)
}
func testTheCachedMinimumStillBlocksWhenTheResponseIsMalformed() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60))
await makeGate(build: 59).check()
MockURLProtocol.respondWithJSON("{")
let relaunched = makeGate(build: 59)
await relaunched.check()
XCTAssertTrue(relaunched.isUpdateRequired)
}
/// Lowering or removing the minimum has to reach a build that was blocked,
/// or it stays locked out on the strength of an old answer.
func testAFetchedLowerMinimumUnblocksAndReplacesTheCache() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60))
await makeGate(build: 59).check()
MockURLProtocol.respondWithJSON(#"{ "ios": {} }"#)
let relaunched = makeGate(build: 59)
XCTAssertTrue(relaunched.isUpdateRequired)
await relaunched.check()
XCTAssertFalse(relaunched.isUpdateRequired)
XCTAssertFalse(makeGate(build: 59).isUpdateRequired, "The cleared minimum should be what is remembered")
}
func testRequestsAppVersionJSONOnTheConfiguredServerWithAShortTimeout() async throws {
var seen: URLRequest?
MockURLProtocol.handler = { request in
seen = request
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(#"{}"#.utf8), response)
}
await makeGate(build: 59).check()
let request = try XCTUnwrap(seen)
XCTAssertEqual(request.url, URL(string: "https://grain.test/app-version.json"))
XCTAssertLessThanOrEqual(request.timeoutInterval, AppUpdateGate.timeout)
}
}