|
|
@@ -0,0 +1,490 @@
|
|
|
+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();
|
|
|
+ }
|
|
|
+}
|