Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import android.content.Context
import android.content.Intent
import android.util.Log
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.bluebubbles.messaging.Constants
Expand Down Expand Up @@ -47,6 +49,9 @@ class SocketIOForegroundService : Service() {
private var isBeingDestroyed: Boolean = false

private var hasStarted: Boolean = false
private val reconnectHandler = Handler(Looper.getMainLooper())
private var reconnectRunnable: Runnable? = null
private var reconnectAttempt: Int = 0

private val eventBlacklist: Array<String> = arrayOf(
"typing-indicator",
Expand Down Expand Up @@ -102,6 +107,9 @@ class SocketIOForegroundService : Service() {
Log.d(Constants.logTag, "Foreground Service is connecting to: $serverUrl")

val opts = IO.Options()
// Reconnects are scheduled by this service so the Socket.IO manager
// cannot race a second retry loop with our URL/service lifecycle.
opts.reconnection = false

try {
// Read the custom headers JSON string from preferences and parse it into a map
Expand All @@ -124,17 +132,20 @@ class SocketIOForegroundService : Service() {
val encodedPw = URLEncoder.encode(storedPassword, "UTF-8")
opts.query = "password=$encodedPw"
mSocket = IO.socket(serverUrl, opts)
mSocket!!.connect()

mSocket!!.on(Socket.EVENT_CONNECT) {
Log.d(Constants.logTag, "Socket.io connected to your server!")
reconnectAttempt = 0
reconnectRunnable?.let { reconnectHandler.removeCallbacks(it) }
reconnectRunnable = null
updateNotification(CONNECTED)
}

mSocket!!.on(Socket.EVENT_CONNECT_ERROR) { args ->
val error = args[0] as Exception
Log.d(Constants.logTag, "Socket.io failed to connect to $serverUrl! Error: ${error.message}")
updateNotification(CONNECT_FAILED + error.message)
tryReconnect()
}

// with reason, details args
Expand All @@ -148,6 +159,7 @@ class SocketIOForegroundService : Service() {
val details = args.getOrNull(1)
Log.d(Constants.logTag, "Socket.io disconnected from server! Reason: $reason, Details: $details")
updateNotification(DISCONNECTED + reason)
tryReconnect()
}

mSocket!!.on("reconnecting") {
Expand All @@ -165,15 +177,17 @@ class SocketIOForegroundService : Service() {
val event = args[0] as String
val message = args[1] as JSONObject

Log.d(Constants.logTag, "Received event of type $event from Socket.io...")
if (!eventBlacklist.contains(event)) {
Log.d(Constants.logTag, "Received event of type $event from Socket.io...")
DartWorkManager.createWorker(applicationContext, "socket-event", hashMapOf("event" to event, "data" to message.toString())) {}
} else {
Log.d(Constants.logTag, "Ignored event of type $event from Socket.io...")
}
}
}

// Register every callback before opening the transport so an
// immediate connect or event cannot race listener setup.
mSocket!!.connect()
} catch (e: Exception) {
if (isBeingDestroyed) {
return
Expand All @@ -190,11 +204,18 @@ class SocketIOForegroundService : Service() {

private fun tryReconnect() {
if (mSocket != null && !mSocket!!.connected()) {
Log.e(Constants.logTag, "Waiting 30 seconds before reconnecting...")

// Sleep for 30 seconds before attempting to reconnect
Thread.sleep(30000)
mSocket!!.connect()
if (reconnectRunnable != null) return
val delaySeconds = 30L * (1L shl reconnectAttempt.coerceAtMost(3))
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(3)
Log.e(Constants.logTag, "Scheduling reconnect in ${delaySeconds}s...")
val runnable = Runnable {
reconnectRunnable = null
if (!isBeingDestroyed && mSocket != null && !mSocket!!.connected()) {
mSocket!!.connect()
}
}
reconnectRunnable = runnable
reconnectHandler.postDelayed(runnable, delaySeconds * 1000L)
}
}

Expand Down Expand Up @@ -261,6 +282,8 @@ class SocketIOForegroundService : Service() {
override fun onDestroy() {
isBeingDestroyed = true
hasStarted = false
reconnectRunnable?.let { reconnectHandler.removeCallbacks(it) }
reconnectRunnable = null
Log.d(Constants.logTag, "BlueBubbles Service is being destroyed!")

super.onDestroy()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,10 @@ class APNService : Service(), MsgReceiver {
override fun receievedMsg(ptr: ULong, retry: ULong) {
Handler(Looper.getMainLooper()).post {
if (MainActivity.engine != null) {
Log.i("ugh running", "here $ptr $retry")
// app is alive, deliver directly there
MethodCallHandler.invokeMethod("APNMsg", mapOf("pointer" to ptr.toString(), "retry" to retry.toString()))
return@post
}
Log.i("ugh running", "backend $ptr $retry")
CoroutineScope(Dispatchers.Main).launch {
DartWorker.callMethod(this@APNService, "APNMsg", mapOf("pointer" to ptr.toString(), "retry" to retry.toString()))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,17 +447,37 @@ class _ChatSubtitleState extends CustomState<ChatSubtitle, void, ConversationTil
bool isDelivered = false;
bool isFromMe = false;

/// Notification text can be assembled from partially populated records while
/// an imported message is being saved. Keep the last usable tile subtitle.
String _subtitleOrFallback(String? candidate, {String? fallback}) {
final value = candidate?.trim();
if (value == null || value.isEmpty) {
final previous = fallback?.trim();
if (previous != null && previous.isNotEmpty) {
return previous;
}
return "Empty message";
}
return value;
}

String _notificationSubtitle(Message? message, {String? fallback}) {
if (message == null) return _subtitleOrFallback(null, fallback: fallback);
return _subtitleOrFallback(MessageHelper.getNotificationText(message), fallback: fallback);
}

@override
void initState() {
super.initState();
tag = controller.chat.guid;
// keep controller in memory since the widget is part of a list
// (it will be disposed when scrolled out of view)
forceDelete = false;
subtitle = MessageHelper.getNotificationText(controller.chat.latestMessage);
cachedLatestMessageGuid = controller.chat.latestMessage.guid!;
cachedDateEdited = controller.chat.latestMessage.dateEdited;
isFromMe = controller.chat.latestMessage.isFromMe!;
final latestMessage = controller.chat.latestMessage;
subtitle = _notificationSubtitle(latestMessage);
cachedLatestMessageGuid = latestMessage.guid;
cachedDateEdited = latestMessage.dateEdited;
isFromMe = latestMessage.isFromMe ?? false;
isDelivered = controller.chat.isGroup || !isFromMe || controller.chat.latestMessage.dateDelivered != null
|| controller.chat.latestMessage.dateRead != null;
fakeText = faker.lorem.words(subtitle.split(" ").length).join(" ");
Expand All @@ -478,8 +498,9 @@ class _ChatSubtitleState extends CustomState<ChatSubtitle, void, ConversationTil
// check if we really need to update this widget
if (message != null && (message.guid != cachedLatestMessageGuid || message.dateEdited != cachedDateEdited)) {
message.handle = message.getHandle();
String newSubtitle = MessageHelper.getNotificationText(message);
String newSubtitle = _notificationSubtitle(message, fallback: subtitle);
if (newSubtitle != subtitle) {
if (!mounted) return;
setState(() {
subtitle = newSubtitle;
fakeText = faker.lorem.words(subtitle.split(" ").length).join(" ");
Expand All @@ -501,7 +522,7 @@ class _ChatSubtitleState extends CustomState<ChatSubtitle, void, ConversationTil
eventDispatcher.stream.listen((event) {
if (event.item1 != 'update-contacts') return;
if (event.item2.isNotEmpty) {
String newSubtitle = MessageHelper.getNotificationText(controller.chat.latestMessage);
String newSubtitle = _notificationSubtitle(controller.chat.latestMessage, fallback: subtitle);
if (newSubtitle != subtitle) {
setState(() {
subtitle = newSubtitle;
Expand All @@ -516,7 +537,7 @@ class _ChatSubtitleState extends CustomState<ChatSubtitle, void, ConversationTil
isFromMe = message.isFromMe ?? false;
isDelivered = controller.chat.isGroup || !isFromMe || message.dateDelivered != null || message.dateRead != null;
if (message.guid != cachedLatestMessageGuid || message.dateEdited != cachedDateEdited) {
String newSubtitle = MessageHelper.getNotificationText(message);
String newSubtitle = _notificationSubtitle(message, fallback: subtitle);
if (newSubtitle != subtitle) {
setState(() {
subtitle = newSubtitle;
Expand Down Expand Up @@ -548,7 +569,14 @@ class _ChatSubtitleState extends CustomState<ChatSubtitle, void, ConversationTil
return Obx(() {
final hideContent = ss.settings.redactedMode.value && ss.settings.hideMessageContent.value;
final hideContacts = ss.settings.redactedMode.value && ss.settings.hideContactInfo.value;
String _subtitle = hideContent ? fakeText : hideContacts && !kIsWeb ? MessageHelper.getNotificationText(Message.findOne(guid: cachedLatestMessageGuid!)!) : subtitle;
final latestMessage = cachedLatestMessageGuid == null ? null : Message.findOne(guid: cachedLatestMessageGuid!);
final resolvedSubtitle = _subtitleOrFallback(subtitle);
final redactedSubtitle = _notificationSubtitle(latestMessage, fallback: resolvedSubtitle);
final String _subtitle = hideContent
? _subtitleOrFallback(fakeText, fallback: "Empty message")
: hideContacts && !kIsWeb
? redactedSubtitle
: resolvedSubtitle;

return RichText(
text: TextSpan(
Expand Down
78 changes: 69 additions & 9 deletions lib/app/layouts/conversation_view/pages/messages_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,14 @@ class MessagesView extends StatefulWidget {
class MessagesViewState extends OptimizedState<MessagesView> {
bool initialized = false;
bool fetching = false;
bool _refreshing = false;
late bool noMoreMessages = widget.customService != null;
List<Message> _messages = <Message>[];

RxList<Widget> smartReplies = <Widget>[].obs;
RxMap<String, Widget> internalSmartReplies = <String, Widget>{}.obs;

late final messageService = widget.customService ?? ms(chat.guid)
..init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage);
late MessagesService messageService;
final smartReply = GoogleMlKit.nlp.smartReply();
final listKey = GlobalKey<SliverAnimatedListState>();
final RxBool dragging = false.obs;
Expand Down Expand Up @@ -134,16 +134,12 @@ class MessagesViewState extends OptimizedState<MessagesView> {
@override
void initState() {
super.initState();
messageService = widget.customService ?? ms(chat.guid);
messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage);

eventDispatcher.stream.listen((e) async {
if (e.item1 == "refresh-messagebloc" && e.item2 == chat.guid) {
// Clear state items
noMoreMessages = false;
_messages = [];
// Reload the state after refreshing
messageService.reload();
messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage);
setState(() {});
await _refreshMessageBloc();
} else if (e.item1 == "add-custom-smartreply") {
if (e.item2 != null && internalSmartReplies['attach-recent'] == null) {
internalSmartReplies['attach-recent'] = _buildReply("Attach recent photo", onTap: () async {
Expand Down Expand Up @@ -198,6 +194,70 @@ class MessagesViewState extends OptimizedState<MessagesView> {
});
}

void _closeMessageControllers(Iterable<Message> messages) {
for (final message in messages) {
final guid = message.guid;
if (guid != null) getActiveMwc(guid)?.close();
}
}

void _bindMessageControllers(Iterable<Message> messages) {
if (!mounted) return;
for (final message in messages) {
if (message.guid == null) continue;
final messageController = mwc(message);
messageController.cvController = controller;
}
}

Future<void> _refreshMessageBloc() async {
if (_refreshing) return;
if (widget.customService != null) {
Logger.info("message_refresh skipped_custom_service");
return;
}
_refreshing = true;
try {
final staleMessages = List<Message>.from(_messages);
_closeMessageControllers(staleMessages);
for (var index = _messages.length - 1; index >= 0; index--) {
listKey.currentState?.removeItem(
index,
(context, animation) => const SizedBox.shrink(),
duration: Duration.zero,
);
}
for (final node in messageFocusNodes.values) {
node.dispose();
}
messageFocusNodes.clear();

noMoreMessages = false;
fetching = false;
_messages = [];

// Get.reload rebuilds the original Get.put instance. Close it instead
// so its subscriptions and in-memory message structure are flushed
// before registering a genuinely new service for this transcript.
messageService.close(force: true);
messageService = ms(chat.guid);
messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage);
await messageService.loadChunk(0, controller);
if (!mounted) return;

_messages = List<Message>.from(messageService.struct.messages);
_messages.sort(Message.sort);
_bindMessageControllers(_messages);
_syncBottomMessageFocusNode();
setState(() {});
for (var index = 0; index < _messages.length; index++) {
listKey.currentState?.insertItem(index, duration: Duration.zero);
}
} finally {
_refreshing = false;
}
}

@override
void dispose() {
if (!kIsWeb && !kIsDesktop) smartReply.close();
Expand Down
Loading