Skip to content
Open
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
178 changes: 142 additions & 36 deletions LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions LoopFollow/Alarm/AlarmListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ struct AlarmListView: View {
AddAlarmSheet { type in
let new = Alarm(type: type)
store.value.append(new)
// First alarm the user adds is the moment notifications become
// useful — request authorization here rather than at app launch.
NotificationAuthorization.requestIfNeeded()
sheetInfo = .editor(id: new.id, isNew: true)
}

Expand Down
29 changes: 12 additions & 17 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// AppDelegate.swift

import AVFoundation
import EventKit
import UIKit
import UserNotifications

Expand All @@ -13,29 +12,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
LogManager.shared.log(category: .general, message: "App started")
LogManager.shared.cleanupOldLogs()

let options: UNAuthorizationOptions = [.alert, .sound, .badge]
notificationCenter.requestAuthorization(options: options) {
didAllow, _ in
if !didAllow {
LogManager.shared.log(category: .general, message: "User has declined notifications")
}
}

let store = EKEventStore()
store.requestCalendarAccess { granted, error in
if !granted {
LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))")
return
}
}
// Notification and calendar permissions are no longer requested here.
// They're deferred to the moment the user opts into the feature that
// needs them (alarms request notifications via NotificationAuthorization;
// the Calendar settings screen requests calendar access), so a fresh
// install isn't fronted with permission prompts before onboarding.

let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])

UNUserNotificationCenter.current().delegate = self

_ = BLEManager.shared
// Only spin up Bluetooth if the user has chosen a BLE-based background
// refresh. Initializing BLEManager creates a CBCentralManager, which
// triggers the Bluetooth permission prompt — deferring it keeps that
// prompt off fresh installs until the feature is actually enabled.
if Storage.shared.backgroundRefreshType.value.isBluetooth {
_ = BLEManager.shared
}
// Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
_ = VolumeButtonHandler.shared

