Turbo Native - Native Authentication Part 3 - Android Client

In this series’s previous two blog posts, we covered setting up a Rails app and an iOS app. In this article, we’re going to do the same for Android. The code can be seen here.

The approach for the Turbo Android app is the same as the Turbo iOS app.
When a user is unauthenticated, we return a 401 status code. We intercept that error and display a native sign-in flow in our iOS app.

Initial Setup

First, we can set up our app as per this blog post. However, instead of setting up No Activity, set up an Empty Activity, which will install all the Jetpack compose libraries.

We then add our Turbo library and the okhttp3 library in the Gradle file.

// hotwire
implementation "dev.hotwire:turbo:7.0.0-rc18"

// okhttp3
implementation("com.squareup.okhttp3:okhttp:4.10.0")

After that, press the sync button to install all our dependencies.

Create a SignInFragment

With everything set up, we next need to trigger a native flow every time a user visits /sign_in or a page that returns a 401 unauthorised status.

The first thing we will do is create our SignInFragment.

private const val AUTH_TOKEN_KEY = "auth_token"
private const val SHARED_PREFS_NAME = "turbo_native_auth"


@TurboNavGraphDestination(uri = "turbo://fragment/sign_in")
class SignInFragment : TurboFragment(), TurboNavDestination {

  override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
    return ComposeView(requireContext()).apply {
      setContent {
        AppTheme {
          SignInForm()
        }
      }
    }
  }

  @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun SignInForm() {
      var email by remember { mutableStateOf("") }
      var password by remember { mutableStateOf("") }

      val context = LocalContext.current

        Column(
            modifier = Modifier
            .padding(16.dp)
            .fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
            ) {
          TextField(
              value = email,
              onValueChange = { newText ->
              email = newText.trimEnd()
              },
              label = { Text("Email") },
              modifier = Modifier.fillMaxWidth()
              )
            Spacer(modifier = Modifier.height(8.dp))
            TextField(
                value = password,
                onValueChange = { password = it },
                label = { Text("Password") },
                visualTransformation = PasswordVisualTransformation(),
                modifier = Modifier.fillMaxWidth()
                )
            Spacer(modifier = Modifier.height(16.dp))
            Button(
                onClick = { performSignIn(context, email, password) },
                modifier = Modifier.align(Alignment.End)
                ) {
              Text("Sign In")
            }
        }
    }
}

We then add this to our list of registered fragments in our MainSessionNavHost.

val BASE_URL = "http://10.0.2.2:3005"
val SIGN_IN_URL = "${BASE_URL}/sign_in"
val PATH_CONFIGURATION_URL = "${BASE_URL}/turbo/android/path_configuration"
val API_SIGN_IN_URL = "${BASE_URL}/api/v1/sessions"

class MainSessionNavHost : TurboSessionNavHostFragment() {
  override var sessionName = "main"
    override var startLocation = BASE_URL

    override val registeredFragments: List<KClass<out Fragment>>
    get() = listOf(
        WebFragment::class,
        WebModalFragment::class,
        SignInFragment::class
        )


    override val registeredActivities: List<KClass<out AppCompatActivity>>
    get() = listOf()

    override val pathConfigurationLocation: TurboPathConfiguration.Location
    get() = TurboPathConfiguration.Location(
        assetFilePath = "json/path_configuration.json",
        remoteFileUrl = PATH_CONFIGURATION_URL
        )
}

Update Path Configuration

Now, we update our path_configuration file so our native fragment gets displayed. Just like turbo-ios, turbo-android relies heavily on path configuration. We can get a file from the server and save one locally. For this project, our path configuration file looks like the following:

{
  "settings": {
    "screenshot_enabled": true
  },
    "rules": [
    {
      "patterns": [
        ".*"
      ],
      "properties": {
        "context": "default",
        "uri": "turbo://fragment/web",
        "pull_refresh_enabled": true
      }
    },
    {
      "patterns": [
        "/new$",
      "/edit$"
      ],
      "properties": {
        "context": "modal",
        "uri": "turbo://fragment/web/modal/sheet",
        "pull_to_refresh_enabled": false
      }
    },
    {
      "patterns": [
        "/sign_in$"
      ],
      "properties": {
        "context": "default",
        "uri": "turbo://fragment/sign_in",
        "pull_refresh_enabled": false
      }
    }
  ]
}

