diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 279f5150a..03662d1bc 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -1294,9 +1294,8 @@ class VideoDetailFragment : bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED) } // Rebound to the service if it was closed via notification or mini player - if (!PlayerHolder.isBound) { - PlayerHolder.startService(false, this@VideoDetailFragment) - } + PlayerHolder.setListener(this@VideoDetailFragment) + PlayerHolder.tryBindIfNeeded(requireContext()) } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index cb5cb97fa..b8f07fd71 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -34,6 +34,7 @@ import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi import org.schabi.newpipe.player.notification.NotificationPlayerUi +import org.schabi.newpipe.player.notification.NotificationUtil import org.schabi.newpipe.util.ThemeHelper import java.lang.ref.WeakReference import java.util.function.Consumer @@ -140,23 +141,23 @@ class PlayerService : MediaBrowserServiceCompat() { } } - val p = player - if (Intent.ACTION_MEDIA_BUTTON == intent.action && p?.playQueue == null) { - // No need to process media button's actions if the player is not working, otherwise - // the player service would strangely start with nothing to play - // Stop the service in this case, which will be removed from the foreground and its - // notification cancelled in its destruction - destroyPlayerAndStopService() + if (player == null) { + // No need to process media button's actions or other system intents if the player is + // not running. However, since the current intent might have been issued by the system + // with `startForegroundService()` (for unknown reasons), we need to ensure that we post + // a (dummy) foreground notification, otherwise we'd incur in + // "Context.startForegroundService() did not then call Service.startForeground()". Then + // we stop the service again. + Log.d(TAG, "onStartCommand() got a useless intent, closing the service"); + NotificationUtil.startForegroundWithDummyNotification(this); return START_NOT_STICKY } - if (p != null) { - val oldPlayerType = p.playerType - p.handleIntent(intent) - p.handleIntentPost(oldPlayerType) - p.UIs().get(MediaSessionPlayerUi::class) - ?.handleMediaButtonIntent(intent) - } + val oldPlayerType = player?.playerType + player?.handleIntent(intent) + player?.handleIntentPost(oldPlayerType) + player?.UIs()?.get(MediaSessionPlayerUi::class.java) + ?.handleMediaButtonIntent(intent) return START_NOT_STICKY } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java index a05990816..084336d54 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java @@ -154,9 +154,6 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An notifyAudioSessionUpdate(true, audioSessionId); } private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) { - if (!PlayerHelper.isUsingDSP()) { - return; - } final Intent intent = new Intent(active ? AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION : AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index 3c69ff78b..c335e9b7c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -296,10 +296,6 @@ public final class PlayerHelper { AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION); } - public static boolean isUsingDSP() { - return true; - } - @NonNull public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) { final CaptioningManager captioningManager = ContextCompat.getSystemService(context, diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt index 6d5c2568d..67086c263 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -172,9 +172,11 @@ object PlayerHolder { startPlayerListener() // ^ will call listener.onPlayerConnected() down the line if there is an active player - // notify the main activity that binding the service has completed, so that it can - // open the bottom mini-player - NavigationHelper.sendPlayerStartedEvent(s) + if (playerService != null && playerService?.player != null) { + // notify the main activity that binding the service has completed and that there is + // a player, so that it can open the bottom mini-player + NavigationHelper.sendPlayerStartedEvent(localBinder.service) + } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java index 7964b13cf..64905b86f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java @@ -5,7 +5,9 @@ import static androidx.media.app.NotificationCompat.MediaStyle; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; import android.annotation.SuppressLint; +import android.app.Notification; import android.app.PendingIntent; +import android.content.Context; import android.content.Intent; import android.content.pm.ServiceInfo; import android.graphics.Bitmap; @@ -24,6 +26,7 @@ import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.PlayerIntentType; +import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; import org.schabi.newpipe.util.NavigationHelper; @@ -90,12 +93,9 @@ public final class NotificationUtil { Log.d(TAG, "createNotification()"); } notificationManager = NotificationManagerCompat.from(player.getContext()); - final NotificationCompat.Builder builder = - new NotificationCompat.Builder(player.getContext(), - player.getContext().getString(R.string.notification_channel_id)); - final MediaStyle mediaStyle = new MediaStyle(); // setup media style (compact notification slots and media session) + final MediaStyle mediaStyle = new MediaStyle(); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { // notification actions are ignored on Android 13+, and are replaced by code in // MediaSessionPlayerUi @@ -108,18 +108,9 @@ public final class NotificationUtil { } // setup notification builder - builder.setStyle(mediaStyle) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_TRANSPORT) - .setShowWhen(false) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setColor(ContextCompat.getColor(player.getContext(), - R.color.dark_background_color)) + final var builder = setupNotificationBuilder(player.getContext(), mediaStyle) .setColorized(player.getPrefs().getBoolean( - player.getContext().getString(R.string.notification_colorize_key), true)) - .setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(), - NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false)); + player.getContext().getString(R.string.notification_colorize_key), true)); // set the initial value for the video thumbnail, updatable with updateNotificationThumbnail setLargeIcon(builder); @@ -168,17 +159,17 @@ public final class NotificationUtil { && notificationBuilder.mActions.get(2).actionIntent != null); } + public static void startForegroundWithDummyNotification(final PlayerService service) { + final var builder = setupNotificationBuilder(service, new MediaStyle()); + startForeground(service, builder.build()); + } + public void createNotificationAndStartForeground() { if (notificationBuilder == null) { notificationBuilder = createNotification(); } updateNotification(); - - // ServiceInfo constants are not used below Android Q, so 0 is set here - final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - ? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0; - ServiceCompat.startForeground(player.getService(), NOTIFICATION_ID, - notificationBuilder.build(), serviceType); + startForeground(player.getService(), notificationBuilder.build()); } public void cancelNotificationAndStopForeground() { @@ -192,6 +183,34 @@ public final class NotificationUtil { } + ///////////////////////////////////////////////////// + // STATIC FUNCTIONS IN COMMON BETWEEN DUMMY AND REAL NOTIFICATION + ///////////////////////////////////////////////////// + + private static NotificationCompat.Builder setupNotificationBuilder(final Context context, + final MediaStyle style) { + return new NotificationCompat.Builder(context, + context.getString(R.string.notification_channel_id)) + .setStyle(style) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setShowWhen(false) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setColor(ContextCompat.getColor(context, R.color.dark_background_color)) + .setDeleteIntent(PendingIntentCompat.getBroadcast(context, + NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false)); + } + + private static void startForeground(final PlayerService service, + final Notification notification) { + // ServiceInfo constants are not used below Android Q, so 0 is set here + final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0; + ServiceCompat.startForeground(service, NOTIFICATION_ID, notification, serviceType); + } + + ///////////////////////////////////////////////////// // ACTIONS ///////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index 5daa3ad82..3cc29afee 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -103,12 +103,12 @@ public final class NewPipeSettings { } public static boolean useStorageAccessFramework(final Context context) { - // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a - // remote (see #6455). - if (DeviceUtils.isFireTv()) { - return false; - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return true; + } else if (DeviceUtils.isFireTv()) { + // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with + // a remote (see #6455). + return false; } final String key = context.getString(R.string.storage_use_saf); diff --git a/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java b/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java index 7aff655a0..1ec78ba21 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java @@ -24,7 +24,11 @@ public class SrtFromTtmlWriter { private final boolean ignoreEmptyFrames; private final Charset charset = StandardCharsets.UTF_8; - private int frameIndex = 0; + // According to the SubRip (.srt) specification, subtitle + // numbering must start from 1. + // Some players accept 0 or even negative indices, + // but to ensure compliance we start at 1. + private int frameIndex = 1; public SrtFromTtmlWriter(final SharpStream out, final boolean ignoreEmptyFrames) { this.out = out; @@ -39,7 +43,8 @@ public class SrtFromTtmlWriter { private void writeFrame(final String begin, final String end, final StringBuilder text) throws IOException { - writeString(String.valueOf(frameIndex++)); + writeString(String.valueOf(frameIndex)); + frameIndex += 1; writeString(NEW_LINE); writeString(begin); writeString(" --> "); diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java index 4be5445bc..9f310c2da 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java @@ -6,10 +6,9 @@ import static coil3.Image_androidKt.toBitmap; import android.content.ActivityNotFoundException; import android.content.ClipData; import android.content.ClipboardManager; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; @@ -25,6 +24,7 @@ import androidx.core.content.FileProvider; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; +import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.util.image.ImageStrategy; @@ -67,8 +67,9 @@ public final class ShareUtils { } /** - * Open the url with the system default browser. If no browser is set as default, falls back to - * {@link #openAppChooser(Context, Intent, boolean)}. + * Open the url with the system default browser. If no browser is installed, falls back to + * {@link #openAppChooser(Context, Intent, boolean)} (for displaying that no apps are available + * to handle the action, or possible OEM-related edge cases). *

