Skip to content
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

[🐛] Dynamic Links (iOS) getInitialLink() always returning null - Multiple environments #8150

Open
2 of 10 tasks
ignaciomendeznole opened this issue Nov 20, 2024 · 2 comments
Labels

Comments

@ignaciomendeznole
Copy link

ignaciomendeznole commented Nov 20, 2024

Issue

Describe your issue here

Description:
We are experiencing an issue where Firebase Dynamic Links no longer work as expected on iOS. When using a dynamic link generated from the Firebase Console, such as:
https://taxfix.page.link/document-manager
(which resolves to https://app.taxfix.de/dlink/document-manager),

the app opens successfully, but calling the getInitialLink method returns null. Previously, getInitialLink would correctly return the deep link URL (https://app.taxfix.de/dlink/document-manager). This behavior stopped working a few days ago and now consistently fails in all cases.

Context:
We have three Firebase Dynamic Link domains set up:

Debug:

Domain: https://taxfixdebug.page.link
Links resolve to our DEBUG application.

Beta:

Domain: https://taxfixbeta.page.link
Links resolve to our BETA application (published on Testflight).

Production:

Domain: https://taxfix.page.link
Links resolve to our Production application (published on stores).
The issue primarily affects the Beta and Production environments. Debug links work perfectly when running our application locally.

Platform-specific Behavior:

iOS: The issue occurs for Beta and Production dynamic links. While the app opens as expected, the getInitialLink method fails to return the link and instead returns null.
Android: Dynamic links work as expected across all environments (Debug, Beta, and Production).
Additional Notes:
This issue appears to be specific to iOS. It seems unrelated to link configuration, as everything worked correctly until a few days ago. We would appreciate any guidance or suggestions to debug or resolve this behavior.


Project Files

Javascript

Click To Expand

package.json:

# 

firebase.json for react-native-firebase v6:

# N/A

iOS

Click To Expand

ios/Podfile:

  • I'm not using Pods
  • I'm using Pods and my Podfile looks like:
require 'json'

firebaseAppPackage =  JSON.parse(
  File.read(File.join(
      File.dirname(`node --print "require.resolve('@react-native-firebase/app/package.json')"`),
        'package.json',
      ),
    ),
   )

$FirebaseSDKVersion = firebaseAppPackage['sdkVersions']['ios']['firebase']
$RNFirebaseAnalyticsGoogleAppMeasurementOnDeviceConversion = true


$ZendeskSDKVersion = '2.14.0'

require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")

# Resolve react_native_pods.rb with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p',
  'require.resolve(
    "react-native/scripts/react_native_pods.rb",
    {paths: [process.argv[1]]},
  )', __dir__]).strip


platform :ios, min_ios_version_supported

prepare_react_native_project!

use_modular_headers!
use_frameworks! linkage: :static

FlipperConfiguration.disabled


target 'Taxfix' do
  permissions_path = '../node_modules/react-native-permissions/ios'  
  pod 'Permission-FaceID', path: "#{permissions_path}/FaceID"
  pod 'Permission-PhotoLibrary', path: "#{permissions_path}/PhotoLibrary"

  pod 'GoogleUtilities'

  # Tracking
  pod 'Analytics', '~> 4.1'
  pod 'Segment-Firebase',
      git: 'https://github.com/taxfix/analytics-ios-integration-firebase.git',
      branch: 'firebase/v10.24.0'
  pod 'Segment-Appboy', '~> 4.2.0'

  pod 'FirebaseFirestore',
      git: 'https://github.com/invertase/firestore-ios-sdk-frameworks.git',
      tag: $FirebaseSDKVersion

  # Pods for Taxfix
  pod 'SwiftyUserDefaults', '~> 5.0'
  pod 'Unbox', '~> 2.5'
  pod 'Wrap', '~> 3.0'
  pod 'SwiftyJSON', '~> 5.0'

  use_expo_modules!(searchPaths: ['../node_modules'])
  post_integrate do |installer|
    begin
      expo_patch_react_imports!(installer)
    rescue => e
      Pod::UI.warn e
    end
  end

  config = use_native_modules!

  # Flags change depending on the env values.
  flags = get_default_flags

  use_react_native!(
    path: config[:reactNativePath],
    # to enable hermes on iOS, change `false` to `true` and then install pods
    hermes_enabled: true,
    # An absolute path to your application root.
    app_path: "#{Pod::Config.instance.installation_root}/..",
  )
end

# Convert all permission pods into static libraries
# https://github.com/zoontek/react-native-permissions#workaround-for-use_frameworks-issues
pre_install do |installer|
  Pod::Installer::Xcode::TargetValidator.send(
    :define_method,
    :verify_no_static_framework_transitive_dependencies,
  ) {}

  installer.pod_targets.each do |pod|
    if pod.name.eql?('RNPermissions') || pod.name.start_with?('Permission-')
      def pod.build_type
        Pod::BuildType.static_library # >= 1.9
      end
    end
  end
end

post_install do |installer|
  react_native_post_install(installer, 
    #TODO: remove if it works without this
    #     '../node_modules/react-native',
      :mac_catalyst_enabled => false
    )
  __apply_Xcode_12_5_M1_post_install_workaround(installer)


  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['ENABLE_BITCODE'] = 'NO'
      config.build_settings['SWIFT_VERSION'] = '5.0'
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
    end

    case target.name
    when 'RCT-Folly'
      target.build_configurations.each do |config|
        config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0'
      end
    when 'FBReactNativeSpec'
      target.build_phases.each do |build_phase|
        if (
             build_phase.respond_to?(:name) &&
               build_phase.name.eql?('[CP-User] Generate Specs')
           )
          target.build_phases.move(build_phase, 0)
        end
      end
    end
  end
end

AppDelegate.swift:

//
//  AppDelegate.swift
//  Taxfix
//
//  Created by joshua may on 26/9/16.
//  Copyright © 2016 joshua may. All rights reserved.
//

import UIKit

import react_native_zendesk_messaging
import Firebase
import Appboy_iOS_SDK
import RNBootSplash
import Singular_React_Native.SingularBridge
// via: https://gist.github.com/somethingkindawierd/09f4e588005925d78ab1
func isRunningUnitTests() -> Bool {
    let env = ProcessInfo.processInfo.environment
    if let configurationFilePath = env["XCTestConfigurationFilePath"] {
        return NSString(string: configurationFilePath).pathExtension == "xctestconfiguration"
    }
    return false
}

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    var window: UIWindow?
    var imageView: UIImageView?

    fileprivate var logoutTimer: Timer?

    let applicationState = ApplicationState(configuration: [
        .apiBaseUrl: ReactNativeConfig.env(for: "API_BASE_URL"),
        .segmentKey: ReactNativeConfig.env(for: "SEGMENT_IOS_KEY"),
        ])

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        let emptyCache = URLCache(memoryCapacity: 0, diskCapacity: 0, diskPath: nil)
        URLCache.shared = emptyCache

        SingularBridge.startSession(launchOptions: launchOptions)

        let filePath = Bundle.main.path(forResource: ReactNativeConfig.env(for: "FIREBASE_CONFIG_FILENAME_IOS"), ofType: "plist")
        guard let fileopts = FirebaseOptions(contentsOfFile: filePath!)
            else { return false }
        FirebaseApp.configure(options: fileopts)

        // Braze
        // override API_KEY coming from Segment settings
        // needs to be called before Segment init
        Appboy.start(withApiKey: ReactNativeConfig.env(for: "BRAZE_IOS_API_KEY"), in:application, withLaunchOptions:launchOptions)

        applicationState.boot(launchOptions: launchOptions)
        appearance()

        trackApplicationdidFinishLaunching()

        var vc: UIViewController

        if isRunningUnitTests() {
            print("Running unit tests, not booting RN!")

            vc = UIViewController()
        } else {
            vc = ReactNativeViewController.init()
        }

        let nav = UINavigationController(rootViewController: vc)
        nav.navigationBar.isHidden = true

        window = UIWindow(frame: UIScreen.main.bounds)
        window?.rootViewController = nav
        window?.makeKeyAndVisible()

        if isRunningUnitTests() == false {
          let view = RCTRootView(
                          bridge: ReactNativeBridge.sharedInstance.bridge!,
                          moduleName: "Home",
                          initialProperties: nil)
          RNBootSplash.initWithStoryboard(ReactNativeConfig.env(for: "LAUNCH_SCREEN_IOS"), rootView: view);
          vc.view = view
        }

        let center = UNUserNotificationCenter.current()
        center.delegate = self
        return true
    }

    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
        return RCTLinkingManager.application(app, open: url, options: options)
     }

    func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool {
        return true
    }

    func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler handler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
        SingularBridge.startSession(with: userActivity)
       return RCTLinkingManager.application(application, continue: userActivity, restorationHandler: handler)
    }

    func application(_ application: UIApplication, didRegister notificationSettings: UIUserNotificationSettings) {
        RNCPushNotificationIOS.didRegister(notificationSettings)
    }

    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        RNCPushNotificationIOS.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)

        ZendeskNativeModule.updatePushNotificationToken(deviceToken)
        SEGAnalytics.shared().registeredForRemoteNotifications(withDeviceToken: deviceToken)
        Appboy.sharedInstance()?.registerDeviceToken(deviceToken)
    }

    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        RNCPushNotificationIOS.didFailToRegisterForRemoteNotificationsWithError(error)
    }

    func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
        RNCPushNotificationIOS.didReceiveRemoteNotification(userInfo, fetchCompletionHandler: completionHandler)

        SEGAnalytics.shared().receivedRemoteNotification(userInfo)

        Appboy.sharedInstance()?.register(application,
                                            didReceiveRemoteNotification: userInfo,
                                            fetchCompletionHandler: completionHandler)
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        Appboy.sharedInstance()?.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler)

      //Zendesk
      // Temporary disable Zendesk PN handling due to an authentication security issue we have not resolved yet
      // https://taxfix.atlassian.net/browse/OPT-175
      /* let userInfo = response.notification.request.content.userInfo
      // This checks whether a received push notification should be handled by Messaging
      let isHandled = ZendeskNativeModule.handleNotification(userInfo, completionHandler: completionHandler)
      if (isHandled){
        return;
      } */
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
      //Zendesk
      let userInfo = notification.request.content.userInfo
      // This checks whether a received push notification should be displayed by Messaging
      let isHandled = ZendeskNativeModule.showNotification(userInfo, completionHandler: completionHandler)
      if(isHandled){
        return;
      }
      completionHandler([.alert, .badge, .sound])
    }

    func showImageView () {
        imageView = UIImageView(frame: UIScreen.main.bounds)
        imageView?.backgroundColor = UIColor.taxfixGreen

        let image = UIImage(named: "AppIcon")
        let imageView2 = UIImageView(image: image)
        imageView2.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
        imageView2.center = imageView!.center
        imageView?.addSubview(imageView2)

        window?.addSubview(imageView!)
    }

    func hideImageView () {
        if imageView != nil {
        imageView?.removeFromSuperview()
        imageView = nil
        }
    }

    func applicationWillResignActive(_ application: UIApplication) {
        // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
        // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
        showImageView()
    }
    func applicationDidEnterBackground(_ application: UIApplication) {
        trackApplicationDidEnterBackground()
    }

    func applicationWillEnterForeground(_ application: UIApplication) {
    }

    func applicationDidBecomeActive(_ application: UIApplication) {
        // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
        hideImageView()
    }

    func applicationWillTerminate(_ application: UIApplication) {
        trackApplicationWillTerminate()
    }

    func application(_ application: UIApplication, shouldAllowExtensionPointIdentifier extensionPointIdentifier: UIApplication.ExtensionPointIdentifier) -> Bool {
        if extensionPointIdentifier == UIApplication.ExtensionPointIdentifier.keyboard {
            return false
        }

        return true
    }
}

