Skip to content

Commit 12e3158

Browse files
committed
Show in-app notification when new app version is available
1 parent 3408264 commit 12e3158

File tree

15 files changed

+426
-63
lines changed

15 files changed

+426
-63
lines changed

ios/Assets/Localizable.xcstrings

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
"" : {
55
"shouldTranslate" : false
66
},
7+
"“Force all apps” is enabled, please disable it before updating or you will lose network connectivity. This will briefly expose your traffic as you reconnect to the VPN." : {
8+
"comment" : "Text in an in-app notification about a new app version, explaining that \"Force all apps\" is enabled and that the user should disable it before updating.",
9+
"isCommentAutoGenerated" : true
10+
},
11+
"“Force all apps” is enabled, please disable it or disconnect before updating or you will lose network connectivity." : {
12+
"comment" : "Body text of an in-app notification that appears when the user has a new app store version available, but the \"Force all apps\" setting is enabled.",
13+
"isCommentAutoGenerated" : true
14+
},
715
"**Attention: This increases network traffic and will also negatively affect speed, latency, and battery usage. Use with caution on limited plans.**" : {
816
"localizations" : {
917
"da" : {
@@ -4152,6 +4160,10 @@
41524160
}
41534161
}
41544162
},
4163+
"After updating, you will have to enable “Force all apps” manually again." : {
4164+
"comment" : "Message in an in-app notification about the need to manually enable \"Force all apps\" after an app update.",
4165+
"isCommentAutoGenerated" : true
4166+
},
41554167
"Agree and continue" : {
41564168
"localizations" : {
41574169
"da" : {
@@ -17378,6 +17390,10 @@
1737817390
}
1737917391
}
1738017392
},
17393+
"Disable “Force all apps”" : {
17394+
"comment" : "Title of an action in an alert presented when a new app version is available that requires the user to disable \"Force all apps\".",
17395+
"isCommentAutoGenerated" : true
17396+
},
1738117397
"Disable all \"%@\" above to activate this setting." : {
1738217398
"localizations" : {
1738317399
"da" : {
@@ -26350,6 +26366,10 @@
2635026366
}
2635126367
}
2635226368
},
26369+
"If you do not wish to disable “Force all apps“, you can disconnect from the VPN instead." : {
26370+
"comment" : "Message in an in-app notification about the option to disconnect from the VPN if they do not want to disable \"Force all apps\".",
26371+
"isCommentAutoGenerated" : true
26372+
},
2635326373
"If you exit the form and try again later, the information you already entered will still be here." : {
2635426374
"localizations" : {
2635526375
"da" : {
@@ -28474,6 +28494,10 @@
2847428494
}
2847528495
}
2847628496
},
28497+
"Install the latest app version to stay up to date." : {
28498+
"comment" : "Body text of an in-app notification that reminds the user to update their app to the latest version.",
28499+
"isCommentAutoGenerated" : true
28500+
},
2847728501
"Internal error occurred. Settings will be reset to defaults and device logged out." : {
2847828502
"localizations" : {
2847928503
"da" : {
@@ -58260,6 +58284,10 @@
5826058284
}
5826158285
}
5826258286
},
58287+
"Update available" : {
58288+
"comment" : "Title of an in-app notification that alerts the user that a new app version is available.",
58289+
"isCommentAutoGenerated" : true
58290+
},
5826358291
"USA" : {
5826458292
"localizations" : {
5826558293
"da" : {

ios/MullvadREST/ApiHandlers/AppStoreMetaDataService.swift

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,18 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
1616
private let checkInterval: TimeInterval = Duration.days(1).timeInterval
1717
private let logger = Logger(label: "AppStoreMetaDataService")
1818

19-
private let tunnelSettings: LatestTunnelSettings
2019
private let urlSession: URLSessionProtocol
2120
private let appPreferences: AppPreferences
2221
private let mainAppBundleIdentifier: String
23-
private let appStoreLink: URL
22+
private let iTunesLink: URL
23+
24+
public var onNewAppVersion: (() -> Void)?
2425

2526
public init(
26-
tunnelSettings: LatestTunnelSettings,
2727
urlSession: URLSessionProtocol,
2828
appPreferences: AppPreferences,
29-
mainAppBundleIdentifier: String
29+
mainAppBundleIdentifier: String,
3030
) {
31-
self.tunnelSettings = tunnelSettings
3231
self.urlSession = urlSession
3332
self.appPreferences = appPreferences
3433
self.mainAppBundleIdentifier = mainAppBundleIdentifier
@@ -38,21 +37,19 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
3837
resolvingAgainstBaseURL: true
3938
)!
4039
urlComponents.queryItems = [URLQueryItem(name: "bundleId", value: mainAppBundleIdentifier)]
41-
appStoreLink = urlComponents.url!
40+
iTunesLink = urlComponents.url!
4241
}
4342

4443
public func scheduleTimer() {
4544
let newTimer = DispatchSource.makeTimerSource()
4645

4746
newTimer.setEventHandler {
4847
Task { [weak self] in
49-
guard let self, tunnelSettings.includeAllNetworks else {
50-
return
51-
}
48+
guard let self else { return }
5249

5350
let newVersionExists = (try? await performVersionCheck()) ?? false
5451
if newVersionExists {
55-
sendNotification()
52+
onNewAppVersion?()
5653
}
5754
}
5855
}
@@ -68,35 +65,7 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
6865
timer = newTimer
6966
}
7067

71-
func performVersionCheck() async throws -> Bool {
72-
appPreferences.lastVersionCheck.date = .now
73-
74-
let appStoreMetaData = try await fetchAppStoreMetaData()
75-
let appStoreVersion = appStoreMetaData?.version ?? ""
76-
77-
if appStoreVersion.isNewerThan(Bundle.main.shortVersion) {
78-
appPreferences.lastVersionCheck.version = appStoreVersion
79-
return true
80-
}
81-
82-
return false
83-
}
84-
85-
private func fetchAppStoreMetaData() async throws -> AppStoreMetaData? {
86-
do {
87-
let data = try await urlSession.data(
88-
for: URLRequest(url: appStoreLink, timeoutInterval: REST.defaultAPINetworkTimeout.timeInterval)
89-
)
90-
let response = try JSONDecoder().decode(AppStoreMetaDataResponse.self, from: data.0)
91-
return response.results.first { $0.bundleId == mainAppBundleIdentifier }
92-
} catch {
93-
logger.log(level: .error, "Could not fetch App Store metadata: \(error.description)")
94-
}
95-
96-
return nil
97-
}
98-
99-
private func sendNotification() {
68+
public func sendSystemNotification() {
10069
let content = UNMutableNotificationContent()
10170
content.title = NSLocalizedString("Update available", comment: "")
10271
content.body = String(
@@ -131,6 +100,34 @@ public final class AppStoreMetaDataService: @unchecked Sendable {
131100
}
132101
}
133102
}
103+
104+
func performVersionCheck() async throws -> Bool {
105+
appPreferences.lastVersionCheck.date = .now
106+
107+
let appStoreMetaData = try await fetchAppStoreMetaData()
108+
let appStoreVersion = appStoreMetaData?.version ?? ""
109+
110+
if appStoreVersion.isNewerThan(Bundle.main.shortVersion) {
111+
appPreferences.lastVersionCheck.version = appStoreVersion
112+
return true
113+
}
114+
115+
return false
116+
}
117+
118+
private func fetchAppStoreMetaData() async throws -> AppStoreMetaData? {
119+
do {
120+
let data = try await urlSession.data(
121+
for: URLRequest(url: iTunesLink, timeoutInterval: REST.defaultAPINetworkTimeout.timeInterval)
122+
)
123+
let response = try JSONDecoder().decode(AppStoreMetaDataResponse.self, from: data.0)
124+
return response.results.first { $0.bundleId == mainAppBundleIdentifier }
125+
} catch {
126+
logger.log(level: .error, "Could not fetch App Store metadata: \(error.description)")
127+
}
128+
129+
return nil
130+
}
134131
}
135132

136133
extension String {

ios/MullvadRESTTests/AppStoreMetaDataServiceTests.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ class AppStoreMetaDataServiceTests: XCTestCase {
3131
)
3232

3333
let metaDataService = AppStoreMetaDataService(
34-
tunnelSettings: LatestTunnelSettings(),
3534
urlSession: URLSessionStub(
3635
response: (mockData, URLResponse())
3736
),
@@ -59,7 +58,6 @@ class AppStoreMetaDataServiceTests: XCTestCase {
5958
)
6059

6160
let metaDataService = AppStoreMetaDataService(
62-
tunnelSettings: LatestTunnelSettings(),
6361
urlSession: URLSessionStub(
6462
response: (mockData, URLResponse())
6563
),
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)