image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

Antipattern : une activité qui gère tout

Définition d'un modèle séparé

Exemple : une liste de courses

Un modèle simple communiquant avec la vue

Comment mettre en place : le pattern observé-observateur ?

Une classe entre la vue et le modèle : ViewModel

Un exemple : une tribune de discussion

Etape 0 : écriture du serveur

#! /usr/bin/env python3

A simple server managing a chatroom


import asyncio, time, sys, json
import websockets

class Post(object):
	def __init__(self, author, content):
		self.author = author
		self.content = content
		self.timestamp = int(time.time()) # unix timestamp
		
class Posts(object):
	def __init__(self):
		self._elements = {}
		self._listeners = set() # to be called when there is a new post
		self.first_id = -1
		self.last_id = -1
	
	def get(self, id):
		return self._elements.get(id)
		
	def add(self, post: Post):
		if self.first_id == -1:
			self.first_id = 0
		self.last_id += 1
		self._elements[self.last_id] = post
		for listener in self._listeners:
			listener()
		
	def add_listener(self, listener):
		self._listeners.add(listener)
		
	def remove_listener(self, listener):
		self._listeners.remove(listener)


async def manage_websocket(posts, websocket, path):
	# the username is based on the IP address and port of the client
	username = "{}/{}".format(websocket.remote_address[0], websocket.remote_address[1])
	
	def on_new_post():
		# called to notify about a new post
		print("Sending available messages: {} {}".format(posts.first_id, posts.last_id), file=sys.stderr)
		asyncio.create_task(websocket.send(json.dumps({"kind": "availableMessages", "first": posts.first_id, "last": posts.last_id})))
	posts.add_listener(on_new_post)
	on_new_post() # call the listener for the first time
	
	async for message in websocket:
		# interpret the message as JSON
		try:
			message_dict = json.loads(message)
		except:
			message_dict = None
		kind = message_dict.get("kind", None) if message_dict else None
		if kind == "fetchMessage":
			id = message_dict.get("id", None)
			post = posts.get(id)
			if post:
				asyncio.create_task(websocket.send(json.dumps(
					{"kind": "message", "id": id, "author": post.author, "timestamp": post.timestamp, "content": post.content})))
		elif kind == "sendMessage":
			content = message_dict.get("content")
			if content:
				posts.add(Post(username, content))

def start(interface, port):
	posts = Posts()
	asyncio.get_event_loop().run_until_complete(
		websockets.serve(lambda websocket, path: manage_websocket(posts, websocket, path), interface, port))
	asyncio.get_event_loop().run_forever()
    
def main():
	try:
		interface, port = (sys.argv[1], int(sys.argv[2]))
	except:
		print("Usage: {} interface port".format(sys.argv[0]))
		sys.exit(1)
	start(interface, port)

if __name__ == "__main__":
	main()

Etape 1 : écriture du modèle représentant un message

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.chatclient.model;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.Serializable;

/** A message posted to the chatroom */
public class ChatMessage implements Serializable {
    public final long id;
    public final String author;
    public final long timestamp;
    public final String content;

    public ChatMessage(long id, String author, long timestamp, String content) {
        this.id = id;
        this.author = author;
        this.timestamp = timestamp;
        this.content = content;
    }

    public ChatMessage(String content) {
        this(-1L, null, -1L, content);
    }

    /** Create a ChatMessage object from JSON data */
    public static ChatMessage fromJSONObject(JSONObject obj) {
        try {
            return new ChatMessage(obj.getLong("id"),
                    obj.getString("author"),
                    obj.getLong("timestamp"),
                    obj.getString("content"));
        } catch (JSONException e) {
            // the input in malformed, we return null
            return null;
        }
    }

    @Override
    public String toString() {
        return String.format("%d: [%s] %s", id, author, content);
    }
}

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.chatclient.model;

import android.os.SystemClock;

import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.stream.Collectors;

/** A set of retrieved chat messages */
public class ChatMessages implements Serializable {
    public final long updateTimestamp;
    public final SortedMap<Long, ChatMessage> messages;

