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..e553804 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; @@ -118,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 @@ -126,6 +131,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 +422,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 +437,32 @@ 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; + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + Log.d("httrack", "Posting notifications is allowed"); + return; + } + // 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; + } + Log.d("httrack", "Requesting permission to post notifications"); + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + REQUEST_POST_NOTIFICATIONS); + } + /* * Callback to receive ensureMediaIsAllowedHardPermissions() permission feedback. */ @@ -450,6 +486,16 @@ 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: { + // 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; } } @@ -1849,6 +1895,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 +2717,19 @@ protected Intent getCurrentIntentReference() { return intent; } + /** + * 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( + 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", @@ -2691,14 +2752,17 @@ protected void sendAbortNotification() { protected void sendSystemNotification(final Intent intent, final CharSequence title, final CharSequence text) { + createNotificationChannel(); + // 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);