Issues with OkHttpClient and Lichess API (on Android)

51 Views Asked by At

I’m very new to Android development. As a learning project, I tried adapting this simple script for accessing the Lichess API in Java to Android. Most people seemed to agree that for Android, it would probably be better to use OkHttpClient instead of the HttpClient used in the original script. So I asked ChatGPT (yes, sorry) to help me rewrite the method readEmail to use OkHttpClient, and it came up with this:

static String readEmail(String access_token) throws Exception {

    // Get that e-mail
    Request emailRequest = new Request.Builder()
            .url(lichessUri + "/api/account/email")
            .header("authorization", "Bearer " + access_token)
            .header("accept", "application/json")
            .build();

    try (Response response = new OkHttpClient().newCall(emailRequest).execute()) {
        int statusCode = response.code();
        String body = response.body().string();
        String email = parseField("email", body);
        if (statusCode != 200) {
            System.out.println("/api/account/email - " + statusCode);
        }
        return email;
    }
}

To test this function out in the simplest way possible, I decided not to use the login method, but to generate an access token in the Lichess settings directly (and only giving it access to my email address).

However, I got the following error message:

> Task :app:compileDebugJavaWithJavac
C:\Users\sorst\AndroidStudioProjects\TestPhoneApp10\app\src\main\java\com\example\testphoneapp10\MainActivity.java:71: error: unreported exception Exception; must be caught or declared to be thrown
        String email = readEmail(access_token);
                                ^
1 error

Here is the full code (it was based on a example “Hello World” app generated by Android Studio):

package com.example.testphoneapp10;

import android.os.Bundle;

import com.google.android.material.snackbar.Snackbar;

import androidx.appcompat.app.AppCompatActivity;

import android.view.View;

import androidx.core.view.WindowCompat;
import androidx.navigation.NavController;
import androidx.navigation.Navigation;
import androidx.navigation.ui.AppBarConfiguration;
import androidx.navigation.ui.NavigationUI;

import com.example.testphoneapp10.databinding.ActivityMainBinding;

import android.view.Menu;
import android.view.MenuItem;

import android.net.Uri;
import android.os.Build;

import androidx.annotation.RequiresApi;

import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;

import okhttp3.*;

public class MainActivity extends AppCompatActivity {

    public static String lichessUri = "https://lichess.org";

    private AppBarConfiguration appBarConfiguration;
    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        setSupportActionBar(binding.toolbar);

        NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
        appBarConfiguration = new AppBarConfiguration.Builder(navController.getGraph()).build();
        NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);

        binding.fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                        .setAnchorView(R.id.fab)
                        .setAction("Action", null).show();
            }
        });

        String access_token = "<my access token (I'd rather not share this here)>";

        String email = readEmail(access_token);
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.menu_main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();

        //noinspection SimplifiableIfStatement
        if (id == R.id.action_settings) {
            return true;
        }

        return super.onOptionsItemSelected(item);
    }

    @Override
    public boolean onSupportNavigateUp() {
        NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
        return NavigationUI.navigateUp(navController, appBarConfiguration)
                || super.onSupportNavigateUp();
    }

    static String readEmail(String access_token) throws Exception {

        // Get that e-mail
        Request emailRequest = new Request.Builder()
                .url(lichessUri + "/api/account/email")
                .header("authorization", "Bearer " + access_token)
                .header("accept", "application/json")
                .build();

        try (Response response = new OkHttpClient().newCall(emailRequest).execute()) {
            int statusCode = response.code();
            String body = response.body().string();
            String email = parseField("email", body);
            if (statusCode != 200) {
                System.out.println("/api/account/email - " + statusCode);
            }
            return email;
        }
    }

    // Light-weight fragile "json" ""parser""...
    static String parseField(String field, String body) {
        try {
            int start = body.indexOf(field) + field.length() + 3;
            int stop = body.indexOf("\"", start);
            return body.substring(start, stop);
        } catch (Exception e) {
            return null;
        }
    }
}

Here is the file build.gradle.kts:

plugins {
    id("com.android.application")
}

android {
    namespace = "com.example.testphoneapp10"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.example.testphoneapp10"
        minSdk = 30
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    buildFeatures {
        viewBinding = true
    }
}

dependencies {

    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
    implementation("androidx.navigation:navigation-fragment:2.7.6")
    implementation("androidx.navigation:navigation-ui:2.7.6")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    implementation("com.squareup.okhttp3:okhttp:4.9.0")
}

In case it helps, here is the entire code that ChatGPT generated when I asked it to adapt the script to Android and use OkHttpClient. Any comments on what it does wrong here and how this could be improved will also be greatly appreciated:

