Migrating from turbo-ios/turbo-navigator to hotwire native

Hi there,

I am trying to migrate my app from turbo-ios + turbo-navigator to hotwire native, but running into a few issues.

In turbo-ios, the docs contained instructions on how to detect 401 responses from the server so we could then prompt the user for authentication:

But I can’t find any information on how to achieve this in hotwire native. Did anybody figure this out?

Any pointers would be much appreciated,
Thank you.

Conform to the NavigatorDelegate and implement the error handler function.

import HotwireNative
import UIKit

let baseURL = URL(string: "http://localhost:3000")!

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

    private let navigator = Navigator()

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        navigator.delegate = self
        window?.rootViewController = navigator.rootViewController
    }
}

extension SceneDelegate: NavigatorDelegate {
    func visitableDidFailRequest(_ visitable: any Visitable, error: any Error, retryHandler: RetryBlock?) {
        if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 404 {
            // Handle 404.
        } else if let errorPresenter = visitable as? ErrorPresenter {
            errorPresenter.presentError(error, retryHandler: retryHandler)
        }
    }
}
1 Like

Hi there!

I’m having some trouble migrating from Turbo to Hotwire. I’m trying to use the native screen that manages GoogleSignIn, but it’s not calling the navigator.route(signInUrl), VisitOptions(action = REPLACE).

I’ve checked the docs and the demo multiple times, but I can’t seem to figure out what I’m missing. Any help would be greatly appreciated!

package com.maybeclub.maybeclub.fragments

import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.tasks.Task
import com.maybeclub.maybeclub.BuildConfig.BASE_URL
import com.maybeclub.maybeclub.R
import dev.hotwire.core.turbo.visit.VisitOptions
import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink
import dev.hotwire.navigation.fragments.HotwireWebFragment
import dev.hotwire.core.turbo.visit.VisitAction.REPLACE

@HotwireDestinationDeepLink(uri = "hotwire://fragment/google_sign_in")
class GoogleSignInFragment : HotwireWebFragment() {
    private lateinit var googleSignInClient: GoogleSignInClient
    private lateinit var signInResultLauncher: ActivityResultLauncher<Intent>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        signInResultLauncher = registerForActivityResult(
            ActivityResultContracts.StartActivityForResult()
        ) { result ->
            Log.i("GoogleSignIn", result.toString())

            if (result.resultCode == Activity.RESULT_OK) {
                val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
                handleSignInResult(task)
            }

            if (result.resultCode == Activity.RESULT_CANCELED) {
                Toast.makeText(requireContext(), "Login cancelado", Toast.LENGTH_LONG).show()

                // Log the Intent's extras if available
                result.data?.extras?.let { bundle ->
                    for (key in bundle.keySet()) {
                        val value = bundle.get(key)
                        Log.i("GoogleSignInExtras", "Extra key: $key, value: $value")
                    }
                }
            }
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_google_sign_in, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestIdToken(getString(R.string.google_oauth_web_client_id)) // Google OAuth Web Client ID
            .requestProfile()
            .requestEmail()
            .build()

        googleSignInClient = GoogleSignIn.getClient(requireActivity(), gso)

        view.findViewById<Button>(R.id.sign_in_button).setOnClickListener {
            signIn()
        }

        val termsOfServiceTextView: TextView = view.findViewById(R.id.terms_of_service)
        termsOfServiceTextView.setOnClickListener {
            val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("${BASE_URL}/public_documents/terms_of_use"))
            startActivity(browserIntent)
        }

        val privacyPolicyTextView: TextView = view.findViewById(R.id.privacy_policy)
        privacyPolicyTextView.setOnClickListener {
            val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("${BASE_URL}/public_documents/privacy_policy"))
            startActivity(browserIntent)
        }
    }

    private fun signIn() {
        val signInIntent = googleSignInClient.signInIntent
        signInResultLauncher.launch(signInIntent)
    }

    private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) {
        try {
            val account = completedTask.getResult(ApiException::class.java)
            // Signed in successfully, show authenticated UI.
            CookieManager.getInstance().setCookie(BASE_URL, "id_token=${account.idToken}") {
                val signInUrl = "${BASE_URL}/turbo_native/sessions/create"
                navigator.route(signInUrl, VisitOptions(action = REPLACE))
            }
        } catch (e: ApiException) {
            AlertDialog.Builder(requireContext()).apply {
                setTitle("Falha na autenticação")
                setMessage("Erro: ${e.statusCode}")
                setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
                show()
            }
        }
    }
}