0

I have been struggling with what I believe is a screen repainting problem for a RecylerView when my underlying model is modified by another Thread. But have run out of ideas.

My application receives messages from a MQTT topic and displays it in a RecyclerView as a sort of "history" or "log" display.

This works fine as long as the MQTT session does not auto reconnect.

Once the MQTT session reconnects after a dropped connection, I still receive messages from the MQTT topic, the messages are still added to my model, I still raise the "Data changed" notification, I still invalidate the RecyclerView control, but the RecyclerView is no longer repainted to reveal the new message on screen.

If I manually force a refresh/repaint of the screen (e.g. scroll the recycler view, switch to another app and back again etc) then the RecyclerView is repainted and shows the "missing" messages.

My question is: what is it about the RecyclerView that seems to be causing it to not repaint when the underlying model is modified as a result of messages being received from an MQTT topic, but only if the MQTT session is dropped and reconnected?????

And obviously, what do I need to do to fix it?????

Update

I've tried adding the following method (which is activated by the onClick of a Floating button).

    public void buttonClick (View v) {
        mAdapter.add("Button Message");
        Toast.makeText(getApplicationContext(),"Button message added", Toast.LENGTH_SHORT).show();
    }

This method suffers from the same problem as messages received from the MQTT topic. If I click it before the MQTT auto-reconnect, the "Button Message" is added to my RecyclerView and displayed.

Once MQTT session is dropped and then auto reconnected, even this "buttonClick" method's "Button Message" is no longer displayed unless I force a refresh of the RecyclerList. FWIW, the "Toast" is always displayed (before and after the MQTT autoreconnect).

Could it be that I've stumbled upon some sort of wierd bug in RecyclerView???

FWIW 1, I've read many posts trying to get RecyclerView to work in relation to background thread updates of the underlying data model. Some suggest running the notification on the MainUI thread. I believe that this does make a difference. Previously it never displayed any of the messages received from the MQTT topic, now it does - but not if the connection is lost and reconnected.

FWIW 2, I know that the notifyDataSetChanged should be used as a last resort as it is least efficient. I have tried some of the other notify methods. These other notification methods produce the exact same problem as notifyDataSetChanged. At this point I am trying to keep it simple, so as to get it to work. Next I can focus on efficiency.

Here are the relevant code snippets.

Firstly, the MQTT callback which is invoked when a message is received:

        @Override
        public void messageArrived(final String topic, MqttMessage message) throws Exception {
            final String msg = new String(message.getPayload());
            if ("glennm/test/temp".equals(topic)) {
                Log.i("RCVD", "Temperature: " + msg);   // This code is shown to illustrate a control that
                if (textViewTemperature != null) {      // always seems to be redisplayed when a message is received
                    textViewTemperature.setText(msg + "°");  // even if the connection is lost and reconnected
                    textViewTemperature.invalidate();
                    temperatureHistory.add(msg);
                    temperatureHistory.dump("TEMP");
                } else {
                    Log.e("NULL", "textView temperature control is null");
                }
            } else if ("glennm/test/humid".equals(topic)) {
// Code that updates the humidity text view omitted for brevity (as it is basically the same as the temperature code above.
            } else {            /***** This is the problem area - other messages logged to the Recycler view ****/
                String wrk = topic;
                if (topic != null && topic.toLowerCase().startsWith("glennm/test")) {
                    wrk = topic.substring(12);
                }
                final String topicToShow = wrk;
                textViewOtherTopic.setText(topicToShow);
                textViewOtherMessage.setText(msg);

//                mAdapter.add(topicToShow + ": " + msg);

                // The notify that the add method calls ***MUST*** be run on the main UI thread.
                // Failure to do so means that the call will sometimes be ignored and the
                // Recycler view is not updated to show the new incoming value.
                // https://stackoverflow.com/questions/36467236/notifydatasetchanged-recyclerview-is-it-an-asynchronous-call/36512407#36512407
                // This seems to help, but we still seem to have the same behaviour if the MQTT connection resets.
                runOnUiThread(new Runnable() {
//                recyclerView.post(new Runnable() {
                    @Override
                    public void run() {
                        mAdapter.add(topicToShow + ": " + msg);
                        recyclerView.invalidate();
                    }
                });
                Log.i("RCVD", "Other Topic: " + topic + ", message: " + msg);
            }
        }
    }