Expand Down
30 changes: 24 additions & 6 deletions LoopFollow/Application/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct MainTabView: View {
@ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition

@State private var showTelemetryConsent = false
@State private var showOnboarding = false

private var orderedItems: [TabItem] {
Storage.shared.orderedTabBarItems()
Expand Down Expand Up @@ -47,21 +48,38 @@ struct MainTabView: View {
// onAppear (not app launch) keeps it off the BG-only refresh path.
MainViewController.bootstrap()

// One-time consent prompt. Previously presented by SceneDelegate,
// which was removed in the storyboard→SwiftUI migration; without
// this, fresh installs stay permanently undecided and telemetry
// never sends. The storage flag keeps it to a single appearance.
if !Storage.shared.telemetryConsentDecisionMade.value {
showTelemetryConsent = true
// Show the first-run onboarding once for everyone. Returning users
// get a prominent Skip on the welcome screen. The telemetry consent
// prompt is deferred until onboarding is dismissed so the two never
// appear on top of one another.
if !Storage.shared.hasCompletedOnboarding.value {
showOnboarding = true
} else {
presentTelemetryConsentIfNeeded()
}
}
.fullScreenCover(isPresented: $showOnboarding, onDismiss: {
presentTelemetryConsentIfNeeded()
}) {
OnboardingContainerView(onClose: { showOnboarding = false })
}
.sheet(isPresented: $showTelemetryConsent) {
// User must explicitly choose — no swipe-to-dismiss.
TelemetryConsentView()
.interactiveDismissDisabled(true)
}
}

// One-time telemetry consent prompt. Previously presented by SceneDelegate,
// which was removed in the storyboard→SwiftUI migration; without this, fresh
// installs stay permanently undecided and telemetry never sends. The storage
// flag keeps it to a single appearance.
private func presentTelemetryConsentIfNeeded() {
if !Storage.shared.telemetryConsentDecisionMade.value {
showTelemetryConsent = true
}
}

@ViewBuilder
private func tabContent(for item: TabItem) -> some View {
switch item {
Expand Down
7 changes: 7 additions & 0 deletions LoopFollow/BackgroundRefresh/BT/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ import Foundation
class BLEManager: NSObject, ObservableObject {
static let shared = BLEManager()

/// Whether the shared instance has been created (and therefore a
/// CBCentralManager exists / the Bluetooth prompt has been triggered).
/// Reading this does not instantiate `shared`, so callers can avoid forcing
/// Bluetooth initialization — and its permission prompt — when not needed.
private(set) static var isInitialized = false

@Published private(set) var devices: [BLEDevice] = []

private var centralManager: CBCentralManager!
private var activeDevice: BluetoothDevice?

override private init() {
super.init()
BLEManager.isInitialized = true

centralManager = CBCentralManager(
delegate: self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ class BackgroundRefreshSettingsViewModel: ObservableObject {
private func handleBackgroundRefreshTypeChange(_ newValue: BackgroundRefreshType) {
LogManager.shared.log(category: .general, message: "Background refresh type changed to: \(newValue.rawValue)")

BLEManager.shared.disconnect()
// Touch BLEManager only when switching to a Bluetooth mode (the user is
// opting in, so the permission prompt belongs here) or when it's already
// running and needs to be torn down. Switching between non-BLE modes must
// not initialize Bluetooth — that would prompt without cause.
if newValue.isBluetooth || BLEManager.isInitialized {
BLEManager.shared.disconnect()
}
}
}
5 changes: 4 additions & 1 deletion LoopFollow/Controllers/BackgroundAlertManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ class BackgroundAlertManager {
removeDeliveredNotifications()

let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth
let expectedHeartbeat = BLEManager.shared.expectedHeartbeatInterval()
// Only query BLEManager for a Bluetooth mode — touching it otherwise would
// initialize CoreBluetooth and trigger the permission prompt for users
// (e.g. Silent Tune) who never opted into Bluetooth.
let expectedHeartbeat = isBluetoothActive ? BLEManager.shared.expectedHeartbeatInterval() : nil

// Define alerts
let alerts: [BackgroundAlert] = [
Expand Down
120 changes: 120 additions & 0 deletions LoopFollow/Helpers/NightscoutUtils.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// LoopFollow
// NightscoutUtils.swift

import CryptoKit
import Foundation

class NightscoutUtils {
Expand Down Expand Up @@ -385,6 +386,125 @@ class NightscoutUtils {
return responseString
}

// MARK: - Token Provisioning

/// Name of the Nightscout authorization subject LoopFollow creates when a
/// user provisions a token from their API secret.
static let provisionedSubjectName = "LoopFollow"

private struct AuthSubject: Decodable {
let id: String?
let name: String?
let accessToken: String?
let roles: [String]?

enum CodingKeys: String, CodingKey {
case id = "_id"
case name, accessToken, roles
}
}

/// Creates (or reuses) a read-only Nightscout access token using the site's
/// API secret. The secret only authorizes these requests and is never
/// persisted. Returns the access token for a `readable` subject named
/// `provisionedSubjectName`.
///
/// The full API secret authenticates as Nightscout's `admin` role (the `*`
/// permission), which includes `admin:api:subjects:create`.
///
/// Nightscout serves the subjects list from an in-memory cache that doesn't
/// refresh promptly after a write, so a freshly-created subject (and its
/// token) can't be read back reliably right after creating it. Instead we
/// derive the token locally: it's a pure function of the subject's `_id`
/// (returned by the create call) and the API secret. See `accessToken(for:)`.
static func provisionReadOnlyToken(url: String, secret: String) async throws -> String {
let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedURL.isEmpty else { throw NightscoutError.emptyAddress }
guard let baseURL = URL(string: trimmedURL),
trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://")
else { throw NightscoutError.invalidURL }

let secretHash = sha1Hex(secret)

// Reuse an existing subject if one is already visible (idempotent re-runs
// once the site's cache has caught up).
if let existing = try await fetchProvisionedToken(baseURL: baseURL, secretHash: secretHash) {
return existing
}

let id = try await createReadOnlySubject(baseURL: baseURL, secretHash: secretHash)
return accessToken(forName: provisionedSubjectName, id: id, secretHash: secretHash)
}

private static func fetchProvisionedToken(baseURL: URL, secretHash: String) async throws -> String? {
let url = baseURL.appendingPathComponent("api/v2/authorization/subjects")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(secretHash, forHTTPHeaderField: "api-secret")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.cachePolicy = .reloadIgnoringLocalCacheData

let (data, response) = try await URLSession.shared.data(for: request)
try validateProvisioningResponse(response)

let subjects = try JSONDecoder().decode([AuthSubject].self, from: data)
return subjects.first(where: { $0.name == provisionedSubjectName })?.accessToken
}

/// Creates the subject and returns its `_id`.
private static func createReadOnlySubject(baseURL: URL, secretHash: String) async throws -> String {
let url = baseURL.appendingPathComponent("api/v2/authorization/subjects")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(secretHash, forHTTPHeaderField: "api-secret")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: [
"name": provisionedSubjectName,
"roles": ["readable"],
])

let (data, response) = try await URLSession.shared.data(for: request)
try validateProvisioningResponse(response)

let subject = try JSONDecoder().decode(AuthSubject.self, from: data)
guard let id = subject.id, !id.isEmpty else { throw NightscoutError.unknown }
return id
}

/// Reproduces Nightscout's subject-token derivation (`lib/authorization`):
/// abbrev = name lowercased, non-`\w` characters removed, first 10 chars
/// digest = sha1( sha1Hex(apiSecret) + subjectId )
/// token = "\(abbrev)-\(digest[0..<16])"
private static func accessToken(forName name: String, id: String, secretHash: String) -> String {
let allowed = Set("abcdefghijklmnopqrstuvwxyz0123456789_")
let abbrev = String(name.lowercased().filter { allowed.contains($0) }.prefix(10))
let digest = sha1Hex(secretHash + id)
return abbrev + "-" + String(digest.prefix(16))
}

private static func validateProvisioningResponse(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw NightscoutError.networkError
}
switch http.statusCode {
case 200 ..< 300:
return
case 401, 403:
// The API secret was missing or wrong.
throw NightscoutError.invalidToken
case 404:
throw NightscoutError.siteNotFound
default:
throw NightscoutError.unknown
}
}

private static func sha1Hex(_ string: String) -> String {
Insecure.SHA1.hash(data: Data(string.utf8))
.map { String(format: "%02x", $0) }
.joined()
}

static func extractErrorReason(from responseString: String) -> String {
// 1) Try to parse the entire string as JSON and return the "message"
if let data = responseString.data(using: .utf8) {
Expand Down
23 changes: 23 additions & 0 deletions LoopFollow/Helpers/NotificationAuthorization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// LoopFollow
// NotificationAuthorization.swift

import UserNotifications

/// Requests notification authorization lazily, the first time the user opts into
/// a feature that needs it (alarms). This keeps the system prompt off the very
/// first launch so it doesn't front the onboarding flow.
enum NotificationAuthorization {
/// Asks for authorization only when the user hasn't decided yet. Safe to call
/// repeatedly — it's a no-op once the status is determined.
static func requestIfNeeded() {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .notDetermined else { return }
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if !granted {
LogManager.shared.log(category: .general, message: "User has declined notifications")
}
}
}
}
}
Loading
Loading