    public ChatMessages(SortedMap<Long, ChatMessage> messages) {
        this.updateTimestamp = System.currentTimeMillis();
        this.messages = Collections.unmodifiableSortedMap(new TreeMap<>(messages)); // create an immutable copy of the map
    }

    @Override
    public String toString() {
        return messages.values().stream().map(ChatMessage::toString).collect(Collectors.joining("\n"));
    }

    /** Save this object to an output stream (using the standard Java serialization) */
    public boolean saveToOutputStream(OutputStream os) {
        try (ObjectOutputStream oos = new ObjectOutputStream(os)) {
            oos.writeObject(this);
            return true;
        } catch (IOException e) {
            return false;
        }
    }

    /** Read this object from an input stream (using the standard Java serialization) */
    public static ChatMessages loadFromInputStream(InputStream is) {
        try (ObjectInputStream ois = new ObjectInputStream(is)) {
            return (ChatMessages)ois.readObject();
        } catch (Exception e) {
            return null;
        }
    }
}

Etape 2 : écriture de la classe gérant la récupération des messages sur le serveur

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.chatclient.connector;

import android.util.Log;

import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.json.JSONException;
import org.json.JSONObject;

import java.lang.ref.WeakReference;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.function.Consumer;

import fr.upem.coursand.chatclient.model.ChatMessage;
import fr.upem.coursand.chatclient.model.ChatMessages;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okhttp3.internal.tls.OkHostnameVerifier;
import okio.ByteString;

/** Manage a websocket connection to the chat room server to retrieve and send messages */
public class ChatRoomConnector {
    private final String url;
    private final OkHttpClient webClient;

    public ChatRoomConnector(String url) {
        this.url = url;
        this.webClient = new OkHttpClient();
    }

    private boolean initialized = false;
    private WebSocket webSocket = null;
    private String offlineReason = "not initialized";


    private WebSocketListener webSocketListener = new WebSocketListener() {
        @Override
        public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
            super.onClosed(webSocket, code, reason);
            ChatRoomConnector.this.webSocket = null;
            if (receivalCallback != null) {
                ChatRoomConnector.this.webSocket = null;
                offlineReason = "normally closed";
                receivalCallback.accept(null); // to signal the closure of the socket
            }
        }

        @Override
        public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
            super.onClosing(webSocket, code, reason);
        }

        @Override
        public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) {
            super.onFailure(webSocket, t, response);
            if (receivalCallback != null) {
                ChatRoomConnector.this.webSocket = null;
                offlineReason = t.getMessage();
                receivalCallback.accept(null); // to signal the closure of the socket
            }
        }

        @Override
        public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) {
            super.onMessage(webSocket, text);
            processReceivedMessage(text);
        }

        @Override
        public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) {
            super.onMessage(webSocket, bytes);
            Log.d(getClass().getName(), "Received binary message");
        }

        @Override
        public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {
            super.onOpen(webSocket, response);
            ChatRoomConnector.this.webSocket = webSocket;
            offlineReason = null;
        }
    };

    private void initWebSocket() {
        if (! initialized) {
            Request r = new Request.Builder().url(url).build();
            webClient.newWebSocket(r, webSocketListener);
            initialized = true;
        }
    }

    /** Map storing retrieved messages */
    private SortedMap<Long, ChatMessage> retrievedMessages = new TreeMap<>();

    private void processReceivedMessage(String text) {
        Log.d(getClass().getName(), "Received message: " + text);
        try {
            JSONObject obj = new JSONObject(text);
            String kind = obj.getString("kind");
            if (kind.equals("availableMessages")) {
                long first = obj.getLong("first");
                long last = obj.getLong("last");
                if (first != -1L)
                    for (long i = first; i <= last; i++)
                        if (! retrievedMessages.containsKey(i))
                            retrieveMessage(i);
                if (receivalCallback != null)
                    receivalCallback.accept(null);
            } else if (kind.equals("message")) {
                // a retrieved message
                ChatMessage cm = ChatMessage.fromJSONObject(obj);
                if (cm != null) {
                    retrievedMessages.put(cm.id, cm);
                    if (receivalCallback != null)
                        receivalCallback.accept(new ChatMessages(retrievedMessages));
                }
            }
        } catch (JSONException e) {
            Log.e(getClass().getName(), "Error while parsing " + text, e);
        }
    }

    private void retrieveMessage(long id) {
        initWebSocket();
        try {
            JSONObject obj = new JSONObject();
            obj.put("kind", "fetchMessage");
            obj.put("id", id);
            webSocket.send(obj.toString());
        } catch (JSONException e)
        {
            Log.e(getClass().getName(), "Error while trying to ask to retrieve message " + id, e);
        }
    }

    private Consumer<ChatMessages> receivalCallback;

    /** Order to be informed about new messages */
    public void startMessageReceival(Consumer<ChatMessages> callback) {
        receivalCallback = callback;
        initWebSocket();
    }

    public boolean sendMessage(ChatMessage message) {
        initWebSocket();
        try {
            JSONObject obj = new JSONObject();
            obj.put("kind", "sendMessage");
            obj.put("content", message.content);
            return webSocket.send(obj.toString()); // thread-safe
        } catch (JSONException e) {
            Log.e(getClass().getName(), "JSON exception", e);
            return false;
        }
    }

    /** Return if the websocket is offline (null it is online) */
    public String getOfflineReason() {
        return offlineReason;
    }

    public void close() {
        if (webSocket != null)
            webSocket.close(1000, "good bye");
    }
}