extension AppDelegate {
    func appearance() {
        let fontBBI = UIFont.mediumTaxfixFont(ofSize: 16)
        for controlState in [
            UIControl.State.disabled,
            UIControl.State.focused,
            UIControl.State.highlighted,
            UIControl.State.selected,
            ] {
                UIBarButtonItem.appearance().setTitleTextAttributes([
                    NSAttributedString.Key.font: fontBBI,
                    ], for: controlState)
        }
        UIBarButtonItem.appearance().setTitleTextAttributes([
            NSAttributedString.Key.font: fontBBI,
            NSAttributedString.Key.foregroundColor: UIColor.taxfixGreen,
            ], for: .normal)

        UINavigationBar.appearance().titleTextAttributes = [
            NSAttributedString.Key.foregroundColor: UIColor.taxfixGrey,
            NSAttributedString.Key.font: UIFont.mediumTaxfixFont(ofSize: 16),
        ]

        if var image = UIImage(named: "NavigationBar-BackIndicatorImage") {
            let insets = UIEdgeInsets(top: 0, left: 0, bottom: -4, right: 0)
            image = image.withAlignmentRectInsets(insets)
            UINavigationBar.appearance().backIndicatorImage = image
            UINavigationBar.appearance().backIndicatorTransitionMaskImage = image
        }

        UINavigationBar.appearance().tintColor = UIColor.taxfixGreen
        UITabBar.appearance().tintColor = UIColor.taxfixGreen
    }

