Skip to content

[LOOP-5328] Fix Deeplinking and Widgets #786

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion Loop Widget Extension/Components/DeeplinkView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import SwiftUI

fileprivate extension Deeplink {
var deeplinkURL: URL {
URL(string: "loop://\(rawValue)")!
URL(string: "loop://\(host.rawValue)")!
}

var accentColor: Color {
Expand Down
2 changes: 1 addition & 1 deletion Loop Widget Extension/Widgets/SystemStatusWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ struct SystemStatusWidgetEntryView: View {
if widgetFamily != .systemSmall {
VStack(alignment: .center, spacing: 5) {
HStack(alignment: .center, spacing: 5) {
DeeplinkView(destination: .carbEntry)
DeeplinkView(destination: .carbEntry(nil))

DeeplinkView(destination: .bolus)
}
Expand Down
4 changes: 0 additions & 4 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,6 @@
84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */; };
84AA81DB2A4A2973000B658B /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DA2A4A2973000B658B /* Date.swift */; };
84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */; };
84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */; };
84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; };
84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EC2CCA361F0098E52F /* ImpactView.swift */; };
84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EE2CCA37680098E52F /* PresetCard.swift */; };
Expand Down Expand Up @@ -1152,7 +1151,6 @@
84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelimeEntry.swift; sourceTree = "<group>"; };
84AA81DA2A4A2973000B658B /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; };
84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelineProvider.swift; sourceTree = "<group>"; };
84AA81E42A4A3981000B658B /* DeeplinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkManager.swift; sourceTree = "<group>"; };
84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = "<group>"; };
84C170EC2CCA361F0098E52F /* ImpactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImpactView.swift; sourceTree = "<group>"; };
84C170EE2CCA37680098E52F /* PresetCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetCard.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2285,7 +2283,6 @@
439BED291E76093C00B0AED5 /* CGMManager.swift */,
C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */,
A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */,
84AA81E42A4A3981000B658B /* DeeplinkManager.swift */,
C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */,
43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */,
C16B983D26B4893300256B05 /* DoseEnactor.swift */,
Expand Down Expand Up @@ -3714,7 +3711,6 @@
B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */,
14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */,
84E8BBB32CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift in Sources */,
84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */,
1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */,
C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */,
4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */,
Expand Down
36 changes: 0 additions & 36 deletions Loop/Managers/DeeplinkManager.swift

This file was deleted.

