🔀 Commit

Backup/Restore limit message history by age, improved link previews
Commitb6f8fd096790cab6a9b22eb3b3a4a00bf513ae6f
AuthorJabJab <noreply@xmpp.tel>
Date2026-07-27
Parentb883db84
commit b6f8fd096790cab6a9b22eb3b3a4a00bf513ae6f
Author: JabJab <noreply@xmpp.tel>
Date:   Mon Jul 27 15:24:38 2026 +0300

    Backup/Restore limit message history by age, improved link previews
---
 build.gradle                                       |   4 +-
 src/main/java/tel/xmpp/jabjab/AppSettings.java     |  15 ++-
 src/main/java/tel/xmpp/jabjab/ui/BaseActivity.java |   6 +
 .../tel/xmpp/jabjab/ui/BotCommandsFragment.java    |  24 +++-
 .../tel/xmpp/jabjab/ui/ConversationFragment.java   |  91 +++++++++++--
 .../tel/xmpp/jabjab/ui/ImportBackupActivity.java   |  45 ++++++-
 .../tel/xmpp/jabjab/ui/adapter/MessageAdapter.java |  77 +++++++----
 .../fragment/settings/BackupSettingsFragment.java  |  33 ++++-
 .../xmpp/jabjab/ui/util/LinkPreviewFetcher.java    |  77 ++++++++---
 .../xmpp/jabjab/ui/wizard/WizardThemeFragment.java |   2 +-
 .../tel/xmpp/jabjab/worker/ExportBackupWorker.java |  42 +++++-
 .../tel/xmpp/jabjab/worker/ImportBackupWorker.java | 116 +++++++++++++++-
 .../xmpp/jabjab/worker/UpdateDownloadWorker.java   | 150 +++++++++++++++++++--
 .../jabjab/xmpp/manager/MultiUserChatManager.java  |  44 ++++++
 src/main/res/layout/activity_import_backup.xml     |  18 +++
 src/main/res/layout/dialog_enter_password.xml      |  11 ++
 src/main/res/values-ar/strings.xml                 |   1 -
 src/main/res/values-cs/strings.xml                 |   1 -
 src/main/res/values-da-rDK/strings.xml             |   1 -
 src/main/res/values-de/strings.xml                 |   1 -
 src/main/res/values-es/strings.xml                 |   1 -
 src/main/res/values-et/strings.xml                 |   1 -
 src/main/res/values-fi/strings.xml                 |   1 -
 src/main/res/values-fr/strings.xml                 |   1 -
 src/main/res/values-it/strings.xml                 |   1 -
 src/main/res/values-ja/strings.xml                 |   1 -
 src/main/res/values-night/themes.xml               |  16 +++
 src/main/res/values-nl/strings.xml                 |   1 -
 src/main/res/values-pl/strings.xml                 |   1 -
 src/main/res/values-pt-rBR/strings.xml             |   1 -
 src/main/res/values-pt/strings.xml                 |   1 -
 src/main/res/values-ro-rRO/strings.xml             |   1 -
 src/main/res/values-ru/strings.xml                 |   1 -
 src/main/res/values-sr/strings.xml                 |   1 -
 src/main/res/values-sv/strings.xml                 |   1 -
 src/main/res/values-zh-rCN/strings.xml             |   1 -
 src/main/res/values-zh-rTW/strings.xml             |   1 -
 src/main/res/values/arrays.xml                     |   7 +
 src/main/res/values/strings.xml                    |   7 +-
 src/main/res/values/theme-settings.xml             |   4 +-
 src/main/res/values/themes.xml                     |  39 +++---
 src/main/res/xml/preferences_backup.xml            |   6 +
 .../xmpp/jabjab/worker/ImportBackupWorkerTest.java |  49 +++++++
 43 files changed, 763 insertions(+), 140 deletions(-)

diff --git a/build.gradle b/build.gradle
index 304813f..25206a3 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,8 +113,8 @@ android {
 
     defaultConfig {
         minSdkVersion 23
-        versionCode 42302
-        versionName "1.0.3"
+        versionCode 42303
+        versionName "1.0.4"
         applicationId "tel.xmpp.jabjab"
         resValue "string", "applicationId", applicationId
         def appName = "JabJab"
diff --git a/src/main/java/tel/xmpp/jabjab/AppSettings.java b/src/main/java/tel/xmpp/jabjab/AppSettings.java
index 0c96370..a4c48a5 100644
--- a/src/main/java/tel/xmpp/jabjab/AppSettings.java
+++ b/src/main/java/tel/xmpp/jabjab/AppSettings.java
@@ -13,6 +13,7 @@ import androidx.preference.PreferenceManager;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
+import com.google.common.primitives.Longs;
 import com.google.common.collect.ImmutableSet;
 import tel.xmpp.jabjab.persistance.FileBackend;
 import tel.xmpp.jabjab.services.QuickConversationsService;
@@ -76,6 +77,7 @@ public class AppSettings {
     public static final String CALL_INTEGRATION = "call_integration";
     public static final String ALIGN_START = "align_start";
     public static final String BACKUP_LOCATION = "backup_location";
+    public static final String BACKUP_MESSAGE_HISTORY = "backup_message_history";
     public static final String AUTO_ACCEPT_FILE_SIZE = "auto_accept_file_size";
     public static final String GIF_AUTO_ACCEPT_SIZE = "gif_auto_accept_size";
     public static final String VIDEO_COMPRESSION = "video_compression";
@@ -296,12 +298,12 @@ public class AppSettings {
     public int getColorThemeOverlay() {
         final String colorTheme = getStringPreference(COLOR_THEME, R.string.color_theme_default);
         switch (colorTheme) {
-            case "amoled": return R.style.ThemeOverlay_Conversations3_AMOLED;
             case "ocean":  return R.style.ThemeOverlay_Conversations3_Ocean;
             case "forest": return R.style.ThemeOverlay_Conversations3_Forest;
             case "sunset": return R.style.ThemeOverlay_Conversations3_Sunset;
             case "purple": return R.style.ThemeOverlay_Conversations3_Purple;
             case "rose":   return R.style.ThemeOverlay_Conversations3_Rose;
+            case "strawberry": return R.style.ThemeOverlay_Conversations3_Strawberry;
             default:       return 0;
         }
     }
@@ -353,6 +355,17 @@ public class AppSettings {
         return asPath(getBackupLocation());
     }
 
+    /** 0 means "no limit, include all messages". Otherwise seconds of history to keep. */
+    public long getBackupMessageHistorySeconds() {
+        final SharedPreferences sharedPreferences =
+                PreferenceManager.getDefaultSharedPreferences(context);
+        final Long value =
+                Longs.tryParse(
+                        Strings.nullToEmpty(
+                                sharedPreferences.getString(BACKUP_MESSAGE_HISTORY, "0")));
+        return value == null ? 0 : value;
+    }
+
     public static String asPath(final Uri uri) {
         final var scheme = uri.getScheme();
         final var path = uri.getPath();
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BaseActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BaseActivity.java
index 44aaab6..99f3393 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BaseActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BaseActivity.java
@@ -6,6 +6,7 @@ import androidx.appcompat.app.AppCompatActivity;
 import androidx.appcompat.app.AppCompatDelegate;
 import tel.xmpp.jabjab.AppSettings;
 import tel.xmpp.jabjab.ui.util.SettingsUtils;
+import tel.xmpp.jabjab.worker.UpdateDownloadWorker;
 
 public abstract class BaseActivity extends AppCompatActivity {
     private Boolean isDynamicColors;
@@ -44,6 +45,11 @@ public abstract class BaseActivity extends AppCompatActivity {
     protected void onResume() {
         super.onResume();
         SettingsUtils.applyScreenshotSetting(this);
+        // Covers the gap where an update finished downloading but was blocked on "install
+        // unknown apps" permission: this picks it back up the moment any screen resumes after
+        // the user grants it (most commonly, right after backing out of the Settings screen
+        // that permission prompt sent them to) instead of leaving it stuck with no way forward.
+        UpdateDownloadWorker.resumePendingInstallIfPossible(this);
     }
 
     public void setDynamicColors(final boolean isDynamicColors) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java
index c913c95..67ebda8 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BotCommandsFragment.java
@@ -34,17 +34,25 @@ public class BotCommandsFragment extends Fragment {
 
     private static final String ARG_ACCOUNT_UUID = "account_uuid";
     private static final String ARG_BOT_JID = "bot_jid";
+    private static final String ARG_IS_MUC = "is_muc";
 
     private RecyclerView commandsList;
     private View commandsLoading;
     private TextView commandsEmpty;
     private BotCommandAdapter adapter;
 
-    public static BotCommandsFragment newInstance(final Conversation conversation) {
+    /**
+     * @param botJid the already-resolved command target: the contact's bare JID for 1:1 chats,
+     *     or the bot occupant's full (room@conference/nick) JID for MUC — see
+     *     ConversationFragment#findCommandBotJid.
+     */
+    public static BotCommandsFragment newInstance(
+            final Conversation conversation, final Jid botJid) {
         final var fragment = new BotCommandsFragment();
         final var args = new Bundle();
         args.putString(ARG_ACCOUNT_UUID, conversation.getAccount().getUuid());
-        args.putString(ARG_BOT_JID, conversation.getContact().getAddress().asBareJid().toString());
+        args.putString(ARG_BOT_JID, botJid.toString());
+        args.putBoolean(ARG_IS_MUC, conversation.getMode() == Conversation.MODE_MULTI);
         fragment.setArguments(args);
         return fragment;
     }
@@ -208,12 +216,20 @@ public class BotCommandsFragment extends Fragment {
      * resource (e.g. data@xmpp.tel/omnibot), not the bare JID — querying the bare JID gets
      * item-not-found even though the bot is reachable. Prefer the contact's current full JID
      * (from presence) and fall back to the bare JID only if we don't have one.
+     *
+     * For MUC, ARG_BOT_JID is already the bot occupant's full (room@conference/nick) JID
+     * resolved by the caller — there's no roster entry for a room occupant, so skip the
+     * roster-based re-resolution entirely.
      */
     @Nullable
     private Jid resolveTargetJid() {
-        final var account = resolveAccount();
         final var bareJid = resolveBotJid();
-        if (account == null || bareJid == null) {
+        final var args = getArguments();
+        if (bareJid == null || args == null || args.getBoolean(ARG_IS_MUC, false)) {
+            return bareJid;
+        }
+        final var account = resolveAccount();
+        if (account == null) {
             return bareJid;
         }
         final var contact = account.getRoster().getContact(bareJid);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index ec4aff7..6735b72 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -125,6 +125,7 @@ import tel.xmpp.jabjab.ui.util.MenuDoubleTabUtil;
 import tel.xmpp.jabjab.ui.util.MucDetailsContextMenuHelper;
 import tel.xmpp.jabjab.ui.util.PendingItem;
 import tel.xmpp.jabjab.ui.util.PresenceSelector;
+import tel.xmpp.jabjab.xml.Namespace;
 import tel.xmpp.jabjab.ui.util.ScrollState;
 import tel.xmpp.jabjab.ui.util.SendButtonAction;
 import tel.xmpp.jabjab.ui.util.SendButtonTool;
@@ -152,6 +153,7 @@ import tel.xmpp.jabjab.xmpp.jingle.OngoingRtpSession;
 import tel.xmpp.jabjab.xmpp.jingle.RtpCapability;
 import tel.xmpp.jabjab.xmpp.manager.BlockingManager;
 import tel.xmpp.jabjab.xmpp.manager.ChatStateManager;
+import tel.xmpp.jabjab.xmpp.manager.DiscoManager;
 import tel.xmpp.jabjab.xmpp.manager.EntityTimeManager;
 import tel.xmpp.jabjab.xmpp.manager.HttpUploadManager;
 import tel.xmpp.jabjab.ui.ChannelDiscoveryActivity;
@@ -1622,12 +1624,17 @@ public class ConversationFragment extends XmppFragment
         this.binding.chatContentContainer.setVisibility(showCommands ? View.GONE : View.VISIBLE);
         this.binding.botCommandsContainer.setVisibility(showCommands ? View.VISIBLE : View.GONE);
         if (showCommands
-                && getChildFragmentManager().findFragmentByTag(BotCommandsFragment.TAG) == null) {
+                && getChildFragmentManager().findFragmentByTag(BotCommandsFragment.TAG) == null
+                && this.conversation != null) {
+            final var botJid = findCommandBotJid(this.conversation);
+            if (botJid == null) {
+                return;
+            }
             getChildFragmentManager()
                     .beginTransaction()
                     .replace(
                             R.id.bot_commands_container,
-                            BotCommandsFragment.newInstance(this.conversation),
+                            BotCommandsFragment.newInstance(this.conversation, botJid),
                             BotCommandsFragment.TAG)
                     .commit();
         }
@@ -1650,17 +1657,16 @@ public class ConversationFragment extends XmppFragment
 
     /**
      * Shows/hides the Commands tab depending on whether the open conversation's counterpart is
-     * in Config.COMMAND_CAPABLE_JIDS, and resets to the Chat tab so switching between bot chats
-     * (or back to a non-bot chat) never leaves a stale Commands-tab state from fragment reuse.
+     * in Config.COMMAND_CAPABLE_JIDS (always shown, e.g. data@xmpp.tel) or has advertised the
+     * XEP-0050 ad-hoc commands feature via disco#info/entity-caps on any of its online
+     * presences, and resets to the Chat tab so switching between bot chats (or back to a
+     * non-bot chat) never leaves a stale Commands-tab state from fragment reuse.
      */
     private void updateBotCommandTabVisibility() {
         if (this.binding == null || this.conversation == null) {
             return;
         }
-        final boolean isBotCommandCapable =
-                this.conversation.getMode() == Conversation.MODE_SINGLE
-                        && Config.COMMAND_CAPABLE_JIDS.contains(
-                                this.conversation.getContact().getAddress().asBareJid());
+        final boolean isBotCommandCapable = findCommandBotJid(this.conversation) != null;
         this.binding.botCommandTabs.setVisibility(isBotCommandCapable ? View.VISIBLE : View.GONE);
         final var childFragmentManager = getChildFragmentManager();
         final var existing = childFragmentManager.findFragmentByTag(BotCommandsFragment.TAG);
@@ -1674,6 +1680,75 @@ public class ConversationFragment extends XmppFragment
         showBotCommandTab(false);
     }
 
+    /**
+     * Resolves which JID the Commands tab should target, or null if it shouldn't be shown.
+     * For 1:1 chats: data@xmpp.tel always qualifies (Config.COMMAND_CAPABLE_JIDS override);
+     * otherwise the contact's presences are checked for the cached XEP-0050 disco#info feature
+     * (populated via entity-caps, no extra round trip).
+     * For MUC: a room is expected to host at most one command bot. If exactly one occupant
+     * advertises the commands feature, that occupant's full (room@conference/nick) JID is
+     * returned; if zero or more than one do, the tab is hidden rather than guessing which bot
+     * to target.
+     */
+    @Nullable
+    private static Jid findCommandBotJid(final Conversation conversation) {
+        if (conversation.getMode() == Conversation.MODE_SINGLE) {
+            final var contact = conversation.getContact();
+            if (Config.COMMAND_CAPABLE_JIDS.contains(contact.getAddress().asBareJid())) {
+                return contact.getAddress().asBareJid();
+            }
+            for (final var capability : contact.getCapabilities()) {
+                if (capability.isPresent() && capability.get().hasFeature(Namespace.COMMANDS)) {
+                    return contact.getAddress().asBareJid();
+                }
+            }
+            return null;
+        }
+        final var mucOptions = conversation.getMucOptions();
+        final var discoManager =
+                conversation.getAccount().getXmppConnection().getManager(DiscoManager.class);
+        Jid commandBot = null;
+        for (final var user : mucOptions.getOnlineUsers()) {
+            final var fullJid = user.getFullJid();
+            if (fullJid == null || mucOptions.isSelf(fullJid)) {
+                continue;
+            }
+            final var infoQuery = discoManager.get(fullJid);
+            final boolean hasCommands =
+                    infoQuery != null && infoQuery.hasFeature(Namespace.COMMANDS);
+            Log.d(
+                    Config.LOGTAG,
+                    "ADHOC-CAPS muc occupant "
+                            + fullJid
+                            + " cachedDisco="
+                            + (infoQuery != null)
+                            + " hasCommandsFeature="
+                            + hasCommands);
+            if (hasCommands) {
+                if (commandBot != null) {
+                    Log.d(
+                            Config.LOGTAG,
+                            "ADHOC-CAPS more than one command-capable occupant in "
+                                    + conversation.getAddress().asBareJid()
+                                    + " ("
+                                    + commandBot
+                                    + ", "
+                                    + fullJid
+                                    + ") — hiding Commands tab");
+                    return null;
+                }
+                commandBot = fullJid;
+            }
+        }
+        Log.d(
+                Config.LOGTAG,
+                "ADHOC-CAPS resolved command bot for "
+                        + conversation.getAddress().asBareJid()
+                        + " = "
+                        + commandBot);
+        return commandBot;
+    }
+
     private void toggleAttachmentChoicesVisibility() {
         setAttachmentChoicesVisibility(this.binding.attachmentChoices.getVisibility() == View.GONE);
     }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ImportBackupActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ImportBackupActivity.java
index 7b379e8..72c28d0 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ImportBackupActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ImportBackupActivity.java
@@ -204,7 +204,12 @@ public class ImportBackupActivity extends ActionBarActivity
                 new FutureCallback<>() {
                     @Override
                     public void onSuccess(List<BackupFile> files) {
-                        runOnUiThread(() -> backupFileAdapter.setFiles(files));
+                        runOnUiThread(
+                                () -> {
+                                    backupFileAdapter.setFiles(files);
+                                    binding.noBackupFilesHint.setVisibility(
+                                            files.isEmpty() ? View.VISIBLE : View.GONE);
+                                });
                     }
 
                     @Override
@@ -283,6 +288,7 @@ public class ImportBackupActivity extends ActionBarActivity
                 getString(
                         R.string.enter_password_to_restore,
                         backupFile.getHeader().getJid().toString()));
+        setUpMessageHistorySpinner(enterPasswordBinding);
         final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
         builder.setView(enterPasswordBinding.getRoot());
         builder.setTitle(R.string.enter_password);
@@ -301,6 +307,27 @@ public class ImportBackupActivity extends ActionBarActivity
         dialog.show();
     }
 
+    private void setUpMessageHistorySpinner(final DialogEnterPasswordBinding binding) {
+        final int[] values = getResources().getIntArray(R.array.backup_message_history_values);
+        final String[] labels = new String[values.length];
+        for (int i = 0; i < values.length; i++) {
+            labels[i] =
+                    values[i] == 0
+                            ? getString(R.string.backup_history_all_messages)
+                            : tel.xmpp.jabjab.utils.TimeFrameUtils.resolve(this, 1000L * values[i]);
+        }
+        final var adapter =
+                new android.widget.ArrayAdapter<>(
+                        this, android.R.layout.simple_spinner_dropdown_item, labels);
+        binding.messageHistory.setAdapter(adapter);
+    }
+
+    private long selectedMaxMessageAgeSeconds(final DialogEnterPasswordBinding binding) {
+        final int[] values = getResources().getIntArray(R.array.backup_message_history_values);
+        final int position = binding.messageHistory.getSelectedItemPosition();
+        return position >= 0 && position < values.length ? values[position] : 0;
+    }
+
     private void onDialogShow(
             final BackupFile backupFile,
             final DialogInterface d,
@@ -323,18 +350,28 @@ public class ImportBackupActivity extends ActionBarActivity
             return;
         }
 
-        importBackup(backupFile, password, enterPasswordBinding.includeKeys.isChecked());
+        importBackup(
+                backupFile,
+                password,
+                enterPasswordBinding.includeKeys.isChecked(),
+                selectedMaxMessageAgeSeconds(enterPasswordBinding));
         d.dismiss();
     }
 
     private void importBackup(
-            final BackupFile backupFile, final String password, final boolean includeOmemo) {
+            final BackupFile backupFile,
+            final String password,
+            final boolean includeOmemo,
+            final long maxMessageAgeSeconds) {
         final OneTimeWorkRequest importBackupWorkRequest =
                 new OneTimeWorkRequest.Builder(ImportBackupWorker.class)
                         .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
                         .setInputData(
                                 ImportBackupWorker.data(
-                                        password, backupFile.getUri(), includeOmemo))
+                                        password,
+                                        backupFile.getUri(),
+                                        includeOmemo,
+                                        maxMessageAgeSeconds))
                         .addTag(ImportBackupWorker.TAG_IMPORT_BACKUP)
                         .build();
 
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
index 325a0fa..189cd87 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
@@ -687,15 +687,18 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                 final String setName = tgStickerSet;
                 final String tgUrl = "tg://addstickers?set=" + setName;
                 card.setTag(tgUrl);
-                // A recycled view holder previously bound to a stickers.xmpp.tel link card would
-                // otherwise keep that card's wider forced width — see
-                // displayStickerWebLinkMessage().
-                setCardWidth(card, android.view.ViewGroup.LayoutParams.MATCH_PARENT);
+                setCardWidth(card, previewCardWidthPx(card.getContext()));
+                // A recycled view holder previously left mid-skeleton (generic OG-preview path)
+                // or showing a header image (blog/video path) would otherwise keep those visible
+                // behind this card — see the identical reasoning in displayStickerWebLinkMessage().
+                viewHolder.linkPreviewTextPlaceholder().setVisibility(View.GONE);
+                viewHolder.linkPreviewTitle().setVisibility(View.VISIBLE);
                 viewHolder.linkPreviewTitle().setText(
                         activity.getString(R.string.sticker_pack_title, setName));
                 viewHolder.linkPreviewDescription().setText(
                         activity.getString(R.string.tap_to_open_in_telegram));
                 viewHolder.linkPreviewDescription().setVisibility(View.VISIBLE);
+                viewHolder.linkPreviewImageFrame().setVisibility(View.GONE);
                 viewHolder.linkPreviewImage().setVisibility(View.GONE);
                 viewHolder.linkPreviewPlay().setVisibility(View.GONE);
                 card.setClickable(true);
@@ -775,10 +778,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
                 // Arm click listener regardless of which path we take below.
                 card.setTag(previewUrl);
-                // A recycled view holder previously bound to a stickers.xmpp.tel link card would
-                // otherwise keep that card's wider forced width — see
-                // displayStickerWebLinkMessage().
-                setCardWidth(card, android.view.ViewGroup.LayoutParams.MATCH_PARENT);
+                setCardWidth(card, previewCardWidthPx(card.getContext()));
                 card.setClickable(true);
                 card.setOnClickListener(v -> {
                     if (!previewUrl.equals(card.getTag())) return;
@@ -1109,12 +1109,14 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
         final android.view.View card = viewHolder.linkPreviewCard();
         card.setTag(link);
-        // A recycled view holder previously bound to a stickers.xmpp.tel link card would
-        // otherwise keep that card's wider forced width — see
-        // displayStickerWebLinkMessage().
-        setCardWidth(card, android.view.ViewGroup.LayoutParams.MATCH_PARENT);
-
+        setCardWidth(card, previewCardWidthPx(card.getContext()));
+        // A recycled view holder previously left mid-skeleton (generic OG-preview path) would
+        // otherwise keep the skeleton bars showing behind this card's own content.
+        viewHolder.linkPreviewTextPlaceholder().setVisibility(View.GONE);
+        viewHolder.linkPreviewTitle().setVisibility(View.VISIBLE);
         viewHolder.linkPreviewTitle().setText(R.string.jabjab_download_card_title);
+        viewHolder.linkPreviewImageFrame().setVisibility(View.VISIBLE);
+        viewHolder.linkPreviewImagePlaceholder().setVisibility(View.GONE);
         viewHolder.linkPreviewPlay().setVisibility(View.GONE);
 
         if (isLatest) {
@@ -1159,14 +1161,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                     "new", "explore", "api", "edit", "static", "favicon.svg", "robots.txt"));
 
     /**
-     * Sets the shared link-preview card's width to an explicit pixel value, or restores
-     * MATCH_PARENT. The card sits inside a wrap_content bubble chain (message_box, and its
-     * inner wrapper in item_message_start/end.xml, are both wrap_content — see the TODO there
-     * about the never-ported max-width constraint). Under that ancestry, a match_parent card
-     * gets measured with an EXACT spec that ignores setMinimumWidth() entirely — confirmed via
-     * device testing, setMinimumWidth() had zero visible effect. Setting LayoutParams.width to
-     * a real pixel value (not the MATCH_PARENT/WRAP_CONTENT sentinels) is what actually works,
-     * since it no longer depends on the ambiguous wrap_content-vs-match_parent propagation.
+     * Sets the shared link-preview card's width to an explicit pixel value — see
+     * previewCardWidthPx() for why MATCH_PARENT doesn't work here (the card sits inside a
+     * wrap_content bubble chain; a match_parent card gets measured with an EXACT spec that
+     * ignores setMinimumWidth() entirely — confirmed via device testing).
      */
     private static void setCardWidth(final android.view.View card, final int widthPx) {
         final var params = card.getLayoutParams();
@@ -1176,6 +1174,21 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         }
     }
 
+    // Same fixed footprint the sticker-pack card already used (two 104dp thumbnail columns +
+    // margins = 224dp minimum) — every preview card (generic OG link, blog, jabjab://,
+    // tg://addstickers, sticker pack) now shares this exact width instead of MATCH_PARENT.
+    // MATCH_PARENT is unreliable here — for some og:image aspect ratios/content combos it
+    // measures the card far narrower than the actual bubble, leaving it squeezed with a gap
+    // next to it (confirmed on-device, not just theoretical). This only touches the card
+    // itself, not message_box/the bubble — forcing the bubble's own width caused worse visual
+    // regressions (bubble/text reflow glitches) and was reverted; a slightly-too-wide bubble
+    // next to a correctly-sized card is the acceptable trade-off here.
+    private static final int LINK_PREVIEW_CARD_WIDTH_DP = 256;
+
+    private static int previewCardWidthPx(final android.content.Context ctx) {
+        return (int) (LINK_PREVIEW_CARD_WIDTH_DP * ctx.getResources().getDisplayMetrics().density);
+    }
+
     private static boolean isStickerWebLinkMessage(final Message message) {
         if (message.getType() != Message.TYPE_TEXT) return false;
         final String body = message.getBody();
@@ -1220,8 +1233,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         viewHolder.linkPreviewDescription().setVisibility(View.VISIBLE);
         // Force room for two 104dp columns — see setCardWidth() for why a plain width/minWidth
         // change doesn't work here (this card sits inside a wrap_content bubble chain).
-        final int cardMinPx = (int) (256 * card.getContext().getResources().getDisplayMetrics().density);
-        setCardWidth(card, cardMinPx);
+        setCardWidth(card, previewCardWidthPx(card.getContext()));
         // Stickers use the dedicated multi-thumbnail row below, not the single-image frame
         // (that one is sized/cropped for full-width blog/video thumbnails).
         viewHolder.linkPreviewImageFrame().setVisibility(View.GONE);
@@ -1387,10 +1399,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         viewHolder.linkPreviewImage().setScaleType(android.widget.ImageView.ScaleType.CENTER_CROP);
         viewHolder.linkPreviewImage().setImageResource(android.R.drawable.ic_menu_gallery);
         viewHolder.linkPreviewPlay().setVisibility(View.GONE);
-        // Reset back to the XML default — a recycled view holder previously bound to a sticker
-        // link card would otherwise keep that card's wider forced width (see
-        // displayStickerWebLinkMessage()).
-        setCardWidth(card, android.view.ViewGroup.LayoutParams.MATCH_PARENT);
+        setCardWidth(card, previewCardWidthPx(card.getContext()));
         card.setMinimumHeight(160);
         card.setClickable(true);
         // Open in-app via BlogPostActivity deep link
@@ -1710,6 +1719,15 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         viewHolder.linkPreviewImage().getLayoutParams().height =
                 (int) (160 * viewHolder.linkPreviewImage().getContext().getResources().getDisplayMetrics().density);
         viewHolder.linkPreviewPlay().setVisibility(View.GONE);
+        // Only displayStickerWebLinkMessage() ever showed this row — nothing else reset it, so
+        // a recycled row previously bound to a sticker-pack card kept its old thumbnails visible
+        // underneath whatever generic/video/blog preview got bound onto the same view next,
+        // looking like two messages' previews merged together.
+        viewHolder.linkPreviewStickerThumbsRow().setVisibility(View.GONE);
+        for (final ImageView thumb : viewHolder.linkPreviewStickerThumbs()) {
+            thumb.setVisibility(View.GONE);
+            thumb.setImageDrawable(null);
+        }
         final boolean omemoEncryption = message.getEncryption() == Message.ENCRYPTION_AXOLOTL;
         final boolean isInValidSession =
                 message.isValidInSession() && (!omemoEncryption || message.isTrusted());
@@ -2561,7 +2579,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                                 titleView.setVisibility(View.VISIBLE);
                             });
                         }
-                    } catch (final Exception ignored) {
+                    } catch (final Exception e) {
+                        android.util.Log.d(Config.LOGTAG, "PREVIEW youtube oembed failed "
+                                + e.getClass().getSimpleName() + ": " + e.getMessage()
+                                + " url=" + previewUrl);
                         // Cache a fallback title so we don't retry every getView().
                         tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.putCache(
                                 previewUrl,
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/BackupSettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/BackupSettingsFragment.java
index 010b2fa..5ca1c34 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/BackupSettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/BackupSettingsFragment.java
@@ -30,6 +30,7 @@ import com.google.common.primitives.Longs;
 import tel.xmpp.jabjab.AppSettings;
 import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.utils.TimeFrameUtils;
 import tel.xmpp.jabjab.worker.ExportBackupWorker;
 import java.util.concurrent.TimeUnit;
 
@@ -71,8 +72,12 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
         setPreferencesFromResource(R.xml.preferences_backup, rootKey);
         final var createOneOffBackup = findPreference(CREATE_ONE_OFF_BACKUP);
         final ListPreference recurringBackup = findPreference(RECURRING_BACKUP);
+        final ListPreference messageHistory = findPreference(AppSettings.BACKUP_MESSAGE_HISTORY);
         final var backupLocation = findPreference(AppSettings.BACKUP_LOCATION);
-        if (createOneOffBackup == null || recurringBackup == null || backupLocation == null) {
+        if (createOneOffBackup == null
+                || recurringBackup == null
+                || messageHistory == null
+                || backupLocation == null) {
             throw new IllegalStateException(
                     "The preference resource file is missing some preferences");
         }
@@ -87,6 +92,17 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
                 recurringBackup,
                 R.array.recurring_backup_values,
                 value -> timeframeValueToName(requireContext(), value));
+        setValues(
+                messageHistory,
+                R.array.backup_message_history_values,
+                value -> historyValueToName(requireContext(), value));
+    }
+
+    private static String historyValueToName(final android.content.Context context, final int value) {
+        if (value == 0) {
+            return context.getString(R.string.backup_history_all_messages);
+        }
+        return TimeFrameUtils.resolve(context, 1000L * value);
     }
 
     private boolean onBackupLocationPreferenceClicked(final Preference preference) {
@@ -142,6 +158,7 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
                                 .setRequiresStorageNotLow(true)
                                 .build();
 
+                final var appSettings = new AppSettings(requireContext());
                 final PeriodicWorkRequest periodicWorkRequest =
                         new PeriodicWorkRequest.Builder(
                                         ExportBackupWorker.class,
@@ -150,7 +167,12 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
                                 .setConstraints(constraints)
                                 .setInputData(
                                         new Data.Builder()
-                                                .putBoolean("recurring_backup", true)
+                                                .putBoolean(
+                                                        ExportBackupWorker.DATA_KEY_RECURRING, true)
+                                                .putLong(
+                                                        ExportBackupWorker
+                                                                .DATA_KEY_MAX_MESSAGE_AGE_SECONDS,
+                                                        appSettings.getBackupMessageHistorySeconds())
                                                 .build())
                                 .build();
                 workManager.enqueueUniquePeriodicWork(
@@ -181,9 +203,16 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
     }
 
     private void startOneOffBackup() {
+        final var appSettings = new AppSettings(requireContext());
         final OneTimeWorkRequest exportBackupWorkRequest =
                 new OneTimeWorkRequest.Builder(ExportBackupWorker.class)
                         .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+                        .setInputData(
+                                new Data.Builder()
+                                        .putLong(
+                                                ExportBackupWorker.DATA_KEY_MAX_MESSAGE_AGE_SECONDS,
+                                                appSettings.getBackupMessageHistorySeconds())
+                                        .build())
                         .build();
         WorkManager.getInstance(requireContext())
                 .enqueueUniqueWork(
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java b/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java
index a8b0694..aba6405 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java
@@ -67,8 +67,8 @@ public class LinkPreviewFetcher {
                     c = HttpConnectionManager.cachedMediaClient(context.getApplicationContext())
                             .newBuilder()
                             .followRedirects(true)
-                            .connectTimeout(5, TimeUnit.SECONDS)
-                            .readTimeout(5, TimeUnit.SECONDS)
+                            .connectTimeout(8, TimeUnit.SECONDS)
+                            .readTimeout(8, TimeUnit.SECONDS)
                             .build();
                     client = c;
                 }
@@ -240,7 +240,7 @@ public class LinkPreviewFetcher {
         // Show instantly from memory cache — eliminates the reload flicker on tab switches.
         final Bitmap cached = bitmapCache.get(imageUrl);
         if (cached != null) {
-            android.util.Log.d(Config.LOGTAG, "LOADIMAGE mem-hit " + imageUrl);
+            android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE mem-hit " + imageUrl);
             imageView.setImageBitmap(cached);
             imageView.setVisibility(View.VISIBLE);
             if (onLoaded != null) onLoaded.run();
@@ -249,19 +249,19 @@ public class LinkPreviewFetcher {
         // Clear any stale bitmap from a recycled ViewHolder so it doesn't show while
         // the correct image loads asynchronously.
         imageView.setImageBitmap(null);
-        android.util.Log.d(Config.LOGTAG, "LOADIMAGE async-start " + imageUrl);
+        android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE async-start " + imageUrl);
         executor.execute(() -> {
             try {
                 final File diskCached = thumbCacheFile(imageView, imageUrl);
                 byte[] bytes = null;
                 if (diskCached.exists()
                         && System.currentTimeMillis() - diskCached.lastModified() < THUMB_MAX_AGE_MS) {
-                    android.util.Log.d(Config.LOGTAG, "LOADIMAGE disk-hit " + diskCached.getName()
+                    android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE disk-hit " + diskCached.getName()
                             + " age=" + ((System.currentTimeMillis() - diskCached.lastModified()) / 1000) + "s"
                             + " url=" + imageUrl);
                     bytes = java.nio.file.Files.readAllBytes(diskCached.toPath());
                 } else if (diskCached.exists()) {
-                    android.util.Log.d(Config.LOGTAG, "LOADIMAGE disk-expired " + diskCached.getName()
+                    android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE disk-expired " + diskCached.getName()
                             + " age=" + ((System.currentTimeMillis() - diskCached.lastModified()) / 1000) + "s"
                             + " url=" + imageUrl);
                 }
@@ -273,10 +273,10 @@ public class LinkPreviewFetcher {
                     if (sentinel.exists()
                             && System.currentTimeMillis() - sentinel.lastModified()
                                     < TimeUnit.DAYS.toMillis(1)) {
-                        android.util.Log.d(Config.LOGTAG, "LOADIMAGE skip-404-sentinel " + imageUrl);
+                        android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE skip-404-sentinel " + imageUrl);
                         return;
                     }
-                    android.util.Log.d(Config.LOGTAG, "LOADIMAGE network-fetch " + imageUrl);
+                    android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE network-fetch " + imageUrl);
                     final Request request = new Request.Builder()
                             .url(imageUrl)
                             .header("User-Agent", "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.144 Mobile Safari/537.36 JabJab/2.20.1")
@@ -284,7 +284,7 @@ public class LinkPreviewFetcher {
                     try (Response response =
                             client(imageView.getContext()).newCall(request).execute()) {
                         if (!response.isSuccessful() || response.body() == null) {
-                            android.util.Log.w(Config.LOGTAG, "LOADIMAGE network-fail http="
+                            android.util.Log.w(Config.LOGTAG, "PREVIEW LOADIMAGE network-fail http="
                                     + response.code() + " url=" + imageUrl);
                             // Write sentinel for permanent errors so we don't hammer dead URLs
                             if (response.code() == 404 || response.code() == 410
@@ -298,19 +298,19 @@ public class LinkPreviewFetcher {
                             return;
                         }
                         bytes = response.body().bytes();
-                        android.util.Log.d(Config.LOGTAG, "LOADIMAGE network-ok bytes=" + bytes.length
+                        android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE network-ok bytes=" + bytes.length
                                 + " url=" + imageUrl);
                         try (final FileOutputStream fos = new FileOutputStream(diskCached)) {
                             fos.write(bytes);
                         } catch (Exception e) {
-                            android.util.Log.w(Config.LOGTAG, "LOADIMAGE disk-write-fail " + e.getMessage()
+                            android.util.Log.w(Config.LOGTAG, "PREVIEW LOADIMAGE disk-write-fail " + e.getMessage()
                                     + " url=" + imageUrl);
                         }
                     }
                 }
                 final Bitmap bm = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
                 if (bm != null) {
-                    android.util.Log.d(Config.LOGTAG, "LOADIMAGE decoded " + bm.getWidth() + "x" + bm.getHeight()
+                    android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE decoded " + bm.getWidth() + "x" + bm.getHeight()
                             + " url=" + imageUrl);
                     bitmapCache.put(imageUrl, bm);
                     mainHandler.post(() -> {
@@ -320,7 +320,7 @@ public class LinkPreviewFetcher {
                             if (onLoaded != null) onLoaded.run();
                             keepScrolledToBottomIfNeeded(imageView);
                         } else {
-                            android.util.Log.d(Config.LOGTAG, "LOADIMAGE tag-mismatch (view recycled) url=" + imageUrl);
+                            android.util.Log.d(Config.LOGTAG, "PREVIEW LOADIMAGE tag-mismatch (view recycled) url=" + imageUrl);
                         }
                     });
                 } else {
@@ -418,9 +418,20 @@ public class LinkPreviewFetcher {
                     .header("User-Agent", "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.144 Mobile Safari/537.36 JabJab/2.20.1")
                     .build();
             try (Response response = client(context).newCall(request).execute()) {
-                if (!response.isSuccessful() || response.body() == null) return null;
+                if (!response.isSuccessful() || response.body() == null) {
+                    android.util.Log.d(Config.LOGTAG, "PREVIEW fetchSync http-fail code="
+                            + response.code() + " url=" + url);
+                    return null;
+                }
                 final String ct = response.header("Content-Type", "");
-                if (ct == null || !ct.contains("text/html")) return null;
+                // Some servers respond with "application/xhtml+xml" or omit charset in a way
+                // that broke the old exact "text/html" substring match — accept anything that
+                // mentions html so real OG-tagged pages aren't silently dropped as previews.
+                if (ct == null || !ct.toLowerCase(Locale.ROOT).contains("html")) {
+                    android.util.Log.d(Config.LOGTAG, "PREVIEW fetchSync non-html content-type="
+                            + ct + " url=" + url);
+                    return null;
+                }
                 final InputStream is = response.body().byteStream();
                 final byte[] buf = new byte[32 * 1024];
                 int total = 0, n;
@@ -430,16 +441,46 @@ public class LinkPreviewFetcher {
                 final String html = new String(buf, 0, total, StandardCharsets.UTF_8);
                 final String title = matchFirst(html, OG_TITLE);
                 final String desc = matchFirst(html, OG_DESC);
-                final String img = matchFirst(html, OG_IMAGE);
-                if (title == null && img == null) return null;
+                final String rawImg = matchFirst(html, OG_IMAGE);
+                // og:image is frequently a path relative to the page (e.g. "/assets/foo.png"),
+                // not an absolute URL — resolve it against the final (post-redirect) request
+                // URL, otherwise loadImage() rejects it outright ("no scheme was found") and
+                // the preview silently renders with no image.
+                final String img = resolveImageUrl(response.request().url(), rawImg);
+                if (title == null && img == null) {
+                    android.util.Log.d(Config.LOGTAG, "PREVIEW fetchSync no-og-tags bytesRead="
+                            + total + " url=" + url);
+                    return null;
+                }
+                android.util.Log.d(Config.LOGTAG, "PREVIEW fetchSync ok title=" + (title != null)
+                        + " desc=" + (desc != null) + " img=" + img + " url=" + url);
                 return new OgData(
                         title != null ? decodeEntities(title) : null,
                         desc != null ? decodeEntities(desc) : null,
                         img);
             }
-        } catch (Exception ignored) {
+        } catch (Exception e) {
+            android.util.Log.d(Config.LOGTAG, "PREVIEW fetchSync exception "
+                    + e.getClass().getSimpleName() + ": " + e.getMessage() + " url=" + url);
+            return null;
+        }
+    }
+
+    @androidx.annotation.Nullable
+    private static String resolveImageUrl(
+            final okhttp3.HttpUrl pageUrl, @androidx.annotation.Nullable final String rawImg) {
+        if (rawImg == null || rawImg.isEmpty()) return null;
+        // BitmapFactory can never decode SVG — an og:image pointing at one (e.g. a site reusing
+        // its favicon.svg as the preview image) would otherwise cache a permanently-broken image
+        // URL: loadImage() fetches it fine, decodeByteArray() returns null every time, and the
+        // card is left showing nothing but the grey placeholder forever. Drop it here instead so
+        // the card falls back to title/description-only, same as a page with no og:image at all.
+        if (rawImg.toLowerCase(Locale.ROOT).contains(".svg")) {
+            android.util.Log.d(Config.LOGTAG, "PREVIEW skipping undecodable svg og:image " + rawImg);
             return null;
         }
+        final var resolved = pageUrl.resolve(rawImg);
+        return resolved != null ? resolved.toString() : null;
     }
 
     private static String matchFirst(final String html, final Pattern[] patterns) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/wizard/WizardThemeFragment.java b/src/main/java/tel/xmpp/jabjab/ui/wizard/WizardThemeFragment.java
index ab58825..e61a54f 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/wizard/WizardThemeFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/wizard/WizardThemeFragment.java
@@ -22,12 +22,12 @@ public class WizardThemeFragment extends Fragment {
 
     private static final String[][] COLOR_THEMES = {
         {"default", "Default (teal)",  "#006A60"},
-        {"amoled",  "AMOLED black",    "#121212"},
         {"ocean",   "Ocean",           "#0061A4"},
         {"forest",  "Forest",          "#2E6E4E"},
         {"sunset",  "Sunset",          "#8B3A00"},
         {"purple",  "Purple",          "#6750A4"},
         {"rose",    "Rose",            "#C0004F"},
+        {"strawberry", "Strawberry",   "#0061A4"},
     };
 
     private String selectedMode = "automatic";
diff --git a/src/main/java/tel/xmpp/jabjab/worker/ExportBackupWorker.java b/src/main/java/tel/xmpp/jabjab/worker/ExportBackupWorker.java
index 2c4add0..f420a78 100644
--- a/src/main/java/tel/xmpp/jabjab/worker/ExportBackupWorker.java
+++ b/src/main/java/tel/xmpp/jabjab/worker/ExportBackupWorker.java
@@ -83,12 +83,17 @@ public class ExportBackupWorker extends Worker {
                     ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
                     : PendingIntent.FLAG_UPDATE_CURRENT;
 
+    public static final String DATA_KEY_RECURRING = "recurring_backup";
+    public static final String DATA_KEY_MAX_MESSAGE_AGE_SECONDS = "max_message_age_seconds";
+
     private final boolean recurringBackup;
+    private final long maxMessageAgeSeconds;
 
     public ExportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
         super(context, workerParams);
         final var inputData = workerParams.getInputData();
-        this.recurringBackup = inputData.getBoolean("recurring_backup", false);
+        this.recurringBackup = inputData.getBoolean(DATA_KEY_RECURRING, false);
+        this.maxMessageAgeSeconds = inputData.getLong(DATA_KEY_MAX_MESSAGE_AGE_SECONDS, 0);
     }
 
     @NonNull
@@ -423,12 +428,35 @@ public class ExportBackupWorker extends Worker {
             throws IOException, WorkStoppedException {
         final var notificationManager =
                 getApplicationContext().getSystemService(NotificationManager.class);
-        try (final Cursor cursor =
-                db.rawQuery(
-                        "select messages.* from messages join conversations on"
-                                + " conversations.uuid=messages.conversationUuid where"
-                                + " conversations.accountUuid=?",
-                        new String[] {uuid})) {
+        // OMEMO keys/sessions and account/conversation rows are always exported in full — this
+        // only trims message *history*, which is what actually makes backup files grow large
+        // over time. A cutoff of 0 (the default) means no limit, same as before this existed.
+        final String query;
+        final String[] args;
+        if (maxMessageAgeSeconds > 0) {
+            final long cutoff = System.currentTimeMillis() - (maxMessageAgeSeconds * 1000L);
+            query =
+                    "select messages.* from messages join conversations on"
+                            + " conversations.uuid=messages.conversationUuid where"
+                            + " conversations.accountUuid=? and messages."
+                            + Message.TIME_SENT
+                            + ">=?";
+            args = new String[] {uuid, String.valueOf(cutoff)};
+            Log.d(
+                    Config.LOGTAG,
+                    "limiting message export to the last "
+                            + maxMessageAgeSeconds
+                            + " seconds (cutoff="
+                            + cutoff
+                            + ")");
+        } else {
+            query =
+                    "select messages.* from messages join conversations on"
+                            + " conversations.uuid=messages.conversationUuid where"
+                            + " conversations.accountUuid=?";
+            args = new String[] {uuid};
+        }
+        try (final Cursor cursor = db.rawQuery(query, args)) {
             final int size = cursor.getCount();
             Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
             long lastUpdate = 0;
diff --git a/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java b/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java
index 5cfae77..b115755 100644
--- a/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java
+++ b/src/main/java/tel/xmpp/jabjab/worker/ImportBackupWorker.java
@@ -67,11 +67,14 @@ import org.bouncycastle.crypto.params.KeyParameter;
 
 public class ImportBackupWorker extends Worker {
 
+    private static final String LOG_PREFIX = "RESTORE";
+
     public static final String TAG_IMPORT_BACKUP = "tag-import-backup";
 
     private static final String DATA_KEY_PASSWORD = "password";
     private static final String DATA_KEY_URI = "uri";
     private static final String DATA_KEY_INCLUDE_OMEMO = "omemo";
+    private static final String DATA_KEY_MAX_MESSAGE_AGE_SECONDS = "max_message_age_seconds";
 
     private static final Collection<String> OMEMO_TABLE_LIST =
             Arrays.asList(
@@ -93,6 +96,8 @@ public class ImportBackupWorker extends Worker {
     private final String password;
     private final Uri uri;
     private final boolean includeOmemo;
+    private final long maxMessageAgeSeconds;
+    private int skippedOldMessages = 0;
 
     public ImportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
         super(context, workerParams);
@@ -100,21 +105,28 @@ public class ImportBackupWorker extends Worker {
         this.password = inputData.getString(DATA_KEY_PASSWORD);
         this.uri = Uri.parse(inputData.getString(DATA_KEY_URI));
         this.includeOmemo = inputData.getBoolean(DATA_KEY_INCLUDE_OMEMO, true);
+        this.maxMessageAgeSeconds = inputData.getLong(DATA_KEY_MAX_MESSAGE_AGE_SECONDS, 0);
     }
 
     @NonNull
     @Override
     public Result doWork() {
+        Log.d(
+                Config.LOGTAG,
+                LOG_PREFIX + " starting restore uri=" + uri + " includeOmemo=" + includeOmemo);
         setForegroundAsync(getForegroundInfo());
         final Result result;
         try {
             result = importBackup(this.uri, this.password);
+            Log.d(Config.LOGTAG, LOG_PREFIX + " doWork finished with " + result);
         } catch (final FileNotFoundException e) {
+            Log.d(Config.LOGTAG, LOG_PREFIX + " backup file not found: " + uri, e);
             return failure(Reason.FILE_NOT_FOUND);
         } catch (final Exception e) {
-            Log.d(Config.LOGTAG, "error restoring backup " + uri, e);
+            Log.d(Config.LOGTAG, LOG_PREFIX + " error restoring backup " + uri, e);
             final Throwable throwable = e.getCause();
             if (throwable instanceof BadPaddingException || e instanceof ZipException) {
+                Log.d(Config.LOGTAG, LOG_PREFIX + " treating failure as wrong password/corrupt file");
                 return failure(Reason.DECRYPTION_FAILED);
             } else {
                 return failure(Reason.GENERIC);
@@ -159,7 +171,7 @@ public class ImportBackupWorker extends Worker {
             throws IOException, InvalidKeySpecException {
         final var context = getApplicationContext();
         final var database = DatabaseBackend.getInstance(context);
-        Log.d(Config.LOGTAG, "importing backup from " + uri);
+        Log.d(Config.LOGTAG, LOG_PREFIX + " importing backup from " + uri);
         final Stopwatch stopwatch = Stopwatch.createStarted();
         final SQLiteDatabase db = database.getWritableDatabase();
         final InputStream inputStream;
@@ -169,6 +181,7 @@ public class ImportBackupWorker extends Worker {
             final File file = new File(path);
             inputStream = new FileInputStream(file);
             fileSize = file.length();
+            Log.d(Config.LOGTAG, LOG_PREFIX + " reading from local file, size=" + fileSize);
         } else {
             final Cursor returnCursor =
                     context.getContentResolver().query(uri, null, null, null, null);
@@ -182,25 +195,36 @@ public class ImportBackupWorker extends Worker {
                 returnCursor.close();
             }
             inputStream = context.getContentResolver().openInputStream(uri);
+            Log.d(Config.LOGTAG, LOG_PREFIX + " reading from content resolver, size=" + fileSize);
         }
         if (inputStream == null) {
+            Log.d(Config.LOGTAG, LOG_PREFIX + " could not open input stream for " + uri);
             return failure(Reason.FILE_NOT_FOUND);
         }
         final CountingInputStream countingInputStream = new CountingInputStream(inputStream);
         final DataInputStream dataInputStream = new DataInputStream(countingInputStream);
         final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
-        Log.d(Config.LOGTAG, backupFileHeader.toString());
+        Log.d(Config.LOGTAG, LOG_PREFIX + " header: " + backupFileHeader);
 
         final var accounts = database.getAccountAddresses();
+        Log.d(Config.LOGTAG, LOG_PREFIX + " existing local accounts: " + accounts.size());
 
         if (QuickConversationsService.isQuicksy() && !accounts.isEmpty()) {
+            Log.d(Config.LOGTAG, LOG_PREFIX + " aborting: Quicksy already has an account");
             return failure(Reason.ACCOUNT_ALREADY_EXISTS);
         }
 
         if (accounts.contains(backupFileHeader.getJid())) {
+            Log.d(
+                    Config.LOGTAG,
+                    LOG_PREFIX
+                            + " aborting: "
+                            + backupFileHeader.getJid()
+                            + " already exists locally");
             return failure(Reason.ACCOUNT_ALREADY_EXISTS);
         }
 
+        Log.d(Config.LOGTAG, LOG_PREFIX + " deriving decryption key for " + backupFileHeader.getJid());
         final byte[] key = ExportBackupWorker.getKey(password, backupFileHeader.getSalt());
 
         final AEADBlockCipher cipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
@@ -216,20 +240,43 @@ public class ImportBackupWorker extends Worker {
         if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) {
             jsonReader.beginArray();
         } else {
+            Log.d(Config.LOGTAG, LOG_PREFIX + " backup file did not begin with a JSON array");
             throw new IllegalStateException("Backup file did not begin with array");
         }
+        if (this.maxMessageAgeSeconds > 0) {
+            Log.d(
+                    Config.LOGTAG,
+                    LOG_PREFIX
+                            + " limiting restored message history to the last "
+                            + this.maxMessageAgeSeconds
+                            + " seconds");
+        }
+        Log.d(Config.LOGTAG, LOG_PREFIX + " decryption OK, importing rows...");
         db.beginTransaction();
+        int rowCount = 0;
         while (jsonReader.hasNext()) {
             if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) {
                 importRow(db, jsonReader, backupFileHeader.getJid(), password);
+                rowCount++;
+                if (rowCount % 1000 == 0) {
+                    Log.d(Config.LOGTAG, LOG_PREFIX + " imported " + rowCount + " rows so far");
+                }
             } else if (jsonReader.peek() == JsonToken.END_ARRAY) {
                 jsonReader.endArray();
                 continue;
             }
             updateImportBackupNotification(fileSize, countingInputStream.getCount());
         }
+        Log.d(
+                Config.LOGTAG,
+                LOG_PREFIX
+                        + " row import complete, total rows="
+                        + rowCount
+                        + ", skipped (too old)="
+                        + this.skippedOldMessages);
         db.setTransactionSuccessful();
         db.endTransaction();
+        Log.d(Config.LOGTAG, LOG_PREFIX + " transaction committed");
         final Jid jid = backupFileHeader.getJid();
         final Cursor countCursor =
                 db.rawQuery(
@@ -240,11 +287,17 @@ public class ImportBackupWorker extends Worker {
                         new String[] {jid.getLocal(), jid.getDomain().toString()});
         countCursor.moveToFirst();
         final int count = countCursor.getInt(0);
-        Log.d(Config.LOGTAG, String.format("restored %d messages in %s", count, stopwatch.stop()));
+        Log.d(
+                Config.LOGTAG,
+                LOG_PREFIX
+                        + String.format(" restored %d messages in %s", count, stopwatch.stop()));
         countCursor.close();
+        Log.d(Config.LOGTAG, LOG_PREFIX + " resetting accounts");
         Conversations.getInstance(getApplicationContext()).resetAccounts();
+        Log.d(Config.LOGTAG, LOG_PREFIX + " stopping background service");
         stopBackgroundService();
         notifySuccess();
+        Log.d(Config.LOGTAG, LOG_PREFIX + " restore complete for " + jid);
         return Result.success();
     }
 
@@ -261,6 +314,7 @@ public class ImportBackupWorker extends Worker {
         }
         final String table = jsonReader.nextString();
         if (!TABLE_ALLOW_LIST.contains(table)) {
+            Log.d(Config.LOGTAG, LOG_PREFIX + " rejecting unrecognized table " + table);
             throw new IOException(String.format("%s is not recognized for import", table));
         }
         final ContentValues contentValues = new ContentValues();
@@ -281,6 +335,7 @@ public class ImportBackupWorker extends Worker {
                     contentValues.put(name, jsonReader.nextString());
                 }
             } else {
+                Log.d(Config.LOGTAG, LOG_PREFIX + " rejecting unexpected column name " + name);
                 throw new IOException(String.format("Unexpected column name %s", name));
             }
         }
@@ -293,14 +348,30 @@ public class ImportBackupWorker extends Worker {
                             contentValues.getAsString(Account.SERVER),
                             null);
             final String password = contentValues.getAsString(Account.PASSWORD);
+            Log.d(Config.LOGTAG, LOG_PREFIX + " account row jid=" + jid);
             if (QuickConversationsService.isQuicksy()) {
                 if (!jid.getDomain().equals(Config.QUICKSY_DOMAIN)) {
+                    Log.d(
+                            Config.LOGTAG,
+                            LOG_PREFIX
+                                    + " rejecting non-Quicksy account "
+                                    + jid
+                                    + " on Quicksy flavor");
                     throw new IOException("Trying to restore non Quicksy account on Quicksy");
                 }
             }
             if (jid.equals(account) && passphrase.equals(password)) {
-                Log.d(Config.LOGTAG, "jid and password from backup header had matching row");
+                Log.d(
+                        Config.LOGTAG,
+                        LOG_PREFIX + " jid and password from backup header had matching row");
             } else {
+                Log.d(
+                        Config.LOGTAG,
+                        LOG_PREFIX
+                                + " jid/password mismatch: header="
+                                + account
+                                + " row="
+                                + jid);
                 throw new IOException("jid or password in table did not match backup");
             }
             final var keys = Account.Keys.parse(contentValues.getAsString(Account.KEYS));
@@ -308,11 +379,35 @@ public class ImportBackupWorker extends Worker {
             final Account.Keys importReadyKeys;
             if (deviceId.isPresent() && this.includeOmemo) {
                 importReadyKeys = new Account.Keys(deviceId.get());
+                Log.d(
+                        Config.LOGTAG,
+                        LOG_PREFIX + " carrying over OMEMO registration id " + deviceId.get());
             } else {
                 importReadyKeys = new Account.Keys();
+                Log.d(
+                        Config.LOGTAG,
+                        LOG_PREFIX
+                                + " not carrying over OMEMO registration id (present="
+                                + deviceId.isPresent()
+                                + ", includeOmemo="
+                                + this.includeOmemo
+                                + ")");
             }
             contentValues.put(Account.KEYS, Services.GSON.toJson(importReadyKeys));
         }
+        if (Message.TABLENAME.equals(table) && this.maxMessageAgeSeconds > 0) {
+            // Same idea as the export-side history limit, but applied on restore: even a backup
+            // file that has full history can be restored with just the recent window, without
+            // needing to go re-create a smaller backup first. Accounts/conversations/OMEMO
+            // tables are never filtered — only message history, which is what actually makes
+            // these files (and, if fully restored, the resulting local database) large.
+            final Long timeSent = contentValues.getAsLong(Message.TIME_SENT);
+            final long cutoff = System.currentTimeMillis() - (this.maxMessageAgeSeconds * 1000L);
+            if (timeSent == null || timeSent < cutoff) {
+                this.skippedOldMessages++;
+                return;
+            }
+        }
         if (this.includeOmemo) {
             db.insert(table, null, contentValues);
         } else {
@@ -321,7 +416,7 @@ public class ImportBackupWorker extends Worker {
                         && contentValues.getAsInteger(SQLiteAxolotlStore.OWN) == 0) {
                     db.insert(table, null, contentValues);
                 } else {
-                    Log.d(Config.LOGTAG, "skipping over omemo key material in table " + table);
+                    Log.d(Config.LOGTAG, LOG_PREFIX + " skipping over omemo key material in table " + table);
                 }
             } else {
                 db.insert(table, null, contentValues);
@@ -390,10 +485,19 @@ public class ImportBackupWorker extends Worker {
     }
 
     public static Data data(final String password, final Uri uri, final boolean includeOmemo) {
+        return data(password, uri, includeOmemo, 0);
+    }
+
+    public static Data data(
+            final String password,
+            final Uri uri,
+            final boolean includeOmemo,
+            final long maxMessageAgeSeconds) {
         return new Data.Builder()
                 .putString(DATA_KEY_PASSWORD, password)
                 .putString(DATA_KEY_URI, uri.toString())
                 .putBoolean(DATA_KEY_INCLUDE_OMEMO, includeOmemo)
+                .putLong(DATA_KEY_MAX_MESSAGE_AGE_SECONDS, maxMessageAgeSeconds)
                 .build();
     }
 
diff --git a/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java b/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
index eb4c216..dfa7518 100644
--- a/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
+++ b/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
@@ -135,14 +135,44 @@ public class UpdateDownloadWorker extends Worker {
                 apkFile.delete();
             }
             cancelNotification();
+            showDownloadFailedNotification();
             return Result.failure();
         } catch (final Exception e) {
             Log.d(Config.LOGTAG, "UPDATE download failed", e);
             cancelNotification();
+            showDownloadFailedNotification();
             return Result.failure();
         }
     }
 
+    /**
+     * A failed download used to just log and end the WorkManager job — nothing visible told the
+     * user anything went wrong, and nothing prompted them to retry (the About screen's "check
+     * for updates" would just fail the exact same silent way, since it drives the same worker).
+     * Tapping this opens the About screen, where they can retry the check manually.
+     */
+    private void showDownloadFailedNotification() {
+        final Context context = getApplicationContext();
+        final Intent intent = new Intent(context, tel.xmpp.jabjab.ui.AboutActivity.class);
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        final PendingIntent pendingIntent =
+                PendingIntent.getActivity(
+                        context, 0, intent,
+                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+        final var notification =
+                new NotificationCompat.Builder(context, CHANNEL_ID)
+                        .setSmallIcon(android.R.drawable.stat_notify_error)
+                        .setContentTitle(context.getString(R.string.update_download_failed_title))
+                        .setContentText(context.getString(R.string.update_download_failed_text))
+                        .setAutoCancel(true)
+                        .setContentIntent(pendingIntent)
+                        .build();
+        final var nm = context.getSystemService(NotificationManager.class);
+        if (nm != null) {
+            nm.notify(NOTIFICATION_ID + 2, notification);
+        }
+    }
+
     /** Deletes any cached update APK that isn't for the build we're about to download — cache
      * files from other versionCodes are dead weight now that the filename is unique per build
      * (they'll never be resumed/reused again), so clean them up instead of leaving them to
@@ -158,8 +188,9 @@ public class UpdateDownloadWorker extends Worker {
         }
     }
 
-    /** Returns null if cancelled or the response was unusable — caller treats that as a
-     * clean stop, not a failure worth retrying. */
+    /** Returns null only if the user cancelled — caller treats that as a clean stop, not a
+     * failure. Any other unrecoverable problem (bad HTTP response, network error) throws
+     * instead, so it's reported as a real failure rather than silently looking like success. */
     private File download(
             final String downloadUrl, final int versionCode, final String versionName,
             final long fileSize) throws Exception {
@@ -179,7 +210,24 @@ public class UpdateDownloadWorker extends Worker {
 
         // Resume: if a partial file exists for THIS exact build (e.g. a previous attempt got
         // interrupted), ask the server to continue from where we left off.
-        final long existingBytes = apkFile.exists() ? apkFile.length() : 0;
+        //
+        // A file that's already >= the expected size isn't a partial download at all — e.g. a
+        // full download can survive an app reinstall if it landed in storage that wasn't wiped
+        // (or was already fully written but the worker never got to the hash-verify/install
+        // step). Asking the server to resume "from" or past the end of a file it considers
+        // complete gets a 416, which used to be treated as a fatal, unretried error. Skip the
+        // network round-trip entirely and let the caller's SHA-256 check decide if this file is
+        // actually good (if not, it deletes it and the next attempt starts a real fresh download).
+        long existingBytes = apkFile.exists() ? apkFile.length() : 0;
+        if (fileSize > 0 && existingBytes >= fileSize) {
+            Log.d(
+                    Config.LOGTAG,
+                    "UPDATE cached file already has all "
+                            + existingBytes
+                            + " expected bytes, skipping re-download");
+            return apkFile;
+        }
+
         final Request.Builder requestBuilder =
                 new Request.Builder()
                         .url(downloadUrl)
@@ -191,13 +239,37 @@ public class UpdateDownloadWorker extends Worker {
             Log.d(Config.LOGTAG, "UPDATE resuming download from byte " + existingBytes);
         }
 
-        final Response response = client.newCall(requestBuilder.build()).execute();
+        Response response = client.newCall(requestBuilder.build()).execute();
+        if (response.code() == 416) {
+            // Belt-and-suspenders for the same stale/mismatched-partial situation the size
+            // check above targets — the local file doesn't line up with what the server has.
+            // Drop it and restart from scratch rather than failing outright.
+            Log.d(Config.LOGTAG, "UPDATE server rejected resume range (416), restarting download");
+            response.close();
+            apkFile.delete();
+            existingBytes = 0;
+            response =
+                    client.newCall(
+                                    new Request.Builder()
+                                            .url(downloadUrl)
+                                            .header(
+                                                    "User-Agent",
+                                                    "JabJab/"
+                                                            + BuildConfig.VERSION_NAME
+                                                            + "+"
+                                                            + BuildConfig.VERSION_CODE)
+                                            .build())
+                            .execute();
+        }
         if (response.code() == 200 && existingBytes > 0) {
             apkFile.delete();
             Log.d(Config.LOGTAG, "UPDATE server rejected Range, restarting download");
         } else if (!response.isSuccessful() || response.body() == null) {
-            Log.d(Config.LOGTAG, "UPDATE unexpected response " + response.code());
-            return null;
+            // A real failure (network error, 4xx/5xx, missing body) — not the same thing as the
+            // user cancelling. Throwing lets it reach doWork()'s catch block, which shows a
+            // retry notification and returns Result.failure() instead of silently pretending
+            // the job succeeded (returning null here used to do exactly that).
+            throw new java.io.IOException("Unexpected response " + response.code());
         }
 
         final boolean appending = response.code() == 206;
@@ -257,9 +329,27 @@ public class UpdateDownloadWorker extends Worker {
      * direct user gesture, which is always allowed.
      */
     private void installApk(final File apkFile) {
-        final Context context = getApplicationContext();
+        installApk(getApplicationContext(), apkFile);
+    }
+
+    private static final String PREFS_NAME = "jabjab_update";
+    private static final String KEY_PENDING_INSTALL_APK = "pending_install_apk";
+
+    private static void installApk(final Context context, final File apkFile) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
                 && !context.getPackageManager().canRequestPackageInstalls()) {
+            // The user still needs to flip "install unknown apps" in Settings. There's no
+            // reliable callback for "they came back after granting it" from here (Settings was
+            // launched as its own task, not something this Worker can attach a result listener
+            // to) — so remember the file and let resumePendingInstallIfPossible() pick it back
+            // up the next time any Activity resumes, which is what actually happens right after
+            // they back out of Settings. Without this, granting the permission led nowhere: the
+            // one notification that could have re-launched the install had already
+            // auto-cancelled itself when they tapped it to get to Settings in the first place.
+            context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+                    .edit()
+                    .putString(KEY_PENDING_INSTALL_APK, apkFile.getAbsolutePath())
+                    .apply();
             final Intent settings =
                     new Intent(
                             android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
@@ -270,9 +360,13 @@ public class UpdateDownloadWorker extends Worker {
                             context, 0, settings,
                             PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
             showTapToProceedNotification(
-                    pendingSettings, context.getString(R.string.update_needs_install_permission));
+                    context, pendingSettings, context.getString(R.string.update_needs_install_permission));
             return;
         }
+        context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+                .edit()
+                .remove(KEY_PENDING_INSTALL_APK)
+                .apply();
         final Uri apkUri =
                 FileProvider.getUriForFile(
                         context, context.getString(R.string.applicationId) + ".files", apkFile);
@@ -285,11 +379,37 @@ public class UpdateDownloadWorker extends Worker {
                         context, 0, intent,
                         PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
         showTapToProceedNotification(
-                pendingInstall, context.getString(R.string.update_ready_to_install));
+                context, pendingInstall, context.getString(R.string.update_ready_to_install));
     }
 
-    private void showTapToProceedNotification(final PendingIntent pendingIntent, final String text) {
-        final Context context = getApplicationContext();
+    /**
+     * Call from an Activity's onResume() (see BaseActivity) — if a previous download finished
+     * but was blocked on the "install unknown apps" permission, and that permission has since
+     * been granted (most likely: the user just came back from the Settings screen this same
+     * flow sent them to), re-show the "tap to install" notification for it instead of leaving
+     * the downloaded update to sit there forever with no way to resume installing it.
+     */
+    public static void resumePendingInstallIfPossible(final Context context) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+                && !context.getPackageManager().canRequestPackageInstalls()) {
+            return;
+        }
+        final var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+        final String path = prefs.getString(KEY_PENDING_INSTALL_APK, null);
+        if (path == null) {
+            return;
+        }
+        final File apkFile = new File(path);
+        if (!apkFile.exists()) {
+            prefs.edit().remove(KEY_PENDING_INSTALL_APK).apply();
+            return;
+        }
+        ensureNotificationChannel(context);
+        installApk(context, apkFile);
+    }
+
+    private static void showTapToProceedNotification(
+            final Context context, final PendingIntent pendingIntent, final String text) {
         final var notification =
                 new NotificationCompat.Builder(context, CHANNEL_ID)
                         .setSmallIcon(android.R.drawable.stat_sys_download_done)
@@ -305,13 +425,17 @@ public class UpdateDownloadWorker extends Worker {
     }
 
     private void ensureNotificationChannel() {
+        ensureNotificationChannel(getApplicationContext());
+    }
+
+    private static void ensureNotificationChannel(final Context context) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
             final var channel =
                     new NotificationChannel(
                             CHANNEL_ID,
-                            getApplicationContext().getString(R.string.update_channel_name),
+                            context.getString(R.string.update_channel_name),
                             NotificationManager.IMPORTANCE_DEFAULT);
-            final var nm = getApplicationContext().getSystemService(NotificationManager.class);
+            final var nm = context.getSystemService(NotificationManager.class);
             if (nm != null) nm.createNotificationChannel(channel);
         }
     }
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/MultiUserChatManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/MultiUserChatManager.java
index bcb95d9..011f2c4 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/MultiUserChatManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/MultiUserChatManager.java
@@ -428,6 +428,50 @@ public class MultiUserChatManager extends AbstractManager {
             hats = Collections.emptySet();
         }
 
+        // Entity-caps from MUC occupant presence were never fed into DiscoManager's cache (unlike
+        // PresenceManager#handleAvailablePresence for roster contacts), so
+        // DiscoManager.get(occupantFullJid) was always empty and the Commands tab could never
+        // detect a bot sitting in a room. Wire it up the same way roster presences do.
+        final var nodeHash = presence.getCapabilities();
+        if (nodeHash != null) {
+            Log.d(
+                    Config.LOGTAG,
+                    "ADHOC-CAPS fetching disco#info for muc occupant "
+                            + from
+                            + " node="
+                            + nodeHash.node
+                            + " hash="
+                            + nodeHash.hash);
+            final var discoFuture =
+                    getManager(DiscoManager.class)
+                            .infoOrCache(Entity.presence(from), nodeHash.node, nodeHash.hash);
+            Futures.addCallback(
+                    discoFuture,
+                    new FutureCallback<>() {
+                        @Override
+                        public void onSuccess(final Void result) {
+                            final var infoQuery = getManager(DiscoManager.class).get(from);
+                            Log.d(
+                                    Config.LOGTAG,
+                                    "ADHOC-CAPS resolved disco#info for muc occupant "
+                                            + from
+                                            + " hasCommandsFeature="
+                                            + (infoQuery != null
+                                                    && infoQuery.hasFeature(Namespace.COMMANDS)));
+                        }
+
+                        @Override
+                        public void onFailure(@NonNull final Throwable throwable) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    "ADHOC-CAPS failed to fetch disco#info for muc occupant "
+                                            + from,
+                                    throwable);
+                        }
+                    },
+                    MoreExecutors.directExecutor());
+        }
+
         final MucOptions.User user =
                 MultiUserChatManager.itemToUser(conversation, item, from, occupantId, hats);
         if (codes.contains(MucUser.STATUS_CODE_SELF_PRESENCE)
diff --git a/src/main/res/layout/activity_import_backup.xml b/src/main/res/layout/activity_import_backup.xml
index 1bd0934..1e07514 100644
--- a/src/main/res/layout/activity_import_backup.xml
+++ b/src/main/res/layout/activity_import_backup.xml
@@ -56,6 +56,24 @@
                 android:layout_height="match_parent"
                 android:orientation="vertical"
                 app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
+
+            <!-- Shown when no local backup files were found — without this the screen was just
+                 a toolbar icon and an empty list, with nothing telling people what to do next
+                 (tap the cloud icon to browse for a backup file). -->
+            <TextView
+                android:id="@+id/no_backup_files_hint"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="center"
+                android:drawablePadding="16dp"
+                android:drawableTop="@drawable/ic_cloud_download_24dp"
+                android:gravity="center"
+                android:padding="32dp"
+                android:text="@string/no_backup_files_found_hint"
+                android:textAlignment="center"
+                android:textAppearance="?attr/textAppearanceBodyMedium"
+                android:alpha="0.65"
+                android:visibility="gone" />
         </androidx.coordinatorlayout.widget.CoordinatorLayout>
 
     </LinearLayout>
diff --git a/src/main/res/layout/dialog_enter_password.xml b/src/main/res/layout/dialog_enter_password.xml
index 332577e..3aa1fa4 100644
--- a/src/main/res/layout/dialog_enter_password.xml
+++ b/src/main/res/layout/dialog_enter_password.xml
@@ -47,6 +47,17 @@
 
             </com.google.android.material.card.MaterialCardView>
 
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="16sp"
+                android:text="@string/pref_backup_message_history"
+                android:textAppearance="?textAppearanceBodySmall" />
+
+            <Spinner
+                android:id="@+id/message_history"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content" />
 
             <com.google.android.material.textfield.TextInputLayout
                 android:id="@+id/account_password_layout"
diff --git a/src/main/res/values-ar/strings.xml b/src/main/res/values-ar/strings.xml
index 10619b3..5495279 100644
--- a/src/main/res/values-ar/strings.xml
+++ b/src/main/res/values-ar/strings.xml
@@ -678,7 +678,6 @@
     <string name="pref_category_link_preview">معاينة الرابط</string>
     <string name="pref_category_stickers">الملصقات</string>
     <string name="pref_color_theme">سمة الألوان</string>
-    <string name="pref_color_theme_amoled">أسود AMOLED</string>
     <string name="pref_color_theme_default">الافتراضي (فيروزي)</string>
     <string name="pref_color_theme_forest">الغابة (أخضر)</string>
     <string name="pref_color_theme_ocean">المحيط (أزرق)</string>
diff --git a/src/main/res/values-cs/strings.xml b/src/main/res/values-cs/strings.xml
index c19f10f..a75b2fe 100644
--- a/src/main/res/values-cs/strings.xml
+++ b/src/main/res/values-cs/strings.xml
@@ -1180,7 +1180,6 @@
     <string name="pref_color_theme">Barevné téma</string>
     <string name="pref_color_theme_summary">Vyberte barevnou paletu aplikace</string>
     <string name="pref_color_theme_default">Výchozí (modrozelená)</string>
-    <string name="pref_color_theme_amoled">AMOLED černá</string>
     <string name="pref_color_theme_ocean">Oceán (modrá)</string>
     <string name="pref_color_theme_forest">Les (zelená)</string>
     <string name="pref_color_theme_sunset">Západ slunce (teplá)</string>
diff --git a/src/main/res/values-da-rDK/strings.xml b/src/main/res/values-da-rDK/strings.xml
index ecf4324..4a28d8a 100644
--- a/src/main/res/values-da-rDK/strings.xml
+++ b/src/main/res/values-da-rDK/strings.xml
@@ -1160,7 +1160,6 @@
     <string name="pref_color_theme">Farvetema</string>
     <string name="pref_color_theme_summary">Vælg en farvepalet til appen</string>
     <string name="pref_color_theme_default">Standard (blågrøn)</string>
-    <string name="pref_color_theme_amoled">AMOLED-sort</string>
     <string name="pref_color_theme_ocean">Hav (blå)</string>
     <string name="pref_color_theme_forest">Skov (grøn)</string>
     <string name="pref_color_theme_sunset">Solnedgang (varm)</string>
diff --git a/src/main/res/values-de/strings.xml b/src/main/res/values-de/strings.xml
index 503a581..3ba5980 100644
--- a/src/main/res/values-de/strings.xml
+++ b/src/main/res/values-de/strings.xml
@@ -1252,7 +1252,6 @@
     <string name="pref_category_link_preview">Linkvorschau</string>
     <string name="pref_category_stickers">Sticker</string>
     <string name="pref_color_theme">Farbschema</string>
-    <string name="pref_color_theme_amoled">AMOLED Schwarz</string>
     <string name="pref_color_theme_default">Standard (Türkis)</string>
     <string name="pref_color_theme_forest">Wald (Grün)</string>
     <string name="pref_color_theme_ocean">Ozean (Blau)</string>
diff --git a/src/main/res/values-es/strings.xml b/src/main/res/values-es/strings.xml
index 21e96dc..3964e0a 100644
--- a/src/main/res/values-es/strings.xml
+++ b/src/main/res/values-es/strings.xml
@@ -1306,7 +1306,6 @@
     <string name="pref_color_theme">Tema de color</string>
     <string name="pref_color_theme_summary">Elige una paleta de colores para la aplicación</string>
     <string name="pref_color_theme_default">Predeterminado (verde azulado)</string>
-    <string name="pref_color_theme_amoled">Negro AMOLED</string>
     <string name="pref_color_theme_ocean">Océano (azul)</string>
     <string name="pref_color_theme_forest">Bosque (verde)</string>
     <string name="pref_color_theme_sunset">Atardecer (cálido)</string>
diff --git a/src/main/res/values-et/strings.xml b/src/main/res/values-et/strings.xml
index e47cbca..c40e68f 100644
--- a/src/main/res/values-et/strings.xml
+++ b/src/main/res/values-et/strings.xml
@@ -1260,7 +1260,6 @@
     <string name="pref_color_theme">Värviteema</string>
     <string name="pref_color_theme_summary">Vali rakendusele värvipalett</string>
     <string name="pref_color_theme_default">Vaikimisi (tirkiis)</string>
-    <string name="pref_color_theme_amoled">AMOLED must</string>
     <string name="pref_color_theme_ocean">Ookean (sinine)</string>
     <string name="pref_color_theme_forest">Mets (roheline)</string>
     <string name="pref_color_theme_sunset">Päikeseloojang (soe)</string>
diff --git a/src/main/res/values-fi/strings.xml b/src/main/res/values-fi/strings.xml
index a9be121..dfc5ecb 100644
--- a/src/main/res/values-fi/strings.xml
+++ b/src/main/res/values-fi/strings.xml
@@ -1156,7 +1156,6 @@
     <string name="pref_color_theme">Väriteema</string>
     <string name="pref_color_theme_summary">Valitse sovellukselle väripaletti</string>
     <string name="pref_color_theme_default">Oletus (sinivihreä)</string>
-    <string name="pref_color_theme_amoled">AMOLED-musta</string>
     <string name="pref_color_theme_ocean">Valtameri (sininen)</string>
     <string name="pref_color_theme_forest">Metsä (vihreä)</string>
     <string name="pref_color_theme_sunset">Auringonlasku (lämmin)</string>
diff --git a/src/main/res/values-fr/strings.xml b/src/main/res/values-fr/strings.xml
index ae84cdb..09156b8 100644
--- a/src/main/res/values-fr/strings.xml
+++ b/src/main/res/values-fr/strings.xml
@@ -1216,7 +1216,6 @@
     <string name="pref_color_theme">Thème de couleur</string>
     <string name="pref_color_theme_summary">Choisissez une palette de couleurs pour l\'application</string>
     <string name="pref_color_theme_default">Par défaut (sarcelle)</string>
-    <string name="pref_color_theme_amoled">Noir AMOLED</string>
     <string name="pref_color_theme_ocean">Océan (bleu)</string>
     <string name="pref_color_theme_forest">Forêt (vert)</string>
     <string name="pref_color_theme_sunset">Coucher de soleil (chaud)</string>
diff --git a/src/main/res/values-it/strings.xml b/src/main/res/values-it/strings.xml
index e17e7f0..bd08b0a 100644
--- a/src/main/res/values-it/strings.xml
+++ b/src/main/res/values-it/strings.xml
@@ -1252,7 +1252,6 @@
     <string name="pref_color_theme">Tema colore</string>
     <string name="pref_color_theme_summary">Scegli una palette di colori per l\'app</string>
     <string name="pref_color_theme_default">Predefinito (foglia di tè)</string>
-    <string name="pref_color_theme_amoled">Nero AMOLED</string>
     <string name="pref_color_theme_ocean">Oceano (blu)</string>
     <string name="pref_color_theme_forest">Foresta (verde)</string>
     <string name="pref_color_theme_sunset">Tramonto (caldo)</string>
diff --git a/src/main/res/values-ja/strings.xml b/src/main/res/values-ja/strings.xml
index 731f005..6bd8711 100644
--- a/src/main/res/values-ja/strings.xml
+++ b/src/main/res/values-ja/strings.xml
@@ -1148,7 +1148,6 @@
     <string name="pref_color_theme">カラーテーマ</string>
     <string name="pref_color_theme_summary">アプリのカラーパレットを選択します</string>
     <string name="pref_color_theme_default">デフォルト(ティール)</string>
-    <string name="pref_color_theme_amoled">AMOLED ブラック</string>
     <string name="pref_color_theme_ocean">オーシャン(ブルー)</string>
     <string name="pref_color_theme_forest">フォレスト(グリーン)</string>
     <string name="pref_color_theme_sunset">サンセット(暖色)</string>
diff --git a/src/main/res/values-night/themes.xml b/src/main/res/values-night/themes.xml
index 6fd578c..45eaf6a 100644
--- a/src/main/res/values-night/themes.xml
+++ b/src/main/res/values-night/themes.xml
@@ -105,4 +105,20 @@
         <item name="colorSurfaceContainerHigh">#271519</item>
     </style>
 
+    <style name="ThemeOverlay.Conversations3.Strawberry" parent="">
+        <item name="colorPrimary">#9ECAFF</item>
+        <item name="colorOnPrimary">#570622</item>
+        <item name="colorPrimaryContainer">#004A77</item>
+        <item name="colorOnPrimaryContainer">#D1E4FF</item>
+        <item name="colorSecondary">#FFA5B8</item>
+        <item name="colorOnSecondary">#934153</item>
+        <item name="colorSecondaryContainer">#3C4858</item>
+        <item name="colorOnSecondaryContainer">#D7E3F7</item>
+        <item name="colorTertiary">#FD72A2</item>
+        <item name="colorOnTertiary">#EFC7C3</item>
+        <item name="colorTertiaryContainer">#004D68</item>
+        <item name="colorOnTertiaryContainer">#C1E8FF</item>
+        <item name="colorSurfaceContainerHigh">#1A2130</item>
+    </style>
+
 </resources>
diff --git a/src/main/res/values-nl/strings.xml b/src/main/res/values-nl/strings.xml
index 332cbc8..48103d8 100644
--- a/src/main/res/values-nl/strings.xml
+++ b/src/main/res/values-nl/strings.xml
@@ -1144,7 +1144,6 @@
     <string name="pref_color_theme">Kleurthema</string>
     <string name="pref_color_theme_summary">Kies een kleurenpalet voor de app</string>
     <string name="pref_color_theme_default">Standaard (turkoois)</string>
-    <string name="pref_color_theme_amoled">AMOLED-zwart</string>
     <string name="pref_color_theme_ocean">Oceaan (blauw)</string>
     <string name="pref_color_theme_forest">Bos (groen)</string>
     <string name="pref_color_theme_sunset">Zonsondergang (warm)</string>
diff --git a/src/main/res/values-pl/strings.xml b/src/main/res/values-pl/strings.xml
index ac4f526..a7acc6f 100644
--- a/src/main/res/values-pl/strings.xml
+++ b/src/main/res/values-pl/strings.xml
@@ -1274,7 +1274,6 @@
     <string name="pref_color_theme">Motyw kolorystyczny</string>
     <string name="pref_color_theme_summary">Wybierz paletę kolorów aplikacji</string>
     <string name="pref_color_theme_default">Domyślny (morski)</string>
-    <string name="pref_color_theme_amoled">Czerń AMOLED</string>
     <string name="pref_color_theme_ocean">Ocean (niebieski)</string>
     <string name="pref_color_theme_forest">Las (zielony)</string>
     <string name="pref_color_theme_sunset">Zachód słońca (ciepły)</string>
diff --git a/src/main/res/values-pt-rBR/strings.xml b/src/main/res/values-pt-rBR/strings.xml
index 0311411..430cc7d 100644
--- a/src/main/res/values-pt-rBR/strings.xml
+++ b/src/main/res/values-pt-rBR/strings.xml
@@ -1240,7 +1240,6 @@
     <string name="pref_color_theme">Tema de cores</string>
     <string name="pref_color_theme_summary">Escolha uma paleta de cores para o aplicativo</string>
     <string name="pref_color_theme_default">Padrão (verde-azulado)</string>
-    <string name="pref_color_theme_amoled">Preto AMOLED</string>
     <string name="pref_color_theme_ocean">Oceano (azul)</string>
     <string name="pref_color_theme_forest">Floresta (verde)</string>
     <string name="pref_color_theme_sunset">Pôr do sol (quente)</string>
diff --git a/src/main/res/values-pt/strings.xml b/src/main/res/values-pt/strings.xml
index 1a64023..83fc33f 100644
--- a/src/main/res/values-pt/strings.xml
+++ b/src/main/res/values-pt/strings.xml
@@ -1251,7 +1251,6 @@
     <string name="pref_color_theme">Tema de cor</string>
     <string name="pref_color_theme_summary">Escolha uma paleta de cores para a aplicação</string>
     <string name="pref_color_theme_default">Predefinido (verde-azulado)</string>
-    <string name="pref_color_theme_amoled">Preto AMOLED</string>
     <string name="pref_color_theme_ocean">Oceano (azul)</string>
     <string name="pref_color_theme_forest">Floresta (verde)</string>
     <string name="pref_color_theme_sunset">Pôr do sol (quente)</string>
diff --git a/src/main/res/values-ro-rRO/strings.xml b/src/main/res/values-ro-rRO/strings.xml
index b3db34a..a106617 100644
--- a/src/main/res/values-ro-rRO/strings.xml
+++ b/src/main/res/values-ro-rRO/strings.xml
@@ -1262,7 +1262,6 @@
     <string name="pref_color_theme">Temă de culoare</string>
     <string name="pref_color_theme_summary">Alegeți o paletă de culori pentru aplicație</string>
     <string name="pref_color_theme_default">Implicit (verde-albastru)</string>
-    <string name="pref_color_theme_amoled">Negru AMOLED</string>
     <string name="pref_color_theme_ocean">Ocean (albastru)</string>
     <string name="pref_color_theme_forest">Pădure (verde)</string>
     <string name="pref_color_theme_sunset">Apus (cald)</string>
diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml
index 23ce4de..82cd70f 100644
--- a/src/main/res/values-ru/strings.xml
+++ b/src/main/res/values-ru/strings.xml
@@ -1297,7 +1297,6 @@
     <string name="pref_color_theme">Цветовая тема</string>
     <string name="pref_color_theme_summary">Выберите цветовую палитру для приложения</string>
     <string name="pref_color_theme_default">По умолчанию (бирюзовый)</string>
-    <string name="pref_color_theme_amoled">AMOLED-чёрный</string>
     <string name="pref_color_theme_ocean">Океан (синий)</string>
     <string name="pref_color_theme_forest">Лес (зелёный)</string>
     <string name="pref_color_theme_sunset">Закат (тёплый)</string>
diff --git a/src/main/res/values-sr/strings.xml b/src/main/res/values-sr/strings.xml
index ca792b1..df54c2b 100644
--- a/src/main/res/values-sr/strings.xml
+++ b/src/main/res/values-sr/strings.xml
@@ -1279,7 +1279,6 @@
     <string name="pref_color_theme">Tema boja</string>
     <string name="pref_color_theme_summary">Izaberite paletu boja za aplikaciju</string>
     <string name="pref_color_theme_default">Podrazumevana (tirkizna)</string>
-    <string name="pref_color_theme_amoled">AMOLED crna</string>
     <string name="pref_color_theme_ocean">Okean (plava)</string>
     <string name="pref_color_theme_forest">Šuma (zelena)</string>
     <string name="pref_color_theme_sunset">Zalazak sunca (topla)</string>
diff --git a/src/main/res/values-sv/strings.xml b/src/main/res/values-sv/strings.xml
index 1a486f2..4c611fb 100644
--- a/src/main/res/values-sv/strings.xml
+++ b/src/main/res/values-sv/strings.xml
@@ -1255,7 +1255,6 @@
     <string name="pref_color_theme">Färgtema</string>
     <string name="pref_color_theme_summary">Välj en färgpalett för appen</string>
     <string name="pref_color_theme_default">Standard (turkos)</string>
-    <string name="pref_color_theme_amoled">AMOLED-svart</string>
     <string name="pref_color_theme_ocean">Ocean (blå)</string>
     <string name="pref_color_theme_forest">Skog (grön)</string>
     <string name="pref_color_theme_sunset">Solnedgång (varm)</string>
diff --git a/src/main/res/values-zh-rCN/strings.xml b/src/main/res/values-zh-rCN/strings.xml
index b3b7c18..c8b0859 100644
--- a/src/main/res/values-zh-rCN/strings.xml
+++ b/src/main/res/values-zh-rCN/strings.xml
@@ -1215,7 +1215,6 @@
     <string name="pref_color_theme">配色主题</string>
     <string name="pref_color_theme_summary">为应用选择一个配色方案</string>
     <string name="pref_color_theme_default">默认(青色)</string>
-    <string name="pref_color_theme_amoled">AMOLED 纯黑</string>
     <string name="pref_color_theme_ocean">海洋(蓝色)</string>
     <string name="pref_color_theme_forest">森林(绿色)</string>
     <string name="pref_color_theme_sunset">日落(暖色)</string>
diff --git a/src/main/res/values-zh-rTW/strings.xml b/src/main/res/values-zh-rTW/strings.xml
index 617ca8c..699c5cc 100644
--- a/src/main/res/values-zh-rTW/strings.xml
+++ b/src/main/res/values-zh-rTW/strings.xml
@@ -1133,7 +1133,6 @@
     <string name="pref_color_theme">配色主題</string>
     <string name="pref_color_theme_summary">為應用程式選擇一個配色方案</string>
     <string name="pref_color_theme_default">預設(青色)</string>
-    <string name="pref_color_theme_amoled">AMOLED 純黑</string>
     <string name="pref_color_theme_ocean">海洋(藍色)</string>
     <string name="pref_color_theme_forest">森林(綠色)</string>
     <string name="pref_color_theme_sunset">日落(暖色)</string>
diff --git a/src/main/res/values/arrays.xml b/src/main/res/values/arrays.xml
index 7252ced..25300c7 100644
--- a/src/main/res/values/arrays.xml
+++ b/src/main/res/values/arrays.xml
@@ -71,6 +71,13 @@
 		<item>604800</item>
 		<item>2592000</item>
 	</integer-array>
+	<integer-array name="backup_message_history_values">
+		<item>0</item>
+		<item>604800</item>
+		<item>2592000</item>
+		<item>7776000</item>
+		<item>31536000</item>
+	</integer-array>
 	<string-array name="omemo_setting_entry_values">
 		<item>always</item>
 		<item>default_on</item>
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 0924067..b00d65f 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -674,12 +674,12 @@
     <string name="pref_color_theme">Color theme</string>
     <string name="pref_color_theme_summary">Choose a color palette for the app</string>
     <string name="pref_color_theme_default">Default (teal)</string>
-    <string name="pref_color_theme_amoled">AMOLED black</string>
     <string name="pref_color_theme_ocean">Ocean (blue)</string>
     <string name="pref_color_theme_forest">Forest (green)</string>
     <string name="pref_color_theme_sunset">Sunset</string>
     <string name="pref_color_theme_purple">Purple</string>
     <string name="pref_color_theme_rose">Rose (red/pink)</string>
+    <string name="pref_color_theme_strawberry">Strawberry</string>
     <string name="pref_use_colorful_bubbles">Colorful chat bubbles</string>
     <string name="pref_use_colorful_bubbles_summary">Distinct background colors for sent and received messages</string>
     <string name="unable_to_connect_to_keychain">Could not connect to OpenKeychain</string>
@@ -1019,6 +1019,7 @@
     <string name="conversations_backup">JabJab backup</string>
     <string name="event">Event</string>
     <string name="open_backup">Open backup</string>
+    <string name="no_backup_files_found_hint">No backup files found here.\nTap the cloud icon in the top corner to search for a backup file to restore.</string>
     <string name="not_a_backup_file">The file you selected is not a JabJab backup file</string>
     <string name="outdated_backup_file_format">You are trying to import an outdated backup file format</string>
     <string name="non_quicksy_backup">Quicksy can only restore backups for quicksy.im accounts</string>
@@ -1189,6 +1190,8 @@
     <string name="update_download_complete">Update downloaded</string>
     <string name="update_ready_to_install">Tap to install</string>
     <string name="update_needs_install_permission">Tap to allow installing updates</string>
+    <string name="update_download_failed_title">Update download failed</string>
+    <string name="update_download_failed_text">Tap to try again</string>
     <string name="check_for_updates">Check for updates</string>
 
     <!-- Setup wizard -->
@@ -1306,6 +1309,8 @@
     <string name="pref_backup_summary">Create one-off, Schedule recurring, Storage location</string>
     <string name="pref_create_backup_one_off_summary">Create one-off backup</string>
     <string name="pref_backup_recurring">Recurring backup</string>
+    <string name="pref_backup_message_history">Message history to include</string>
+    <string name="backup_history_all_messages">All messages</string>
     <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>
diff --git a/src/main/res/values/theme-settings.xml b/src/main/res/values/theme-settings.xml
index 5227a59..57a4533 100644
--- a/src/main/res/values/theme-settings.xml
+++ b/src/main/res/values/theme-settings.xml
@@ -17,21 +17,21 @@
     <string name="color_theme_default">default</string>
     <string-array name="color_themes">
         <item>@string/pref_color_theme_default</item>
-        <item>@string/pref_color_theme_amoled</item>
         <item>@string/pref_color_theme_ocean</item>
         <item>@string/pref_color_theme_forest</item>
         <item>@string/pref_color_theme_sunset</item>
         <item>@string/pref_color_theme_purple</item>
         <item>@string/pref_color_theme_rose</item>
+        <item>@string/pref_color_theme_strawberry</item>
     </string-array>
     <string-array name="color_themes_values">
         <item>default</item>
-        <item>amoled</item>
         <item>ocean</item>
         <item>forest</item>
         <item>sunset</item>
         <item>purple</item>
         <item>rose</item>
+        <item>strawberry</item>
     </string-array>
 
 </resources>
diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml
index e80759a..7fa1010 100644
--- a/src/main/res/values/themes.xml
+++ b/src/main/res/values/themes.xml
@@ -89,29 +89,6 @@
     <!-- Applied via getTheme().applyStyle() in BaseActivity.onCreate() -->
     <!-- They only override color tokens, leaving the base light/dark theme intact -->
 
-    <!-- AMOLED: pure black surfaces for OLED screens in dark mode.
-         Teal primary/secondary pins mirror the Conversations-flavor md_theme_dark_* values
-         so the bottom-nav active indicator and FAB stay teal rather than defaulting to
-         whatever Material3 baseline color was last applied. -->
-    <style name="ThemeOverlay.Conversations3.AMOLED" parent="">
-        <item name="android:colorBackground">#000000</item>
-        <item name="colorSurface">#000000</item>
-        <item name="colorSurfaceVariant">#111111</item>
-        <item name="colorSurfaceInverse">#E0E0E0</item>
-        <item name="colorPrimary">#7DDC7A</item>
-        <item name="colorOnPrimary">#00390A</item>
-        <item name="colorPrimaryContainer">#005313</item>
-        <item name="colorOnPrimaryContainer">#98F994</item>
-        <item name="colorSecondary">#BACCB3</item>
-        <item name="colorOnSecondary">#253423</item>
-        <item name="colorSecondaryContainer">#3B4B38</item>
-        <item name="colorOnSecondaryContainer">#D5E8CF</item>
-        <item name="colorTertiary">#A0CFD4</item>
-        <item name="colorOnTertiary">#00363B</item>
-        <item name="colorTertiaryContainer">#1F4D52</item>
-        <item name="colorOnTertiaryContainer">#BCEBF0</item>
-    </style>
-
     <!-- Ocean: deep blue + teal accent (light mode) -->
     <style name="ThemeOverlay.Conversations3.Ocean" parent="">
         <item name="colorPrimary">#0061A4</item>
@@ -212,6 +189,22 @@
         <item name="colorSurfaceContainerHigh">#EDE0E4</item>
     </style>
 
+    <style name="ThemeOverlay.Conversations3.Strawberry" parent="">
+        <item name="colorPrimary">#0061A4</item>
+        <item name="colorOnPrimary">#FFFFFF</item>
+        <item name="colorPrimaryContainer">#D1E4FF</item>
+        <item name="colorOnPrimaryContainer">#001D36</item>
+        <item name="colorSecondary">#DD5A75</item>
+        <item name="colorOnSecondary">#FFFFFF</item>
+        <item name="colorSecondaryContainer">#D7E3F7</item>
+        <item name="colorOnSecondaryContainer">#101C2B</item>
+        <item name="colorTertiary">#C1134E</item>
+        <item name="colorOnTertiary">#FFFFFF</item>
+        <item name="colorTertiaryContainer">#CBE6FF</item>
+        <item name="colorOnTertiaryContainer">#001E30</item>
+        <item name="colorSurfaceContainerHigh">#ECF0F9</item>
+    </style>
+
     <style name="MaterialPreferenceThemeOverlay" parent="@style/PreferenceThemeOverlay">
         <item name="switchPreferenceCompatStyle">@style/MaterialSwitchPreference</item>
     </style>
diff --git a/src/main/res/xml/preferences_backup.xml b/src/main/res/xml/preferences_backup.xml
index 8ee89ed..a018dad 100644
--- a/src/main/res/xml/preferences_backup.xml
+++ b/src/main/res/xml/preferences_backup.xml
@@ -9,6 +9,12 @@
             android:key="recurring_backup"
             android:title="@string/pref_backup_recurring" />
 
+        <ListPreference
+            android:defaultValue="0"
+            android:icon="@drawable/ic_schedule_24dp"
+            android:key="backup_message_history"
+            android:title="@string/pref_backup_message_history" />
+
         <Preference
             android:icon="@drawable/ic_archive_24dp"
             android:key="create_one_off_backup"
diff --git a/src/test/java/tel/xmpp/jabjab/worker/ImportBackupWorkerTest.java b/src/test/java/tel/xmpp/jabjab/worker/ImportBackupWorkerTest.java
new file mode 100644
index 0000000..e88a9fb
--- /dev/null
+++ b/src/test/java/tel/xmpp/jabjab/worker/ImportBackupWorkerTest.java
@@ -0,0 +1,49 @@
+package tel.xmpp.jabjab.worker;
+
+import android.content.ContentValues;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.ConscryptMode;
+
+/**
+ * The backup-history-limit filter in ImportBackupWorker#importRow reads Message.TIME_SENT via
+ * ContentValues#getAsLong — but every value coming out of the backup JSON parser is put into
+ * ContentValues as a String (JsonWriter#value(String) always writes a quoted JSON string, never
+ * a raw number, so the reader side always sees JsonToken.STRING and calls
+ * contentValues.put(name, jsonReader.nextString())). This only works because ContentValues
+ * coerces a String-backed value to Long on demand; if that assumption were ever wrong, every
+ * restore with a history limit set would silently skip every message (getAsLong returning null
+ * is treated as "too old, skip"), rather than failing loudly.
+ */
+@RunWith(RobolectricTestRunner.class)
+@ConscryptMode(ConscryptMode.Mode.OFF)
+public class ImportBackupWorkerTest {
+
+    @Test
+    public void getAsLong_parsesStringBackedNumericValue() {
+        final var contentValues = new ContentValues();
+        // Mirrors exactly how importRow() populates a column that arrived as a JSON string.
+        contentValues.put("timeSent", String.valueOf(1_753_500_000_000L));
+        final Long parsed = contentValues.getAsLong("timeSent");
+        Assert.assertNotNull(
+                "ContentValues.getAsLong() must coerce a String-backed numeric value, "
+                        + "otherwise the history-limit filter silently drops every message",
+                parsed);
+        Assert.assertEquals(1_753_500_000_000L, (long) parsed);
+    }
+
+    @Test
+    public void getAsLong_returnsNullForMissingKey() {
+        final var contentValues = new ContentValues();
+        Assert.assertNull(contentValues.getAsLong("timeSent"));
+    }
+
+    @Test
+    public void getAsLong_returnsNullForNonNumericString() {
+        final var contentValues = new ContentValues();
+        contentValues.put("timeSent", "not-a-number");
+        Assert.assertNull(contentValues.getAsLong("timeSent"));
+    }
+}

Built with passion for open-source software, XMPP, privacy, and security —
the belief that people deserve communication tools that serve them, not surveil them.

Dedicated to every brave man and woman who stands up against tyranny and oppression across the world.
And in memory of those who were silenced before they could speak freely.