Connect4 — 08: זהות משתמש לפני משחק רשת


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

מפת המסלול

בסוף הפרק אפשר ליצור חשבון, להתחבר ולצאת. עדיין אין חדרים. משחק מקומי ומחשב ממשיכים לעבוד גם בלי חשבון ובלי קובץ Firebase.

הכנת Firebase עם המורה

  1. המורה פותח את פרויקט Firebase המשותף שבו נרשמה אפליקציית com.example.connect4, ומפעיל את ספק Email/Password תחת Authentication.
  2. מורידים את google-services.json של אותה אפליקציה. אין לשנות package כדי להתאים לקובץ של פרויקט אחר. את הקובץ מעתיקים לתיקיית המודול app שמתקבלת דרך Open In > Explorer על המודול; זו אינה תיקיית assets.
  3. מוסיפים את ה־plugin והתלויות המופיעים בהמשך, ואז מבצעים Sync ובנייה מחדש. הקובץ נמסר בנפרד ואינו חלק מחבילות השיעור. אין להעביר סיסמאות או מפתח service account.
  4. להסבר מסכי ההקמה ראו הקמת Firebase. בפרק הזה משתמשים רק ב־Authentication, ללא כתיבת נתוני משחק.

היישום מזהה אדם לפי UID של Firebase ולא לפי כתובת שמוצגת במסך. אין לשמור סיסמה ב־SharedPreferences; ה־SDK מטפל בשימור ההתחברות.

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

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

build.gradle.kts

Gradle Scripts > build.gradle.kts (Project)

מוסיפים את Google services plugin ברמת הפרויקט עם apply false. אין למחוק את Android plugin.

 // Top-level build file where you can add configuration options common to all sub-projects/modules.
 plugins {
     alias(libs.plugins.android.application) apply false
+    id("com.google.gms.google-services") version "4.5.0" apply false
 }

build.gradle.kts

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

