Connect4 — 09: כניסה עם חשבון Google


בונים מצב עובד אחד מתוך המסלול

מפת המסלול

בסוף הפרק נוסף כפתור Continue with Google. הוא פותח בורר חשבונות, מקבל ID token ומחליף אותו בזהות Firebase. מסך המשתמש המחובר נשאר אותו מסך מהפרק הקודם.

הכנה עם המורה

  1. הפעילו את ספק Google ב־Firebase Authentication ובחרו כתובת תמיכה לפרויקט.
  2. רשמו SHA-1 ו־SHA-256 של תעודת החתימה שבה בניתם את האפליקציה. ב־Android Studio פתחו Gradle > app > Tasks > android > signingReport, או הריצו gradlew signingReport ב־Terminal של הפרויקט.
  3. הורידו מחדש את google-services.json עבור com.example.connect4 והחליפו את הקובץ במודול app. Sync ובנייה מייצרים את המשאב default_web_client_id; אין להמציא מזהה או להדביק בו את מזהה Android client.
  4. בדקו במכשיר או emulator עם Google Play services וחשבון בדיקה. ראו גם Google ו־SHA-1.

סיסמת Google אינה עוברת דרך שדה הסיסמה שלנו. ה־ID token מועבר ל־Firebase לאימות, ולא נשמר ב־SharedPreferences.

השינויים לפי סדר העבודה

התחילו במצב שעבד בסוף הפרק הקודם. קובץ חדש מוצג במלואו; בקובץ קיים מופיעים רק האזורים שמשתנים. בקטעי diff מסירים את שורות -, מוסיפים את שורות +, ומשאירים את שורות ההקשר. הסימנים עצמם אינם חלק מקוד Java או XML. אין למחוק קוד אחר שאינו מוצג.

build.gradle.kts

Gradle Scripts > build.gradle.kts (Module :app)

מוסיפים Credential Manager, חיבור Play services וספריית Google ID. אלה תלויות Android; הן אינן מודל ML.

     }
 }
 
 dependencies {
+    implementation("androidx.credentials:credentials:1.3.0")
+    implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
+    implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
     implementation(platform("com.google.firebase:firebase-bom:34.19.0"))
     implementation("com.google.firebase:firebase-auth")
     implementation("com.google.ai.edge.litert:litert:2.1.0")
     implementation("androidx.lifecycle:lifecycle-viewmodel:2.10.0")

GoogleSignIn.java

app > kotlin+java > com.example.connect4 > GoogleSignIn.java

המחלקה עוטפת את בורר החשבונות. getCredentialAsync מחזירה דרך callback. מזהה הלקוח נקרא מהמשאבים כדי שבנייה ללא Firebase תמשיך לעבוד ותציג שגיאה מוסברת במקום לקרוס.

CancellationSignal מאפשר לסגור בקשה כשה־Activity נהרס. ביטול משתמש מייצר הודעת ביטול, לא הודעת הצלחה. clearSession מנקה את מצב ספק האישורים אחרי signOut כדי לאפשר בחירת חשבון מחדש.

package com.example.connect4;

import android.app.Activity;
import android.os.CancellationSignal;
import androidx.core.content.ContextCompat;
import androidx.credentials.ClearCredentialStateRequest;
import androidx.credentials.Credential;
import androidx.credentials.CredentialManager;
import androidx.credentials.CredentialManagerCallback;
import androidx.credentials.CustomCredential;
import androidx.credentials.GetCredentialRequest;
import androidx.credentials.GetCredentialResponse;
import androidx.credentials.exceptions.ClearCredentialException;
import androidx.credentials.exceptions.GetCredentialCancellationException;
import androidx.credentials.exceptions.GetCredentialException;
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption;
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential;

/** Presents Google's account chooser; Firebase verifies the returned ID token. */
final class GoogleSignIn {
    interface Callback {
        void token(String token);
        void error(String message);
    }
    private final Activity activity;
    private final CredentialManager manager;
    private CancellationSignal cancellation;

    GoogleSignIn(Activity activity) {
        this.activity = activity;
        manager = CredentialManager.create(activity);
    }

    void signIn(Callback callback) {
        // Dynamic lookup keeps offline builds valid when Firebase config is absent.
        int resource = activity.getResources().getIdentifier("default_web_client_id", "string", activity.getPackageName());
        if (resource == 0) {
            callback.error("Google sign-in needs the updated Firebase OAuth configuration.");
            return;
        }
        GetSignInWithGoogleOption option = new GetSignInWithGoogleOption.Builder(activity.getString(resource)).build();
        GetCredentialRequest request = new GetCredentialRequest.Builder().addCredentialOption(option).build();
        cancellation = new CancellationSignal();
        manager.getCredentialAsync(activity, request, cancellation, ContextCompat.getMainExecutor(activity),
                new CredentialManagerCallback<GetCredentialResponse, GetCredentialException>() {
                    @Override public void onResult(GetCredentialResponse response) {
                        cancellation = null;
                        Credential credential = response.getCredential();
                        if (!(credential instanceof CustomCredential)) {
                            callback.error("Google returned an unsupported credential.");
                            return;
                        }
                        try {
                            callback.token(GoogleIdTokenCredential.createFrom(credential.getData()).getIdToken());
                        } catch (Exception invalid) {
                            callback.error("Google sign-in returned an invalid token. Please try again.");
                        }
                    }
                    @Override public void onError(GetCredentialException error) {
                        cancellation = null;
                        callback.error(error instanceof GetCredentialCancellationException ? "Google sign-in cancelled."
                                : "Google sign-in could not finish: " + error.getMessage());
                    }
                });
    }

    void clearSession() {
        manager.clearCredentialStateAsync(new ClearCredentialStateRequest(), null,
                ContextCompat.getMainExecutor(activity), new CredentialManagerCallback<Void, ClearCredentialException>() {
                    @Override public void onResult(Void ignored) { }
                    @Override public void onError(ClearCredentialException ignored) { }
                });
    }

    void cancel() { if (cancellation != null) { cancellation.cancel(); cancellation = null; } }
}

MultiplayerRepository.java

app > kotlin+java > com.example.connect4 > multiplayer > MultiplayerRepository.java

מוסיפים פעולה אחת שממירה Google token ל־Firebase credential. ההצלחה מחזירה UID באותו ResultCallback כמו Email/Password. מכאן והלאה החדרים אינם צריכים לדעת כיצד המשתמש התחבר.

 package com.example.connect4.multiplayer;
 import com.google.firebase.auth.FirebaseAuth;
 import com.google.firebase.auth.FirebaseUser;
+import com.google.firebase.auth.GoogleAuthProvider;
 
 /** Owns Firebase authentication and closes its listeners explicitly. */
 public final class MultiplayerRepository {
     private final FirebaseAuth auth;
         void onSuccess(T result);
         void onError(RepositoryError error);
     }
     public interface AuthListener { void onAuthChanged(String uid); }
+
+    /** Exchanges a Google ID token obtained by the Android credential UI for Firebase Auth. */
+    public void signInWithGoogle(String idToken, ResultCallback<String> callback) {
+        if (idToken == null || idToken.trim().isEmpty()) {
+            callback.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
+                    "Google sign-in returned no ID token", null));
+            return;
+        }
+        auth.signInWithCredential(GoogleAuthProvider.getCredential(idToken.trim(), null))
+                .addOnSuccessListener(result -> callback.onSuccess(result.getUser().getUid()))
+                .addOnFailureListener(exception -> callback.onError(firebaseError(exception, null)));
+    }
 }