51 changes: 39 additions & 12 deletions Loop/Managers/LoopAppManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ class LoopAppManager: NSObject {
private var analyticsServicesManager = AnalyticsServicesManager()
private(set) var testingScenariosManager: TestingScenariosManager?
private var resetLoopManager: ResetLoopManager!
private var deeplinkManager: DeeplinkManager!
private var temporaryPresetsManager: TemporaryPresetsManager!
private var loopDataManager: LoopDataManager!
private var mealDetectionManager: MealDetectionManager!
Expand Down Expand Up @@ -465,8 +464,6 @@ class LoopAppManager: NSObject {
windowProvider: windowProvider,
userDefaults: UserDefaults.appGroup!)

deeplinkManager = DeeplinkManager(rootViewController: rootViewController)

for support in supportManager.availableSupports {
if let analyticsService = support as? AnalyticsService {
analyticsServicesManager.addService(analyticsService)
Expand Down Expand Up @@ -623,21 +620,27 @@ class LoopAppManager: NSObject {
)

let statusTableView = StatusTableView(viewModel: viewModel)
.environmentObject(deviceDataManager.displayGlucosePreference)
.environment(\.appName, Bundle.main.bundleDisplayName)
.environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice)
.environment(\.loopStatusColorPalette, .loopStatus)
.environment(\.settingsManager, settingsManager)
.environment(\.temporaryPresetsManager, temporaryPresetsManager)
.edgesIgnoringSafeArea(.top)

self.statusTableViewController = statusTableView.viewController

var rootNavigationController = rootViewController as? RootNavigationController
if rootNavigationController == nil {
rootNavigationController = RootNavigationController()
rootViewController = rootNavigationController
}

rootNavigationController?.setViewControllers([UIHostingController(rootView: statusTableView)], animated: true)
rootNavigationController?.setViewControllers([
UIHostingController(
rootView: statusTableView
.environmentObject(deviceDataManager.displayGlucosePreference)
.environment(\.appName, Bundle.main.bundleDisplayName)
.environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice)
.environment(\.loopStatusColorPalette, .loopStatus)
.environment(\.settingsManager, settingsManager)
.environment(\.temporaryPresetsManager, temporaryPresetsManager)
.edgesIgnoringSafeArea(.top)
)
], animated: true)

await deviceDataManager.refreshDeviceData()

Expand Down Expand Up @@ -689,7 +692,29 @@ class LoopAppManager: NSObject {
// MARK: - Deeplinking

func handle(_ url: URL) -> Bool {
deeplinkManager.handle(url)
guard let deeplink = Deeplink(url: url) else {
return false
}

switch deeplink {
case let .carbEntry(carbEntryLink):
if let carbEntryLink {
switch carbEntryLink {
case let .carbEntryDetected(value, source):
statusTableViewController?.presentCarbEntryScreen(nil, value: value, source: source)
}
} else {
statusTableViewController?.presentCarbEntryScreen(nil)
}
case .preMeal:
statusTableViewController?.presentPresets()
case .bolus:
statusTableViewController?.presentBolusScreen()
case .customPresets:
statusTableViewController?.presentPresets()
}

return true
}

// MARK: - Continuity
Expand Down Expand Up @@ -765,6 +790,8 @@ class LoopAppManager: NSObject {
get { windowProvider?.window?.rootViewController }
set { windowProvider?.window?.rootViewController = newValue }
}

private var statusTableViewController: StatusTableViewController?
}

// MARK: - AlertPresenter
Expand Down
65 changes: 58 additions & 7 deletions Loop/Models/Deeplink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,69 @@
//

import Foundation
import LoopAlgorithm

enum Deeplink: String, CaseIterable {
case carbEntry = "carb-entry"
case bolus = "manual-bolus"
case preMeal = "pre-meal-preset"
case customPresets = "custom-presets"
enum Deeplink: Hashable {

struct AppSource: Hashable {
let name: String
let bundleId: String?
}
Comment on lines +14 to +17
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ps2 this is something I was prototyping around with; a way to pre-fill the carbs value via a deepLink. Lmk your thoughts!

Copy link
Member Author

@Camji55 Camji55 Apr 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deeper value here could be the ability to observe carb entries from 3rd parties and then have Loop notify you to bolus for it. Or having a Siri Shortcut for common carb entries.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BundleId here could allow to query the app icon from the App Store API if we wanted to display it 🤷🏻‍♂️


enum Host: String, CaseIterable {
case carbEntry = "carb-entry"
case bolus = "manual-bolus"
case preMeal = "pre-meal-preset"
case customPresets = "custom-presets"
}

enum CarbEntryLink: Hashable {
case carbEntryDetected(value: LoopQuantity, source: AppSource?)
}

case carbEntry(CarbEntryLink?)

case bolus
case preMeal
case customPresets

var host: Host {
switch self {
case .carbEntry: .carbEntry
case .bolus: .bolus
case .preMeal: .preMeal
case .customPresets: .customPresets
}
}

init?(url: URL?) {
guard let url, let host = url.host, let deeplink = Deeplink.allCases.first(where: { $0.rawValue == host }) else {
guard let url, let host = url.host, let deeplinkHost = Deeplink.Host.allCases.first(where: { $0.rawValue == host }) else {
return nil
}

let components = URLComponents(url: url, resolvingAgainstBaseURL: true)

self = deeplink
switch deeplinkHost {
case .carbEntry:
if let value = components?.queryItems?.first(where: { $0.name == "value" })?.value, let doubleValue = Double(value) {
let sourceBundleId = components?.queryItems?.first(where: { $0.name == "sourceBundleId" })?.value
let sourceName = components?.queryItems?.first(where: { $0.name == "sourceName" })?.value
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about possible misuse of this; any app could send a spoofed name to make the carb entry look it it came from a different app.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or is sourceName coming from iOS here?


var source: AppSource?
if let sourceName {
source = AppSource(name: sourceName, bundleId: sourceBundleId)
}

self = .carbEntry(.carbEntryDetected(value: LoopQuantity(unit: .gram, doubleValue: doubleValue), source: source))
} else {
self = .carbEntry(nil)
}
case .bolus:
self = .bolus
case .preMeal:
self = .preMeal
case .customPresets:
self = .customPresets
}
}
}
24 changes: 1 addition & 23 deletions Loop/View Controllers/RootNavigationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,9 @@
//

import UIKit
import LoopKit
import LoopKitUI

/// The root view controller in Loop
class RootNavigationController: UINavigationController {

/// Its root view controller is always StatusTableViewController after loading
var statusTableViewController: StatusTableViewController? {
return viewControllers.first as? StatusTableViewController
}

func navigate(to deeplink: Deeplink) {
switch deeplink {
case .carbEntry:
statusTableViewController?.presentCarbEntryScreen(nil)
case .preMeal:
statusTableViewController?.presentPresets()
case .bolus:
statusTableViewController?.presentBolusScreen()
case .customPresets:
statusTableViewController?.presentPresets()
}
}

override func restoreUserActivityState(_ activity: NSUserActivity) {
switch activity.activityType {
case NSUserActivity.viewLoopStatusActivityType:
Expand All @@ -41,8 +20,7 @@ class RootNavigationController: UINavigationController {
popToRootViewController(animated: false)
}
default:
statusTableViewController?.restoreUserActivityState(activity)
viewControllers.first?.restoreUserActivityState(activity)
}
}

}
7 changes: 6 additions & 1 deletion Loop/View Controllers/StatusTableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1441,20 +1441,25 @@ final class StatusTableViewController: LoopChartsTableViewController {
presentCarbEntryScreen(nil)
}

func presentCarbEntryScreen(_ activity: NSUserActivity?) {
func presentCarbEntryScreen(_ activity: NSUserActivity?, value: LoopQuantity? = nil, source: Deeplink.AppSource? = nil) {
let navigationWrapper: UINavigationController
if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled {
let viewModel = SimpleBolusViewModel(delegate: loopManager, displayMealEntry: true, displayGlucosePreference: deviceManager.displayGlucosePreference)
if let activity = activity {
viewModel.restoreUserActivityState(activity)
}
if let carbString = value?.doubleValue(for: .gram) {
viewModel.enteredCarbString = carbString.formatted()
}
let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(deviceManager.displayGlucosePreference)
let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false)
navigationWrapper = UINavigationController(rootViewController: hostingController)
hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation))
present(navigationWrapper, animated: true)
} else {
let viewModel = CarbEntryViewModel(delegate: loopManager)
viewModel.carbsQuantity = value?.doubleValue(for: .gram)
viewModel.carbsSource = source
viewModel.deliveryDelegate = deviceManager
viewModel.analyticsServicesManager = loopManager.analyticsServicesManager
if let activity {
Expand Down
2 changes: 2 additions & 0 deletions Loop/View Models/CarbEntryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ final class CarbEntryViewModel: ObservableObject {
var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity
var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity

var carbsSource: Deeplink.AppSource?

@Published var time = Date()
private var date = Date()
var minimumDate: Date {
Expand Down
16 changes: 16 additions & 0 deletions Loop/Views/CarbEntryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ struct CarbEntryView: View, HorizontalSizeClassOverride {

CardSectionDivider()

if let source = viewModel.carbsSource {
HStack(spacing: 2) {
Text("Source")
.foregroundColor(.primary)
.frame(maxWidth: .infinity, alignment: .leading)

Spacer()

Text(source.name)
.foregroundColor(Color(.secondaryLabel))
}
.accessibilityElement(children: .combine)

CardSectionDivider()
}

Comment on lines +134 to +149
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a way I thought we could display the source in the CarbEntryView if it's available:

Simulator Screenshot - iPhone 16 Pro - 2025-04-22 at 14 44 12

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also make editing disabled if presented via deeplink

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we'd want to disable editing? Seems like that should always be a good thing, for cases where the originating app might not be correct? Or the user changes their mind?

DatePickerRow(date: $viewModel.time, isFocused: timeFocused, minimumDate: viewModel.minimumDate, maximumDate: viewModel.maximumDate)

CardSectionDivider()
Expand Down