mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Top level deeplink handling (#1660)
* Handle link opening on the top most levels and prepare for percolating them throughout the app * Add support for a custom app scheme * Add specific AppRoute parsers. * Integrate custom scheme in the AppRouteURLParser * Switch to `element://call` and cleanup route parsing
This commit is contained in:
@@ -5196,6 +5196,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
APP_CUSTOM_SCHEME = element;
|
||||||
APP_DISPLAY_NAME = "Element X";
|
APP_DISPLAY_NAME = "Element X";
|
||||||
APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)";
|
APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)";
|
||||||
APP_NAME = ElementX;
|
APP_NAME = ElementX;
|
||||||
@@ -5265,6 +5266,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
APP_CUSTOM_SCHEME = element;
|
||||||
APP_DISPLAY_NAME = "Element X";
|
APP_DISPLAY_NAME = "Element X";
|
||||||
APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)";
|
APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)";
|
||||||
APP_NAME = ElementX;
|
APP_NAME = ElementX;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
|
|
||||||
let notificationManager: NotificationManagerProtocol
|
let notificationManager: NotificationManagerProtocol
|
||||||
|
|
||||||
|
private let appRouteURLParser: AppRouteURLParser
|
||||||
@Consumable private var storedAppRoute: AppRoute?
|
@Consumable private var storedAppRoute: AppRoute?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -75,6 +76,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.appSettings = appSettings
|
self.appSettings = appSettings
|
||||||
|
appRouteURLParser = AppRouteURLParser(appSettings: appSettings)
|
||||||
|
|
||||||
navigationRootCoordinator = NavigationRootCoordinator()
|
navigationRootCoordinator = NavigationRootCoordinator()
|
||||||
|
|
||||||
@@ -137,10 +139,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUniversalLink(_ url: URL) {
|
func handleDeepLink(_ url: URL) -> Bool {
|
||||||
// Parse into an AppRoute to redirect these in a type safe way.
|
// Parse into an AppRoute to redirect these in a type safe way.
|
||||||
|
|
||||||
if let route = AppRouteURLParser.route(from: url) {
|
if let route = appRouteURLParser.route(from: url) {
|
||||||
switch route {
|
switch route {
|
||||||
case .genericCallLink(let url):
|
case .genericCallLink(let url):
|
||||||
if let userSessionFlowCoordinator {
|
if let userSessionFlowCoordinator {
|
||||||
@@ -152,13 +154,15 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Until we have an OIDC callback AppRoute, handle it manually.
|
// Until we have an OIDC callback AppRoute, handle it manually.
|
||||||
if url.absoluteString.starts(with: appSettings.oidcRedirectURL.absoluteString) {
|
if url.absoluteString.starts(with: appSettings.oidcRedirectURL.absoluteString) {
|
||||||
MXLog.error("OIDC callback through Universal Links not implemented.")
|
MXLog.error("OIDC callback through Universal Links not implemented.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AuthenticationCoordinatorDelegate
|
// MARK: - AuthenticationCoordinatorDelegate
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ import Foundation
|
|||||||
|
|
||||||
protocol AppCoordinatorProtocol: CoordinatorProtocol {
|
protocol AppCoordinatorProtocol: CoordinatorProtocol {
|
||||||
var notificationManager: NotificationManagerProtocol { get }
|
var notificationManager: NotificationManagerProtocol { get }
|
||||||
func handleUniversalLink(_ url: URL)
|
@discardableResult func handleDeepLink(_ url: URL) -> Bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,14 @@ struct Application: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
appCoordinator.toPresentable()
|
appCoordinator.toPresentable()
|
||||||
.statusBarHidden(shouldHideStatusBar)
|
.statusBarHidden(shouldHideStatusBar)
|
||||||
.onOpenURL { appCoordinator.handleUniversalLink($0) }
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
|
if appCoordinator.handleDeepLink(url) {
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
|
||||||
|
return .systemAction
|
||||||
|
})
|
||||||
|
.onOpenURL { appCoordinator.handleDeepLink($0) }
|
||||||
.introspect(.window, on: .iOS(.v16)) { window in
|
.introspect(.window, on: .iOS(.v16)) { window in
|
||||||
// Workaround for SwiftUI not consistently applying the tint colour to Alerts/Confirmation Dialogs.
|
// Workaround for SwiftUI not consistently applying the tint colour to Alerts/Confirmation Dialogs.
|
||||||
window.tintColor = .compound.textActionPrimary
|
window.tintColor = .compound.textActionPrimary
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum AppRoute: Equatable {
|
enum AppRoute: Equatable {
|
||||||
|
case oidcCallback(url: URL)
|
||||||
case roomList
|
case roomList
|
||||||
case room(roomID: String)
|
case room(roomID: String)
|
||||||
case roomDetails(roomID: String)
|
case roomDetails(roomID: String)
|
||||||
@@ -24,32 +25,87 @@ enum AppRoute: Equatable {
|
|||||||
case genericCallLink(url: URL)
|
case genericCallLink(url: URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AppRouteURLParser {
|
struct AppRouteURLParser {
|
||||||
private enum KnownHosts: String, CaseIterable {
|
let urlParsers: [URLParser]
|
||||||
case elementIo = "element.io"
|
|
||||||
case appElementIo = "app.element.io"
|
init(appSettings: AppSettings) {
|
||||||
case stagingElementIo = "staging.element.io"
|
urlParsers = [
|
||||||
case developElementIo = "develop.element.io"
|
ElementAppURLParser(appSettings: appSettings),
|
||||||
case mobileElementIo = "mobile.element.io"
|
ElementCallURLParser()
|
||||||
case callElementIo = "call.element.io"
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
static func route(from url: URL) -> AppRoute? {
|
func route(from url: URL) -> AppRoute? {
|
||||||
guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
for parser in urlParsers {
|
||||||
let host = urlComponents.host else {
|
if let appRoute = parser.route(from: url) {
|
||||||
MXLog.error("Failed parsing URL: \(url)")
|
return appRoute
|
||||||
return nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard KnownHosts.allCases.map(\.rawValue).contains(host) else {
|
|
||||||
return .genericCallLink(url: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
if host == KnownHosts.callElementIo.rawValue {
|
|
||||||
return .genericCallLink(url: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep linking not supported at the moment
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents a type that can parse a `URL` into an `AppRoute`.
|
||||||
|
///
|
||||||
|
/// The following Universal Links are missing parsers.
|
||||||
|
/// - app.element.io
|
||||||
|
/// - mobile.element.io
|
||||||
|
protocol URLParser {
|
||||||
|
func route(from url: URL) -> AppRoute?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parser for the main Element website.
|
||||||
|
struct ElementAppURLParser: URLParser {
|
||||||
|
private let knownHosts = ["app.element.io", "staging.element.io", "develop.element.io"]
|
||||||
|
|
||||||
|
let appSettings: AppSettings
|
||||||
|
|
||||||
|
func route(from url: URL) -> AppRoute? {
|
||||||
|
guard let host = url.host, knownHosts.contains(host) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let pathComponents = url.pathComponents
|
||||||
|
|
||||||
|
// OIDC callback URL.
|
||||||
|
if pathComponents.count == 3, pathComponents[0] == "mobile", pathComponents[1] == "oidc" {
|
||||||
|
return .oidcCallback(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parser for Element Call links. This always returns a `.genericCallLink`
|
||||||
|
struct ElementCallURLParser: URLParser {
|
||||||
|
private let knownHosts = ["call.element.io"]
|
||||||
|
private let customSchemeHost = "call"
|
||||||
|
private let customSchemeURLQueryParameterName = "url"
|
||||||
|
|
||||||
|
func route(from url: URL) -> AppRoute? {
|
||||||
|
// First try processing URLs with custom schemes
|
||||||
|
if let scheme = url.scheme,
|
||||||
|
scheme == InfoPlistReader.app.appScheme {
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
|
components.host == customSchemeHost else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let encodedURLString = components.queryItems?.first(where: { $0.name == customSchemeURLQueryParameterName })?.value,
|
||||||
|
let callURL = URL(string: encodedURLString) else {
|
||||||
|
MXLog.error("Invalid custom scheme call parameters: \(url)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return .genericCallLink(url: callURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise try to interpret it as an universal link
|
||||||
|
guard let host = url.host, knownHosts.contains(host) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return .genericCallLink(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
|
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
|
||||||
case .invites:
|
case .invites:
|
||||||
break
|
break
|
||||||
case .genericCallLink:
|
case .genericCallLink, .oidcCallback:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
self.stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated))
|
self.stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated))
|
||||||
case .genericCallLink(let url):
|
case .genericCallLink(let url):
|
||||||
self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
|
self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
|
||||||
|
case .oidcCallback:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ struct InfoPlistReader {
|
|||||||
static let otlpTracingURL = "otlpTracingURL"
|
static let otlpTracingURL = "otlpTracingURL"
|
||||||
static let otlpTracingUsername = "otlpTracingUsername"
|
static let otlpTracingUsername = "otlpTracingUsername"
|
||||||
static let otlpTracingPassword = "otlpTracingPassword"
|
static let otlpTracingPassword = "otlpTracingPassword"
|
||||||
|
|
||||||
|
static let bundleURLTypes = "CFBundleURLTypes"
|
||||||
|
static let bundleURLSchemes = "CFBundleURLSchemes"
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum Values {
|
private enum Values {
|
||||||
@@ -111,6 +114,19 @@ struct InfoPlistReader {
|
|||||||
infoPlistValue(forKey: Keys.otlpTracingPassword)
|
infoPlistValue(forKey: Keys.otlpTracingPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom App Scheme
|
||||||
|
|
||||||
|
var appScheme: String {
|
||||||
|
let urlTypes: [[String: Any]] = infoPlistValue(forKey: Keys.bundleURLTypes)
|
||||||
|
|
||||||
|
guard let urlSchemes = urlTypes.first?[Keys.bundleURLSchemes] as? [String],
|
||||||
|
let scheme = urlSchemes.first else {
|
||||||
|
fatalError("Invalid custon application scheme configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Mention Pills
|
// MARK: - Mention Pills
|
||||||
|
|
||||||
/// Mention Pills UTType
|
/// Mention Pills UTType
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ enum RoomScreenViewAction {
|
|||||||
case itemAppeared(itemID: TimelineItemIdentifier)
|
case itemAppeared(itemID: TimelineItemIdentifier)
|
||||||
case itemDisappeared(itemID: TimelineItemIdentifier)
|
case itemDisappeared(itemID: TimelineItemIdentifier)
|
||||||
case itemTapped(itemID: TimelineItemIdentifier)
|
case itemTapped(itemID: TimelineItemIdentifier)
|
||||||
case linkClicked(url: URL)
|
|
||||||
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
|
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
|
||||||
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
|
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
|
||||||
case paginateBackwards
|
case paginateBackwards
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
Task { await timelineController.processItemDisappearance(id) }
|
Task { await timelineController.processItemDisappearance(id) }
|
||||||
case .itemTapped(let id):
|
case .itemTapped(let id):
|
||||||
Task { await itemTapped(with: id) }
|
Task { await itemTapped(with: id) }
|
||||||
case .linkClicked(let url):
|
|
||||||
MXLog.warning("Link clicked: \(url)")
|
|
||||||
case .toggleReaction(let emoji, let itemId):
|
case .toggleReaction(let emoji, let itemId):
|
||||||
Task { await timelineController.toggleReaction(emoji, to: itemId) }
|
Task { await timelineController.toggleReaction(emoji, to: itemId) }
|
||||||
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
|
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
|
||||||
|
|||||||
-1
@@ -86,7 +86,6 @@ struct NotificationSettingsScreenStrings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationSettingsScreenViewAction {
|
enum NotificationSettingsScreenViewAction {
|
||||||
case linkClicked(url: URL)
|
|
||||||
case changedEnableNotifications
|
case changedEnableNotifications
|
||||||
case groupChatsTapped
|
case groupChatsTapped
|
||||||
case directChatsTapped
|
case directChatsTapped
|
||||||
|
|||||||
-2
@@ -57,8 +57,6 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
|
|||||||
|
|
||||||
override func process(viewAction: NotificationSettingsScreenViewAction) {
|
override func process(viewAction: NotificationSettingsScreenViewAction) {
|
||||||
switch viewAction {
|
switch viewAction {
|
||||||
case .linkClicked(let url):
|
|
||||||
MXLog.warning("Link clicked: \(url)")
|
|
||||||
case .changedEnableNotifications:
|
case .changedEnableNotifications:
|
||||||
toggleNotifications()
|
toggleNotifications()
|
||||||
case .groupChatsTapped:
|
case .groupChatsTapped:
|
||||||
|
|||||||
-4
@@ -79,10 +79,6 @@ struct NotificationSettingsScreen: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, ListRowPadding.horizontal)
|
.padding(.horizontal, ListRowPadding.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.environment(\.openURL, OpenURLAction { url in
|
|
||||||
context.send(viewAction: .linkClicked(url: url))
|
|
||||||
return .systemAction
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ struct RoomTimelineItemView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
context.send(viewAction: .itemDisappeared(itemID: viewState.identifier))
|
context.send(viewAction: .itemDisappeared(itemID: viewState.identifier))
|
||||||
}
|
}
|
||||||
.environment(\.openURL, OpenURLAction { url in
|
|
||||||
context.send(viewAction: .linkClicked(url: url))
|
|
||||||
return .systemAction
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var timelineView: some View {
|
@ViewBuilder private var timelineView: some View {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class UITestsAppCoordinator: AppCoordinatorProtocol {
|
|||||||
navigationRootCoordinator.toPresentable()
|
navigationRootCoordinator.toPresentable()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUniversalLink(_ url: URL) {
|
func handleDeepLink(_ url: URL) -> Bool {
|
||||||
fatalError("Not implemented.")
|
fatalError("Not implemented.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class UnitTestsAppCoordinator: AppCoordinatorProtocol {
|
|||||||
AnyView(ProgressView("Running Unit Tests"))
|
AnyView(ProgressView("Running Unit Tests"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUniversalLink(_ url: URL) {
|
func handleDeepLink(_ url: URL) -> Bool {
|
||||||
fatalError("Not implemented.")
|
fatalError("Not implemented.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,19 @@
|
|||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>$(BASE_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>$(APP_CUSTOM_SCHEME)</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ targets:
|
|||||||
CFBundleDisplayName: $(APP_DISPLAY_NAME)
|
CFBundleDisplayName: $(APP_DISPLAY_NAME)
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
|
CFBundleURLTypes: [
|
||||||
|
{
|
||||||
|
CFBundleTypeRole: Editor,
|
||||||
|
CFBundleURLName: $(BASE_BUNDLE_IDENTIFIER),
|
||||||
|
CFBundleURLSchemes: [
|
||||||
|
$(APP_CUSTOM_SCHEME)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
UISupportedInterfaceOrientations: [
|
UISupportedInterfaceOrientations: [
|
||||||
UIInterfaceOrientationPortrait,
|
UIInterfaceOrientationPortrait,
|
||||||
UIInterfaceOrientationPortraitUpsideDown,
|
UIInterfaceOrientationPortraitUpsideDown,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ settings:
|
|||||||
BASE_BUNDLE_IDENTIFIER: io.element.elementx
|
BASE_BUNDLE_IDENTIFIER: io.element.elementx
|
||||||
APP_NAME: ElementX
|
APP_NAME: ElementX
|
||||||
APP_DISPLAY_NAME: Element X
|
APP_DISPLAY_NAME: Element X
|
||||||
|
APP_CUSTOM_SCHEME: element
|
||||||
KEYCHAIN_ACCESS_GROUP_IDENTIFIER: $(AppIdentifierPrefix)$(BASE_BUNDLE_IDENTIFIER)
|
KEYCHAIN_ACCESS_GROUP_IDENTIFIER: $(AppIdentifierPrefix)$(BASE_BUNDLE_IDENTIFIER)
|
||||||
MARKETING_VERSION: 1.2.9
|
MARKETING_VERSION: 1.2.9
|
||||||
CURRENT_PROJECT_VERSION: 1
|
CURRENT_PROJECT_VERSION: 1
|
||||||
|
|||||||
Reference in New Issue
Block a user