GameSession.java

app > kotlin+java > com.example.connect4 > GameSession.java

מספר בקשה נפרד מזהה את ניסיון Google הפעיל. הוא מונע מבורר ישן שהסתיים אחרי החלפת מסך להשלים זרימה שכבר בוטלה.

     public boolean authPending;
     public String onlineMessage = "";
     public String onlineUid;
     public long onlineGeneration;
+    public long googleRequestGeneration;
     public Runnable refreshOnline;
     public String selectedModelId = ModelCatalog.DEFAULT_MODEL_ID;
 
     public GameEngine engine = new GameEngine();

activity_main.xml

app > res > layout > activity_main.xml

מוסיפים את הכפתור בתוך authPanel, כך שהוא נעלם יחד עם טופס ההתחברות לאחר הצלחה.

         android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="gone">
         <TextView android:id="@+id/onlineStatus" android:layout_width="match_parent" android:layout_height="wrap_content" />
         <LinearLayout android:id="@+id/authPanel" android:orientation="vertical"
             android:layout_width="match_parent" android:layout_height="wrap_content">
+            <Button android:id="@+id/googleSignIn" android:layout_width="match_parent"
+                android:layout_height="wrap_content" android:text="Continue with Google" />
             <EditText android:id="@+id/email" android:layout_width="match_parent" android:layout_height="wrap_content"
                 android:hint="Email" android:inputType="textEmailAddress" android:autofillHints="emailAddress" />
             <EditText android:id="@+id/password" android:layout_width="match_parent" android:layout_height="wrap_content"
                 android:hint="Password" android:inputType="textPassword" android:autofillHints="password" android:saveEnabled="false" />

OnlineController.java

app > kotlin+java > com.example.connect4 > OnlineController.java

שומרים מצב pickingGoogle, מכבים כפתורים בזמן בקשה ובודקים מספר בקשה ודור מסך לפני שימוש ב־token. Destroy מבטל בקשה פתוחה; signOut מנקה גם Firebase וגם את מצב Credential Manager. כישלון מחזיר את הפקדים למצב שמאפשר ניסיון נוסף.

     private final ActivityMainBinding ui;
     private final GameSession session;
     private final Host host;
     private boolean active;