    static var shared: AppDelegate {
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
            fatalError("Main application delegate is not instance of AppDelegate")
        }

        return appDelegate
    }
}

extension AppDelegate: LifecycleAnalytics {
    func trackApplicationdidFinishLaunching() {
        // move this tracking to js/lib/containers/Home.js to make it be called after identify()
        // let analytics = Analytics()
        // analytics.log(event: .appOpen)
    }

    func trackApplicationWillTerminate() {
        let analytics = Analytics()
        analytics.log(event: .appClosed)
    }

    func trackApplicationDidEnterBackground() {
        let analytics = Analytics()
        analytics.log(event: .appSetToBackground)
    }
}

extension UIWindow {
    func topViewController() -> UIViewController? {
        guard let vc = rootViewController else {
            return nil
        }

        return topViewController(base: vc)
    }

    func topViewController(base: UIViewController) -> UIViewController? {
        if let presented = base.presentedViewController {
            return topViewController(base: presented)
        }

        return base
    }
}

extension UIFont {
    static func taxfix(name: String, ofSize size: CGFloat) -> UIFont {
        guard let font = UIFont(name: name, size: size) else {
            assertionFailure("Cannot find font named \(name)")

            return UIFont.systemFont(ofSize: size)
        }

        return font
    }

    static func boldTaxfixFont(ofSize size: CGFloat) -> UIFont {
        return UIFont.taxfix(name: "CircularStd-Bold", ofSize: size)
    }

