|
|
@@ -1,490 +0,0 @@
|
|
|
-package com.example.user;
|
|
|
-
|
|
|
-import android.content.SharedPreferences;
|
|
|
-import android.os.Build;
|
|
|
-import android.os.Bundle;
|
|
|
-import android.os.Handler;
|
|
|
-import android.os.Looper;
|
|
|
-import android.provider.Settings;
|
|
|
-import android.text.Editable;
|
|
|
-import android.text.TextUtils;
|
|
|
-import android.view.KeyEvent;
|
|
|
-import android.view.View;
|
|
|
-import android.view.ViewGroup;
|
|
|
-import android.view.inputmethod.EditorInfo;
|
|
|
-import android.widget.EditText;
|
|
|
-import android.widget.FrameLayout;
|
|
|
-import android.widget.TextView;
|
|
|
-
|
|
|
-import androidx.activity.EdgeToEdge;
|
|
|
-import androidx.appcompat.app.AlertDialog;
|
|
|
-import androidx.appcompat.app.AppCompatActivity;
|
|
|
-import androidx.core.graphics.Insets;
|
|
|
-import androidx.core.view.ViewCompat;
|
|
|
-import androidx.core.view.WindowInsetsCompat;
|
|
|
-import androidx.recyclerview.widget.LinearLayoutManager;
|
|
|
-import androidx.recyclerview.widget.RecyclerView;
|
|
|
-
|
|
|
-import com.example.user.client.ChatWebSocketClient;
|
|
|
-import com.example.user.model.ChatMessage;
|
|
|
-import com.example.user.model.MessageType;
|
|
|
-import com.example.user.model.WsMessage;
|
|
|
-import com.example.user.ui.ChatMessageAdapter;
|
|
|
-import com.google.android.material.button.MaterialButton;
|
|
|
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
|
|
-import com.google.android.material.textfield.TextInputEditText;
|
|
|
-
|
|
|
-import java.net.URI;
|
|
|
-import java.net.URISyntaxException;
|
|
|
-import java.util.Locale;
|
|
|
-
|
|
|
-public class MainActivity extends AppCompatActivity {
|
|
|
- private static final String PREFS_NAME = "chat_prefs";
|
|
|
- private static final String KEY_USERNAME = "username";
|
|
|
- private static final long RETRY_DELAY_MS = 1500L;
|
|
|
-
|
|
|
- private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
|
|
- private final Runnable reconnectRunnable = this::runReconnect;
|
|
|
-
|
|
|
- private TextView connectionStatus;
|
|
|
- private MaterialButton retryButton;
|
|
|
- private MaterialButton settingsButton;
|
|
|
- private EditText messageInput;
|
|
|
- private MaterialButton sendButton;
|
|
|
- private RecyclerView chatRecyclerView;
|
|
|
- private ChatMessageAdapter messageAdapter;
|
|
|
-
|
|
|
- private SharedPreferences preferences;
|
|
|
- private ChatWebSocketClient webSocketClient;
|
|
|
- private String currentUsername = "";
|
|
|
- private boolean isConnected = false;
|
|
|
- private boolean isConnecting = false;
|
|
|
- private boolean isLoggedIn = false;
|
|
|
- private boolean reconnectScheduled = false;
|
|
|
- private boolean destroyed = false;
|
|
|
-
|
|
|
- @Override
|
|
|
- protected void onCreate(Bundle savedInstanceState) {
|
|
|
- super.onCreate(savedInstanceState);
|
|
|
- EdgeToEdge.enable(this);
|
|
|
- setContentView(R.layout.activity_main);
|
|
|
-
|
|
|
- preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
|
|
|
- connectionStatus = findViewById(R.id.connectionStatus);
|
|
|
- retryButton = findViewById(R.id.retryButton);
|
|
|
- settingsButton = findViewById(R.id.settingsButton);
|
|
|
- messageInput = findViewById(R.id.messageInput);
|
|
|
- sendButton = findViewById(R.id.sendButton);
|
|
|
- chatRecyclerView = findViewById(R.id.chatRecyclerView);
|
|
|
-
|
|
|
- messageAdapter = new ChatMessageAdapter();
|
|
|
- chatRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
|
|
- chatRecyclerView.setAdapter(messageAdapter);
|
|
|
-
|
|
|
- retryButton.setOnClickListener(v -> connectToServer());
|
|
|
- settingsButton.setOnClickListener(v -> promptForUsername(true, false));
|
|
|
- sendButton.setOnClickListener(v -> sendCurrentMessage());
|
|
|
- messageInput.setOnEditorActionListener((TextView v, int actionId, KeyEvent event) -> {
|
|
|
- boolean sendAction = actionId == EditorInfo.IME_ACTION_SEND;
|
|
|
- boolean enterKey = event != null
|
|
|
- && event.getKeyCode() == KeyEvent.KEYCODE_ENTER
|
|
|
- && event.getAction() == KeyEvent.ACTION_DOWN;
|
|
|
- if (sendAction || enterKey) {
|
|
|
- sendCurrentMessage();
|
|
|
- return true;
|
|
|
- }
|
|
|
- return false;
|
|
|
- });
|
|
|
-
|
|
|
- ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
|
|
|
- Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
|
|
- v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
|
|
|
- return insets;
|
|
|
- });
|
|
|
-
|
|
|
- updateConnectionUi(getString(R.string.disconnected));
|
|
|
-
|
|
|
- String savedUsername = loadSavedUsername();
|
|
|
- if (TextUtils.isEmpty(savedUsername)) {
|
|
|
- currentUsername = buildDefaultUsername();
|
|
|
- promptForUsername(true, false);
|
|
|
- } else {
|
|
|
- currentUsername = savedUsername;
|
|
|
- chatRecyclerView.post(this::connectToServer);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private void connectToServer() {
|
|
|
- if (destroyed || isConnecting || (isConnected && webSocketClient != null)) {
|
|
|
- return;
|
|
|
- }
|
|
|
- if (TextUtils.isEmpty(currentUsername)) {
|
|
|
- promptForUsername(true, false);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- cancelReconnect();
|
|
|
- closeCurrentClient();
|
|
|
-
|
|
|
- URI serverUri;
|
|
|
- try {
|
|
|
- serverUri = new URI(getString(R.string.default_server_url));
|
|
|
- } catch (URISyntaxException ex) {
|
|
|
- appendSystemMessage(getString(R.string.connect_error) + ex.getMessage());
|
|
|
- updateConnectionUi(getString(R.string.disconnected));
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- isConnecting = true;
|
|
|
- isConnected = false;
|
|
|
- isLoggedIn = false;
|
|
|
- updateConnectionUi(getString(R.string.connecting));
|
|
|
-
|
|
|
- final ChatWebSocketClient[] clientRef = new ChatWebSocketClient[1];
|
|
|
- ChatWebSocketClient client = new ChatWebSocketClient(serverUri, new ChatWebSocketClient.Listener() {
|
|
|
- @Override
|
|
|
- public void onOpen() {
|
|
|
- mainHandler.post(() -> {
|
|
|
- if (webSocketClient != clientRef[0]) {
|
|
|
- return;
|
|
|
- }
|
|
|
- isConnecting = false;
|
|
|
- isConnected = true;
|
|
|
- isLoggedIn = false;
|
|
|
- updateConnectionUi(getString(R.string.signing_in));
|
|
|
- try {
|
|
|
- clientRef[0].sendMessage(WsMessage.login(currentUsername));
|
|
|
- } catch (IllegalStateException ex) {
|
|
|
- appendSystemMessage(getString(R.string.connect_error) + ex.getMessage());
|
|
|
- }
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- @Override
|
|
|
- public void onMessage(String rawMessage) {
|
|
|
- mainHandler.post(() -> {
|
|
|
- if (webSocketClient == clientRef[0]) {
|
|
|
- handleIncomingMessage(rawMessage);
|
|
|
- }
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- @Override
|
|
|
- public void onClose(int code, String reason, boolean remote) {
|
|
|
- mainHandler.post(() -> {
|
|
|
- if (webSocketClient != clientRef[0]) {
|
|
|
- return;
|
|
|
- }
|
|
|
- webSocketClient = null;
|
|
|
- isConnecting = false;
|
|
|
- isConnected = false;
|
|
|
- isLoggedIn = false;
|
|
|
- updateConnectionUi(getString(R.string.disconnected));
|
|
|
- scheduleReconnect();
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- @Override
|
|
|
- public void onError(Exception ex) {
|
|
|
- mainHandler.post(() -> {
|
|
|
- if (webSocketClient != clientRef[0]) {
|
|
|
- return;
|
|
|
- }
|
|
|
- isConnecting = false;
|
|
|
- isConnected = false;
|
|
|
- isLoggedIn = false;
|
|
|
- updateConnectionUi(getString(R.string.disconnected));
|
|
|
- appendSystemMessage(getString(R.string.connect_error) + describeError(ex));
|
|
|
- scheduleReconnect();
|
|
|
- });
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- clientRef[0] = client;
|
|
|
- webSocketClient = client;
|
|
|
- client.connect();
|
|
|
- }
|
|
|
-
|
|
|
- private void sendCurrentMessage() {
|
|
|
- if (webSocketClient == null || !webSocketClient.isOpen() || !isLoggedIn) {
|
|
|
- appendSystemMessage(getString(R.string.login_pending));
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- String content = safeText(messageInput.getText());
|
|
|
- if (TextUtils.isEmpty(content)) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- webSocketClient.sendMessage(WsMessage.chat(currentUsername, content));
|
|
|
- messageInput.setText("");
|
|
|
- } catch (IllegalStateException ex) {
|
|
|
- appendSystemMessage(getString(R.string.connect_error) + ex.getMessage());
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private void handleIncomingMessage(String rawMessage) {
|
|
|
- try {
|
|
|
- WsMessage payload = WsMessage.fromJson(rawMessage);
|
|
|
- if (consumeInternalMessage(payload)) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (payload.getType() == MessageType.SYSTEM || payload.getType() == MessageType.ERROR) {
|
|
|
- appendSystemMessage(payload.getContent());
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- String sender = TextUtils.isEmpty(payload.getFrom())
|
|
|
- ? getString(R.string.system_sender)
|
|
|
- : payload.getFrom();
|
|
|
- boolean incoming = !sender.equals(currentUsername);
|
|
|
- if (incoming) {
|
|
|
- appendIncomingMessage(sender, payload.getContent(), payload.getTimestamp());
|
|
|
- } else {
|
|
|
- appendOutgoingMessage(sender, payload.getContent(), payload.getTimestamp());
|
|
|
- }
|
|
|
- } catch (RuntimeException ex) {
|
|
|
- appendSystemMessage(rawMessage);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private boolean consumeInternalMessage(WsMessage payload) {
|
|
|
- if (payload == null) {
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- if (payload.getType() == MessageType.ONLINE) {
|
|
|
- isLoggedIn = true;
|
|
|
- updateConnectionUi(getString(R.string.connected));
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- if (payload.getType() != MessageType.SYSTEM && payload.getType() != MessageType.ERROR) {
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- String content = payload.getContent() == null ? "" : payload.getContent().trim();
|
|
|
- if (content.startsWith("Welcome,")) {
|
|
|
- isLoggedIn = true;
|
|
|
- updateConnectionUi(getString(R.string.connected));
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- if ("Username already in use.".equals(content)) {
|
|
|
- isLoggedIn = false;
|
|
|
- isConnected = false;
|
|
|
- isConnecting = false;
|
|
|
- closeCurrentClient();
|
|
|
- updateConnectionUi(getString(R.string.disconnected));
|
|
|
- promptForUsername(true, true);
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- if (content.contains("Client has not logged in.")) {
|
|
|
- if (!isLoggedIn) {
|
|
|
- updateConnectionUi(getString(R.string.signing_in));
|
|
|
- }
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- private void promptForUsername(boolean required, boolean conflict) {
|
|
|
- FrameLayout container = new FrameLayout(this);
|
|
|
- int horizontal = dp(24);
|
|
|
- int vertical = dp(8);
|
|
|
- container.setPadding(horizontal, vertical, horizontal, 0);
|
|
|
-
|
|
|
- TextInputEditText input = new TextInputEditText(this);
|
|
|
- input.setHint(R.string.nickname_hint);
|
|
|
- input.setSingleLine(true);
|
|
|
- input.setText(TextUtils.isEmpty(currentUsername) ? buildDefaultUsername() : currentUsername);
|
|
|
- input.setSelection(input.getText() == null ? 0 : input.getText().length());
|
|
|
- container.addView(input, new FrameLayout.LayoutParams(
|
|
|
- ViewGroup.LayoutParams.MATCH_PARENT,
|
|
|
- ViewGroup.LayoutParams.WRAP_CONTENT
|
|
|
- ));
|
|
|
-
|
|
|
- MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this)
|
|
|
- .setTitle(conflict ? R.string.nickname_conflict_title : R.string.nickname_title)
|
|
|
- .setMessage(conflict ? R.string.nickname_conflict_message : R.string.nickname_message)
|
|
|
- .setView(container)
|
|
|
- .setPositiveButton(R.string.nickname_save, null);
|
|
|
-
|
|
|
- if (!required) {
|
|
|
- builder.setNegativeButton(android.R.string.cancel, null);
|
|
|
- }
|
|
|
-
|
|
|
- AlertDialog dialog = builder.create();
|
|
|
- dialog.setCancelable(!required);
|
|
|
- dialog.setCanceledOnTouchOutside(!required);
|
|
|
- dialog.setOnShowListener(ignored -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
|
|
|
- String candidate = normalizeUsername(input.getText());
|
|
|
- if (TextUtils.isEmpty(candidate)) {
|
|
|
- input.setError(getString(R.string.nickname_required));
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- currentUsername = candidate;
|
|
|
- saveUsername(candidate);
|
|
|
- dialog.dismiss();
|
|
|
- connectToServer();
|
|
|
- }));
|
|
|
- dialog.show();
|
|
|
- }
|
|
|
-
|
|
|
- private void appendIncomingMessage(String sender, String content, long timestamp) {
|
|
|
- messageAdapter.append(ChatMessage.incoming(sender, content, timestamp));
|
|
|
- scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- private void appendOutgoingMessage(String sender, String content, long timestamp) {
|
|
|
- messageAdapter.append(ChatMessage.outgoing(sender, content, timestamp));
|
|
|
- scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- private void appendSystemMessage(String content) {
|
|
|
- if (TextUtils.isEmpty(content)) {
|
|
|
- return;
|
|
|
- }
|
|
|
- messageAdapter.append(ChatMessage.system(content));
|
|
|
- scrollToBottom();
|
|
|
- }
|
|
|
-
|
|
|
- private void scrollToBottom() {
|
|
|
- chatRecyclerView.post(() -> {
|
|
|
- int lastPosition = messageAdapter.getItemCount() - 1;
|
|
|
- if (lastPosition >= 0) {
|
|
|
- chatRecyclerView.scrollToPosition(lastPosition);
|
|
|
- }
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- private void updateConnectionUi(String statusText) {
|
|
|
- connectionStatus.setText(statusText);
|
|
|
- int color = getColor((isConnected && isLoggedIn) ? R.color.chat_primary : R.color.chat_text_muted);
|
|
|
- connectionStatus.setTextColor(color);
|
|
|
- sendButton.setEnabled(isConnected && isLoggedIn);
|
|
|
- retryButton.setVisibility((isConnecting || (isConnected && isLoggedIn)) ? View.GONE : View.VISIBLE);
|
|
|
- retryButton.setEnabled(!isConnecting);
|
|
|
- }
|
|
|
-
|
|
|
- private void closeCurrentClient() {
|
|
|
- ChatWebSocketClient client = webSocketClient;
|
|
|
- webSocketClient = null;
|
|
|
- if (client != null) {
|
|
|
- try {
|
|
|
- client.close();
|
|
|
- } catch (Exception ignored) {
|
|
|
- // Best-effort close.
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private void scheduleReconnect() {
|
|
|
- if (destroyed || reconnectScheduled || TextUtils.isEmpty(currentUsername)) {
|
|
|
- return;
|
|
|
- }
|
|
|
- reconnectScheduled = true;
|
|
|
- updateConnectionUi(getString(R.string.reconnecting));
|
|
|
- mainHandler.postDelayed(reconnectRunnable, RETRY_DELAY_MS);
|
|
|
- }
|
|
|
-
|
|
|
- private void cancelReconnect() {
|
|
|
- reconnectScheduled = false;
|
|
|
- mainHandler.removeCallbacks(reconnectRunnable);
|
|
|
- }
|
|
|
-
|
|
|
- private void runReconnect() {
|
|
|
- reconnectScheduled = false;
|
|
|
- if (!destroyed && webSocketClient == null && !isConnecting && !TextUtils.isEmpty(currentUsername)) {
|
|
|
- connectToServer();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private String loadSavedUsername() {
|
|
|
- return normalizeUsername(preferences.getString(KEY_USERNAME, ""));
|
|
|
- }
|
|
|
-
|
|
|
- private void saveUsername(String username) {
|
|
|
- preferences.edit().putString(KEY_USERNAME, username).apply();
|
|
|
- }
|
|
|
-
|
|
|
- private String buildDefaultUsername() {
|
|
|
- String model = sanitizeUsernamePart(Build.MODEL);
|
|
|
- if (TextUtils.isEmpty(model)) {
|
|
|
- model = "user";
|
|
|
- }
|
|
|
-
|
|
|
- String androidId = Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID);
|
|
|
- String normalizedId = sanitizeUsernamePart(androidId);
|
|
|
- String suffix = normalizedId.length() >= 4
|
|
|
- ? normalizedId.substring(normalizedId.length() - 4)
|
|
|
- : (TextUtils.isEmpty(normalizedId) ? "0000" : normalizedId);
|
|
|
-
|
|
|
- return model + "-" + suffix;
|
|
|
- }
|
|
|
-
|
|
|
- private static String normalizeUsername(Editable editable) {
|
|
|
- return editable == null ? "" : normalizeUsername(editable.toString());
|
|
|
- }
|
|
|
-
|
|
|
- private static String normalizeUsername(String value) {
|
|
|
- if (value == null) {
|
|
|
- return "";
|
|
|
- }
|
|
|
- return value.trim().replaceAll("\\s+", " ");
|
|
|
- }
|
|
|
-
|
|
|
- private static String safeText(Editable editable) {
|
|
|
- return editable == null ? "" : editable.toString().trim();
|
|
|
- }
|
|
|
-
|
|
|
- private static String safeText(CharSequence value) {
|
|
|
- return value == null ? "" : value.toString().trim();
|
|
|
- }
|
|
|
-
|
|
|
- private static String sanitizeUsernamePart(String value) {
|
|
|
- if (TextUtils.isEmpty(value)) {
|
|
|
- return "";
|
|
|
- }
|
|
|
-
|
|
|
- String normalized = value.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-");
|
|
|
- normalized = normalized.replaceAll("^-+|-+$", "");
|
|
|
- if (normalized.length() > 12) {
|
|
|
- normalized = normalized.substring(0, 12);
|
|
|
- }
|
|
|
- return normalized;
|
|
|
- }
|
|
|
-
|
|
|
- private int dp(int value) {
|
|
|
- return Math.round(getResources().getDisplayMetrics().density * value);
|
|
|
- }
|
|
|
-
|
|
|
- private static String describeError(Exception ex) {
|
|
|
- StringBuilder builder = new StringBuilder(ex.getClass().getSimpleName());
|
|
|
- if (!TextUtils.isEmpty(ex.getMessage())) {
|
|
|
- builder.append(" - ").append(ex.getMessage());
|
|
|
- }
|
|
|
- Throwable cause = ex.getCause();
|
|
|
- while (cause != null) {
|
|
|
- builder.append(" | cause=").append(cause.getClass().getSimpleName());
|
|
|
- if (!TextUtils.isEmpty(cause.getMessage())) {
|
|
|
- builder.append(": ").append(cause.getMessage());
|
|
|
- }
|
|
|
- cause = cause.getCause();
|
|
|
- }
|
|
|
- return builder.toString();
|
|
|
- }
|
|
|
-
|
|
|
- @Override
|
|
|
- protected void onDestroy() {
|
|
|
- destroyed = true;
|
|
|
- cancelReconnect();
|
|
|
- closeCurrentClient();
|
|
|
- super.onDestroy();
|
|
|
- }
|
|
|
-}
|