* This function selects the package to open based on which apps respond to the {@code http://} * schema alone, which should exclude special non-browser apps that are can handle the url (e.g. @@ -82,44 +83,26 @@ public final class ShareUtils { * @param url the url to browse **/ public static void openUrlInBrowser(@NonNull final Context context, final String url) { - // Resolve using a generic http://, so we are sure to get a browser and not e.g. the yt app. + // Target a generic http://, so we are sure to get a browser and not e.g. the yt app. // Note that this requires the `http` schema to be added to `` in the manifest. - final ResolveInfo defaultBrowserInfo; final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://")); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, - PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY)); - } else { - defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, - PackageManager.MATCH_DEFAULT_ONLY); - } final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - if (defaultBrowserInfo == null) { - // No app installed to open a web URL, but it may be handled by other apps so try - // opening a system chooser for the link in this case (it could be bypassed by the - // system if there is only one app which can open the link or a default app associated - // with the link domain on Android 12 and higher) + // See https://stackoverflow.com/a/58801285 and `setSelector` documentation + intent.setSelector(browserIntent); + try { + context.startActivity(intent); + } catch (final ActivityNotFoundException e) { + // No browser is available. This should, in the end, yield a nice AOSP error message + // indicating that no app is available to handle this action. + // + // Note: there are some situations where modified OEM ROMs have apps that appear + // to be browsers but are actually app choosers. If starting the Activity fails + // related to this, opening the system app chooser is still the correct behavior. + intent.setSelector(null); openAppChooser(context, intent, true); - return; - } - - final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName; - - if (defaultBrowserPackage.equals("android")) { - // No browser set as default (doesn't work on some devices) - openAppChooser(context, intent, true); - } else { - try { - intent.setPackage(defaultBrowserPackage); - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // Not a browser but an app chooser because of OEMs changes - intent.setPackage(null); - openAppChooser(context, intent, true); - } } } @@ -195,6 +178,18 @@ public final class ShareUtils { chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with)); } + // Avoid opening in NewPipe + // (Implementation note: if the URL is one for which NewPipe itself + // is set as handler on Android >= 12, we actually remove the only eligible app + // for this link, and browsers will not be offered to the user. For that, use + // `openUrlInBrowser`.) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + chooserIntent.putExtra( + Intent.EXTRA_EXCLUDE_COMPONENTS, + new ComponentName[]{new ComponentName(context, RouterActivity.class)} + ); + } + // Migrate any clip data and flags from the original intent. final int permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index 04930b002..54340ce5d 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -661,7 +661,8 @@ public class DownloadMission extends Mission { * @return {@code true}, if storage is invalid and cannot be used */ public boolean hasInvalidStorage() { - return errCode == ERROR_PROGRESS_LOST || storage == null || !storage.existsAsFile(); + // Don't consider ERROR_PROGRESS_LOST as invalid storage - it can be recovered + return storage == null || !storage.existsAsFile(); } /** diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index d02f77bc1..7a2055aaa 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -24,6 +24,8 @@ import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; +import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; +import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; public class DownloadManager { private static final String TAG = DownloadManager.class.getSimpleName(); @@ -149,12 +151,31 @@ public class DownloadManager { if (sub.getName().equals(".tmp")) continue; DownloadMission mis = Utility.readFromFile(sub); - if (mis == null || mis.isFinished() || mis.hasInvalidStorage()) { + if (mis == null) { //noinspection ResultOfMethodCallIgnored sub.delete(); continue; } + // DON'T delete missions that are truly finished - let them be moved to finished list + if (mis.isFinished()) { + // Move to finished missions instead of deleting + setFinished(mis); + //noinspection ResultOfMethodCallIgnored + sub.delete(); + continue; + } + + // DON'T delete missions with storage issues - try to recover them + if (mis.hasInvalidStorage() && mis.errCode != ERROR_PROGRESS_LOST) { + // Only delete if it's truly unrecoverable (not just progress lost) + if (mis.storage == null) { + //noinspection ResultOfMethodCallIgnored + sub.delete(); + continue; + } + } + mis.threads = new Thread[0]; boolean exists; @@ -163,16 +184,13 @@ public class DownloadManager { exists = !mis.storage.isInvalid() && mis.storage.existsAsFile(); } catch (Exception ex) { Log.e(TAG, "Failed to load the file source of " + mis.storage.toString(), ex); - mis.storage.invalidate(); + // Don't invalidate storage immediately - try to recover first exists = false; } if (mis.isPsRunning()) { if (mis.psAlgorithm.worksOnSameFile) { // Incomplete post-processing results in a corrupted download file - // because the selected algorithm works on the same file to save space. - // the file will be deleted if the storage API - // is Java IO (avoid showing the "Save as..." dialog) if (exists && mis.storage.isDirect() && !mis.storage.delete()) Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); } @@ -181,10 +199,11 @@ public class DownloadManager { mis.errCode = DownloadMission.ERROR_POSTPROCESSING_STOPPED; } else if (!exists) { tryRecover(mis); - - // the progress is lost, reset mission state - if (mis.isInitialized()) - mis.resetState(true, true, DownloadMission.ERROR_PROGRESS_LOST); + // Keep the mission even if recovery fails - don't reset to ERROR_PROGRESS_LOST + // This allows user to see the failed download and potentially retry + if (mis.isInitialized() && mis.errCode == ERROR_NOTHING) { + mis.resetState(true, true, ERROR_PROGRESS_LOST); + } } if (mis.psAlgorithm != null) { @@ -448,7 +467,7 @@ public class DownloadManager { continue; resumeMission(mission); - if (mission.errCode != DownloadMission.ERROR_NOTHING) continue; + if (mission.errCode != ERROR_NOTHING) continue; if (mPrefQueueLimit) return true; flag = true; @@ -512,6 +531,15 @@ public class DownloadManager { } } + public boolean canRecoverMission(DownloadMission mission) { + if (mission == null) return false; + + // Can recover missions with progress lost or storage issues + return mission.errCode == ERROR_PROGRESS_LOST || + mission.storage == null || + !mission.storage.existsAsFile(); + } + public MissionState checkForExistingMission(StoredFileHelper storage) { synchronized (this) { DownloadMission pending = getPendingMission(storage); @@ -584,8 +612,13 @@ public class DownloadManager { ArrayList finished = new ArrayList<>(mMissionsFinished); List remove = new ArrayList<>(hidden); - // hide missions (if required) - remove.removeIf(mission -> pending.remove(mission) || finished.remove(mission)); + // Don't hide recoverable missions + remove.removeIf(mission -> { + if (mission instanceof DownloadMission dm && canRecoverMission(dm)) { + return false; // Don't remove recoverable missions + } + return pending.remove(mission) || finished.remove(mission); + }); int fakeTotal = pending.size(); if (fakeTotal > 0) fakeTotal++; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bea2550ad..86966a41c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ desugar-jdk-libs-nio = "2.0.4" documentFile = "1.0.1" exoplayer = "2.18.7" fragment-compose = "1.8.2" -gradle = "8.7.1" +gradle = "8.13.0" groupie = "2.10.1" hilt = "2.51.1" jetpack-compose = "2024.10.01" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4ea536e77..57939822b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists