@@ -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>
  
  
  
    
    @@ -1069,6 +1069,8 @@ public class XmppConnectionService extends Service {
         }
         final var extras =  intent == null ? null : intent.getExtras();
         try {
+            Log.d(Config.LOGTAG, "looking for and sending scheduled messages");
+            sendScheduledMessages();
             internalPingExecutor.execute(() -> manageAccountConnectionStates(action, extras));
         } catch (final RejectedExecutionException e) {
             Log.e(Config.LOGTAG, "can not schedule connection states manager");
@@ -1154,6 +1156,26 @@ public class XmppConnectionService extends Service {
         WakeLockHelper.release(wakeLock);
     }
 
+    private void sendScheduledMessages() {
+        List<Conversation> conversations = getConversations();
+        for (Conversation conversation : conversations) {
+            final Account 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) {
+                sendUnsentMessages(conversation);
+            }
+        }
+    }
+
     private void handleOrbotStartedEvent() {
         for (final Account account : accounts) {
             if (account.getStatus() == Account.State.TOR_NOT_AVAILABLE) {
@@ -1830,9 +1852,19 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    @TargetApi(Build.VERSION_CODES.M)
     private void scheduleNextIdlePing() {
-        final long timeToWake = SystemClock.elapsedRealtime() + (Config.IDLE_PING_INTERVAL * 1000);
+        scheduleNextIdlePing(0);
+    }
+
+    @TargetApi(Build.VERSION_CODES.M)
+    private void scheduleNextIdlePing(long epochMillis) {
+        final long timeToWake;
+        if (epochMillis > 0) {
+            timeToWake = epochMillis;
+            Log.d(Config.LOGTAG, "scheduling next idle ping for " + timeToWake);
+        } else {
+            timeToWake = SystemClock.elapsedRealtime() + (Config.IDLE_PING_INTERVAL * 1000);
+        }
         final AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
         if (alarmManager == null) {
             return;
@@ -2027,7 +2059,8 @@ public class XmppConnectionService extends Service {
             }
         }
 
-        if (account.isOnlineAndConnected() && !inProgressJoin && !waitForPreview) {
+        // TODO: use timer to grab list of currently scheduled messages from in mem representation and sends them
+        if (account.isOnlineAndConnected() && !inProgressJoin && !waitForPreview && !(message.getTimeSent() > System.currentTimeMillis())) {
             switch (message.getEncryption()) {
                 case Message.ENCRYPTION_NONE:
                     if (message.needsUploading()) {
@@ -2121,6 +2154,10 @@ public class XmppConnectionService extends Service {
             message.setCounterpart(conversation.getMucOptions().getSelf().getFullJid());
         }
 
+        if (message.getTimeSent() > System.currentTimeMillis()) {
+            scheduleNextIdlePing(message.getTimeSent());
+        }
+
         if (resend) {
             if (packet != null && addToConversation) {
                 if (account.getXmppConnection().getFeatures().sm() || mucMessage) {
  
  
  
    
    @@ -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);
@@ -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,23 @@ public class ConversationFragment extends XmppFragment
         startActivity(intent);
     }
 
+    private void scheduleMessage() {
+        // TODO: also gate menu option in UI behind version check
+        // TODO: upgrade to material you/3
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
+            final Calendar now = Calendar.getInstance();
+            new DatePickerDialog(activity, (view, year, month, day) -> {
+                new TimePickerDialog(activity, (view1, hour, minute) -> {
+                    final Calendar c = Calendar.getInstance();
+                    c.set(year, month, day, hour, minute);
+                    final long timestamp = c.getTimeInMillis();
+                    sendMessage(timestamp);
+                    Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": scheduled message for " + timestamp);
+                }, now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), true).show();
+            }, now.get(Calendar.YEAR), now.get(Calendar.MONTH), now.get(Calendar.DAY_OF_MONTH)).show();
+        }
+    }
+
     private void returnToOngoingCall() {
         final Optional<OngoingRtpSession> ongoingRtpSession =
                 activity.xmppConnectionService