Just like that, every time we navigate to sign_in, we display a native sign-in form. However, it doesn’t do much yet. We still need to be able to send requests and save the cookies and auth token.

Native Sign in

For our iOS application, we saved our authentication token to the keychain to be held securely and retrieved in other parts of the application via a getter method.

For the Android application, we will use the Shared Preferences api.

private fun getAuthToken(context: Context): String? {
  val sharedPreferences = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
    return sharedPreferences.getString(AUTH_TOKEN_KEY, null)
}

This method retrieves the AuthToken if we need to make an API request.

We also need to be able to write the token to our shared preferences.

private fun saveAuthToken(context: Context, authToken: String?) {
  val sharedPreferences = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
    sharedPreferences.edit().putString(AUTH_TOKEN_KEY, authToken).apply()
}

Finally, we have to save our cookies.

private fun saveCookies(cookies: List<String>) {
  val cookieManager = CookieManager.getInstance()

    for (cookie in cookies) {
      parse(API_SIGN_IN_URL.toHttpUrlOrNull()!!, cookie)?.let {
        cookieManager.setCookie(API_SIGN_IN_URL, it.toString())
      }
    }
}

Now we can add all call all these methods from our performSignIn method in the SignInFragment:

private fun performSignIn(context: Context, email: String, password: String) {
  val client = OkHttpClient()

    val requestBody = FormBody.Builder()
    .add("email", email)
    .add("password", password)
    .build()

    val request = Request.Builder()
    .url(API_SIGN_IN_URL)
    .post(requestBody)
    .build()

    client.newCall(request).enqueue(object : Callback {
        override fun onFailure(call: Call, e: IOException) {
        // Handle network failure or API error
        }

        override fun onResponse(call: Call, response: Response) {
        if (response.isSuccessful) {
        val authToken = response.header("X-Session-Token")
        val cookies = response.headers("Set-Cookie")

        saveAuthToken(context, authToken)
        saveCookies(cookies)

        // execute on the main thread
        requireActivity().runOnUiThread {
        navigateUp()
        sessionNavHostFragment.reset()
        }

        } else {
        // Raise error
        }
        }
    })
}

Since this is an async request, you must ask Turbo Android to navigate back to the previous page.

// execute on the main thread
requireActivity().runOnUiThread {
  navigateUp()
    sessionNavHostFragment.reset()
}

Handling a 401 error.

So now we should be able to spin up our Android app and sign in and out.

The next thing we need to do is handle the 401 unauthorised errors.

In our WebFragment, we can override the onVisitErrorReceived method and navigate to our sign-in fragment.

override fun onVisitErrorReceived(location: String, errorCode: Int) {
  when (errorCode) {
    401 -> navigate(SIGN_IN_URL, TurboVisitOptions(action = REPLACE))
      else -> super.onVisitErrorReceived(location, errorCode)
  }
}

Unauthorised modal

In our example app, when we navigate to /new, a WebModalFragment is displayed. I could not figure out how to dismiss it and then navigate to the SignInFragment, so I handled the case where there was a 401 error.

By overriding the createErrorView, we can inject our own.

@TurboNavGraphDestination(uri = "turbo://fragment/web/modal/sheet")
class WebModalFragment: TurboWebBottomSheetDialogFragment(), TurboNavDestination {

  @SuppressLint("InflateParams")
    override fun createErrorView(statusCode: Int): View {
      when (statusCode) {
        401 -> return layoutInflater.inflate(R.layout.turbo_auth_error, null)
          else -> return super.createErrorView(statusCode)
      }
    }
}

The customer error layout informs the user that they are not logged in, so they can dismiss and log in. Not a perfect flow, but it works for now.

Turbo Android is an excellent library for quickly building hybrid apps. As you can see, utilising native for specific scenarios is straightforward. It’s simply a matter of creating and adding the fragment to our list of registered fragments.

Learn to Build Android, iOS and Rails Apps using Hotwire

© 2024 William Kennedy, Inc. All rights reserved.