import java.awt.Desktop;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.Random;
import java.util.stream.Collectors;

import com.sun.net.httpserver.HttpServer;

public class LichessPKCE {

    public static String lichessUri = "https://lichess.org";

    /**
     * This demo application will launch a Web Browser,
     * where authentication with Lichess can be made,
     * for authorization of this demo application to
     * request the e-mail address of the authenticating
     * Lichess user - and if granted - the e-mail address
     * will be fetched and printed on standard output.
     */
    public static void main(String... args) throws Exception {

        // Perform the OAuth2 PKCE flow
        var access_token = login();

        // Fetch the e-mail address
        var email = readEmail(access_token);

        System.out.println("e-mail: " + email);

        // Logout
        logout(access_token);

    }

    static String login() throws Exception {

        // Prepare a new login.
        // We will generate a lot of parameters which will be used in this login,
        // and then the parameters are thrown away, not to be re-used.
        // I.e, next login request will have new parameters generated.

        // Setup a local bind address which we will use in redirect_uri
        var local = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);
        var httpServer = HttpServer.create(local, 0);
        var redirectHost = local.getAddress().getHostAddress();
        var redirectPort = httpServer.getAddress().getPort();

        var code_verifier = generateRandomCodeVerifier();

        var code_challenge_method = "S256";
        var code_challenge = generateCodeChallenge(code_verifier);
        var response_type = "code";
        var client_id = "apptest";
        var redirect_uri = "http://" + redirectHost + ":" + redirectPort + "/";
        var scope = "email:read";
        var state = generateRandomState();

        var parameters = Map.of(
                "code_challenge_method", code_challenge_method,
                "code_challenge", code_challenge,
                "response_type", response_type,
                "client_id", client_id,
                "redirect_uri", redirect_uri,
                "scope", scope,
                "state", state
                );

        var paramString = parameters.entrySet().stream()
            .map(kv -> kv.getKey() + "=" + kv.getValue())
            .collect(Collectors.joining("&"));

        // Front Channel URL, all these parameters are non-sensitive.
        // The actual authentication between User and Lichess happens outside of this demo application,
        // i.e in the browser over HTTPS.
        var frontChannelUrl = URI.create(lichessUri + "/oauth" + "?" + paramString);

        // Prepare for handling the upcoming redirect,
        // after User has authenticated with Lichess,
        // and granted this demo application permission
        // to fetch the e-mail address.
        // The random code_verifier we generated for this single login,
        // will be sent to Lichess on a "Back Channel" so they can verify that
        // the Front Channel request really came from us.
        var cf = registerRedirectHandler(httpServer, parameters, code_verifier);

        // Now we let the User authorize with Lichess,
        // using their browser
        if (Desktop.isDesktopSupported()) {
            var desktop = Desktop.getDesktop();
            if (desktop.isSupported(Desktop.Action.BROWSE)) {
                desktop.browse(frontChannelUrl);
            } else {
                System.out.format("%s%n%n%s%n  %s%n%n",
                        "Doh, Desktop.Action.BROWSE not supported...",
                        "Could you manually go to the following URL :) ?",
                        frontChannelUrl);
            }
        } else {
            System.out.format("%s%n%n%s%n  %s%n%n",
                    "Doh, Desktop not supported...",
                    "Could you manually go to the following URL :) ?",
                    frontChannelUrl);
        }

        // Blocking until user has authorized,
        // and we've exchanged the incoming authorization code for an access token
        var access_token = cf.get();

        httpServer.stop(0);

