🔀 Commit

Update via 'About' to fix Updater issue. Still should be finally fixed with this update
Commit888108696bcb11c60ff7aa2676c987350a9cca89
AuthorJabJab <noreply@xmpp.tel>
Date2026-07-22
Parentd2d99a02
commit 888108696bcb11c60ff7aa2676c987350a9cca89
Author: JabJab <noreply@xmpp.tel>
Date:   Wed Jul 22 16:20:08 2026 +0300

    Update via 'About' to fix Updater issue. Still should be finally fixed with this update
---
 build.gradle                                       |   4 +-
 .../xmpp/jabjab/worker/UpdateDownloadWorker.java   | 109 +++++++++++++++++----
 src/main/res/values/strings.xml                    |   3 +
 3 files changed, 96 insertions(+), 20 deletions(-)

diff --git a/build.gradle b/build.gradle
index 8993725..99af5c2 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,8 +113,8 @@ android {
 
     defaultConfig {
         minSdkVersion 23
-        versionCode 42298
-        versionName "1.0.1"
+        versionCode 42299
+        versionName "1.0.2"
         applicationId "tel.xmpp.jabjab"
         resValue "string", "applicationId", applicationId
         def appName = "JabJab"
diff --git a/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java b/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
index 45d0f58..a591b27 100644
--- a/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
+++ b/src/main/java/tel/xmpp/jabjab/worker/UpdateDownloadWorker.java
@@ -106,24 +106,36 @@ public class UpdateDownloadWorker extends Worker {
         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()) {
+            // Several attempts, not just one retry: the cache-filename fix above (now unique
+            // per versionCode) removes the specific stale-cross-build-collision cause, but a
+            // hash mismatch could still happen from plain network flakiness on the fresh
+            // download itself — a few extra attempts is cheap insurance against that, rather
+            // than leaving the user stuck needing to notice and retry manually via About.
+            final int maxAttempts = 4;
+            for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+                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()) {
+                    cancelNotification();
+                    installApk(apkFile);
+                    return Result.success();
+                }
                 final String actual = sha256Of(apkFile);
-                if (!sha256.equalsIgnoreCase(actual)) {
-                    Log.e(Config.LOGTAG, "UPDATE hash mismatch — expected " + sha256 + " got " + actual);
-                    apkFile.delete();
+                if (sha256.equalsIgnoreCase(actual)) {
+                    Log.d(Config.LOGTAG, "UPDATE SHA-256 verified OK");
                     cancelNotification();
-                    return Result.failure();
+                    installApk(apkFile);
+                    return Result.success();
                 }
-                Log.d(Config.LOGTAG, "UPDATE SHA-256 verified OK");
+                Log.e(Config.LOGTAG, "UPDATE hash mismatch (attempt " + attempt + "/" + maxAttempts
+                        + ") — expected " + sha256 + " got " + actual);
+                apkFile.delete();
             }
             cancelNotification();
-            installApk(apkFile);
-            return Result.success();
+            return Result.failure();
         } catch (final Exception e) {
             Log.d(Config.LOGTAG, "UPDATE download failed", e);
             cancelNotification();
@@ -131,6 +143,21 @@ public class UpdateDownloadWorker extends Worker {
         }
     }
 
+    /** 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
+     * accumulate in cache indefinitely across every rebuild during active development. */
+    private void purgeOtherCachedApks(final File apkDir, final int keepVersionCode) {
+        final File[] files = apkDir.listFiles();
+        if (files == null) return;
+        final String keepSuffix = "-" + keepVersionCode + ".apk";
+        for (final File f : files) {
+            if (!f.getName().endsWith(keepSuffix)) {
+                f.delete();
+            }
+        }
+    }
+
     /** Returns null if cancelled or the response was unusable — caller treats that as a
      * clean stop, not a failure worth retrying. */
     private File download(
@@ -139,10 +166,19 @@ public class UpdateDownloadWorker extends Worker {
         final Context context = getApplicationContext();
         final var apkDir = new File(context.getCacheDir(), "updates");
         apkDir.mkdirs();
-        final var apkFile = new File(apkDir, "jabjab-" + versionName + ".apk");
+        // Keyed on versionCode, not just versionName: during active development the same
+        // versionName ("1.0.1") gets reused across many different versionCodes/rebuilds. A
+        // filename based on versionName alone means a stale cache file left over from a
+        // *completely different build* sharing that name gets wrongly "resumed" against
+        // whatever new build is being downloaded next — producing a corrupted file that
+        // matches neither hash. This isn't about an interrupted mid-download at all; the stale
+        // file can have been sitting there indefinitely. Including versionCode makes the
+        // filename unique per actual build, so it can never collide with a different one again.
+        purgeOtherCachedApks(apkDir, versionCode);
+        final var apkFile = new File(apkDir, "jabjab-" + versionName + "-" + versionCode + ".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.
+        // 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;
         final Request.Builder requestBuilder =
                 new Request.Builder()
@@ -207,6 +243,17 @@ public class UpdateDownloadWorker extends Worker {
         return sb.toString();
     }
 
+    /**
+     * Doesn't call startActivity() directly — since Android 10, starting an Activity from a
+     * background context (this Worker, even running as a WorkManager foreground service) is
+     * routinely blocked by the platform's background-activity-launch restrictions unless it
+     * happens as a direct result of a user tap, often silently with no visible error. That's
+     * exactly the "downloads fine, installer just never appears" symptom this fixes: previously
+     * this only ever ran from the About screen's Activity context (already foregrounded by a
+     * user action), which is why it used to work there. Showing a notification instead — whose
+     * PendingIntent fires the install Intent when tapped — guarantees the launch happens as a
+     * direct user gesture, which is always allowed.
+     */
     private void installApk(final File apkFile) {
         final Context context = getApplicationContext();
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
@@ -216,7 +263,12 @@ public class UpdateDownloadWorker extends Worker {
                             android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
                             Uri.parse("package:" + context.getPackageName()));
             settings.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-            context.startActivity(settings);
+            final PendingIntent pendingSettings =
+                    PendingIntent.getActivity(
+                            context, 0, settings,
+                            PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+            showTapToProceedNotification(
+                    pendingSettings, context.getString(R.string.update_needs_install_permission));
             return;
         }
         final Uri apkUri =
@@ -226,7 +278,28 @@ public class UpdateDownloadWorker extends Worker {
         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);
+        final PendingIntent pendingInstall =
+                PendingIntent.getActivity(
+                        context, 0, intent,
+                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+        showTapToProceedNotification(
+                pendingInstall, context.getString(R.string.update_ready_to_install));
+    }
+
+    private void showTapToProceedNotification(final PendingIntent pendingIntent, final String text) {
+        final Context context = getApplicationContext();
+        final var notification =
+                new NotificationCompat.Builder(context, CHANNEL_ID)
+                        .setSmallIcon(android.R.drawable.stat_sys_download_done)
+                        .setContentTitle(context.getString(R.string.update_download_complete))
+                        .setContentText(text)
+                        .setAutoCancel(true)
+                        .setContentIntent(pendingIntent)
+                        .build();
+        final var nm = context.getSystemService(NotificationManager.class);
+        if (nm != null) {
+            nm.notify(NOTIFICATION_ID + 1, notification);
+        }
     }
 
     private void ensureNotificationChannel() {
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index d94ed7b..0374b52 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -1161,6 +1161,9 @@
     <string name="update_download">Download</string>
     <string name="update_downloading_progress">Downloading… %d%%</string>
     <string name="update_cancel">Cancel</string>
+    <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="check_for_updates">Check for updates</string>
 
     <!-- Setup wizard -->

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.