Secondly, the code that is invoked to add the message to the underlying data model (and notify the UI to repaint it).

  public void add(String msg) {
    Log.d("HISTORY", "Adding message: " + msg);
    messageList.add(msg);
    while (messageList.size() > MAX_HISTORY) {
      messageList.remove(0);
    }
    Log.d("HISTORY", "Notifying data set changed");
    _parent.runOnUiThread(new Runnable () {
      @Override
      public void run() {
        notifyDataSetChanged();
      }
    });
    Log.d("HISTORY", "Notifying data set changed - complete");
  }

Finally, here are three screen snapshots which try to illustrate the problem. In the first, messages have been received from the MQTT topic and are displayed both in the journal and the "current message" field (textViewOtherTopic and textViewOtherMessage controls) located below the humidity.

Message displays in both "current message" and "message log"

What happens between the first and second screen shot is that the MQTT service lost its connection and auto-reconnected. After that, the received message is only displayed in the "current message" view and not the "message log" recycler view (despite being added to the model).

after MQTT connection reset, messages only appear in "current message" text view controls

Only when the Recycler View is forced to be repainted by an external (manual) user action (e.g. scrolling) does the missing message(s) show up.

Manual user action causing screen repaint causes "missing message(s)" to be displayed in RecylerView

Here is an example of a command using the mosquitto mqtt client that is used to post a message to the app:

mosquitto_pub -h test.mosquitto.org -t "glennm/test/comment" -q 1 -m "It's a new day, but still cold! 3"

Following is the full code for the two classes (including many of the commented out attempts I've made)...

The Main activity:

package com.gtajb.tempmonitorapp;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import android.os.Bundle;
import android.util.Log;
import android.widget.TextView;

import org.eclipse.paho.android.service.MqttAndroidClient;
import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions;
import org.eclipse.paho.client.mqttv3.IMqttActionListener;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.IMqttMessageListener;
import org.eclipse.paho.client.mqttv3.IMqttToken;
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.MqttMessage;

import java.util.ArrayList;
import java.util.UUID;

public class MainActivity extends AppCompatActivity {
    private MqttAndroidClient mqttAndroidClient;

    private String serverUri = "tcp://test.mosquitto.org:1883";
    public static final String clientId = UUID.randomUUID().toString();

    public final String subscriptionTopic = "glennm/test/#";
    public final String publishTopic = "glennm/test/tome";
    public final String publishMessage = "Hello from Android test client";

    private TextView textViewTemperature;
    private TextView textViewHumidity;
    private TextView textViewOtherTopic;
    private TextView textViewOtherMessage;

    private MessageCallBack messageCallBack = new MessageCallBack();
    private RecyclerView recyclerView;
    private MessageHistory mAdapter;
    private RecyclerView.LayoutManager layoutManager;

    private ReadingsHistory temperatureHistory = new ReadingsHistory();
    private ReadingsHistory humidityHistory = new ReadingsHistory();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Log.d("INFO", "Createing the mqtt client with client ID: " + clientId);
        mqttAndroidClient = new MqttAndroidClient(getApplicationContext(), serverUri, clientId);
        mqttAndroidClient.setCallback(new MqttClientCallback());

        MqttConnectOptions mqttConnectOptions = new MqttConnectOptions();
        mqttConnectOptions.setAutomaticReconnect(true);
        mqttConnectOptions.setCleanSession(false);

        textViewTemperature = findViewById(R.id.temperatureValueLabel);
        textViewTemperature.setText("Temp goes here");
        textViewHumidity = findViewById(R.id.humidtyValueLabel);
        textViewHumidity.setText("Humid goes here");

        textViewOtherTopic = findViewById(R.id.otherTopicValueLabel);
        textViewOtherMessage = findViewById(R.id.otherMessageValueLabel);
        textViewOtherTopic.setText(".");
        textViewOtherMessage.setText(".");

        recyclerView = findViewById(R.id.historyPanel);
        mAdapter = new MessageHistory(new ArrayList<String>(), this, recyclerView);
        layoutManager = new LinearLayoutManager(this);
        recyclerView.setLayoutManager(layoutManager);
        recyclerView.setAdapter(mAdapter);

        mAdapter.add("A test message");
        messageCallBack = new MessageCallBack();

        try {
            mqttAndroidClient.connect(mqttConnectOptions, null, new IMqttActionListener() {
                @Override
                public void onSuccess(IMqttToken asyncActionToken) {
                    DisconnectedBufferOptions disconnectedBufferOptions = new DisconnectedBufferOptions();
                    disconnectedBufferOptions.setBufferEnabled(true);
                    disconnectedBufferOptions.setBufferSize(100);
                    disconnectedBufferOptions.setPersistBuffer(false);
                    disconnectedBufferOptions.setDeleteOldestMessages(false);
                    mqttAndroidClient.setBufferOpts(disconnectedBufferOptions);
                    //subscribeToTopic();
                }

                @Override
                public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
                    Log.e("CONNECT", "Failed to connect to " + serverUri);
                    Log.e("CONNECT", exception.getMessage());
                }
            });
        } catch (MqttException e) {
            Log.e("CONNECT", "Exception connecting to " + serverUri);
            Log.e("CONNECT", e.getMessage());
            Log.e("CONNECT", Log.getStackTraceString(e));
            e.printStackTrace();
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        Log.i("CONN", "Closing MQTT connection");
        mqttAndroidClient.close();
    }



    public class MqttClientCallback implements MqttCallbackExtended {
        @Override
        public void connectComplete(boolean reconnect, String serverURI) {
            if (reconnect) {
                Log.i("CONN", "Reconnected to: " + serverURI);
                subscribeToTopic();
            } else {
                Log.i("CONN", "Connected to: " + serverURI);
                subscribeToTopic();
            }
        }

        @Override
        public void connectionLost(Throwable cause) {
            Log.i("CONN", "Connection lost");
        }

        @Override
        public void messageArrived(String topic, MqttMessage message) throws Exception {
            Log.i("MSG", topic + " - " + new String(message.getPayload()));
        }

        @Override
        public void deliveryComplete(IMqttDeliveryToken token) {
            Log.i("PUB", "Delivery complete");
        }
    }


    public void subscribeToTopic() {
        try {
            Log.i("TOPIC", "Subscribing to: " + subscriptionTopic);
//            mqttAndroidClient.subscribe(subscriptionTopic, 0, null, new IMqttActionListener() {
//                @Override
//                public void onSuccess(IMqttToken asyncActionToken) {
//                    Log.i("SUBS", "Subscription to " + subscriptionTopic + " on " + serverUri + " successful");
//                }
//
//                @Override
//                public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
//                    Log.i("SUBS", "Subscription to " + subscriptionTopic + " on " + serverUri + " FAILED");
//                }
//            });
            mqttAndroidClient.subscribe(subscriptionTopic, 0,  messageCallBack);
        } catch (MqttException e) {
            Log.e("SUBS", "Failed to subscribe to topic: " + subscriptionTopic + " on " + serverUri);
            Log.e("SUBS", e.getMessage());
        }
    }

    public void setTemperatureValue(String val) {
        textViewTemperature.setText(val);
    }


    public void setHumidityValue(String val) {
        textViewHumidity.setText(val);
    }


    public class MessageCallBack implements IMqttMessageListener , IMqttActionListener {

        @Override
        public void onSuccess(IMqttToken asyncActionToken) {
            Log.i("MQTT", "Successful operation " + asyncActionToken.toString());
            textViewTemperature.setText("Subscribed");
        }

        @Override
        public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
            Log.i("MQTT", "Un Successful operation + " + asyncActionToken.toString());
            textViewTemperature.setText("Not Subscribed");
        }

        @Override
        public void messageArrived(final String topic, MqttMessage message) throws Exception {
            final String msg = new String(message.getPayload());
            if ("glennm/test/temp".equals(topic)) {
                Log.i("RCVD", "Temperature: " + msg);
                if (textViewTemperature != null) {
                    textViewTemperature.setText(msg + "°");
                    textViewTemperature.invalidate();
                    temperatureHistory.add(msg);
                    temperatureHistory.dump("TEMP");
                } else {
                    Log.e("NULL", "textView temperature control is null");
                }
            } else if ("glennm/test/humid".equals(topic)) {
                Log.i("RCVD", "Humidity: " + msg);
                textViewHumidity.setText(msg + "%");
                textViewHumidity.invalidate();
                humidityHistory.add(msg);
                humidityHistory.dump("HUMID");
            } else {
                String wrk = topic;
                if (topic != null && topic.toLowerCase().startsWith("glennm/test")) {
                    wrk = topic.substring(12);
                }
                final String topicToShow = wrk;
                textViewOtherTopic.setText(topicToShow);
                textViewOtherMessage.setText(msg);

//                mAdapter.add(topicToShow + ": " + msg);

                // The notify that the add method calls ***MUST*** be run on the main UI thread.
                // Failure to do so means that the call will sometimes be ignored and the
                // Recycler view is not updated to show the new incoming value.
                // https://stackoverflow.com/questions/36467236/notifydatasetchanged-recyclerview-is-it-an-asynchronous-call/36512407#36512407
                // This seems to help, but we still seem to have the same behaviour.
                runOnUiThread(new Runnable() {
//                recyclerView.post(new Runnable() {
                    @Override
                    public void run() {
                        mAdapter.add(topicToShow + ": " + msg);
//                        recyclerView.invalidate();
                    }
                });
                Log.i("RCVD", "Other Topic: " + topic + ", message: " + msg);
//                Context context = getApplicationContext();
//                Toast msgPopup = Toast.makeText(context, msg, Toast.LENGTH_SHORT);
//                msgPopup.show();
            }
        }
    }
}