+    private final GoogleSignIn googleSignIn;
+    private boolean pickingGoogle;
     private MultiplayerRepository.Subscription auth = MultiplayerRepository.Subscription.EMPTY;
     private final Runnable refreshCallback = this::refreshScreen;
 
     OnlineController(android.app.Activity activity, ActivityMainBinding ui, GameSession session, Host host) {
         this.ui=ui; this.session=session; this.host=host;
+        googleSignIn=new GoogleSignIn(activity);
+        ui.googleSignIn.setOnClickListener(v -> authenticateGoogle());
         ui.signIn.setOnClickListener(v -> authenticate(false));
         ui.signUp.setOnClickListener(v -> authenticate(true));
         ui.signOut.setOnClickListener(v -> {
             session.onlineGeneration++;
             if (session.repository != null) session.repository.signOut();
+            googleSignIn.clearSession();
             refreshScreen();
         });
     }
     }
     private void refreshScreen() { if (active) host.changed(); }
     private void refreshLatestScreen() { if (session.refreshOnline!=null) session.refreshOnline.run(); }
     private void showError(RepositoryError error) { session.onlineMessage=error.getMessage(); }
-    void destroy() { }
+    void destroy() {
+        if (pickingGoogle) {
+            session.googleRequestGeneration++;
+            session.authPending = false;
+            pickingGoogle = false;
+        }
+        googleSignIn.cancel();
+    }
 
     /** Shows the session's identity and enables only available actions. */
     void render() {
         boolean signedIn=session.repository!=null && session.repository.currentUid()!=null;
         ui.authPanel.setVisibility(signedIn ? View.GONE : View.VISIBLE);
         ui.signOut.setVisibility(signedIn ? View.VISIBLE : View.GONE);
         ui.signIn.setEnabled(session.repository!=null && !session.authPending);
         ui.signUp.setEnabled(session.repository!=null && !session.authPending);
+        ui.googleSignIn.setEnabled(session.repository!=null && !session.authPending);
         ui.onlineStatus.setText(signedIn ? "Signed in: " + session.repository.currentUid() : session.onlineMessage);
     }
 
     private void authenticate(boolean create) {
         if (create) session.repository.createAccount(email, password, callback);
         else session.repository.signIn(email, password, callback);
         host.changed();
     }
+
+    private void authenticateGoogle() {
+        if (session.repository == null || session.authPending) return;
+        session.authPending = true;
+        pickingGoogle = true;
+        long request = ++session.googleRequestGeneration;
+        long generation = session.onlineGeneration;
+        googleSignIn.signIn(new GoogleSignIn.Callback() {
+            @Override public void token(String token) {
+                if (request != session.googleRequestGeneration) return;
+                pickingGoogle = false;
+                if (generation != session.onlineGeneration || session.mode != GameSession.MODE_ONLINE) {
+                    session.authPending = false;
+                    refreshLatestScreen();
+                    return;
+                }
+                session.repository.signInWithGoogle(token, new MultiplayerRepository.ResultCallback<String>() {
+                    @Override public void onSuccess(String uid) {
+                        session.authPending = false;
+                        session.onlineMessage = "Signed in with Google";
+                        refreshLatestScreen();
+                    }
+                    @Override public void onError(RepositoryError error) {
+                        session.authPending = false;
+                        showError(error);
+                        refreshLatestScreen();
+                    }
+                });
+            }
+            @Override public void error(String message) {
+                if (request != session.googleRequestGeneration) return;
+                pickingGoogle = false;
+                session.authPending = false;
+                session.onlineMessage = message;
+                refreshLatestScreen();
+            }
+        });
+        host.changed();
+    }
 }

מריצים ובודקים

  1. בדקו כניסה עם חשבון Google והופעת UID במסך.
  2. בטלו את הבורר: מוצגת הודעה ואפשר לנסות שוב.
  3. בצעו signOut והיכנסו עם חשבון אחר: הזהות צריכה להתחלף.
  4. בדקו סיבוב או עזיבה בזמן שהבורר פתוח: אין השלמה מאוחרת במסך אחר.

באימות המסלול נבדקו בנייה ומסלול שגיאה כאשר תצורת OAuth חסרה. כניסה אינטראקטיבית, ביטול הבורר והחלפת חשבון Google דורשים בדיקה ידנית עם תצורה וחשבון אמיתיים; הם אינם מסומנים כבדיקות שעברו.

בדיקת הבנה

איזו מחלקה פותחת את בורר Google ואיזו מאמתת את ה־token מול Firebase? מדוע signOut של Firebase לבדו אינו כל הפעולה בפרק זה?