מפעילים את ה־plugin רק כאשר קובץ התצורה קיים. ה־BoM מתאם את גרסאות Firebase; תלות Auth אינה צריכה מספר גרסה נפרד. הבדיקה על הקובץ מאפשרת להמשיך לבנות את מצבי offline לפני קבלת התצורה.

 plugins {
     alias(libs.plugins.android.application)
+}
+
+// Offline modes also build before Firebase configuration is supplied.
+if (file("google-services.json").exists()) {
+    apply(plugin = "com.google.gms.google-services")
 }
 
 android {
     namespace = "com.example.connect4"
     }
 }
 
 dependencies {
+    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")
     implementation(libs.activity.ktx)
     implementation(libs.appcompat)

AndroidManifest.xml

app > manifests > AndroidManifest.xml

מוסיפים INTERNET לפני application. זו הרשאת Manifest רגילה, ללא בקשת הרשאה בזמן ריצה.

 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools">
+
+    <uses-permission android:name="android.permission.INTERNET" />
 
     <application
         android:allowBackup="true"
         android:dataExtractionRules="@xml/data_extraction_rules"

RepositoryError.java

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

צרו package בשם multiplayer. אובייקט השגיאה מפריד בין קוד שגיאה לבין הודעה למסך. roomId יהיה null בפעולות הזדהות; אותו חוזה ישמש בהמשך גם לפעולות חדר.

package com.example.connect4.multiplayer;

/** Carries a recoverable operation error to the screen. */
public final class RepositoryError {
    public enum Code { INVALID_ARGUMENT, UNKNOWN }
    private final Code code;
    private final String message;
    private final String roomId;
    RepositoryError(Code code, String message, String roomId) {
        this.code=code; this.message=message; this.roomId=roomId;
    }
    public Code getCode() { return code; }
    public String getMessage() { return message; }
    public String getRoomId() { return roomId; }
}

MultiplayerRepository.java

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

כרגע המחלקה מטפלת רק בהזדהות. ResultCallback מחזיר הצלחה או כישלון בעתיד; אסור להניח שהתשובה מוכנה בשורה שאחרי הקריאה. subscribeAuth מחזירה Subscription שאפשר לסגור, כדי שה־Activity לא יישאר מאזין אחרי שעזבנו.

הבנאי שמקבל FirebaseAuth מאפשר לבדוק עם emulator בלי לגעת בפרויקט האמיתי. הבנאי הרגיל משתמש בתצורה שסיפק המורה. שגיאת קלט נעצרת לפני פנייה לשרת.

package com.example.connect4.multiplayer;
import com.google.firebase.auth.FirebaseAuth;
import com.google.firebase.auth.FirebaseUser;

/** Owns Firebase authentication and closes its listeners explicitly. */
public final class MultiplayerRepository {
    private final FirebaseAuth auth;
    /** Uses the app registered by the teacher. */
    public MultiplayerRepository() { this(FirebaseAuth.getInstance()); }
    /** Allows isolated Auth emulator instances in tests. */
    public MultiplayerRepository(FirebaseAuth auth) { this.auth=auth; }

    /** Returns the authenticated Firebase user's UID, or {@code null} when signed out. */
    public String currentUid() {
        FirebaseUser user = auth.getCurrentUser();
        return user == null ? null : user.getUid();
    }

    /** Signs in with email/password and reports the resulting UID asynchronously. */
    public void signIn(String email, String password, ResultCallback<String> callback) {
        if (email == null || email.trim().isEmpty() || password == null || password.isEmpty()) {
            callback.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
                    "Email and password are required", null));
            return;
        }
        auth.signInWithEmailAndPassword(email.trim(), password)
                .addOnSuccessListener(result -> callback.onSuccess(result.getUser().getUid()))
                .addOnFailureListener(exception -> callback.onError(firebaseError(exception, null)));
    }

    /** Creates an email/password account and reports the resulting UID asynchronously. */
    public void createAccount(String email, String password, ResultCallback<String> callback) {
        if (email == null || email.trim().isEmpty() || password == null || password.isEmpty()) {
            callback.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
                    "Email and password are required", null));
            return;
        }
        auth.createUserWithEmailAndPassword(email.trim(), password)
                .addOnSuccessListener(result -> callback.onSuccess(result.getUser().getUid()))
                .addOnFailureListener(exception -> callback.onError(firebaseError(exception, null)));
    }

    /** Clears the current Firebase authentication session. */
    public void signOut() {
        auth.signOut();
    }

    /** Subscribes to authentication changes; callers must close the returned subscription. */
    public Subscription subscribeAuth(AuthListener listener) {
        FirebaseAuth.AuthStateListener firebaseListener = firebaseAuth -> {
            FirebaseUser user = firebaseAuth.getCurrentUser();
            listener.onAuthChanged(user == null ? null : user.getUid());
        };
        auth.addAuthStateListener(firebaseListener);
        return once(() -> auth.removeAuthStateListener(firebaseListener));
    }

    private static RepositoryError firebaseError(Exception exception, String roomId) {
        String message = exception == null || exception.getLocalizedMessage() == null
                ? "Firebase operation failed" : exception.getLocalizedMessage();
        return error(RepositoryError.Code.UNKNOWN, message, roomId);
    }

    private static RepositoryError error(RepositoryError.Code code, String message,
                                         String roomId) {
        return new RepositoryError(code, message == null ? "Operation failed" : message, roomId);
    }

    private static Subscription once(Runnable remover) {
        return new Subscription() {
            private boolean closed;

            @Override
            public synchronized void close() {
                if (!closed) {
                    closed = true;
                    remover.run();
                }
            }
        };
    }

    public interface Subscription extends AutoCloseable {
        Subscription EMPTY = () -> { };
        @Override void close();
    }
    public interface ResultCallback<T> {
        void onSuccess(T result);
        void onError(RepositoryError error);
    }
    public interface AuthListener { void onAuthChanged(String uid); }
}

GameSession.java

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

ה־repository והפעולה הממתינה נשמרים לצד מצב המשחק. ה־Activity מציג את הזהות הנוכחית, ו־refreshOnline מצביע רק למסך הפעיל; מנקים אותו ב־stop.

 import androidx.annotation.MainThread;
 import androidx.lifecycle.ViewModel;
 import com.example.connect4.ai.ComputerMoveService;
 import com.example.connect4.ai.ModelCatalog;