Etape 3 : écriture du ViewModel

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.chatclient.viewmodel;

import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.LinkedList;
import java.util.Queue;

import fr.upem.coursand.chatclient.connector.ChatRoomConnector;
import fr.upem.coursand.chatclient.model.ChatMessage;
import fr.upem.coursand.chatclient.model.ChatMessages;

public class ChatMessagesViewModel extends AndroidViewModel {
    public static final long RETRY_CONNECTION_DELAY = 30000L; // in millis

    private ChatRoomConnector connector = null;
    private MutableLiveData<ChatMessages> chatMessages = new MutableLiveData<>();
    private MutableLiveData<String> offlineReason = new MutableLiveData<String>();
    private Queue<ChatMessage> sendMessageQueue = new LinkedList<>();
    private Handler handler = new Handler(); // to retry connection periodically
    private SharedPreferences prefs;



    public ChatMessagesViewModel(@NonNull Application application) {
        super(application);
        prefs = getApplication().getApplicationContext().getSharedPreferences("params", Context.MODE_PRIVATE);
        // reinitialize the connector if the url is specified by the user or if it is changed in the preferences
        prefs.registerOnSharedPreferenceChangeListener((sharedPreferences, key) -> { if (key.equals("serverUrl")) initConnector(); });
        ChatMessages restoredMessages = restoreBackupedMessages();
        if (restoredMessages != null) chatMessages.setValue(restoredMessages);
        initConnector();
    }

    private Runnable retryConnectionRunnable = () -> initConnector();

    private void initConnector() {
        String url = prefs.getString("serverUrl", null);
        if (url != null) {
            Log.i(getClass().getName(), "Trying to connect to the websocket " + url);
            handler.removeCallbacks(retryConnectionRunnable);
            if (connector != null) connector.close(); // close the previous connector
            offlineReason.setValue("Connecting to " + url);
            connector = new ChatRoomConnector(url);
            // when new messages are received, post the value to the live data (that is observed by the activity)
            connector.startMessageReceival((ms) -> {
                offlineReason.postValue(connector.getOfflineReason());
                if (ms == null) {
                    // the socket has been closed
                    if (connector.getOfflineReason() != null)
                        handler.postDelayed(retryConnectionRunnable, RETRY_CONNECTION_DELAY); // retry to connect later
                } else {
                    chatMessages.postValue(ms);
                }
            });
        }
    }

    public LiveData<ChatMessages> getChatMessages() {
        return chatMessages;
    }

    public LiveData<String> getOfflineReason() {
        return offlineReason;
    }

    /** Add the message to the queue */
    public void sendMessage(ChatMessage message) {
        sendMessageQueue.add(message);
        flushMessageQueue();
    }

    /** Ask the connector to send the queued messages (if the connector is initialized) */
    private void flushMessageQueue() {
        if (connector != null && connector.getOfflineReason() == null)
            while (! sendMessageQueue.isEmpty()) {
                ChatMessage cm = sendMessageQueue.peek();
                if (connector.sendMessage(cm))
                    sendMessageQueue.poll(); // remove the message from the queue
                else break; // the websocket cannot send the messages
            }
    }

    private void backupMessages() {
        ChatMessages cms = chatMessages.getValue();
        // backup the messages
        if (cms != null) {
            Log.i(getClass().getName(), "Backup " + cms.messages.size() + " messages");
            try (OutputStream os = getApplication().getApplicationContext().openFileOutput("cachedMessages", Context.MODE_PRIVATE)) {
                cms.saveToOutputStream(os);
            } catch (IOException e) {
                Log.e(getClass().getName(), "error while backuping messages", e);
            }
        }
    }

    private ChatMessages restoreBackupedMessages() {
        try (InputStream is = getApplication().getApplicationContext().openFileInput("cachedMessages")) {
            return ChatMessages.loadFromInputStream(is);
        } catch (IOException e) {
            Log.e(getClass().getName(), "error while loading messages", e);
            return null;
        }
    }

    /** Call when the viewmodel is destroyed (because the activity using it is finished) */
    @Override
    protected void onCleared() {
        Log.i(getClass().getName(), "Cleared!");
        if (connector != null)
            connector.close(); // close the websocket
        backupMessages();
        super.onCleared();
    }
}

Etape 4 : écriture de l'activité utilisant le ViewModel

Composants de l'activité :

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.chatclient.view;

import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;

import android.content.Context;
import android.content.DialogInterface;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.InputType;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;

import fr.upem.coursand.R;
import fr.upem.coursand.chatclient.model.ChatMessage;
import fr.upem.coursand.chatclient.viewmodel.ChatMessagesViewModel;

public class ChatClientActivity extends AppCompatActivity {

    private ChatMessagesViewModel viewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_chat_client);
        viewModel = ViewModelProvider.AndroidViewModelFactory.getInstance(this.getApplication())
                .create(ChatMessagesViewModel.class);
        // update the status bar with the offline reason
        viewModel.getOfflineReason().observe(this, offlineReason -> {
            TextView tv = findViewById(R.id.statusTextView);
            tv.setText(offlineReason != null ? offlineReason : "online");
        });
        // update the messages list
        viewModel.getChatMessages().observe(this, messages -> {
            TextView tv = findViewById(R.id.messagesView);
            tv.setText(messages.toString());
        });
    }

    private String getServerUrl() {
        SharedPreferences prefs = getApplicationContext().getSharedPreferences("params", Context.MODE_PRIVATE);
        return prefs.getString("serverUrl", "");
    }

    private void changeServerUrl(String url) {
        SharedPreferences prefs = getApplicationContext().getSharedPreferences("params", Context.MODE_PRIVATE);
        prefs.edit().putString("serverUrl", url).commit();
    }

    private void displayServerUrlDialog() {
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle("URL of the chat server");
        final EditText input = new EditText(this);
        input.setInputType(InputType.TYPE_TEXT_VARIATION_URI);
        input.setText(getServerUrl());
        builder.setView(input);
        builder.setPositiveButton("OK", (dialog, which) -> changeServerUrl(input.getText().toString()));
        builder.setNegativeButton("Cancel", (dialog, which) -> dialog.cancel());
        builder.show();
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.chat_server_client_menu, menu);
        return super.onCreateOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        if (item.getItemId() == R.id.setServerUrl) {
            displayServerUrlDialog();
        }
        return super.onOptionsItemSelected(item);
    }

    public void sendMessage(View v) {
        EditText et = findViewById(R.id.messageToSendEditText);
        viewModel.sendMessage(new ChatMessage(et.getText().toString()));
        et.setText("");
    }
}