🔀 Commit

Notification fix for edited blog posts. Bug fix in updater
Commit6a00383f7f05ab011ef3b6902192621bd8b114e8
AuthorJabJab <noreply@xmpp.tel>
Date2026-07-20
Parentd118d4d1
commit 6a00383f7f05ab011ef3b6902192621bd8b114e8
Author: JabJab <noreply@xmpp.tel>
Date:   Mon Jul 20 12:45:11 2026 +0300

    Notification fix for edited blog posts. Bug fix in updater
---
 build.gradle                                       |   2 +-
 .../java/tel/xmpp/jabjab/ui/BlogPostActivity.java  |  43 +++-
 .../xmpp/jabjab/ui/util/BlogNotifiedTracker.java   |  61 +++--
 .../tel/xmpp/jabjab/ui/util/UpdateChecker.java     | 182 +-------------
 .../jabjab/ui/util/UpdateDownloadReceiver.java     |   3 +-
 .../xmpp/jabjab/worker/UpdateDownloadWorker.java   | 274 +++++++++++++++++++++
 .../tel/xmpp/jabjab/xmpp/manager/BlogManager.java  |  16 +-
 7 files changed, 375 insertions(+), 206 deletions(-)

diff --git a/build.gradle b/build.gradle
index 15480a9..8469813 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,7 +113,7 @@ android {
 
     defaultConfig {
         minSdkVersion 23
-        versionCode 42295
+        versionCode 42297
         versionName "1.0.1"
         applicationId "tel.xmpp.jabjab"
         resValue "string", "applicationId", applicationId
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
index eedbbbb..f56770e 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogPostActivity.java
@@ -275,18 +275,45 @@ public class BlogPostActivity extends XmppActivity {
         // Case 2: authorJid belongs to someone else (a contact's post, e.g. opened via a
         // "new blog post" notification) — no local account's own jid matches, so the loop
         // above never fires fetchItem() and the spinner previously spun forever. Fetch the
-        // item from the author's own PubSub node instead, via any online connection.
+        // item from the author's own PubSub node instead, via an online connection. Prefer
+        // an account that actually has the author in its roster: an inbound PEP push (which
+        // is how the notification got here) rides the s2s session the author's server already
+        // opened TO us, but fetchItemFrom() opens a fresh OUTBOUND s2s session FROM whichever
+        // account we pick — with two non-xmpp.tel accounts on different servers, that direction
+        // can be firewalled/refused even though the original push arrived fine (asymmetric
+        // reachability). The rostered account is the one whose server most likely already has
+        // a working s2s relationship with the author's server; a random other local account
+        // has never talked to that domain at all.
         if (authorJid != null) {
             Log.d(Config.LOGTAG, "BLOG post view: fetching " + itemId + " from " + authorJid);
+            final Jid authorBareJid;
+            try {
+                authorBareJid = Jid.of(authorJid).asBareJid();
+            } catch (final Exception e) {
+                loadingView.setVisibility(View.GONE);
+                metaView.setText("Could not load post");
+                metaView.setVisibility(View.VISIBLE);
+                return;
+            }
+            final java.util.List<tel.xmpp.jabjab.entities.Account> candidates = new java.util.ArrayList<>();
             for (final var acc : xmppConnectionService.getAccounts()) {
-                final var conn = acc.getXmppConnection();
-                if (conn == null || !acc.isOnlineAndConnected()) continue;
-                final Jid authorJidObj;
-                try {
-                    authorJidObj = Jid.of(authorJid).asBareJid();
-                } catch (final Exception e) {
-                    break;
+                if (acc.getXmppConnection() == null || !acc.isOnlineAndConnected()) continue;
+                // getContact() is @NonNull — it auto-creates an empty placeholder Contact for
+                // any JID that isn't already known, so it's never actually null here. The real
+                // "is this account genuinely rostered with them" check is showInRoster(), same
+                // as BlogManager.notifyNewPosts() already uses correctly. Without this, every
+                // online account looked "rostered" and the wrong one (by iteration order) could
+                // get picked — which is exactly what happened: a non-xmpp.tld account got used
+                // to fetch a same-server post, hitting real cross-domain federation for no reason.
+                if (acc.getRoster().getContact(authorBareJid).showInRoster()) {
+                    candidates.add(0, acc); // rostered account goes first
+                } else {
+                    candidates.add(acc);
                 }
+            }
+            for (final var acc : candidates) {
+                final var conn = acc.getXmppConnection();
+                final Jid authorJidObj = authorBareJid;
                 final var mgr = conn.getManager(BlogManager.class);
                 loadingView.setVisibility(View.VISIBLE);
                 titleView.setVisibility(View.GONE);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/BlogNotifiedTracker.java b/src/main/java/tel/xmpp/jabjab/ui/util/BlogNotifiedTracker.java
index 1d7f1d2..4d20765 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/BlogNotifiedTracker.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/BlogNotifiedTracker.java
@@ -4,22 +4,28 @@ import android.content.Context;
 import android.content.SharedPreferences;
 
 /**
- * Tracks, per blog post item id, when we last produced a "new blog post" notification for
- * it. Blog has no SQLite cache to attach this to (posts are fetched on demand — see
- * {@code BlogManager}), so this mirrors the existing lightweight SharedPreferences-backed
- * stores already used for blog feature state ({@code jabjab_blog_drafts},
- * {@code jabjab_blog_headers}).
+ * Tracks, per blog post item id, the last content version (an entry's {@code updated}, or
+ * {@code published} if unset) we notified about, plus when. Blog has no SQLite cache to
+ * attach this to (posts are fetched on demand — see {@code BlogManager}), so this mirrors the
+ * existing lightweight SharedPreferences-backed stores already used for blog feature state
+ * ({@code jabjab_blog_drafts}, {@code jabjab_blog_headers}).
  *
  * A post edit republishes with the SAME item id (see {@code BlogManager.editPost}), so the
- * same PEP push we use to detect new posts is also how we learn about edits. Rather than a
- * plain "already seen" set (which would silently swallow every edit forever), this stores a
- * timestamp and re-notifies after {@link #RENOTIFY_THROTTLE_MS} — so an edit made well after
- * the fact still surfaces, while rapid-fire re-edits (fixing a typo three times) don't spam.
+ * same PEP push/catch-up fetch we use to detect new posts is also how we learn about edits —
+ * but the catch-up fetch (triggered on every contact-presence change or account reconnect,
+ * see {@code BlogManager.fetchAndNotify}) re-delivers a contact's ENTIRE post list every time,
+ * not just what's new. An earlier version of this tracker only recorded "have we notified
+ * about this itemId, and how long ago" — with no check that the content had actually changed,
+ * so any post untouched for {@link #RENOTIFY_THROTTLE_MS} would get flagged as "updated" again
+ * on the next presence toggle, even though nothing changed. The content-version comparison
+ * below is the real gate; the throttle only suppresses re-notifying the *same* genuine edit if
+ * it gets redelivered by multiple catch-up polls in quick succession.
  */
 public class BlogNotifiedTracker {
 
     private static final String PREFS_NAME = "jabjab_blog_notified";
-    private static final String KEY_PREFIX = "notified_at_";
+    private static final String KEY_VERSION_PREFIX = "version_";
+    private static final String KEY_NOTIFIED_AT_PREFIX = "notified_at_";
     private static final long RENOTIFY_THROTTLE_MS = 15L * 60L * 1000L; // 15 minutes
 
     private static BlogNotifiedTracker instance;
@@ -38,22 +44,35 @@ public class BlogNotifiedTracker {
         return instance;
     }
 
-    /** True the first time we see itemId, and again once RENOTIFY_THROTTLE_MS has passed
-     * since the last notification for it — covers both a genuinely new post and an edit
-     * that arrives long enough after the last notification to be worth surfacing again. */
-    public boolean shouldNotify(final String itemId) {
-        final long lastNotifiedAt = prefs.getLong(KEY_PREFIX + itemId, 0L);
-        return lastNotifiedAt == 0L
-                || System.currentTimeMillis() - lastNotifiedAt >= RENOTIFY_THROTTLE_MS;
+    /**
+     * True the first time we see itemId, or if contentVersion differs from the last version
+     * we notified about AND the throttle window has elapsed since then. False for a genuinely
+     * unchanged post (contentVersion matches what's stored), no matter how much time passed.
+     */
+    public boolean shouldNotify(final String itemId, final String contentVersion) {
+        final String storedVersion = prefs.getString(KEY_VERSION_PREFIX + itemId, null);
+        if (storedVersion == null) {
+            return true; // never notified about this itemId before
+        }
+        if (contentVersion != null && contentVersion.equals(storedVersion)) {
+            return false; // unchanged — this is just a re-delivery, not an edit
+        }
+        final long lastNotifiedAt = prefs.getLong(KEY_NOTIFIED_AT_PREFIX + itemId, 0L);
+        return System.currentTimeMillis() - lastNotifiedAt >= RENOTIFY_THROTTLE_MS;
     }
 
     /** True if we've notified about this itemId before — distinguishes "new post" from
-     * "edited post" wording when {@link #shouldNotify} allows a re-notification through. */
+     * "edited post" wording when {@link #shouldNotify} allows a notification through. */
     public boolean wasAlreadyNotifiedBefore(final String itemId) {
-        return prefs.getLong(KEY_PREFIX + itemId, 0L) != 0L;
+        return prefs.contains(KEY_VERSION_PREFIX + itemId);
     }
 
-    public void markNotified(final String itemId) {
-        prefs.edit().putLong(KEY_PREFIX + itemId, System.currentTimeMillis()).apply();
+    public void markNotified(final String itemId, final String contentVersion) {
+        final SharedPreferences.Editor editor = prefs.edit();
+        if (contentVersion != null) {
+            editor.putString(KEY_VERSION_PREFIX + itemId, contentVersion);
+        }
+        editor.putLong(KEY_NOTIFIED_AT_PREFIX + itemId, System.currentTimeMillis());
+        editor.apply();
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
index 15aa8b7..d69776f 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateChecker.java
@@ -5,26 +5,19 @@ import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.content.Context;
 import android.content.Intent;
-import android.net.Uri;
 import android.os.Build;
 import android.util.Log;
 import androidx.core.app.NotificationCompat;
 import androidx.core.app.NotificationManagerCompat;
-import androidx.core.content.FileProvider;
 import okhttp3.OkHttpClient;
 import okhttp3.Request;
 import okhttp3.Response;
-import okhttp3.ResponseBody;
 import org.json.JSONObject;
 import tel.xmpp.jabjab.BuildConfig;
 import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.R;
-import java.io.File;
-import java.io.FileOutputStream;
 import java.io.IOException;
-import java.security.MessageDigest;
 import java.util.concurrent.Executors;
-import java.util.concurrent.atomic.AtomicBoolean;
 
 public class UpdateChecker {
 
@@ -32,15 +25,9 @@ public class UpdateChecker {
 
     private static final String CHANNEL_ID = "app_updates";
     private static final int NOTIFY_UPDATE = 1001;
-    // Reuse the same ID so the "update available" notification morphs into the download progress
-    private static final int NOTIFY_DOWNLOAD = NOTIFY_UPDATE;
 
     public static final String ACTION_CANCEL_DOWNLOAD = "tel.xmpp.jabjab.UPDATE_CANCEL";
 
-    // Guards against multiple concurrent downloads and provides a cancel signal.
-    private static final AtomicBoolean sDownloading = new AtomicBoolean(false);
-    private static volatile boolean sCancelled = false;
-
     public interface ManualCheckCallback {
         void onUpToDate();
         void onUpdateAvailable(int versionCode, String versionName, String changelog, String downloadUrl, long fileSize, String sha256);
@@ -185,168 +172,23 @@ public class UpdateChecker {
         notificationManager.notify(NOTIFY_UPDATE, notification);
     }
 
-    /** Called by the Cancel notification action to abort an in-progress download. */
-    public static void cancelDownload() {
-        sCancelled = true;
-        Log.d(Config.LOGTAG, "UPDATE download cancel requested");
-    }
-
+    /**
+     * Downloads and installs the update. Delegates to {@link tel.xmpp.jabjab.worker.UpdateDownloadWorker}
+     * (a WorkManager foreground worker) rather than running the transfer on a plain background
+     * thread here: a bare thread spawned from a BroadcastReceiver has nothing keeping the app
+     * process alive once onReceive() returns, and Android's background execution limits could —
+     * and per field reports did — kill the process moments later, aborting the download
+     * mid-flight. That looked like "the progress bar rushes to 100% and the notification just
+     * vanishes," while triggering the same download from an on-screen Activity (About page)
+     * worked, because the Activity kept the process alive for the whole transfer.
+     */
     public void downloadAndInstall(
             final String downloadUrl,
             final int versionCode,
             final String versionName,
             final long fileSize,
             final String sha256) {
-        // Ignore taps if a download is already running — prevents concurrent writes to the APK file.
-        if (!sDownloading.compareAndSet(false, true)) {
-            Log.d(Config.LOGTAG, "UPDATE download already in progress, ignoring duplicate request");
-            return;
-        }
-        sCancelled = false;
-        // Show the progress notification immediately, before any network I/O: the
-        // "update available" notification has setAutoCancel(true), so it's dismissed
-        // the instant it's tapped. Without this, there's a visible gap — no
-        // notification at all — until the first HTTP response arrives, which can take
-        // a few seconds and reads as "it just disappeared" rather than "download started."
-        showDownloadProgress(versionCode, versionName, 0);
-        Executors.newSingleThreadExecutor().execute(() -> {
-            try {
-                downloadAndInstallInternal(downloadUrl, versionCode, versionName, fileSize, sha256);
-            } catch (final Exception e) {
-                Log.d(Config.LOGTAG, "UPDATE download failed", e);
-                notificationManager.cancel(NOTIFY_DOWNLOAD);
-            } finally {
-                sDownloading.set(false);
-            }
-        });
-    }
-
-    private void downloadAndInstallInternal(
-            final String downloadUrl, final int versionCode, final String versionName,
-            final long fileSize, final String sha256) throws Exception {
-        final var apkDir = new File(context.getCacheDir(), "updates");
-        apkDir.mkdirs();
-        final var apkFile = new File(apkDir, "jabjab-" + versionName + ".apk");
-
-        // Resume: if a partial file exists, ask the server to continue from where we left off.
-        final long existingBytes = apkFile.exists() ? apkFile.length() : 0;
-        final Request.Builder requestBuilder = new Request.Builder()
-                .url(downloadUrl)
-                .header("User-Agent", "JabJab/" + BuildConfig.VERSION_NAME);
-        if (existingBytes > 0) {
-            requestBuilder.header("Range", "bytes=" + existingBytes + "-");
-            Log.d(Config.LOGTAG, "UPDATE resuming download from byte " + existingBytes);
-        }
-
-        final Response response = client.newCall(requestBuilder.build()).execute();
-        // 206 = partial content (resume accepted), 200 = server ignored Range (start over)
-        if (response.code() == 200 && existingBytes > 0) {
-            // Server doesn't support range requests — discard the partial file and start fresh
-            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;
-        }
-
-        final boolean appending = response.code() == 206;
-        boolean completed = false;
-        try (final ResponseBody body = response.body();
-                final FileOutputStream fos = new FileOutputStream(apkFile, appending)) {
-            final byte[] buffer = new byte[8192];
-            long downloaded = existingBytes;
-            long lastNotify = 0;
-            while (!sCancelled) {
-                final int read = body.byteStream().read(buffer);
-                if (read == -1) { completed = true; break; }
-                fos.write(buffer, 0, read);
-                downloaded += read;
-                final long now = System.currentTimeMillis();
-                if (now - lastNotify > 500) {
-                    lastNotify = now;
-                    final int progress = fileSize > 0 ? (int) (downloaded * 100 / fileSize) : 0;
-                    showDownloadProgress(versionCode, versionName, progress);
-                }
-            }
-        }
-
-        if (sCancelled) {
-            Log.d(Config.LOGTAG, "UPDATE download cancelled by user");
-            apkFile.delete();
-            notificationManager.cancel(NOTIFY_DOWNLOAD);
-            return;
-        }
-
-        if (!completed) return;
-
-        // Verify SHA-256 if the server provided one
-        if (!sha256.isEmpty()) {
-            final String actual = sha256Of(apkFile);
-            if (!sha256.equalsIgnoreCase(actual)) {
-                Log.e(Config.LOGTAG, "UPDATE hash mismatch — expected " + sha256 + " got " + actual);
-                apkFile.delete();
-                notificationManager.cancel(NOTIFY_DOWNLOAD);
-                return;
-            }
-            Log.d(Config.LOGTAG, "UPDATE SHA-256 verified OK");
-        }
-
-        notificationManager.cancel(NOTIFY_DOWNLOAD);
-        installApk(apkFile);
-    }
-
-    private static String sha256Of(final File file) throws Exception {
-        final MessageDigest md = MessageDigest.getInstance("SHA-256");
-        final byte[] buf = new byte[65536];
-        try (final java.io.FileInputStream fis = new java.io.FileInputStream(file)) {
-            int n;
-            while ((n = fis.read(buf)) != -1) md.update(buf, 0, n);
-        }
-        final StringBuilder sb = new StringBuilder();
-        for (final byte b : md.digest()) sb.append(String.format("%02x", b));
-        return sb.toString();
-    }
-
-    private void showDownloadProgress(final int versionCode, final String versionName, final int progress) {
-        final var cancelIntent = new Intent(context, UpdateDownloadReceiver.class);
-        cancelIntent.setAction(ACTION_CANCEL_DOWNLOAD);
-        final var cancelPending = PendingIntent.getBroadcast(
-                context, 0, cancelIntent,
-                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
-        final var notification =
-                new NotificationCompat.Builder(context, CHANNEL_ID)
-                        .setSmallIcon(android.R.drawable.stat_sys_download)
-                        .setContentTitle(context.getString(R.string.update_available_with_code, versionName, versionCode))
-                        .setContentText(context.getString(R.string.update_downloading_progress, progress))
-                        .setProgress(100, progress, false)
-                        .setOngoing(true)
-                        .setSilent(true)
-                        .addAction(android.R.drawable.ic_menu_close_clear_cancel,
-                                context.getString(R.string.update_cancel), cancelPending)
-                        .build();
-        notificationManager.notify(NOTIFY_DOWNLOAD, notification);
-    }
-
-    private void installApk(final File apkFile) {
-        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O
-                && !context.getPackageManager().canRequestPackageInstalls()) {
-            // User hasn't granted "Install unknown apps" for JabJab — send them to settings
-            final Intent settings = new Intent(
-                    android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
-                    Uri.parse("package:" + context.getPackageName()));
-            settings.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-            context.startActivity(settings);
-            return;
-        }
-        final Uri apkUri =
-                FileProvider.getUriForFile(
-                        context,
-                        context.getString(R.string.applicationId) + ".files",
-                        apkFile);
-        final Intent intent = new Intent(Intent.ACTION_VIEW);
-        intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
-        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
-        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-        context.startActivity(intent);
+        tel.xmpp.jabjab.worker.UpdateDownloadWorker.enqueue(
+                context, downloadUrl, versionCode, versionName, fileSize, sha256);
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateDownloadReceiver.java b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateDownloadReceiver.java
index 66a84af..9956a91 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/UpdateDownloadReceiver.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/UpdateDownloadReceiver.java
@@ -3,13 +3,14 @@ package tel.xmpp.jabjab.ui.util;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
+import tel.xmpp.jabjab.worker.UpdateDownloadWorker;
 
 public class UpdateDownloadReceiver extends BroadcastReceiver {
 
     @Override
     public void onReceive(final Context context, final Intent intent) {
         if (UpdateChecker.ACTION_CANCEL_DOWNLOAD.equals(intent.getAction())) {
-            UpdateChecker.cancelDownload();
+            UpdateDownloadWorker.cancel(context);
             return;
         }
         final String downloadUrl = intent.getStringExtra("download_url");
diff --git a/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java b/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
new file mode 100644
index 0000000..45d0f58
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
@@ -0,0 +1,274 @@
+package tel.xmpp.jabjab.worker;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.net.Uri;
+import android.os.Build;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.core.content.FileProvider;
+import androidx.work.Data;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.ForegroundInfo;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkManager;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import tel.xmpp.jabjab.BuildConfig;
+import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.R;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.security.MessageDigest;
+
+/**
+ * Downloads and installs an app update. Runs as a proper WorkManager foreground worker
+ * (not a plain background thread) because the original implementation spawned its download
+ * thread from a BroadcastReceiver with nothing keeping the process alive afterward — Android's
+ * background execution limits could (and, per field reports, did) kill the process moments
+ * after onReceive() returned, aborting the download mid-flight. This looked like "the progress
+ * bar rushes to the end and the notification just vanishes" because the last couple of queued
+ * progress notifications could still flush out right before the kill. Tapping "check for
+ * updates" from the About screen worked fine because that keeps an Activity (and therefore the
+ * process) alive for the whole download. setForegroundAsync() below is what actually fixes it —
+ * mirrors the existing ExportBackupWorker pattern already used for the same reason.
+ */
+public class UpdateDownloadWorker extends Worker {
+
+    public static final String UNIQUE_WORK_NAME = "update_download";
+    private static final String CHANNEL_ID = "app_updates";
+    private static final int NOTIFICATION_ID = 1001;
+
+    private static final String KEY_DOWNLOAD_URL = "download_url";
+    private static final String KEY_VERSION_CODE = "version_code";
+    private static final String KEY_VERSION_NAME = "version_name";
+    private static final String KEY_FILE_SIZE = "file_size";
+    private static final String KEY_SHA256 = "sha256";
+
+    private final OkHttpClient client = new OkHttpClient();
+
+    public UpdateDownloadWorker(
+            @NonNull final Context context, @NonNull final WorkerParameters workerParams) {
+        super(context, workerParams);
+    }
+
+    public static void enqueue(
+            final Context context,
+            final String downloadUrl,
+            final int versionCode,
+            final String versionName,
+            final long fileSize,
+            final String sha256) {
+        final Data input =
+                new Data.Builder()
+                        .putString(KEY_DOWNLOAD_URL, downloadUrl)
+                        .putInt(KEY_VERSION_CODE, versionCode)
+                        .putString(KEY_VERSION_NAME, versionName)
+                        .putLong(KEY_FILE_SIZE, fileSize)
+                        .putString(KEY_SHA256, sha256 == null ? "" : sha256)
+                        .build();
+        final OneTimeWorkRequest request =
+                new OneTimeWorkRequest.Builder(UpdateDownloadWorker.class)
+                        .setInputData(input)
+                        .build();
+        // KEEP, not REPLACE: a second tap while a download is already running should be a
+        // no-op, not restart the transfer from scratch.
+        WorkManager.getInstance(context)
+                .enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.KEEP, request);
+    }
+
+    public static void cancel(final Context context) {
+        Log.d(Config.LOGTAG, "UPDATE download cancel requested");
+        WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_WORK_NAME);
+    }
+
+    @NonNull
+    @Override
+    public Result doWork() {
+        final Data input = getInputData();
+        final String downloadUrl = input.getString(KEY_DOWNLOAD_URL);
+        final String versionName = input.getString(KEY_VERSION_NAME);
+        final int versionCode = input.getInt(KEY_VERSION_CODE, 0);
+        final long fileSize = input.getLong(KEY_FILE_SIZE, 0);
+        final String sha256 = input.getString(KEY_SHA256);
+        if (downloadUrl == null || versionName == null) {
+            return Result.failure();
+        }
+        ensureNotificationChannel();
+        setForegroundAsync(getForegroundInfo(versionCode, versionName, 0));
+        try {
+            final File apkFile = download(downloadUrl, versionCode, versionName, fileSize);
+            if (apkFile == null) {
+                // Cancelled (isStopped()) or the transfer didn't complete — already cleaned up.
+                return Result.success();
+            }
+            if (sha256 != null && !sha256.isEmpty()) {
+                final String actual = sha256Of(apkFile);
+                if (!sha256.equalsIgnoreCase(actual)) {
+                    Log.e(Config.LOGTAG, "UPDATE hash mismatch — expected " + sha256 + " got " + actual);
+                    apkFile.delete();
+                    cancelNotification();
+                    return Result.failure();
+                }
+                Log.d(Config.LOGTAG, "UPDATE SHA-256 verified OK");
+            }
+            cancelNotification();
+            installApk(apkFile);
+            return Result.success();
+        } catch (final Exception e) {
+            Log.d(Config.LOGTAG, "UPDATE download failed", e);
+            cancelNotification();
+            return Result.failure();
+        }
+    }
+
+    /** Returns null if cancelled or the response was unusable — caller treats that as a
+     * clean stop, not a failure worth retrying. */
+    private File download(
+            final String downloadUrl, final int versionCode, final String versionName,
+            final long fileSize) throws Exception {
+        final Context context = getApplicationContext();
+        final var apkDir = new File(context.getCacheDir(), "updates");
+        apkDir.mkdirs();
+        final var apkFile = new File(apkDir, "jabjab-" + versionName + ".apk");
+
+        // Resume: if a partial file exists (e.g. a previous attempt got interrupted before this
+        // fix), ask the server to continue from where we left off.
+        final long existingBytes = apkFile.exists() ? apkFile.length() : 0;
+        final Request.Builder requestBuilder =
+                new Request.Builder()
+                        .url(downloadUrl)
+                        .header("User-Agent", "JabJab/" + BuildConfig.VERSION_NAME);
+        if (existingBytes > 0) {
+            requestBuilder.header("Range", "bytes=" + existingBytes + "-");
+            Log.d(Config.LOGTAG, "UPDATE resuming download from byte " + existingBytes);
+        }
+
+        final Response response = client.newCall(requestBuilder.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;
+        }
+
+        final boolean appending = response.code() == 206;
+        boolean completed = false;
+        try (final ResponseBody body = response.body();
+                final FileOutputStream fos = new FileOutputStream(apkFile, appending)) {
+            final byte[] buffer = new byte[8192];
+            long downloaded = existingBytes;
+            long lastNotify = 0;
+            while (!isStopped()) {
+                final int read = body.byteStream().read(buffer);
+                if (read == -1) {
+                    completed = true;
+                    break;
+                }
+                fos.write(buffer, 0, read);
+                downloaded += read;
+                final long now = System.currentTimeMillis();
+                if (now - lastNotify > 500) {
+                    lastNotify = now;
+                    final int progress = fileSize > 0 ? (int) (downloaded * 100 / fileSize) : 0;
+                    setForegroundAsync(getForegroundInfo(versionCode, versionName, progress));
+                }
+            }
+        }
+
+        if (isStopped()) {
+            Log.d(Config.LOGTAG, "UPDATE download cancelled by user");
+            apkFile.delete();
+            cancelNotification();
+            return null;
+        }
+        return completed ? apkFile : null;
+    }
+
+    private static String sha256Of(final File file) throws Exception {
+        final MessageDigest md = MessageDigest.getInstance("SHA-256");
+        final byte[] buf = new byte[65536];
+        try (final java.io.FileInputStream fis = new java.io.FileInputStream(file)) {
+            int n;
+            while ((n = fis.read(buf)) != -1) md.update(buf, 0, n);
+        }
+        final StringBuilder sb = new StringBuilder();
+        for (final byte b : md.digest()) sb.append(String.format("%02x", b));
+        return sb.toString();
+    }
+
+    private void installApk(final File apkFile) {
+        final Context context = getApplicationContext();
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+                && !context.getPackageManager().canRequestPackageInstalls()) {
+            final Intent settings =
+                    new Intent(
+                            android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
+                            Uri.parse("package:" + context.getPackageName()));
+            settings.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+            context.startActivity(settings);
+            return;
+        }
+        final Uri apkUri =
+                FileProvider.getUriForFile(
+                        context, context.getString(R.string.applicationId) + ".files", apkFile);
+        final Intent intent = new Intent(Intent.ACTION_VIEW);
+        intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
+        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        context.startActivity(intent);
+    }
+
+    private void ensureNotificationChannel() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            final var channel =
+                    new NotificationChannel(
+                            CHANNEL_ID,
+                            getApplicationContext().getString(R.string.update_channel_name),
+                            NotificationManager.IMPORTANCE_DEFAULT);
+            final var nm = getApplicationContext().getSystemService(NotificationManager.class);
+            if (nm != null) nm.createNotificationChannel(channel);
+        }
+    }
+
+    private void cancelNotification() {
+        final var nm = getApplicationContext().getSystemService(NotificationManager.class);
+        if (nm != null) nm.cancel(NOTIFICATION_ID);
+    }
+
+    private ForegroundInfo getForegroundInfo(
+            final int versionCode, final String versionName, final int progress) {
+        final Context context = getApplicationContext();
+        final var cancelPending = WorkManager.getInstance(context).createCancelPendingIntent(getId());
+        final var notification =
+                new NotificationCompat.Builder(context, CHANNEL_ID)
+                        .setSmallIcon(android.R.drawable.stat_sys_download)
+                        .setContentTitle(
+                                context.getString(
+                                        R.string.update_available_with_code, versionName, versionCode))
+                        .setContentText(context.getString(R.string.update_downloading_progress, progress))
+                        .setProgress(100, progress, false)
+                        .setOngoing(true)
+                        .setSilent(true)
+                        .addAction(
+                                android.R.drawable.ic_menu_close_clear_cancel,
+                                context.getString(R.string.update_cancel),
+                                cancelPending)
+                        .build();
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            return new ForegroundInfo(
+                    NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
+        }
+        return new ForegroundInfo(NOTIFICATION_ID, notification);
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java
index 7e8d673..1c0527f 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/BlogManager.java
@@ -80,18 +80,24 @@ public class BlogManager extends AbstractManager {
                         service.getApplicationContext());
         for (final Map.Entry<String, Entry> e : entryMap.entrySet()) {
             final String itemId = e.getKey();
-            if (!tracker.shouldNotify(itemId)) {
+            final Entry entry = e.getValue();
+            // updated changes on every real edit; published never changes, so it's the right
+            // fallback for posts that have never been edited (updated absent) — either way,
+            // an unchanged post keeps reporting the same version string forever.
+            final String contentVersion =
+                    entry.getUpdated() != null ? entry.getUpdated() : entry.getPublished();
+            if (!tracker.shouldNotify(itemId, contentVersion)) {
                 Log.d(Config.LOGTAG, "BLOG notify: skipping " + itemId
-                        + " — notified within the last hour");
+                        + " — unchanged or within re-notify throttle");
                 continue;
             }
             final boolean isEdit = tracker.wasAlreadyNotifiedBefore(itemId);
-            tracker.markNotified(itemId);
+            tracker.markNotified(itemId, contentVersion);
             Log.d(Config.LOGTAG, "BLOG notify: pushing " + (isEdit ? "edit" : "new post")
                     + " notification for " + itemId + " from " + from
-                    + " title=" + e.getValue().getTitle());
+                    + " title=" + entry.getTitle());
             service.getNotificationService()
-                    .pushNewBlogPost(getAccount(), from, itemId, e.getValue().getTitle(), isEdit);
+                    .pushNewBlogPost(getAccount(), from, itemId, entry.getTitle(), isEdit);
         }
     }
 

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.