+import com.example.connect4.multiplayer.MultiplayerRepository;
 import com.example.connect4.core.GameEngine;
 import com.example.connect4.core.GameState;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 public final class GameSession extends ViewModel {
     public static final int MODE_LOCAL = 0;
     public static final int MODE_HEURISTIC = 1;
     public static final int MODE_MODEL = 2;
+    public static final int MODE_ONLINE = 3;
+    public MultiplayerRepository repository;
+    public boolean authPending;
+    public String onlineMessage = "";
+    public String onlineUid;
+    public long onlineGeneration;
+    public Runnable refreshOnline;
     public String selectedModelId = ModelCatalog.DEFAULT_MODEL_ID;
 
     public GameEngine engine = new GameEngine();
     public int mode = MODE_LOCAL;

OnlineController.java

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

המחלקה מחברת בין פקדי התחברות ל־repository. start מחברת מאזין ו־stop מסירה אותו; יציאה מהמסך אינה signOut. בזמן בקשה מכבים את כפתורי ההתחברות. מיד אחרי קריאת הסיסמה מנקים את שדה הטקסט. הצלחה מפנה לתצוגת UID, ושגיאה מוצגת בלי להפיל את המשחק.

package com.example.connect4;

import android.view.View;
import com.example.connect4.databinding.ActivityMainBinding;
import com.example.connect4.multiplayer.*;

/** Connects the login controls to retained authentication state. */
final class OnlineController {
    interface Host { void changed(); }
    private final ActivityMainBinding ui;
    private final GameSession session;
    private final Host host;
    private boolean active;
    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;
        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();
            refreshScreen();
        });
    }

    /** Attaches a single auth listener while the online screen is visible. */
    void start() {
        if (active || session.mode != GameSession.MODE_ONLINE) return;
        active=true;
        session.refreshOnline=refreshCallback;
        try {
            if (session.repository==null) session.repository=new MultiplayerRepository();
            auth=session.repository.subscribeAuth(uid -> {
                if (!active) return;
                session.onlineUid=uid;
                refreshScreen();
            });
        } catch (IllegalStateException failure) {
            session.onlineMessage="Firebase setup unavailable. Add the registered Connect4 google-services.json and rebuild.";
        }
        refreshScreen();
    }

    /** Releases Activity-owned callbacks without signing the user out. */
    void stop() {
        active=false;
        auth.close();
        if (session.refreshOnline==refreshCallback) session.refreshOnline=null;
    }
    void modeChanged() {
        session.onlineGeneration++;
        if (session.mode==GameSession.MODE_ONLINE) start(); else stop();
    }
    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() { }

    /** 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.onlineStatus.setText(signedIn ? "Signed in: " + session.repository.currentUid() : session.onlineMessage);
    }

    private void authenticate(boolean create) {
        if (session.repository == null || session.authPending) return;
        session.authPending = true;
        String email = ui.email.getText().toString();
        String password = ui.password.getText().toString();
        ui.password.setText("");
        MultiplayerRepository.ResultCallback<String> callback = new MultiplayerRepository.ResultCallback<String>() {
            @Override public void onSuccess(String uid) {
                session.authPending = false;
                session.onlineMessage = "Signed in";
                refreshLatestScreen();
            }
            @Override public void onError(RepositoryError error) {
                session.authPending = false;
                showError(error);
                refreshLatestScreen();
            }
        };
        if (create) session.repository.createAccount(email, password, callback);
        else session.repository.signIn(email, password, callback);
        host.changed();
    }
}

strings.xml

app > res > values > strings.xml

מוסיפים את Online rooms רק עכשיו, כשהמסך שלו פעיל ומאפשר הזדהות.

     <string-array name="game_modes">
         <item>Two players · this device</item>
         <item>Play heuristic</item>
         <item>Play model / MCTS</item>
+        <item>Online rooms</item>
     </string-array>
     <string-array name="player_sides">
         <item>You are Red · play first</item>
         <item>You are Yellow · play second</item>

activity_main.xml

app > res > layout > activity_main.xml

עוטפים את התוכן ב־ScrollView עם ילד LinearLayout יחיד. קבוצת המשחק הקיימת מקבלת את השם offlineGame; את Fragment ההגדרות משאירים מחוץ לקבוצה כדי שאפשר יהיה להחליף מצב גם כשמסך ההתחברות מוצג. authPanel מכילה רק פקדים שעובדים בפרק זה.

 <?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent"
+    android:saveEnabled="false" android:fillViewport="true">
+    <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical">
+    <androidx.fragment.app.FragmentContainerView android:id="@+id/gameSetup"
+        android:name="com.example.connect4.GameSetupFragment"
+        android:layout_width="match_parent" android:layout_height="wrap_content" />
 <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/main"
+    android:id="@+id/offlineGame"
     android:layout_width="match_parent"
-    android:layout_height="match_parent"
+    android:layout_height="wrap_content"
     tools:context=".MainActivity">
-
-    <androidx.fragment.app.FragmentContainerView
-        android:id="@+id/gameSetup"
-        android:name="com.example.connect4.GameSetupFragment"
-        android:layout_width="0dp" android:layout_height="wrap_content"
-        app:layout_constraintTop_toTopOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintEnd_toEndOf="parent" />
 
     <TextView
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:id="@+id/status"
         android:text="Tap a column"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/gameSetup" />
+        app:layout_constraintTop_toTopOf="parent" />
 
     <TextView
         android:id="@+id/aiStatus" android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:id="@+id/restart"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:text="New game"
+        app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintTop_toBottomOf="@id/board"
         app:layout_constraintStart_toStartOf="parent" />
 
-</androidx.constraintlayout.widget.ConstraintLayout>
+</androidx.constraintlayout.widget.ConstraintLayout>    <LinearLayout android:id="@+id/onlinePanel" android:orientation="vertical"
+        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">
+            <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" />
+            <Button android:id="@+id/signIn" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Sign in" />
+            <Button android:id="@+id/signUp" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Create account" />
+        </LinearLayout>
+        <Button android:id="@+id/signOut" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Sign out" />
+    </LinearLayout>
+    </LinearLayout>
+</ScrollView>

MainActivity.java

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

ה־Activity מוסר ל־OnlineController callback לרענון. מחברים start/stop למחזור החיים, מסתירים את המשחק במצב online ומפסיקים לבקש מהלכי מחשב. אין כאן עדיין בקשת מהלך משרת.

     private ActivityMainBinding binding;
     private GameSetupFragment setup;
     private GameSession session;
     private boolean started;
+    private OnlineController online;
     private ModelCatalog modelCatalog;
 
     /** Exposes the activity binding only to the package-local online controller. */
     ActivityMainBinding viewBinding() {

לפני

boolean initializeModelStatus = false;
if (!session.restored) {
    android.content.SharedPreferences saved = getPreferences(MODE_PRIVATE);
    session.mode = saved.getInt("mode", GameSession.MODE_LOCAL);
    if (session.mode < GameSession.MODE_LOCAL || session.mode > GameSession.MODE_MODEL) {
        session.mode = GameSession.MODE_LOCAL;
    }
    session.humanSide = saved.getInt("side", GameState.RED);
    if (session.humanSide != GameState.RED && session.humanSide != GameState.YELLOW) {

אחרי

boolean initializeModelStatus = false;
if (!session.restored) {
    android.content.SharedPreferences saved = getPreferences(MODE_PRIVATE);
    session.mode = saved.getInt("mode", GameSession.MODE_LOCAL);
    if (session.mode < GameSession.MODE_LOCAL || session.mode > GameSession.MODE_ONLINE) {
        session.mode = GameSession.MODE_LOCAL;
    }
    session.humanSide = saved.getInt("side", GameState.RED);
    if (session.humanSide != GameState.RED && session.humanSide != GameState.YELLOW) {
         } else if (initializeModelStatus) {
             session.modelStatus = selectedModel().displayName()
                     + " loads on the computer’s turn";
         }
+        online = new OnlineController(this, binding, session, new OnlineController.Host() {
+            @Override public void changed() { persist(); render(); }
+        });
         setup.configure(modelCatalog.entries().stream()
                 .map(ModelCatalog.Entry::displayName).toArray(String[]::new),
                 session.mode, session.humanSide - 1, selectedModelPosition(),
                 new GameSetupFragment.Listener() {
                     session.mode = position;
                     session.modelStatus = selectedModel().displayName()
                             + " loads on the computer’s turn";
                     session.cancelComputerMove();
-                    newGame();
+                    online.modeChanged();
+                    if (position != GameSession.MODE_ONLINE) newGame();
                 }
                 render();
             }
             @Override public void onSideSelected(int position) {
 
 
     private boolean humanTurn() {
         GameState state = session.engine.snapshot();
+        if (session.mode == GameSession.MODE_ONLINE) return false;
         return started && !state.isTerminal() && !session.isComputerThinking()
                 && (session.mode == GameSession.MODE_LOCAL
                 || state.currentPlayer() == session.humanSide);
     }
     }
 
     private void render() {
         GameState state = session.engine.snapshot();
+        boolean online = session.mode == GameSession.MODE_ONLINE;
         setup.showMode(session.mode);
+        binding.onlinePanel.setVisibility(online ? View.VISIBLE : View.GONE);
+        binding.offlineGame.setVisibility(online ? View.GONE : View.VISIBLE);
         binding.restart.setText(R.string.new_game);
         binding.restart.setEnabled(true);
         String player = playerName(state.currentPlayer());
         if (state.isDraw()) {
         } else {
             binding.aiStatus.setText(session.modelStatus);
         }
         binding.board.show(state, humanTurn());
+        if (online && this.online != null) this.online.render();
+        binding.restart.setVisibility(binding.offlineGame.getVisibility());
         requestComputerMoveIfNeeded();
     }

לפני

/** Captures one turn before handing its immutable state to the computer worker. */
private void requestComputerMoveIfNeeded() {
    GameState requestedState = session.engine.snapshot();
    if (!started || session.mode == GameSession.MODE_LOCAL
            || session.isComputerThinking()
            || session.hasComputerMoveFailed() || requestedState.isTerminal()
            || requestedState.currentPlayer() == session.humanSide) return;

    int requestedMode = session.mode;

אחרי

/** Captures one turn before handing its immutable state to the computer worker. */
private void requestComputerMoveIfNeeded() {
    GameState requestedState = session.engine.snapshot();
    if (!started || session.mode == GameSession.MODE_LOCAL
            || session.mode == GameSession.MODE_ONLINE || session.isComputerThinking()
            || session.hasComputerMoveFailed() || requestedState.isTerminal()
            || requestedState.currentPlayer() == session.humanSide) return;

    int requestedMode = session.mode;
         }
         return 0;
     }
 
-    @Override protected void onStart() { super.onStart(); started = true; render(); }
+    @Override protected void onStart() { super.onStart(); started = true; online.start(); render(); }
     @Override protected void onStop() {
         started = false;
         session.cancelComputerMove();
+        online.stop();
         persist();
         super.onStop();
     }
 
+    @Override protected void onDestroy() { online.destroy(); super.onDestroy(); }
 }

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

  1. ללא קובץ תצורה, Online מציג הודעת setup; חזרה למשחק מקומי עדיין עובדת.
  2. עם תצורה תקינה וספק Email/Password פעיל, צרו חשבון בדיקה. בדקו שמוצג UID.
  3. בצעו signOut ואז signIn באותו חשבון: ה־UID נשאר זהה.
  4. בדקו סיסמה שגויה ושדות ריקים: מוצגת שגיאה ואפשר לנסות שוב.
  5. סובבו או עברו בין המצבים: לא נוצרים מאזיני Auth כפולים.

באימות האוטומטי נוצר חשבון ב־Auth emulator בלבד, בוצעו יציאה וכניסה, והושווה UID. בדיקה זו אינה אישור שספקי ההתחברות בפרויקט Firebase החי מוגדרים נכון.

בדיקת הבנה

מה ההבדל בין stop של המסך לבין signOut? מדוע תוצאת createAccount מועברת ל־callback ולא מוחזרת מיד כמחרוזת?