    static func bookTaxfixFont(ofSize size: CGFloat) -> UIFont {
        return UIFont.taxfix(name: "CircularStd-Book", ofSize: size)
    }

    static func mediumTaxfixFont(ofSize size: CGFloat) -> UIFont {
        return UIFont.taxfix(name: "CircularStd-Medium", ofSize: size)
    }

    static func larkenBoldTaxfixFont(ofSize size: CGFloat) -> UIFont {
        return UIFont.taxfix(name: "Larken-Bold", ofSize: size)
    }

    static func larkenMediumTaxfixFont(ofSize size: CGFloat) -> UIFont {
        return UIFont.taxfix(name: "Larken-Medium", ofSize: size)
    }

    static func larkenRegularTaxfixFont(ofSize size: CGFloat) -> UIFont {
        return UIFont.taxfix(name: "Larken-Regular", ofSize: size)
    }

    static func larkenMediumItalicTaxfixFont(ofSize size: CGFloat) -> UIFont {
        return UIFont.taxfix(name: "Larken-MediumItalic", ofSize: size)
    }
}

extension UIColor {
    static var taxfixGreen: UIColor {
        let launchScreen = ReactNativeConfig.env(for: "LAUNCH_SCREEN_IOS");
        return UIColor(red: 173/255, green: 238/255, blue: 104/255, alpha: 1)
    }