The Message History class:

package com.gtajb.tempmonitorapp;

import android.app.Activity;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

import java.util.ArrayList;

public class MessageHistory extends RecyclerView.Adapter<MessageHistory.Callback> {

    private ArrayList<String> messageList = new ArrayList();
    public static final int MAX_HISTORY = 100;
    private Activity _parent;
    private RecyclerView rv;

    public class Callback extends RecyclerView.ViewHolder {
        TextView mTextView;

        Callback(View itemView) {
            super(itemView);
            mTextView = itemView.findViewById(R.id.row_text);
        }
    }

    public MessageHistory(ArrayList<String> messageList, Activity parent, RecyclerView rv) {
        super();
        this.messageList = messageList;
        this._parent = parent;
        this.rv = rv;
        add("Test Message 1");
        add("Test Message 2");
    }

    @NonNull
    @Override
    public Callback onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.history_row, parent, false);
        return new Callback(v);
    }

    @Override
    public void onBindViewHolder(@NonNull Callback holder, int position) {
        Log.d("HISTORY", "Setting " + position + ": " + messageList.get(position));
        holder.mTextView.setText(messageList.get(position));
    }

    @Override
    public int getItemCount() {
        Log.d("HISTORY", "Message Count: " + messageList.size());
        return messageList.size();
    }

    /**
     * Add a message to the message log.
     * @param msg the message to add.
     */
    public void add(String msg) {
        Log.d("HISTORY", "Adding message: " + msg);
        messageList.add(msg);
        while (messageList.size() > MAX_HISTORY) {
            messageList.remove(0);
        }
//        getItemCount();
        Log.d("HISTORY", "Notifying data set changed");
        _parent.runOnUiThread(new Runnable () {
            @Override
            public void run() {
                notifyDataSetChanged();
            }
        });
        //this.notifyDataSetChanged();
        Log.d("HISTORY", "Notifying data set changed - complete");

//        rv.invalidate();
//        rv.refreshDrawableState();
//        this.notifyItemInserted(messageList.size());
//        final RecyclerView.Adapter adapter = this;
//        _parent.runOnUiThread(new Runnable() {
//            @Override
//            public void run() {
//                adapter.notifyDataSetChanged();
//            }
//        });
    }
}
GMc
  • 1,764
  • 1
  • 8
  • 26
  • create method clear all items in adapter, inside method connectionLost() you should call adapter.clear() when you already call adapter.notifyDataSetChange don't call recyclerview.invalidate(), it's redundant – Công Hải May 08 '20 at 03:51
  • Show me the code you setup MqttAndroidClient, I wonder that why message send again when you create mqtt and subscribe topic in case connection lost? – Công Hải May 08 '20 at 03:53
  • @CôngHải Thanks for your quick reply. I double checked, without the Invalidate, the RecyclerView is repainted (unless the connection lost / auto reconnect scenario happens). I will add the full code to my question. – GMc May 08 '20 at 05:04
  • Did you try clear messageList when get connectionLost? – Công Hải May 08 '20 at 07:05
  • I think root cause is message you already get has get again when you subscribe, I recommend you listen messageArrived in MqttClientCallback instead of MessageCallBack – Công Hải May 08 '20 at 07:06
  • @CôngHải I do not want to clear the messageList when the connection is lost - because I want to keep the log. *However,* I did try this suggestion. When the connection is restored I clear the history. Any new messages are not displayed until I scroll the RecyclerView. At that point the existing messages disappear any only the new message is displayed. In relation to your second point, the messages are all different - the number at the end is different. However, I tried a completely different message ("Blah, Blah") and the same thing happens (i.e. nothing until i manually force a repaint). – GMc May 08 '20 at 07:20
  • The messageArrived method in MqttClientCallback does not seem to be invoked (and thus does not do anything when a message is received). – GMc May 08 '20 at 07:25

0 Answers0