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
300 changes: 217 additions & 83 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
52 changes: 45 additions & 7 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,58 @@ 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 {
runPostOnboardingPrompts()
}
}
.sheet(isPresented: $showTelemetryConsent) {
.fullScreenCover(isPresented: $showOnboarding, onDismiss: {
// Covers both finishing and skipping onboarding — the telemetry and
// notification steps live inside the flow, so anyone who skips still
// needs these handled here.
runPostOnboardingPrompts()
}) {
OnboardingContainerView(onClose: { showOnboarding = false })
}
.sheet(isPresented: $showTelemetryConsent, onDismiss: {
// Ask for notifications only once telemetry is resolved, so the system
// prompt never stacks on top of the consent sheet.
requestNotificationsIfAlarmsEnabled()
}) {
// User must explicitly choose — no swipe-to-dismiss.
TelemetryConsentView()
.interactiveDismissDisabled(true)
}
}

/// Runs after onboarding closes, whether it was completed or skipped. Telemetry
/// consent and notification permission both live inside the onboarding flow, so
/// a skip would otherwise bypass them. Telemetry consent goes first (as a
/// sheet); the notification request follows on its dismissal so the two never
/// appear at once. When the user completed the flow these are already decided,
/// so both calls are no-ops.
private func runPostOnboardingPrompts() {
if !Storage.shared.telemetryConsentDecisionMade.value {
showTelemetryConsent = true // notifications requested on its dismiss
} else {
requestNotificationsIfAlarmsEnabled()
}
}

/// Deferred-permission policy: only ask for notifications once there's an
/// enabled alarm that needs them. Safe to call repeatedly — it's a no-op once
/// the status is determined.
private func requestNotificationsIfAlarmsEnabled() {
if Storage.shared.alarms.value.contains(where: { $0.isEnabled }) {
NotificationAuthorization.requestIfNeeded()
}
}

@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
132 changes: 132 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,137 @@ 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)

// Nightscout returns the created subject wrapped in an array
// (`[{…}]`) on current versions, but a bare object on some older ones,
// so accept either shape.
let subject = try decodeCreatedSubject(from: data)
guard let id = subject.id, !id.isEmpty else { throw NightscoutError.unknown }
return id
}

private static func decodeCreatedSubject(from data: Data) throws -> AuthSubject {
let decoder = JSONDecoder()
if let array = try? decoder.decode([AuthSubject].self, from: data) {
guard let first = array.first else { throw NightscoutError.unknown }
return first
}
return try decoder.decode(AuthSubject.self, from: data)
}

/// 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
30 changes: 30 additions & 0 deletions LoopFollow/Helpers/NotificationAuthorization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// 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. `completion` runs
/// on the main queue after the prompt is dismissed (or immediately when the
/// status was already determined), so a caller can wait for the system prompt
/// before moving on.
static func requestIfNeeded(completion: @escaping () -> Void = {}) {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .notDetermined else {
DispatchQueue.main.async { completion() }
return
}
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if !granted {
LogManager.shared.log(category: .general, message: "User has declined notifications")
}
DispatchQueue.main.async { completion() }
}
}
}
}
2 changes: 1 addition & 1 deletion LoopFollow/Helpers/Telemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ struct TelemetryConsentView: View {
VStack(alignment: .leading, spacing: 16) {
Text("You can choose to share anonymous information with the developers to help improve LoopFollow—such as app and iOS version, device type, which app you're following, and a few settings. Your health data, credentials, time zone, and logs remain on your device.")

Text("You can change this any time in Settings → Diagnostics.")
Text("You can change this any time in Settings → General → Diagnostics.")
.font(.subheadline)
.foregroundColor(.secondary)

Expand Down
Loading