בסוף הפרק ניתוק משאיר את הלוח האחרון גלוי אך חוסם מהלכים. לאחר חיבור מחדש נקרא מצב החדר מהשרת לפני שנפתח את הקלט. חזרה ללובי ופתיחת חדר אינן מאפסות אותו.
יש הבדל בין שלושה מצבים: יש צילום אחרון; קיימת כרגע תקשורת; קראנו מחדש את החדר אחרי החיבור. רק הצירוף המתאים מאפשר מהלך. אירוע connected לבדו אינו הוכחה שהלוח המוצג מעודכן.
כבר בפרקי החדרים שמרנו זהות של כתיבה ממתינה ב־repository שנשאר ב־Session. עכשיו מחברים לכך חיווי תקשורת וקריאה מחדש. לא שולחים אוטומטית שוב פעולה שתוצאתה אינה ידועה.
השינויים לפי סדר העבודה
התחילו במצב שעבד בסוף הפרק הקודם. קובץ חדש מוצג במלואו; בקובץ קיים מופיעים רק האזורים שמשתנים. בקטעי diff מסירים את שורות -, מוסיפים את שורות +, ומשאירים את שורות ההקשר. הסימנים עצמם אינם חלק מקוד Java או XML. אין למחוק קוד אחר שאינו מוצג.
MultiplayerRepository.java
app > kotlin+java > com.example.connect4 > multiplayer > MultiplayerRepository.java
מוסיפים Subscription ל־.info/connected, נתון מצב של Firebase ולא נתוני משחק. סוגרים את אותו listener בדיוק בעת stop. השגיאה חוזרת דרך אותו חוזה שגיאות ולא הופכת למהלך חדש.
auth.addAuthStateListener(firebaseListener);
return once(() -> auth.removeAuthStateListener(firebaseListener));
}
+ /** Subscribes to Firebase's connection-state signal. */
+ public Subscription subscribeConnection(ConnectionListener listener) {
+ DatabaseReference connectionReference = FirebaseRefs.connection(database);
+ ValueEventListener firebaseListener = new ValueEventListener() {
+ @Override
+ public void onDataChange(DataSnapshot snapshot) {
+ listener.onConnectionChanged(Boolean.TRUE.equals(snapshot.getValue(Boolean.class)));
+ }
+
+ @Override
+ public void onCancelled(DatabaseError databaseError) {
+ listener.onError(databaseError(databaseError, null));
+ }
+ };
+ connectionReference.addValueEventListener(firebaseListener);
+ return once(() -> connectionReference.removeEventListener(firebaseListener));
+ }
/** Subscribes to the lobby's validated room summaries. */
public Subscription subscribeLobby(LobbyListener listener) {
if (currentUid() == null) {
public interface AuthListener {
void onAuthChanged(String uid);
}
+ public interface ConnectionListener {
+ void onConnectionChanged(boolean connected);
+
+ void onError(RepositoryError error);
+ }
+
public interface LobbyListener {
void onRooms(List<RoomSummary> rooms);
void onError(RepositoryError error);
GameSession.java
app > kotlin+java > com.example.connect4 > GameSession.java
connected מתאר חיבור; reconciled מציין שהמסך השלים קריאת חדר אחרי החיבור. שומרים בנפרד כדי שלא לתת לצילום ישן סמכות רק מפני שהרשת חזרה.
public RoomSnapshot room;
public String onlineMessage = "";
public String onlineUid;
public long onlineGeneration;
+ public boolean connected;
+ public boolean reconciled;
public boolean authPending;
public long googleRequestGeneration;
public Runnable refreshOnline;
private final ExecutorService computerWorker = Executors.newSingleThreadExecutor();
activity_main.xml
app > res > layout > activity_main.xml
מוסיפים Reconnect / retry. הכפתור מנתק ומחבר subscriptions של המסך; הוא אינו משדר שוב מהלך ואינו יוצר חדר נוסף.
<LinearLayout android:id="@+id/rooms" android:orientation="vertical"
android:layout_width="match_parent" android:layout_height="wrap_content" />
</LinearLayout>
<Button android:id="@+id/backToLobby" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Return to lobby" />
+ <Button android:id="@+id/retryOnline" android:layout_width="match_parent"
+ android:layout_height="wrap_content" android:text="Reconnect / retry" />
<Button android:id="@+id/signOut" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Sign out" />
</LinearLayout>
</LinearLayout>
</ScrollView>
OnlineController.java
app > kotlin+java > com.example.connect4 > OnlineController.java
בתחילת מסך או שינוי חיבור מאפסים reconciled. כאשר החיבור חוזר, יוצרים את ההאזנה המתאימה וקוראים getRoom. callback בודק שהמסך הפעיל, דור הבקשה ומצב החיבור עדיין מתאימים, ורק אז מאפשר קלט.
מכבים יצירה, join ומהלך בזמן ניתוק או פעולה ממתינה. signOut חסום במהלך כתיבה כדי שלא לשייך השלמה למשתמש אחר. errors משאירות מצב שימושי עם קלט חסום וכפתור retry. New room יוצרת push key חדש; החדר הקודם נשאר לקריאה.
גם בעת stop סוגרים listeners ומנקים רק את callback של ה־Activity הנוכחי. זהות הבקשה הממתינה נשארת ב־Session/Repository, ולכן Activity חדש יכול להציג pending במקום להתחיל אותה כתיבה שוב.
refreshScreen();
});
ui.createRoom.setOnClickListener(v -> createRoom());
ui.backToLobby.setOnClickListener(v -> lobby());
+ ui.retryOnline.setOnClickListener(v -> { stop(); start(); });
}
void start() {
if (active || session.mode != GameSession.MODE_ONLINE) return;
active = true;
session.refreshOnline = refreshCallback;
+ session.connected = false;
+ session.reconciled = false;
try {
if (session.repository == null) session.repository = new MultiplayerRepository();
MultiplayerRepository repo = session.repository;
subscriptions.add(repo.subscribeAuth(uid -> {
session.onlineUid = uid;
}
refreshScreen();
}));
+ subscriptions.add(repo.subscribeConnection(new MultiplayerRepository.ConnectionListener() {
+ @Override public void onConnectionChanged(boolean connected) {
+ if (!active) return;
+ session.connected = connected;
+ session.reconciled = false;
+ if (connected) refreshScreen();
+ else host.changed();
+ }
+ @Override public void onError(RepositoryError error) { showError(error); }
+ }));
refreshScreen();
} catch (IllegalStateException exception) {
session.onlineMessage = "Firebase setup unavailable. Add the registered Connect4 google-services.json and rebuild.";
host.changed();
screen.close();
screen = MultiplayerRepository.Subscription.EMPTY;
for (MultiplayerRepository.Subscription subscription : subscriptions) subscription.close();
subscriptions.clear();
+ session.reconciled = false;
}
void modeChanged() {
session.onlineGeneration++;
private void refreshScreen() {
screen.close();
screen = MultiplayerRepository.Subscription.EMPTY;
long token = ++screenGeneration;
+ session.reconciled = false;
if (!active || session.repository == null || session.repository.currentUid() == null) { host.changed(); return; }
if (session.roomId == null) {
screen = session.repository.subscribeLobby(new MultiplayerRepository.LobbyListener() {
@Override public void onRooms(List<RoomSummary> rooms) {
if (!active || token != screenGeneration) return;
+ session.reconciled = session.connected;
ui.rooms.removeAllViews();
for (RoomSummary room : rooms) {
Button button = new Button(ui.getRoot().getContext());
button.setText(room.getName() + " · " + (room.isValid() ? room.getStatus().toString().toLowerCase(java.util.Locale.ROOT) : "invalid room"));
- button.setEnabled(room.isValid() && !pending());
+ button.setEnabled(room.isValid() && session.connected && !pending());
button.setOnClickListener(v -> open(room.getRoomId()));
ui.rooms.addView(button);
}
host.changed();
host.received(room);
}
@Override public void onError(RepositoryError error) { if (token == screenGeneration) showError(error); }
});
+ if (session.connected) {
+ session.repository.getRoom(session.roomId, new MultiplayerRepository.ResultCallback<RoomSnapshot>() {
+ @Override public void onSuccess(RoomSnapshot room) {
+ if (!active || token != screenGeneration || !session.connected) return;
+ session.room = room;
+ session.reconciled = true;
+ host.received(room);
+ }
+ @Override public void onError(RepositoryError error) {
+ if (active && token == screenGeneration) showError(error);
+ }
+ });
+ }
}
host.changed();
}
לפני
googleSignIn.cancel();
}
void createRoom() {
if (pending() || session.repository == null) return;
long token = session.onlineGeneration;
session.onlineMessage = "Creating room…";
session.repository.createRoom(ui.roomName.getText().toString(), new MultiplayerRepository.ResultCallback<String>() {
@Override public void onSuccess(String roomId) {אחרי
googleSignIn.cancel();
}
void createRoom() {
if (!session.connected || pending() || session.repository == null) return;
long token = session.onlineGeneration;
session.onlineMessage = "Creating room…";
session.repository.createRoom(ui.roomName.getText().toString(), new MultiplayerRepository.ResultCallback<String>() {
@Override public void onSuccess(String roomId) {לפני
host.changed();
}
void newRoom() {
if (session.room != null && !pending()) {
ui.roomName.setText(session.room.getRoom().getName());
createRoom();
} else {
lobby();
}
}
private void open(String roomId) {
if (pending()) return;
session.onlineGeneration++;
long token = session.onlineGeneration;
session.roomId = roomId;
session.room = null;אחרי
host.changed();
}
void newRoom() {
if (session.room != null && session.connected && !pending()) {
ui.roomName.setText(session.room.getRoom().getName());
createRoom();
} else {
lobby();
}
}
private void open(String roomId) {
if (!session.connected || pending()) return;
session.onlineGeneration++;
long token = session.onlineGeneration;
session.roomId = roomId;
session.room = null; host.changed();
}
boolean canMove() {
- return active && !pending()
+ return active && session.connected && session.reconciled && !pending()
&& session.room != null && session.room.canMove();
}
private boolean pending() {
}
private void showError(RepositoryError error) {
session.onlineMessage = error.getMessage();
+ session.reconciled = false;
if (active) host.changed();
}
void render() {
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.createRoom.setEnabled(!pending());
+ ui.createRoom.setEnabled(session.connected && !pending());
ui.signOut.setEnabled(!pending() && !session.authPending);
for (int i = 0; i < ui.rooms.getChildCount(); i++) {
// Invalid rooms are never actionable; each button also guards connection in open().
- ui.rooms.getChildAt(i).setAlpha(!pending() ? 1f : .5f);
+ ui.rooms.getChildAt(i).setAlpha(session.connected && !pending() ? 1f : .5f);
}
String roomLabel = session.room == null || !inRoom ? "" : session.room.getRoom().getName() + " · "
+ session.room.getRole().toString().toLowerCase(java.util.Locale.ROOT) + " · "
+ session.room.getStatus().toString().toLowerCase(java.util.Locale.ROOT) + "\n";
- ui.onlineStatus.setText(roomLabel + "Room screen"
+ ui.onlineStatus.setText(roomLabel + (session.connected ? "Connected" : "Disconnected")
+ (pending() ? " · write pending / uncertain" : "") + "\n" + session.onlineMessage);
ui.offlineGame.setVisibility(inRoom ? View.VISIBLE : View.GONE);
ui.restart.setText("New room");
- ui.restart.setEnabled(session.room != null && !pending());
+ ui.restart.setEnabled(session.connected && session.reconciled && session.room != null && !pending());
ui.aiStatus.setText("Online · " + (session.room == null ? "reconciling room" : session.room.getRole().toString().toLowerCase(java.util.Locale.ROOT)));
}
}
מריצים ובודקים
- נתקו רשת באמצע חדר: הלוח נשאר, החיווי מציג Disconnected, ומהלך חדש אינו נשלח.
- בצד השני בצעו מהלך בתורו; חברו מחדש את המכשיר הראשון. הוא מציג את ההיסטוריה החדשה לפני חידוש הקלט.
- סובבו בזמן פתיחת חדר ובזמן כתיבה. אין חדר כפול או מהלך כפול.
- חזרו ללובי בזמן בקשה ואז פתחו חדר אחר: callback מאוחר אינו מחזיר את המסך לחדר הקודם.
- בדקו חדר חסר או היסטוריה פגומה: הודעה ניתנת לתיקון, בלי קריסה ובלי המשך משחק על נתון לא חוקי.
בדיקת reconnect ב־Android emulator מכבה ומחזירה חיבור ל־RTDB, בודקת מהלך שהתקבל בינתיים ודוחה קידומת ישנה. בסביבת האימות נדרשו גם adb reverse tcp:9000 tcp:9000 ו־adb reverse tcp:9099 tcp:9099: ה־Firebase emulator מפרסם כתובת loopback לאחר reconnect. אלה הגדרות סביבת בדיקה, לא קוד לתוך האפליקציה.
בדיקת הבנה
ציירו רצף שבו move נשלחה, המשתמש איבד רשת, ואז חזר. למה הודעת timeout אינה הוכחה שהמהלך לא נשמר? מדוע retry של המסך אינו submitMove נוסף?