Merge branch 'scheduled-messages'

Stephen Paul Weber created

* scheduled-messages:
  Switch to material components
  Gate schedule menu on version and text to send
  Scheduled messages cleanup
  Scheduled messages

Change summary

src/cheogram/res/drawable/schedule_message.xml                                 | 11 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java       | 46 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java              | 44 
src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java |  5 
src/main/res/menu/fragment_conversation.xml                                    |  4 
src/main/res/values/strings.xml                                                |  1 
6 files changed, 106 insertions(+), 5 deletions(-)

Detailed changes

src/cheogram/res/drawable/schedule_message.xml 🔗

@@ -0,0 +1,11 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?colorControlNormal"
+    android:viewportWidth="960"
+    android:viewportHeight="960"
+    android:autoMirrored="false">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M120,800L120,160L692,400Q689,400 686,400Q683,400 680,400Q645,400 614,408Q583,416 554,430L200,280L200,420L440,480L200,540L200,680L416,588Q408,611 404,633.5Q400,656 400,680Q400,680 400,681Q400,682 400,682L120,800ZM680,880Q597,880 538.5,821.5Q480,763 480,680Q480,597 538.5,538.5Q597,480 680,480Q763,480 821.5,538.5Q880,597 880,680Q880,763 821.5,821.5Q763,880 680,880ZM746,774L774,746L700,672L700,560L660,560L660,688L746,774ZM200,588Q200,540 200,498Q200,456 200,430L200,280L200,420L200,420L200,540L200,540L200,680L200,588Z"/>
+</vector>

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -91,6 +91,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Hashtable;
 import java.util.Iterator;
@@ -269,6 +270,7 @@ public class XmppConnectionService extends Service {
     private final ReplacingSerialSingleThreadExecutor mStickerScanExecutor = new ReplacingSerialSingleThreadExecutor("StickerScan");
     private long mLastActivity = 0;
     private long mLastMucPing = 0;
+    private Map<String, Message> mScheduledMessages = new HashMap<>();
     private long mLastStickerRescan = 0;
     private final FileBackend fileBackend = new FileBackend(this);
     private MemorizingTrustManager mMemorizingTrustManager;
@@ -1067,6 +1069,7 @@ public class XmppConnectionService extends Service {
                 }
                 return START_NOT_STICKY;
         }