    static var taxfixGrey: UIColor {
        return UIColor(white: 59/255, alpha: 1)
    }
}


Android

Click To Expand

Have you converted to AndroidX?

  • my application is an AndroidX application?
  • I am using android/gradle.settings jetifier=true for Android compatibility?
  • I am using the NPM package jetifier for react-native compatibility?

android/build.gradle:

// N/A

android/app/build.gradle:

// N/A

android/settings.gradle:

// N/A

MainApplication.java:

// N/A

AndroidManifest.xml:

<!-- N/A -->


Environment

Click To Expand

react-native info output:

System:
  OS: macOS 15.0.1
  CPU: (8) arm64 Apple M1 Pro
  Memory: 7.29 GB / 32.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 18.20.3
    path: ~/.nvm/versions/node/v18.20.3/bin/node
  Yarn:
    version: 4.4.1
    path: /opt/homebrew/bin/yarn
  npm:
    version: 10.7.0
    path: ~/.nvm/versions/node/v18.20.3/bin/npm
  Watchman:
    version: 2024.10.14.00
    path: /opt/homebrew/bin/watchman
Managers:
  CocoaPods:
    version: 1.15.2
    path: /Users/ignaciomendeznole/.rvm/gems/ruby-3.3.0/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 24.1
      - iOS 18.1
      - macOS 15.1
      - tvOS 18.1
      - visionOS 2.1
      - watchOS 11.1
  Android SDK: Not Found
IDEs:
  Android Studio: 2024.2 AI-242.23339.11.2421.12483815
  Xcode:
    version: 16.1/16B40
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.13
    path: /usr/bin/javac
  Ruby:
    version: 3.3.0
    path: /Users/ignaciomendeznole/.rvm/rubies/ruby-3.3.0/bin/ruby
npmPackages:
  "@react-native-community/cli": Not Found
  react: Not Found
  react-native: Not Found
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: Not found
  newArchEnabled: Not found
iOS:
  hermesEnabled: Not found
  newArchEnabled: Not found
  • Platform that you're experiencing the issue on:
    • iOS
    • Android
    • iOS but have not tested behavior on Android
    • Android but have not tested behavior on iOS
    • Both
  • react-native-firebase version you're using that has this issue:
    • 19.3.0
  • Firebase module(s) you're using that has the issue:
    • @react-native-firebase/dynamic-links
  • Are you using TypeScript?
    • Y & 5.5.4


@ignaciomendeznole ignaciomendeznole changed the title [🐛] Dynamic Links (iOS) - Multiple environments [🐛] Dynamic Links (iOS) getInitialLink() always returning null - Multiple environments Nov 20, 2024
@mikehardy
Copy link
Collaborator

🤔

19.3.0

That's pretty dated - I encourage you to reproduce this on up to date versions before filing issues, that said, this is not something that we have a lot of control over at this level, our dynamic link resolution delegates to firebase-ios-sdk pretty purely for these APIs

Have you tried reproducing with some native app logging using a native quickstart? That would allow you to access support where you will most likely need it - in the firebase-ios-sdk repository --> https://github.com/firebase/quickstart-ios/tree/main/dynamiclinks

usually those allow for a reproduction pretty quickly from checkout to some quick modifications hacked in for your test to reproducing it...

@exzos28
Copy link
Contributor

exzos28 commented Nov 20, 2024

I can confirm what I have seen with similar behavior:
If you download an application from a link and open it, the initial url does NOT come to getInitialLink, but at the same time the onLink listener is triggered.

This will also work if you just download the app (but don't open it) and then click on the link.
!! This behavior is only on the first app opening, all the next ones are correct. And only on iOS

For myself I created a small “proxy hack” under the dynamic links implementation, which for the first application launch within 1000ms collects links from onLink and returns the received link to getInitialLink.

This turned out to be the easiest, since dynamic links will soon be disabled and we are ready to switch to our own solution.

I hope this will be helpful to someone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants