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

Scenes on React Native 0.71 #132

Open
gavrichards opened this issue May 25, 2023 · 23 comments
Open

Scenes on React Native 0.71 #132

gavrichards opened this issue May 25, 2023 · 23 comments

Comments

@gavrichards
Copy link

I posted this issue on the React Native Github but haven't had a response, maybe it's a bit too niche as most RN devs probably aren't using scenes.

facebook/react-native#37278

I wondered if anyone here might have any thoughts, particularly as this repository now relies on Scenes for newer functionality, or the Now Playing template.

@birkir
Copy link
Owner

birkir commented May 25, 2023

I am updating our scene example to RN 71, so stay tuned!

@birkir
Copy link
Owner

birkir commented May 26, 2023

@gavrichards
Copy link
Author

Thanks, I'll give this a try.

@gavrichards
Copy link
Author

I have got this working on iPhone, not yet tested CarPlay.

Do you know anything about launchOptions versus connectionOptions?

launchOptions is used to pass information about how the app was launched, for example the payload from a push notification, or info about a Siri Shortcut.

With Scenes, "launchOptions" in the AppDelegate is always empty, and instead similar data is provided to the scene delegate as "connectionOptions".

But the RN Bridge expects launchOptions to be passed when it's initialised, and that happens in AppDelegate. So you can see how this is an issue.

To get around it, pre RN 0.71, I had to intercept connectionOptions in the Scene delegate's willConnectTo method, parse and rebuild it in the "launchOptions" format, and then initialise the Bridge there instead of AppDelegate. I did this by checking if "appDelegate.bridge == nil", as it could've already been initialised by the other scene delegate.

But with RN 0.71, initialisation of the bridge is abstracted, so there's seemingly no way to still do this.

Any help would be hugely appreciated. As things stand, RN CarPlay using Scenes, along with RN 0.71, can't really be used in an app that also uses push notifications or Siri Shortcuts.

@birkir
Copy link
Owner

birkir commented Jun 1, 2023

https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/AppDelegate/RCTAppDelegate.mm#L55-L85

This is all the "abstraction", that was previously in RN70 and older inside AppDelegate.m, as you can see its rather easy to copy this into your own swift code. For example in the PhoneScene you could initialize the bridge like this.

guard let appDelegate = (UIApplication.shared.delegate as? AppDelegate) else { return }
if appDelegate.bridge == nil {
  appDelegate.bridge = appDelegate.createBridge(delegate: appDelegate, launchOptions: launchOptions)
}

Or create a wrapper function in your AppDelegate.swift and call it from a scene delegate.

func application() {
  return true;
}

func initStuff() {
  let application = UIApplication.shared;
  let launchOptions = /* your previously mapped options */
  let app = super.application(application, didFinishLaunchingWithOptions: launchOptions);
  // rest of code
}

@gavrichards
Copy link
Author

Thanks @birkir. I would be inclined to go with the latter approach, but I think it suffers two issues:

  1. It creates rootViewController, which we don't want to happen when the app is launched on CarPlay first (i.e. CarScene) - I think that should only be created when PhoneScene is initialised
  2. It doesn't store rootView anywhere, which is useful for passing to libraries like react-native-bootsplash

@DanielKuhn
Copy link
Contributor

DanielKuhn commented Jul 26, 2023

Hi @gavrichards - while adding CarPlay-support to my react native app (with push notifications support) I am now stuck at exactly this point. Did you manage to find a solution yet?

I'm using react-native-splash-screen instead of react-native-bootsplash but the problem is the same: They both expect rootView.

@gavrichards
Copy link
Author

@DanielKuhn If it helps, my PhoneScene.swift now looks like this:

import Foundation
import UIKit
import SwiftUI

class PhoneSceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let appDelegate = (UIApplication.shared.delegate as? AppDelegate) else { return }
    guard let windowScene = (scene as? UIWindowScene) else { return }

    appDelegate.initAppFromScene(connectionOptions: connectionOptions)

    let rootViewController = UIViewController()
    rootViewController.view = appDelegate.rootView;

    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = rootViewController
    self.window = window
    window.makeKeyAndVisible()

    // Added for react-native-bootsplash
    // Help for using Swift: https://github.com/zoontek/react-native-bootsplash/issues/245
    RNBootSplash.initWithStoryboard("Launch Screen", rootView: appDelegate.rootView as! RCTRootView) // <- initialization using the storyboard file name
  }

  func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    RNSSSiriShortcuts.scene(scene, continue: userActivity)
  }
}

and then in my AppDelegate I have:

  /**
   Replicate didFinishLaunchingWithOptions from RCTAppDelegate with two differences:
   1. Store rootView property, so it can be used in PhoneScene to pass to RNBootSplash
   2. Not creating rootViewController as this should only be created when PhoneScene is initiated, not CarScene
   */
  func initAppFromScene(connectionOptions: UIScene.ConnectionOptions) {
    // If bridge has already been initiated by another scene, there's nothing to do here
    if (self.bridge != nil) {
      return;
    }
    
    let application = UIApplication.shared;

    let enableTM = false;
#if RCT_NEW_ARCH_ENABLED
    enableTM = self.turboModuleEnabled;
#endif
    RCTAppSetupPrepareApp(application, enableTM);
    
    self.bridge = super.createBridge(
      with: self,
      launchOptions: self.connectionOptionsToLaunchOptions(connectionOptions: connectionOptions)
    )
    
#if RCT_NEW_ARCH_ENABLED
    self.bridgeAdapter = RCTSurfacePresenterBridgeAdapter(initWithBridge: self.bridge, contextContainer:_contextContainer);
    self.bridge.surfacePresenter = self.bridgeAdapter.surfacePresenter;

    self.unstable_registerLegacyComponents();
    RCTComponentViewFactory.currentComponentViewFactory().thirdPartyFabricComponentsProvider = self;
#endif
    
    let initProps = self.prepareInitialProps();
    self.rootView = self.createRootView(with: self.bridge, moduleName: self.moduleName, initProps: initProps)
    self.rootView!.backgroundColor = UIColor.systemBackground
  }
  
  /**
   Convert ConnectionOptions to LaunchOptions
   When Scenes are used, the launchOptions param in "didFinishLaunchingWithOptions" is always null, and the expected data is provided through SceneDelegate's ConnectionOptions instead but in a different format
   */
  func connectionOptionsToLaunchOptions(connectionOptions: UIScene.ConnectionOptions) -> [UIApplication.LaunchOptionsKey: Any] {
    var launchOptions: [UIApplication.LaunchOptionsKey: Any] = [:];

    if connectionOptions.notificationResponse != nil {
      launchOptions[UIApplication.LaunchOptionsKey.remoteNotification] = connectionOptions.notificationResponse?.notification.request.content.userInfo;
    }
    
    if !connectionOptions.userActivities.isEmpty {
      let userActivity = connectionOptions.userActivities.first;
      let userActivityDictionary = [
        "UIApplicationLaunchOptionsUserActivityTypeKey": userActivity?.activityType as Any,
        "UIApplicationLaunchOptionsUserActivityKey": userActivity!
      ] as [String : Any];
      launchOptions[UIApplication.LaunchOptionsKey.userActivityDictionary] = userActivityDictionary;
    }
    
    return launchOptions;
  }

@DanielKuhn
Copy link
Contributor

@gavrichards Thanks a ton!
I got my app running on the phone by moving the RNSplashScreen.show()-call to after the rootView-creation from @birkir 's example and then launching the CarPlay-app afterwards, but I bet I'll come back to this once I start fiddling around with starting it in CarPlay first.

Thanks to @birkir for the awesome work on this package by the way!

@eduardooris
Copy link

eduardooris commented Sep 12, 2023

Can you tell me if it's possible to do it without transforming the project into Swift?

@DanielKuhn @birkir @gavrichards

@DanielKuhn
Copy link
Contributor

DanielKuhn commented Sep 12, 2023

@eduardooris : I found that the Podverse sources contain a "regular" ObjC implementation using scenes: https://github.com/podverse/podverse-rn
Seems to work just as well.

@eduardooris
Copy link

eduardooris commented Sep 12, 2023

@DanielKuhn I'm building a screen for CarPlay in a Swift class. But my application is in React Native with Objective-c. As I'm using the carplay-driving-task right, if I don't declare the scenes in the .plist, xcode gives me the error: application does not implement carplay template application lifecycle methods in its scenedelegate. I already configured the entire .plist. But, my iPhone app is completely dark. I realized it was because I hadn't declared the scenes in Delegate.
Can you tell me how I can do this in Objective-c ?: func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
if (connectingSceneSession.role == UISceneSession.Role.carTemplateApplication) {
let scene = UISceneConfiguration(name: "CarPlay", sessionRole: connectingSceneSession.role)
scene.delegateClass = CarSceneDelegate.self
return scene
} else {
let scene = UISceneConfiguration(name: "Phone", sessionRole: connectingSceneSession.role)
scene.delegateClass = PhoneSceneDelegate.self
return scene
}
}

@DanielKuhn
Copy link
Contributor

@eduardooris as commented above: Check the podverse-rn repo: https://github.com/podverse/podverse-rn/blob/develop/ios/podverse/AppDelegate.m#L67

@eduardooris
Copy link

@DanielKuhn I gave it a read. But, I saw that there is no AppDelegate

@DanielKuhn
Copy link
Contributor

@eduardooris I don't understand. The link points specifically to the AppDelegate

@eduardooris
Copy link

Hi @DanielKuhn . I ended up using AppDelegate for Swift to be able to handle the scenes. But, I ended up having a problem with SplashScreen. Could you tell me how you resolved it?

@DanielKuhn
Copy link
Contributor

DanielKuhn commented Oct 10, 2023

@eduardooris : If you're using RNBootSplash check @gavrichards code above.
I'm using RNSplashScreen and my setup currently looks like this:

AppDelegate.swift:

import AVFoundation
import UIKit
import React
import CarPlay
import CodePush
import GoogleCast
import RNCPushNotificationIOS

@main
class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
  
  var rootView: UIView?;
  var concurrentRootEnabled = true;
  
  static var shared: AppDelegate { return UIApplication.shared.delegate as! AppDelegate }
  
  override func application(_ application: UIApplication,
                            didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    moduleName = "MyModuleName"
    
    // If bridge has already been initiated by another scene, there's nothing to do here
    if (self.bridge != nil) {
      return true
    }
    
    let success = super.application(application, didFinishLaunchingWithOptions: launchOptions)
    
    if (success) {
      // Added for react-native-google-cast
      let receiverAppID = kGCKDefaultMediaReceiverApplicationID // or "ABCD1234"
      let criteria = GCKDiscoveryCriteria(applicationID: receiverAppID)
      let options = GCKCastOptions(discoveryCriteria: criteria)
      GCKCastContext.setSharedInstanceWith(options)
      GCKCastContext.sharedInstance().useDefaultExpandedMediaControls = true
      
      // Added for react-native-community/push-notification-ios
      let center = UNUserNotificationCenter.current()
      center.delegate = self
    }
    
    self.rootView = self.createRootView(
      with: self.bridge,
      moduleName: self.moduleName,
      initProps: self.prepareInitialProps()
    );
    
    // Added for react-native-splash-screen
    RNSplashScreen.show()
    
    return success;
  }
  
  override func application(_ application: UIApplication,
                            configurationForConnecting connectingSceneSession: UISceneSession,
                            options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    if (connectingSceneSession.role == UISceneSession.Role.carTemplateApplication) {
      let scene =  UISceneConfiguration(name: "CarPlay", sessionRole: connectingSceneSession.role)
      scene.delegateClass = CarSceneDelegate.self
      return scene
    } else {
      let scene =  UISceneConfiguration(name: "Phone", sessionRole: connectingSceneSession.role)
      scene.delegateClass = PhoneSceneDelegate.self
      return scene
    }
  }
  
  override func sourceURL(for bridge: RCTBridge!) -> URL! {
#if DEBUG
    return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index");
#else
    // Originally:
    // return Bundle.main.url(forResource:"main", withExtension:"jsbundle")
    return CodePush.bundleURL()
#endif
  }
  
  // not exposed from RCTAppDelegate, recreating.
  func prepareInitialProps() -> [String: Any] {
    var initProps = self.initialProps as? [String: Any] ?? [String: Any]()
#if RCT_NEW_ARCH_ENABLED
    initProps["kRNConcurrentRoot"] = concurrentRootEnabled()
#endif
    return initProps
  }
  
  /*
   * Orientation:
   */
  override func application(_ application: UIApplication,
                            supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
    return Orientation.getOrientation()
  }
  
  /*
   * Notifications:
   */
  // Required for the register event.
  override func application(_ application: UIApplication,
                            didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    RNCPushNotificationIOS.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
  }
  
  // Required for the notification event. You must call the completion handler after handling the remote notification.
  override func application(_ application: UIApplication,
                            didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                            fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    RNCPushNotificationIOS.didReceiveRemoteNotification(userInfo, fetchCompletionHandler: completionHandler)
  }
  
  // Required for the registrationError event.
  override func application(_ application: UIApplication,
                            didFailToRegisterForRemoteNotificationsWithError error: Error) {
    RNCPushNotificationIOS.didFailToRegisterForRemoteNotificationsWithError(error)
  }
  
  // Required for local notification tapped event
  func userNotificationCenter(_ center: UNUserNotificationCenter,
                              didReceive response: UNNotificationResponse,
                              withCompletionHandler completionHandler: @escaping () -> Void) {
    RNCPushNotificationIOS.didReceive(response)
    completionHandler()
  }
  
  // Called when a notification is delivered to a foreground app.
  func userNotificationCenter(_ center: UNUserNotificationCenter,
                              willPresent notification: UNNotification,
                              withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
    completionHandler([.sound, .badge, .alert])
  }
  
  /*
   * Linking:
   */
  // Required for custom scheme linking and Universal Links
  override func application(_ application: UIApplication,
                            open url: URL,
                            options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    RCTLinkingManager.application(application, open: url, options: options)
  }
  
  // Required for Universal Links
  override func application(_ application: UIApplication,
                            continue userActivity: NSUserActivity,
                            restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
  }
}

PhoneScene.swift:

import UIKit
import SwiftUI

class PhoneSceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
  func scene(_ scene: UIScene,
             willConnectTo session: UISceneSession,
             options connectionOptions: UIScene.ConnectionOptions) {
    guard let appDelegate = (UIApplication.shared.delegate as? AppDelegate) else { return }
    if appDelegate.bridge == nil {
      appDelegate.bridge = RCTBridge(delegate: appDelegate, launchOptions: [:])
      appDelegate.rootView = RCTAppSetupDefaultRootView(appDelegate.bridge, "MyModuleName", appDelegate.prepareInitialProps(), false)
    }
    
    guard let windowScene = (scene as? UIWindowScene) else { return }

    let rootViewController = UIViewController()
    rootViewController.view = appDelegate.rootView;
    
    appDelegate.window = UIWindow(windowScene: windowScene)
    appDelegate.window.rootViewController = rootViewController
    appDelegate.window.makeKeyAndVisible()
  }
}

CarScene.swift:

import CarPlay

class CarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
  func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
                                  didConnect interfaceController: CPInterfaceController) {
    guard let appDelegate = (UIApplication.shared.delegate as? AppDelegate) else { return }
    if appDelegate.bridge == nil {
      appDelegate.bridge = RCTBridge(delegate: appDelegate, launchOptions: [:])
      appDelegate.rootView = RCTAppSetupDefaultRootView(appDelegate.bridge, "MyModuleName", appDelegate.prepareInitialProps(), false)
    }
    RNCarPlay.connect(with: interfaceController, window: templateApplicationScene.carWindow);
  }

  func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
                                didDisconnectInterfaceController interfaceController: CPInterfaceController) {
    RNCarPlay.disconnect()
  }
}

@harrymash2006
Copy link

@DanielKuhn I tried same scene files from podverse project but I am still getting the blank screen. I get this message in bundler Running "App" with {"rootTag":1,"initialProps":{}} in terminal but still it shows blank screen on CarPlay

@DanielKuhn
Copy link
Contributor

DanielKuhn commented Nov 16, 2023

Hey @gavrichards I'm finally debugging startup on CarPlay without the app running on phone. I have two separate approaches, both of which are not working properly.

The one I posted above seems to render the app twice(?) when starting on phone. But it does launch the carplay app without the app running on phone - even though all my hooks get fired twice and the CarPlay app isn't working properly when the phone app isn't running. Once I start the app on phone alongside with CarPlay it works perfectly.

The other approach is your setup from above (with initAppFromScene), which produces only a single render and the CarPlay app is also working perfectly - but ONLY WHEN THE APPP IS RUNNING ON PHONE!

So I think I'm missing some initialization code in my CarSceneDelegate. Could you please do me a big favor and also add your CarSceneDelegate-code with initAppFromScene here to complete the picture of your solution?

Thanks!

@DanielKuhn
Copy link
Contributor

DanielKuhn commented Nov 21, 2023

After fiddling around with this topic for quite some time now, always in doubt of what's happening under the hood and seeing ever more questions around starting on CarPlay without having the app running on phone, I took the liberty to create (and document!) an example app which runs independently of the phone app and supports launching on CarPlay directly (without having the phone app running) in this PR: #158
Patches welcome, feel free to add comments and improvements.

@alex-vasylchenko
Copy link

@DanielKuhn hi, are you in a discord group? if so, what is your nickname? i have something to tell you

@DanielKuhn
Copy link
Contributor

@DanielKuhn hi, are you in a discord group? if so, what is your nickname? i have something to tell you

Now I am, nick is DanielKuhn

@susonthapa
Copy link
Contributor

This is my solution. I had to refactor the app initialization logic to be UI-independent. I think this approach is pretty efficient as it doesn't create a RootView if the app is only opened on CarPlay.

AppDelegate

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
  ) -> Bool {
    // We are not calling the super method as RCTAppDelegate does not support
    // SceneDelegate at the moment
    RCTAppSetupPrepareApp(application, false)

    // This must be called before setting ReactRootView
    bridge = RCTBridge(
      delegate: self,
      launchOptions: launchOptions
    )

    return true
  }

SceneDelegate

class SceneDelegate: UIResponder, UIWindowSceneDelegate,
  CPTemplateApplicationSceneDelegate
{
  var window: UIWindow?

  func scene(
    _ scene: UIScene,
    willConnectTo _: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions
  ) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    window = UIWindow(windowScene: windowScene)

    let rootViewController = UIViewController()
    let rootView = RCTRootView(
      bridge: (UIApplication.shared.delegate as! AppDelegate).bridge,
      moduleName: "main",
      initialProperties: nil
    )
    rootViewController.view = rootView

    window!.rootViewController = rootViewController
    window!.tintColor = UIColor(
      red: 0.0,
      green: 0.0,
      blue: 0.0,
      alpha: 1.0
    )
    window!.makeKeyAndVisible()
  }

  // MARK: - CPTemplateApplicationSceneDelegate

  func templateApplicationScene(
    _: CPTemplateApplicationScene,
    didConnect interfaceController: CPInterfaceController,
    to window: CPWindow
  ) {
    RNCarPlay.connect(with: interfaceController, window: window)
  }

  func templateApplicationScene(
    _: CPTemplateApplicationScene,
    didDisconnect _: CPInterfaceController,
    from _: CPWindow
  ) {
    RNCarPlay.disconnect()
  }
}

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

No branches or pull requests

7 participants