        return access_token;
    }

    static String readEmail(String access_token) throws Exception {

        // Get that e-mail
        var emailRequest = HttpRequest.newBuilder(URI.create(lichessUri + "/api/account/email"))
            .GET()
            .header("authorization", "Bearer " + access_token)
            .header("accept", "application/json")
            .build();

        var response = HttpClient.newHttpClient().send(emailRequest, BodyHandlers.ofString());
        var statusCode = response.statusCode();
        var body = response.body();
        var email = parseField("email", body);
        if (statusCode != 200) {
            System.out.println("/api/account/email - " + statusCode);
        }
        return email;

    }

    static void logout(String access_token) throws Exception {
        var logoutRequest = HttpRequest.newBuilder(URI.create(lichessUri + "/api/token"))
            .DELETE()
            .header("authorization", "Bearer " + access_token)
            .build();

        var response = HttpClient.newHttpClient().send(logoutRequest, BodyHandlers.discarding());
        var statusCode = response.statusCode();
        if (statusCode != 204) {
            System.out.println("/api/token - " + response.statusCode());
        }
    }

    static String generateRandomCodeVerifier() {
        var bytes = new byte[32];
        new Random().nextBytes(bytes);
        var code_verifier = encodeToString(bytes);
        return code_verifier;
    }

    static String generateCodeChallenge(String code_verifier) {
        var asciiBytes = code_verifier.getBytes(StandardCharsets.US_ASCII);
        MessageDigest md;

        try {
            md = MessageDigest.getInstance("SHA-256");
        } catch (NoSuchAlgorithmException nsa_ehhh) {
            throw new RuntimeException(nsa_ehhh);
        }

        var s256bytes = md.digest(asciiBytes);

        var code_challenge = encodeToString(s256bytes);
        return code_challenge;
    }

    static String generateRandomState() {
        var bytes = new byte[16];
        new Random().nextBytes(bytes);
        // Not sure how long the parameter "should" be,
        // going for 8 characters here...
        return encodeToString(bytes).substring(0,8);
    }

    static String encodeToString(byte[] bytes) {
         return Base64.getUrlEncoder().encodeToString(bytes)
            .replaceAll(  "=",  "")
            .replaceAll("\\+", "-")
            .replaceAll("\\/", "_");
    }

    static CompletableFuture<String> registerRedirectHandler(HttpServer httpServer, Map<String, String> requestParams, String code_verifier) {
        var cf = new CompletableFuture<String>();
        httpServer.createContext("/",
                (exchange) -> {
                    httpServer.removeContext("/");

                    // The redirect arrives...
                    var query = exchange
                        .getRequestURI()
                        .getQuery();

                    var inparams = Arrays.stream(query.split("&"))
                        .collect(Collectors.toMap(
                                    s -> s.split("=")[0],
                                    s -> s.split("=")[1]
                                    ));

                    var code = inparams.get("code");
                    var state = inparams.get("state");

                    if (! state.equals(requestParams.get("state"))) {
                        cf.completeExceptionally(new Exception("The \"state\" parameter we sent and the one we recieved didn't match!"));
                        return;
                    }

                    if (code == null) {
                        exchange.sendResponseHeaders(503, -1);
                        cf.completeExceptionally(new Exception("Authorization Failed"));
                        return;
                    }

                    // We have received meta data from Lichess,
                    // about the fact that the User has authorized us - yay!

                    // Let's respond with a nice HTML page in celebration.
                    var responseBytes = "<html><body><h1>Success, you may close this page</h1></body></html>".getBytes();
                    exchange.sendResponseHeaders(200, responseBytes.length);
                    exchange.getResponseBody().write(responseBytes);


                    // Now,
                    // let's go to Lichess and ask for a token - using the meta data we've received
                    var tokenParameters = Map.of(
                            "code_verifier", code_verifier,
                            "grant_type", "authorization_code",
                            "code", code,
                            "redirect_uri", requestParams.get("redirect_uri"),
                            "client_id", requestParams.get("client_id")
                            );

                    var tokenParamsString = tokenParameters.entrySet().stream()
                        .map(kv -> kv.getKey() + "=" + kv.getValue())
                        .collect(Collectors.joining("&"));

                    var tokenRequest = HttpRequest.newBuilder(URI.create(lichessUri + "/api/token"))
                        .POST(BodyPublishers.ofString(tokenParamsString))
                        .header("content-type", "application/x-www-form-urlencoded")
                        .build();

                    try {
                        var response = HttpClient.newHttpClient().send(tokenRequest, BodyHandlers.ofString());
                        var statusCode = response.statusCode();
                        var body = response.body();

                        if (statusCode != 200) {
                            System.out.println("/api/token - " + statusCode);
                        }
                        var access_token = parseField("access_token", body);

                        if (access_token == null) {
                            System.out.println("Body: " + body);
                            cf.completeExceptionally(new Exception("Authorization Failed"));
                            return;
                        }

                        // Ok, we have successfully retrieved a token which we can use
                        // to fetch the e-mail address
                        cf.complete(access_token);

                    } catch (Exception e) {
                        e.printStackTrace();
                    }
        });
        httpServer.start();
        return cf;
    }

    // Light-weight fragile "json" ""parser""...
    static String parseField(String field, String body) {
        try {
            int start = body.indexOf(field) + field.length() + 3;
            int stop = body.indexOf("\"", start);
            var field_value = body.substring(start, stop);
            return field_value;
        } catch (Exception e){
            return null;
        }
    }

}
0

There are 0 best solutions below