From 2b3bc4a6030eddadf52d5bda72632ce1e33829b3 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Fri, 17 Jul 2026 10:27:17 +0200 Subject: [PATCH 1/3] app: make notifications survive targetSdk 36 Three separate breakages in sendSystemNotification(), the single sink for every notification the app posts. All three are inert at targetSdk 25, so this lands now and the target bump stays a one-variable change. The PendingIntent is the severe one: a bare 0 flag throws from API 31 on, and one of the three callers is RunnerFragment.onDestroy, so the app would crash on every aborted crawl rather than merely lose a notification. The extras are read back only by us, so FLAG_IMMUTABLE is the right one. Lint has been reporting this as UnspecifiedImmutableFlag all along; it went unnoticed because abortOnError is false. The channel is mandatory from API 26 on, and without it the system drops the notification with nothing but a log line. NotificationChannelCompat registers it with no version gate. POST_NOTIFICATIONS is enforced by the OS from Android 13, but targeting 33+ also removes the automatic prompt, so an explicit request is the only way to get anything at all. It is asked at crawl start rather than at launch because two refusals make it permanent, and a prompt at launch has nothing to point at yet. setContentInfo() went with the rewrite: it has been ignored since API 24. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Xavier Roche --- app/src/main/AndroidManifest.xml | 3 + .../com/httrack/android/HTTrackActivity.java | 64 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 53a8be9..f4afb1c 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + + diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java index a9e5c56..dd150b4 100755 --- a/app/src/main/java/com/httrack/android/HTTrackActivity.java +++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java @@ -90,7 +90,9 @@ import android.widget.Toast; import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationChannelCompat; import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import androidx.fragment.app.Fragment; @@ -126,6 +128,10 @@ public class HTTrackActivity extends FragmentActivity { // ".nomedia" ; prevents media scanner from reading media files public static final String NOMEDIA_FILE = ".nomedia"; + // Channel carrying every notification we post. Never rename: the user's sound/importance + // choices are keyed on it, and a new id silently resets them. + protected static final String NOTIFICATION_CHANNEL_ID = "mirror"; + // Running instances of HTTrack (based on winprofile.ini path) protected static final HashSet runningInstances = new HashSet(); @@ -413,6 +419,7 @@ protected void ensureInternetIsAvailable() { */ private static final int REQUEST_WRITE_STORAGE = 1; private static final int REQUEST_INTERNET = 2; + private static final int REQUEST_POST_NOTIFICATIONS = 3; protected void ensureMediaIsAllowedHardPermissions() { final boolean hasPermissionStorage = (ContextCompat.checkSelfPermission(this, @@ -427,6 +434,26 @@ protected void ensureMediaIsAllowedHardPermissions() { } } + /* + * Only a runtime permission from Android 13 on; below that the manifest entry is enough. + * Targeting 33+ also drops the automatic prompt, so without this the app posts nothing. + */ + protected void ensureNotificationsAreAllowed() { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + return; + } + final boolean hasPermissionNotify = (ContextCompat.checkSelfPermission(this, + Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED); + if (!hasPermissionNotify) { + Log.d("httrack", "Requesting permission to post notifications"); + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + REQUEST_POST_NOTIFICATIONS); + } else { + Log.d("httrack", "Posting notifications is allowed"); + } + } + /* * Callback to receive ensureMediaIsAllowedHardPermissions() permission feedback. */ @@ -450,6 +477,14 @@ public void onRequestPermissionsResult(int requestCode, String[] permissions, in } } break; + // No Toast unlike the two above: a refusal costs only the end-of-mirror notice. + case REQUEST_POST_NOTIFICATIONS: { + final boolean granted = grantResults.length != 0 + && grantResults[0] == PackageManager.PERMISSION_GRANTED; + Log.d("httrack", "Permission to post notifications is " + + (granted ? "granted" : "denied")); + } + break; } } @@ -515,6 +550,9 @@ protected void onCreate(final Bundle savedInstanceState) { throw new RuntimeException(e); } + // Register before anything can post: a notification sent to an unknown channel is dropped. + createNotificationChannel(); + // Compute target directory on external storage ensureExternalStorage(); @@ -1849,6 +1887,8 @@ public void beforeTextChanged(final CharSequence s, final int start, break; case R.layout.activity_mirror_progress: setProgressLinesInternal(new String[] { getString(R.string.starting_worker_thread) }); + // Asked at crawl start, not at launch: a refusal is permanent after two of them. + ensureNotificationsAreAllowed(); startRunner(); if (runner != null) { ProgressBar.class.cast(findViewById(R.id.progressMirror)) @@ -2669,6 +2709,17 @@ protected Intent getCurrentIntentReference() { return intent; } + /** + * Register the channel every notification below is posted to. Mandatory from API 26 on, + * where a channel-less notification is dropped with nothing but a log line; a no-op before. + */ + protected void createNotificationChannel() { + final NotificationChannelCompat channel = new NotificationChannelCompat.Builder( + NOTIFICATION_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(getString(R.string.app_name)).build(); + NotificationManagerCompat.from(this).createNotificationChannel(channel); + } + /** Send a notification. **/ protected void sendAbortNotification() { final String title = getString(R.string.mirror_xxx_stopped).replace("%s", @@ -2693,12 +2744,13 @@ protected void sendSystemNotification(final Intent intent, // Create notification final long when = System.currentTimeMillis(); - final PendingIntent pintent = PendingIntent.getActivity(this, 0, intent, 0); - final Notification notification = new NotificationCompat.Builder(this) - .setContentTitle(title).setContentText(text).setTicker(title) - .setSmallIcon(R.drawable.ic_launcher).setWhen(when) - .setContentInfo(getString(R.string.start)).setContentIntent(pintent) - .setAutoCancel(true).build(); + // FLAG_IMMUTABLE: mandatory from API 31 on, and the extras are only ever read back by us. + final PendingIntent pintent = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_IMMUTABLE); + final Notification notification = new NotificationCompat.Builder(this, + NOTIFICATION_CHANNEL_ID).setContentTitle(title).setContentText(text) + .setTicker(title).setSmallIcon(R.drawable.ic_launcher).setWhen(when) + .setContentIntent(pintent).setAutoCancel(true).build(); // Send final NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); From de1b588bebd850b41b4cb26305a4ac84d4582b6c Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Fri, 17 Jul 2026 10:36:54 +0200 Subject: [PATCH 2/3] app: ask for POST_NOTIFICATIONS once, and register the channel at post time Review of the first commit found the request could fire twice. The activity has no configChanges, so a rotation mid-crawl restores pane_id, re-enters the progress pane and re-asks; a user who declined once and reflexively declines the repeat has denied POST_NOTIFICATIONS for good. A field would die with the instance like pane_id does, so the latch lives in the existing preferences file. Creating the channel in onCreate was also wrong in a subtler way. androidx branches on the device's SDK_INT, never on targetSdk, so the channel really was created on every launch of every API 26+ device -- and for an app targeting 32 or lower, creating a channel is precisely what makes the system show its own permission dialog. Startup is the worst moment to ask, being the one point where there is no mirror to announce. Registering it in sendSystemNotification() instead keeps it ahead of every post, including the save-failure one that can fire without any crawl. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Xavier Roche --- .../com/httrack/android/HTTrackActivity.java | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java index dd150b4..0f30790 100755 --- a/app/src/main/java/com/httrack/android/HTTrackActivity.java +++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java @@ -120,6 +120,9 @@ public class HTTrackActivity extends FragmentActivity { // Preferences protected static final String PREFS_NAME = "HTTrackPreferences"; protected static final String BASE_NAME = "BasePath"; + // Whether POST_NOTIFICATIONS was ever asked for. Has to outlive the activity: pane_id is + // restored on rotation, and a second refusal is the one that sticks for good. + protected static final String NOTIFY_ASKED_NAME = "NotificationPermissionAsked"; //
Pattern protected static final Pattern brHtmlPattern = Pattern.compile(Pattern @@ -442,16 +445,22 @@ protected void ensureNotificationsAreAllowed() { if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { return; } - final boolean hasPermissionNotify = (ContextCompat.checkSelfPermission(this, - Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED); - if (!hasPermissionNotify) { - Log.d("httrack", "Requesting permission to post notifications"); - ActivityCompat.requestPermissions(this, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, - REQUEST_POST_NOTIFICATIONS); - } else { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { Log.d("httrack", "Posting notifications is allowed"); + return; + } + // Once only: the user's refusal stands until they revisit it in the system settings. + final SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0); + if (settings.getBoolean(NOTIFY_ASKED_NAME, false)) { + Log.d("httrack", "Permission to post notifications was already declined"); + return; } + settings.edit().putBoolean(NOTIFY_ASKED_NAME, true).apply(); + Log.d("httrack", "Requesting permission to post notifications"); + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + REQUEST_POST_NOTIFICATIONS); } /* @@ -550,9 +559,6 @@ protected void onCreate(final Bundle savedInstanceState) { throw new RuntimeException(e); } - // Register before anything can post: a notification sent to an unknown channel is dropped. - createNotificationChannel(); - // Compute target directory on external storage ensureExternalStorage(); @@ -2710,8 +2716,10 @@ protected Intent getCurrentIntentReference() { } /** - * Register the channel every notification below is posted to. Mandatory from API 26 on, + * Register the channel the notifications below are posted to. Mandatory from API 26 on, * where a channel-less notification is dropped with nothing but a log line; a no-op before. + * Idempotent, and called at post time rather than at startup: creating a channel is what + * makes the system prompt an app targeting 32 or lower, and launch is too early to ask. */ protected void createNotificationChannel() { final NotificationChannelCompat channel = new NotificationChannelCompat.Builder( @@ -2742,6 +2750,8 @@ protected void sendAbortNotification() { protected void sendSystemNotification(final Intent intent, final CharSequence title, final CharSequence text) { + createNotificationChannel(); + // Create notification final long when = System.currentTimeMillis(); // FLAG_IMMUTABLE: mandatory from API 31 on, and the extras are only ever read back by us. From d55ba3d02db4af5817dd3d510cbc5deab0119fb8 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Sat, 18 Jul 2026 07:05:37 +0200 Subject: [PATCH 3/3] app: latch the notification prompt on a real answer, not on asking Review follow-up. The latch was set just before requestPermissions, so dismissing the dialog without answering -- a tap outside or Back, which Android does not count toward its two-strike lockout -- burned the one chance to ask. It now moves to onRequestPermissionsResult and is set only when the result is non-empty, i.e. an actual grant or deny; a dismissal leaves it clear and the prompt returns on the next crawl. The double-ask this latch exists to stop still cannot happen: a real deny sets it before the next pane entry. The only reopened window is rotating while the dialog is up, whose worst case is one duplicate prompt, never a permanent loss. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Xavier Roche --- .../com/httrack/android/HTTrackActivity.java | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java index 0f30790..e553804 100755 --- a/app/src/main/java/com/httrack/android/HTTrackActivity.java +++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java @@ -450,13 +450,13 @@ protected void ensureNotificationsAreAllowed() { Log.d("httrack", "Posting notifications is allowed"); return; } - // Once only: the user's refusal stands until they revisit it in the system settings. - final SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0); - if (settings.getBoolean(NOTIFY_ASKED_NAME, false)) { - Log.d("httrack", "Permission to post notifications was already declined"); + // Ask once: a refusal stands until the user revisits it in the system settings. The latch + // is set from the result below, not here, so dismissing the dialog (which Android does not + // count as a refusal) leaves it to be offered again rather than silently lost. + if (getSharedPreferences(PREFS_NAME, 0).getBoolean(NOTIFY_ASKED_NAME, false)) { + Log.d("httrack", "Permission to post notifications was already answered"); return; } - settings.edit().putBoolean(NOTIFY_ASKED_NAME, true).apply(); Log.d("httrack", "Requesting permission to post notifications"); ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.POST_NOTIFICATIONS}, @@ -488,10 +488,12 @@ public void onRequestPermissionsResult(int requestCode, String[] permissions, in break; // No Toast unlike the two above: a refusal costs only the end-of-mirror notice. case REQUEST_POST_NOTIFICATIONS: { - final boolean granted = grantResults.length != 0 - && grantResults[0] == PackageManager.PERMISSION_GRANTED; - Log.d("httrack", "Permission to post notifications is " - + (granted ? "granted" : "denied")); + // An empty result is a dismissal, not an answer, so leave the latch clear and re-offer. + if (grantResults.length != 0) { + getSharedPreferences(PREFS_NAME, 0).edit().putBoolean(NOTIFY_ASKED_NAME, true).apply(); + Log.d("httrack", "Permission to post notifications is " + + (grantResults[0] == PackageManager.PERMISSION_GRANTED ? "granted" : "denied")); + } } break; }