Skip to content

Commit f41544f

Browse files
committed
Show in-app notification when new app version is available
1 parent 09a8712 commit f41544f

20 files changed

+558
-110
lines changed

ios/Assets/Localizable.xcstrings

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28550,6 +28550,10 @@
2855028550
}
2855128551
}
2855228552
},
28553+
"Install the latest app version to stay up to date." : {
28554+
"comment" : "Body text of an in-app notification that reminds the user to update their app to the latest version.",
28555+
"isCommentAutoGenerated" : true
28556+
},
2855328557
"Internal error occurred. Settings will be reset to defaults and device logged out." : {
2855428558
"localizations" : {
2855528559
"da" : {
@@ -58484,6 +58488,10 @@
5848458488
}
5848558489
}
5848658490
},
58491+
"Update available" : {
58492+
"comment" : "Title of an in-app notification that alerts the user that a new app version is available.",
58493+
"isCommentAutoGenerated" : true
58494+
},
5848758495
"USA" : {
5848858496
"localizations" : {
5848958497
"da" : {

ios/MullvadREST/ApiHandlers/AppStoreMetaDataService.swift renamed to ios/MullvadREST/ApiHandlers/AppVersionService.swift

Lines changed: 26 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// AppStoreMetaDataService.swift
2+
// AppVersionService.swift
33
// MullvadVPN
44
//
55
// Created by Jon Petersson on 2026-01-09.
@@ -9,26 +9,28 @@
99
import MullvadLogging
1010
import MullvadSettings
1111
import MullvadTypes
12-
import UserNotifications
1312

14-
public final class AppStoreMetaDataService: @unchecked Sendable {
13+
public final class AppVersionService: @unchecked Sendable {
14+
public enum Deadline {
15+
case now, nextCheck
16+
}
17+
1518
private var timer: DispatchSourceTimer?
1619
private let checkInterval: TimeInterval = Duration.days(1).timeInterval
17-
private let logger = Logger(label: "AppStoreMetaDataService")
20+
private let logger = Logger(label: "AppVersionService")
1821

19-
private let tunnelSettings: LatestTunnelSettings
2022
private let urlSession: URLSessionProtocol
2123
private let appPreferences: AppPreferences
2224
private let mainAppBundleIdentifier: String
23-
private let appStoreLink: URL
25+
private let iTunesLink: URL
26+
27+
public var onNewAppVersion: (() -> Void)?
2428

2529
public init(
26-
tunnelSettings: LatestTunnelSettings,
2730
urlSession: URLSessionProtocol,
2831
appPreferences: AppPreferences,
29-
mainAppBundleIdentifier: String
32+
mainAppBundleIdentifier: String,
3033
) {
31-
self.tunnelSettings = tunnelSettings
3234
self.urlSession = urlSession
3335
self.appPreferences = appPreferences
3436
self.mainAppBundleIdentifier = mainAppBundleIdentifier
@@ -38,28 +40,31 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
3840
resolvingAgainstBaseURL: true
3941
)!
4042
urlComponents.queryItems = [URLQueryItem(name: "bundleId", value: mainAppBundleIdentifier)]
41-
appStoreLink = urlComponents.url!
43+
iTunesLink = urlComponents.url!
4244
}
4345

44-
public func scheduleTimer() {
46+
public func scheduleTimer(deadline: Deadline) {
4547
let newTimer = DispatchSource.makeTimerSource()
4648

4749
newTimer.setEventHandler {
4850
Task { [weak self] in
49-
guard let self, tunnelSettings.includeAllNetworks.includeAllNetworksIsEnabled else {
50-
return
51-
}
51+
guard let self else { return }
5252

5353
let newVersionExists = (try? await performVersionCheck()) ?? false
5454
if newVersionExists {
55-
sendNotification()
55+
onNewAppVersion?()
5656
}
5757
}
5858
}
5959

60-
// Resume deadline if there's time left from previous check. Otherwise, fire away.
61-
let elapsed = Date.now.timeIntervalSince(appPreferences.lastVersionCheck.date)
62-
let deadline = max(checkInterval - elapsed, 0)
60+
let deadline =
61+
switch deadline {
62+
case .now:
63+
0.0
64+
case .nextCheck:
65+
// Resume deadline if there's time left from previous check. Otherwise, fire away.
66+
max(checkInterval - Date.now.timeIntervalSince(appPreferences.lastVersionCheck.date), 0)
67+
}
6368

6469
newTimer.schedule(deadline: .now() + deadline, repeating: .seconds(Int(checkInterval)))
6570
newTimer.activate()
@@ -69,11 +74,11 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
6974
}
7075

7176
func performVersionCheck() async throws -> Bool {
72-
appPreferences.lastVersionCheck.date = .now
73-
7477
let appStoreMetaData = try await fetchAppStoreMetaData()
7578
let appStoreVersion = appStoreMetaData?.version ?? ""
7679

80+
appPreferences.lastVersionCheck.date = .now
81+
7782
if appStoreVersion.isNewerThan(Bundle.main.shortVersion) {
7883
appPreferences.lastVersionCheck.version = appStoreVersion
7984
return true
@@ -85,7 +90,7 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
8590
private func fetchAppStoreMetaData() async throws -> AppStoreMetaData? {
8691
do {
8792
let data = try await urlSession.data(
88-
for: URLRequest(url: appStoreLink, timeoutInterval: REST.defaultAPINetworkTimeout.timeInterval)
93+
for: URLRequest(url: iTunesLink, timeoutInterval: REST.defaultAPINetworkTimeout.timeInterval)
8994
)
9095
let response = try JSONDecoder().decode(AppStoreMetaDataResponse.self, from: data.0)
9196
return response.results.first { $0.bundleId == mainAppBundleIdentifier }
@@ -95,42 +100,6 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
95100

96101
return nil
97102
}
98-
99-
private func sendNotification() {
100-
let content = UNMutableNotificationContent()
101-
content.title = NSLocalizedString("Update available", comment: "")
102-
content.body = String(
103-
format: NSLocalizedString(
104-
"Disable “%@” or disconnect before updating in order not to lose network connectivity.",
105-
comment: ""
106-
),
107-
"Force all apps"
108-
)
109-
110-
// When scheduling a user notification we need to make sure that the date has not passed
111-
// when it's actually added to the system. Giving it a few seconds leeway lets us be sure
112-
// that this is the case.
113-
let dateComponents = Calendar.current.dateComponents(
114-
[.second, .minute, .hour, .day, .month, .year],
115-
from: Date(timeIntervalSinceNow: 5)
116-
)
117-
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
118-
119-
let request = UNNotificationRequest(
120-
identifier: NotificationProviderIdentifier.newAppVersionSystemNotification.domainIdentifier,
121-
content: content,
122-
trigger: trigger
123-
)
124-
125-
let identifier = request.identifier
126-
UNUserNotificationCenter.current().add(request) { [weak self, identifier] error in
127-
if let error {
128-
self?.logger.error(
129-
"Failed to add notification request with identifier \(identifier). Error: \(error.description)"
130-
)
131-
}
132-
}
133-
}
134103
}
135104

136105
extension String {

ios/MullvadRESTTests/AppStoreMetaDataServiceTests.swift renamed to ios/MullvadRESTTests/AppVersionServiceTests.swift

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// AppStoreMetaDataServiceTests.swift
2+
// AppVersionServiceTests.swift
33
// MullvadVPN
44
//
55
// Created by Jon Petersson on 2026-01-15.
@@ -12,7 +12,7 @@ import XCTest
1212
@testable import MullvadREST
1313
@testable import MullvadSettings
1414

15-
class AppStoreMetaDataServiceTests: XCTestCase {
15+
class AppVersionServiceTests: XCTestCase {
1616
private let encoder = JSONEncoder()
1717

1818
func testPerformVersionCheckNewVersionExists() async throws {
@@ -30,8 +30,7 @@ class AppStoreMetaDataServiceTests: XCTestCase {
3030
]
3131
)
3232

33-
let metaDataService = AppStoreMetaDataService(
34-
tunnelSettings: LatestTunnelSettings(),
33+
let appVersionService = AppVersionService(
3534
urlSession: URLSessionStub(
3635
response: (mockData, URLResponse())
3736
),
@@ -40,7 +39,7 @@ class AppStoreMetaDataServiceTests: XCTestCase {
4039

4140
)
4241

43-
let shouldSendNotification = try await metaDataService.performVersionCheck()
42+
let shouldSendNotification = try await appVersionService.performVersionCheck()
4443
XCTAssertTrue(shouldSendNotification)
4544
}
4645

@@ -58,16 +57,15 @@ class AppStoreMetaDataServiceTests: XCTestCase {
5857
]
5958
)
6059

61-
let metaDataService = AppStoreMetaDataService(
62-
tunnelSettings: LatestTunnelSettings(),
60+
let appVersionService = AppVersionService(
6361
urlSession: URLSessionStub(
6462
response: (mockData, URLResponse())
6563
),
6664
appPreferences: AppPreferences(),
6765
mainAppBundleIdentifier: bundleId
6866
)
6967

70-
let shouldSendNotification = try await metaDataService.performVersionCheck()
68+
let shouldSendNotification = try await appVersionService.performVersionCheck()
7169
XCTAssertFalse(shouldSendNotification)
7270
}
7371

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// InAppNotificationDescriptor.swift
3+
// MullvadVPN
4+
//
5+
// Created by pronebird on 09/12/2022.
6+
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import UIKit.UIImage
11+
12+
/// Struct describing in-app notification.
13+
public struct InAppNotificationDescriptor: Equatable {
14+
/// Notification identifier.
15+
public var identifier: NotificationProviderIdentifier
16+
17+
/// Notification banner style.
18+
public var style: NotificationBannerStyle
19+
20+
/// Notification title.
21+
public var title: String
22+
23+
/// Notification body.
24+
public var body: NSAttributedString
25+
26+
/// Notification action (optional).
27+
public var button: InAppNotificationAction?
28+
29+
/// Notification button action (optional).
30+
public var tapAction: InAppNotificationAction?
31+
32+
public init(
33+
identifier: NotificationProviderIdentifier,
34+
style: NotificationBannerStyle,
35+
title: String,
36+
body: NSAttributedString,
37+
button: InAppNotificationAction? = nil,
38+
tapAction: InAppNotificationAction? = nil
39+
) {
40+
self.identifier = identifier
41+
self.style = style
42+
self.title = title
43+
self.body = body
44+
self.button = button
45+
self.tapAction = tapAction
46+
}
47+
}
48+
49+
/// Type describing a specific in-app notification action.
50+
public struct InAppNotificationAction: Equatable {
51+
/// Image assigned to action button.
52+
public var image: UIImage?
53+
54+
/// Action handler for button.
55+
public var handler: (() -> Void)?
56+
57+
public init(image: UIImage? = nil, handler: (() -> Void)?) {
58+
self.image = image
59+
self.handler = handler
60+
}
61+
62+
public static func == (lhs: InAppNotificationAction, rhs: InAppNotificationAction) -> Bool {
63+
lhs.image == rhs.image
64+
}
65+
}
66+
67+
public enum NotificationBannerStyle {
68+
case success, warning, error
69+
}

ios/MullvadTypes/NotificationProviderIdentifier.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ public enum NotificationPriority: Int, Comparable {
2121

2222
public enum NotificationProviderIdentifier: String {
2323
case accountExpirySystemNotification = "AccountExpiryNotification"
24-
case newAppVersionSystemNotification = "NewAppVersionNotification"
24+
case newAppVersionSystemNotification = "NewAppVersionSystemNotification"
25+
case newAppVersionInAppNotification = "NewAppVersionInAppNotification"
2526
case accountExpiryInAppNotification = "AccountExpiryInAppNotification"
2627
case registeredDeviceInAppNotification = "RegisteredDeviceInAppNotification"
2728
case tunnelStatusNotificationProvider = "TunnelStatusNotificationProvider"

0 commit comments

Comments
 (0)