+        sendScheduledMessages();
         final var extras =  intent == null ? null : intent.getExtras();
         try {
             internalPingExecutor.execute(() -> manageAccountConnectionStates(action, extras));
@@ -1154,6 +1157,30 @@ public class XmppConnectionService extends Service {
         WakeLockHelper.release(wakeLock);
     }
 
+    private void sendScheduledMessages() {
+        Log.d(Config.LOGTAG, "looking for and sending scheduled messages");
+
+        for (final var message : new ArrayList<>(mScheduledMessages.values())) {
+            if (message.getTimeSent() > System.currentTimeMillis()) continue;
+
+            final var conversation = message.getConversation();
+            final var account = conversation.getAccount();
+            final boolean inProgressJoin;
+            synchronized (account.inProgressConferenceJoins) {
+                inProgressJoin = account.inProgressConferenceJoins.contains(conversation);
+            }
+            final boolean pendingJoin;
+            synchronized (account.pendingConferenceJoins) {
+                pendingJoin = account.pendingConferenceJoins.contains(conversation);
+            }
+            if (conversation.getAccount() == account
+                    && !pendingJoin
+                    && !inProgressJoin) {
+                resendMessage(message, false);
+            }
+        }
+    }
+
     private void handleOrbotStartedEvent() {
         for (final Account account : accounts) {
             if (account.getStatus() == Account.State.TOR_NOT_AVAILABLE) {
@@ -1832,9 +1859,16 @@ public class XmppConnectionService extends Service {
 
     @TargetApi(Build.VERSION_CODES.M)
     private void scheduleNextIdlePing() {
-        final long timeToWake = SystemClock.elapsedRealtime() + (Config.IDLE_PING_INTERVAL * 1000);
+        long timeUntilWake = Config.IDLE_PING_INTERVAL * 1000;
+        final var now = System.currentTimeMillis();
+        for (final var message : mScheduledMessages.values()) {
+            if (message.getTimeSent() <= now) continue; // Just in case
+            if (message.getTimeSent() - now < timeUntilWake) timeUntilWake = message.getTimeSent() - now;
+        }
+        final var timeToWake = SystemClock.elapsedRealtime() + timeUntilWake;
         final AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
         if (alarmManager == null) {
+            Log.d(Config.LOGTAG, "no alarm manager?");
             return;
         }
         final Intent intent = new Intent(this, SystemEventReceiver.class);
@@ -2027,7 +2061,7 @@ public class XmppConnectionService extends Service {
             }
         }
 
-        if (account.isOnlineAndConnected() && !inProgressJoin && !waitForPreview) {
+        if (account.isOnlineAndConnected() && !inProgressJoin && !waitForPreview && message.getTimeSent() <= System.currentTimeMillis()) {
             switch (message.getEncryption()) {
                 case Message.ENCRYPTION_NONE:
                     if (message.needsUploading()) {
@@ -2115,6 +2149,14 @@ public class XmppConnectionService extends Service {
             }
         }
 
+        synchronized (mScheduledMessages) {
+            if (message.getTimeSent() > System.currentTimeMillis()) {
+                mScheduledMessages.put(message.getUuid(), message);
+                scheduleNextIdlePing();
+            } else {
+                mScheduledMessages.remove(message.getUuid());
+            }
+        }
 
         boolean mucMessage = conversation.getMode() == Conversation.MODE_MULTI && !message.isPrivateMessage();
         if (mucMessage) {

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -12,9 +12,11 @@ import static eu.siacs.conversations.utils.PermissionUtils.writeGranted;
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.Activity;
+import android.app.DatePickerDialog;
 import android.app.Fragment;
 import android.app.FragmentManager;
 import android.app.PendingIntent;
+import android.app.TimePickerDialog;
 import android.content.ActivityNotFoundException;
 import android.content.Context;
 import android.content.DialogInterface;
@@ -23,6 +25,7 @@ import android.content.IntentSender.SendIntentException;
 import android.content.SharedPreferences;
 import android.content.pm.PackageManager;
 import android.content.res.ColorStateList;
+import android.icu.util.Calendar;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
@@ -928,6 +931,10 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void sendMessage() {
+        sendMessage((Long) null);
+    }
+
+    private void sendMessage(Long sendAt) {
         if (mediaPreviewAdapter.hasAttachments()) {
             commitAttachments();
             return;
@@ -998,6 +1005,7 @@ public class ConversationFragment extends XmppFragment
             message.setServerMsgId(null);
             message.setUuid(UUID.randomUUID().toString());
         }
+        if (sendAt != null) message.setTime(sendAt);
         switch (conversation.getNextEncryption()) {
             case Message.ENCRYPTION_PGP:
                 sendPgpMessage(message);
@@ -1347,7 +1355,7 @@ public class ConversationFragment extends XmppFragment
             } else {
                 menuUnmute.setVisible(false);
             }
-            ConversationMenuConfigurator.configureAttachmentMenu(conversation, menu);
+            ConversationMenuConfigurator.configureAttachmentMenu(conversation, menu, TextUtils.isEmpty(binding.textinput.getText()));
             ConversationMenuConfigurator.configureEncryptionMenu(conversation, menu);
             if (conversation.getBooleanAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, false)) {
                 menuTogglePinned.setTitle(R.string.remove_from_favorites);
@@ -1621,7 +1629,7 @@ public class ConversationFragment extends XmppFragment
                 MenuItem newItem = menu.add(item.getGroupId(), item.getItemId(), item.getOrder(), item.getTitle());
                 newItem.setIcon(item.getIcon());
             }
-            ConversationMenuConfigurator.configureAttachmentMenu(conversation, menu);
+            ConversationMenuConfigurator.configureAttachmentMenu(conversation, menu, TextUtils.isEmpty(binding.textinput.getText()));
             return;
         }
 
@@ -1934,6 +1942,9 @@ public class ConversationFragment extends XmppFragment
             case R.id.attach_subject:
                 binding.textinputSubject.setVisibility(binding.textinputSubject.getVisibility() == View.GONE ? View.VISIBLE : View.GONE);
                 break;
+            case R.id.attach_schedule:
+                scheduleMessage();
+                break;
             case R.id.action_search:
                 startSearch();
                 break;
@@ -2011,6 +2022,35 @@ public class ConversationFragment extends XmppFragment
         startActivity(intent);
     }
 
+    private void scheduleMessage() {
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
+            final var datePicker = com.google.android.material.datepicker.MaterialDatePicker.Builder.datePicker()
+                .setTitleText("Schedule Message")
+                .setSelection(com.google.android.material.datepicker.MaterialDatePicker.todayInUtcMilliseconds())
+                .setCalendarConstraints(
+                    new com.google.android.material.datepicker.CalendarConstraints.Builder()
+                        .setStart(com.google.android.material.datepicker.MaterialDatePicker.todayInUtcMilliseconds())
+                        .build()
+                 )
+                .build();
+            datePicker.addOnPositiveButtonClickListener((date) -> {
+                final Calendar now = Calendar.getInstance();
+                final var timePicker = new com.google.android.material.timepicker.MaterialTimePicker.Builder()
+                    .setTitleText("Schedule Message")
+                    .setHour(now.get(Calendar.HOUR))
+                    .setMinute(now.get(Calendar.MINUTE))
+                    .build();
+                timePicker.addOnPositiveButtonClickListener((v2) -> {
+                        final long timestamp = date + (timePicker.getHour() * 3600000) + (timePicker.getMinute() * 60000);
+                        sendMessage(timestamp);
+                        Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": scheduled message for " + timestamp);
+                    });
+                timePicker.show(activity.getSupportFragmentManager(), "schedulMessageTime");
+            });
+            datePicker.show(activity.getSupportFragmentManager(), "schedulMessageDate");
+        }
+    }
+
     private void returnToOngoingCall() {
         final Optional<OngoingRtpSession> ongoingRtpSession =
                 activity.xmppConnectionService

src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java 🔗

@@ -51,7 +51,7 @@ public class ConversationMenuConfigurator {
 		microphoneAvailable = context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_MICROPHONE);
 	}
 
-	public static void configureAttachmentMenu(@NonNull Conversation conversation, Menu menu) {
+	public static void configureAttachmentMenu(@NonNull Conversation conversation, Menu menu, boolean isTextEmpty) {
 		final MenuItem menuAttach = menu.findItem(R.id.action_attach_file);
 
 		final boolean visible;
@@ -63,6 +63,9 @@ public class ConversationMenuConfigurator {
 		if (menuAttach != null) menuAttach.setVisible(visible);
 		if (visible) menu.findItem(R.id.attach_record_voice).setVisible(microphoneAvailable);
 		menu.findItem(R.id.attach_subject).setVisible(conversation.getNextEncryption() == Message.ENCRYPTION_NONE);
+		if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.N || isTextEmpty) {
+			menu.findItem(R.id.attach_schedule).setVisible(false);
+		}
 	}
 
 	public static void configureEncryptionMenu(@NonNull Conversation conversation, Menu menu) {

src/main/res/menu/fragment_conversation.xml 🔗

@@ -66,6 +66,10 @@
                 android:id="@+id/attach_subject"
                 android:icon="@drawable/subject"
                 android:title="Add Subject" />
+            <item
+                android:id="@+id/attach_schedule"
+                android:icon="@drawable/schedule_message"
+                android:title="@string/schedule_message" />
         </menu>
     </item>
     <item

src/main/res/values/strings.xml 🔗

@@ -1072,4 +1072,5 @@
     <string name="pref_fullscreen_notification">Full screen notifications</string>
     <string name="pref_fullscreen_notification_summary">Allow this app to show incoming call notifications that take up the full screen when the device is locked.</string>
     <string name="unsupported_operation">Unsupported operation</string>
+    <string name="schedule_message">Send later</string>
 </resources>