Compare commits

..

7 Commits

Author SHA1 Message Date
Zane Schepke 4b2714c151 revert tun bump 2025-08-08 15:29:40 -04:00
Zane Schepke c56b11599f ci: fix debug build path 2025-08-08 15:28:51 -04:00
Zane Schepke 753575c50d chore: gradle checksum 2025-08-08 15:12:15 -04:00
Zane Schepke 78b419dc6e chore: bump deps 2025-08-08 04:46:32 -04:00
Zane Schepke e8681af273 feat: app database backup and restore
closes #541
2025-08-08 04:07:04 -04:00
Zane Schepke cb92c9605f fix: startup splash bug 2025-08-08 02:35:58 -04:00
Zane Schepke 38ecb0b66b feat!: tun monitoring, move ping restarts to auto-tunnel w/recovery (#885)
This is a big one.. oops.

Main changes:
- Make ping monitor more robust and global, with ping target overrides of the default cloudflare fallback target per tunnel (for full tunnels, otherwise we ping the internal tun ip)
- Include ping restart recovery to prevent tun being down if dns failures happen after a bounce
- Ping monitoring itself remains per tunnel and works without auto tunnel active, but moves the restart feature back to be managed by and integrated with auto tunnel to prevent inconsistencies and conflicts
- Ping statistics can be optionally included to be displayed with tun statistics
- Adds the beginnings of monitoring logs for handshake and data packet failures for userspace tuns (to be incorporated with restarts/tun status later)
- Improve tun error notifications, adds ping restart notifications
- Major refactor of auto tunnel logic to make it more modular and extensible for new auto tunnel conditions
- A bunch of other stuff..
2025-08-07 18:19:36 -04:00
15 changed files with 244 additions and 65 deletions
+6 -1
View File
@@ -118,6 +118,11 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: android_artifacts_${{ inputs.flavor }}
path: app/build/outputs/apk/${{ inputs.flavor }}/${{ inputs.build_type }}/wgtunnel-${{ inputs.flavor }}${{ inputs.flavor == 'fdroid' && '-release' || '' }}-*.apk
path: >-
app/build/outputs/apk/${{ inputs.flavor }}/${{ inputs.build_type }}/${{
inputs.flavor == 'fdroid' && inputs.build_type == 'release'
&& 'wgtunnel-fdroid-release-*.apk'
|| format('wgtunnel-{0}-v*.apk', inputs.flavor)
}}
retention-days: 1
if-no-files-found: warn
+5 -2
View File
@@ -121,8 +121,8 @@ android {
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
licensee {
Constants.allowedLicenses.forEach { allow(it) }
Constants.allowedLicenseUrls.forEach { allowUrl(it) }
allowedLicenses().forEach { allow(it) }
allowedLicenseUrls().forEach { allowUrl(it) }
}
applicationVariants.all {
@@ -225,6 +225,9 @@ dependencies {
implementation(libs.shizuku.provider)
implementation(libs.reorderable)
implementation(libs.roomdatabasebackup) {
exclude(group = "org.reactivestreams", module = "reactive-streams")
}
}
tasks.register<Copy>("copyLicenseeJsonToAssets") {
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -35,6 +36,9 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog
@@ -67,11 +71,16 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.license.LicenseScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.restartApp
import com.zaneschepke.wireguardautotunnel.util.extensions.showToast
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import dagger.hilt.android.AndroidEntryPoint
import de.raphaelebner.roomdatabasebackup.core.RoomBackup
import javax.inject.Inject
import kotlin.system.exitProcess
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import org.amnezia.awg.backend.GoBackend.VpnService
@AndroidEntryPoint
@@ -83,8 +92,16 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var networkMonitor: NetworkMonitor
@Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher
@Inject @MainDispatcher lateinit var mainDispatcher: CoroutineDispatcher
@Inject lateinit var appDatabase: AppDatabase
private var lastLocationPermissionState: Boolean? = null
private lateinit var roomBackup: RoomBackup
val REQUEST_CODE = 123
@SuppressLint("BatteryLife")
@@ -97,6 +114,7 @@ class MainActivity : AppCompatActivity() {
window.isNavigationBarContrastEnforced = false
}
super.onCreate(savedInstanceState)
roomBackup = RoomBackup(this)
val viewModel by viewModels<AppViewModel>()
@@ -250,7 +268,7 @@ class MainActivity : AppCompatActivity() {
MainScreen(appUiState, appViewState, viewModel)
}
composable<Route.Settings> {
SettingsScreen(appUiState, viewModel)
SettingsScreen(appUiState, appViewState, viewModel)
}
composable<Route.SettingsAdvanced> {
SettingsAdvancedScreen(appUiState, viewModel)
@@ -338,4 +356,53 @@ class MainActivity : AppCompatActivity() {
super.onPause()
WireGuardAutoTunnel.setUiActive(false)
}
fun performBackup() =
lifecycleScope.launch(ioDispatcher) {
roomBackup
.database(appDatabase)
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
.enableLogDebug(true)
.maxFileCount(5)
.apply {
onCompleteListener { success, message, exitCode ->
lifecycleScope.launch(mainDispatcher) {
if (success) {
showToast(
getString(
R.string.backup_success,
getString(R.string.restarting_app),
)
)
restartApp()
} else showToast(R.string.backup_failed)
}
}
}
.backup()
}
fun performRestore() =
lifecycleScope.launch {
roomBackup
.database(appDatabase)
.enableLogDebug(true)
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
.apply {
onCompleteListener { success, message, exitCode ->
lifecycleScope.launch(mainDispatcher) {
if (success) {
showToast(
getString(
R.string.restore_success,
getString(R.string.restarting_app),
)
)
restartApp()
} else showToast(R.string.restore_failed)
}
}
}
.restore()
}
}
@@ -143,6 +143,15 @@ fun currentNavBackStackEntryAsNavBarState(
NavBarState(
topTitle = { Text(stringResource(R.string.settings)) },
route = Route.Settings,
topTrailing = {
ActionIconButton(Icons.Rounded.Menu, R.string.quick_actions) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(
AppViewState.BottomSheet.BACKUP_AND_RESTORE
)
)
}
},
)
backStackEntry.isCurrentRoute(Route.Appearance::class) ->
@@ -22,16 +22,24 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.*
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable
fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) {
fun SettingsScreen(uiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) {
val isTv = LocalIsAndroidTV.current
val focusManager = LocalFocusManager.current
val navController = LocalNavController.current
val interactionSource = remember { MutableInteractionSource() }
when (appViewState.bottomSheet) {
AppViewState.BottomSheet.BACKUP_AND_RESTORE -> {
SettingsBottomSheet(viewModel)
}
else -> Unit
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top),
@@ -0,0 +1,78 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsBottomSheet(viewModel: AppViewModel) {
val context = LocalContext.current
ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = {
viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE))
},
) {
Row(
modifier =
Modifier.fillMaxWidth()
.clickable {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)
)
(context as? MainActivity)?.performBackup()
}
.padding(10.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.database),
contentDescription = null,
modifier = Modifier.padding(10.dp),
)
Text(
text = stringResource(R.string.backup_application),
modifier = Modifier.padding(10.dp),
)
}
HorizontalDivider()
Row(
modifier =
Modifier.fillMaxWidth()
.clickable {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)
)
(context as? MainActivity)?.performRestore()
}
.padding(10.dp)
) {
Icon(
imageVector = Icons.Outlined.Restore,
contentDescription = null,
modifier = Modifier.padding(10.dp),
)
Text(
text = stringResource(R.string.restore_application),
modifier = Modifier.padding(10.dp),
)
}
}
}
@@ -22,6 +22,7 @@ data class AppViewState(
}
enum class BottomSheet {
BACKUP_AND_RESTORE,
EXPORT_TUNNELS,
IMPORT_TUNNELS,
LOGS,
@@ -8,7 +8,6 @@ import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
@@ -16,18 +15,16 @@ import android.provider.Settings
import android.service.quicksettings.TileService
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.location.LocationManagerCompat
import androidx.core.net.toUri
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.util.Constants
import timber.log.Timber
import java.io.File
import java.io.InputStream
import timber.log.Timber
import kotlin.system.exitProcess
fun Context.openWebUrl(url: String): Result<Unit> {
return kotlin
@@ -113,15 +110,14 @@ fun Context.launchShareFile(file: Uri) {
this.startActivity(chooserIntent)
}
fun Context.isLocationServicesEnabled(): Boolean {
val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
return LocationManagerCompat.isLocationEnabled(locationManager)
}
fun Context.showToast(resId: Int) {
Toast.makeText(this, this.getString(resId), Toast.LENGTH_LONG).show()
}
fun Context.showToast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
fun Context.launchSupportEmail() {
val intent =
Intent(Intent.ACTION_SENDTO).apply {
@@ -239,17 +235,9 @@ fun Activity.setScreenBrightness(brightness: Float) {
window.attributes = window.attributes.apply { screenBrightness = brightness }
}
fun Activity.enableImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
}
fun Activity.disableImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, true)
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.show(WindowInsetsCompat.Type.systemBars())
window.statusBarColor = android.graphics.Color.TRANSPARENT
fun MainActivity.restartApp() {
Intent(this, MainActivity::class.java).also {
startActivity(it)
exitProcess(0)
}
}
@@ -35,14 +35,8 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.withFirstState
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.IOException
import java.net.URL
import java.time.Instant
import java.util.*
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -51,6 +45,12 @@ import org.amnezia.awg.config.Config
import rikka.shizuku.Shizuku
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
import java.io.IOException
import java.net.URL
import java.time.Instant
import java.util.*
import javax.inject.Inject
import javax.inject.Provider
@HiltViewModel
class AppViewModel
@@ -70,12 +70,7 @@ constructor(
private var logsJob: Job? = null
private val _eventFlow =
MutableSharedFlow<AppEvent>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val _eventChannel = Channel<AppEvent>(Channel.BUFFERED)
private val tunnelMutex = Mutex()
private val settingsMutex = Mutex()
@@ -139,7 +134,7 @@ constructor(
if (state.appState.isLocalLogsEnabled) logsJob = startCollectingLogs()
handleTunnelMessages()
}
_eventFlow.collect { event ->
for (event in _eventChannel) {
val state = uiState.value
when (event) {
AppEvent.ToggleLocalLogging ->
@@ -247,6 +242,7 @@ constructor(
is AppEvent.SaveAllConfigs -> saveAllTunnels(event.tunnels)
AppEvent.ToggleShowDetailedPingStats ->
handleToggleShowDetailedPingStats(state.appState)
is AppEvent.SaveMonitoringSettings ->
handleMonitoringSaveChanges(
state.appSettings,
@@ -258,10 +254,12 @@ constructor(
AppEvent.TogglePingMonitoring -> handleTogglePingMonitoring(state.appSettings)
is AppEvent.SetPingAttempts ->
saveSettings(state.appSettings.copy(tunnelPingAttempts = event.count))
is AppEvent.SetPingInterval ->
saveSettings(
state.appSettings.copy(tunnelPingIntervalSeconds = event.interval)
)
is AppEvent.SetPingTimeout ->
saveSettings(
state.appSettings.copy(tunnelPingTimeoutSeconds = event.timeout)
@@ -275,7 +273,7 @@ constructor(
viewModelScope.launch(mainDispatcher) { _uiEvent.emit(event) }
fun handleEvent(event: AppEvent) {
_eventFlow.tryEmit(event)
_eventChannel.trySend(event)
}
private suspend fun handleTogglePingMonitoring(appSettings: AppSettings) {
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M480,840q-151,0 -255.5,-46.5T120,680v-400q0,-66 105.5,-113T480,120q149,0 254.5,47T840,280v400q0,67 -104.5,113.5T480,840ZM480,361q89,0 179,-25.5T760,281q-11,-29 -100.5,-55T480,200q-91,0 -178.5,25.5T200,281q14,30 101.5,55T480,361ZM480,560q42,0 81,-4t74.5,-11.5q35.5,-7.5 67,-18.5t57.5,-25v-120q-26,14 -57.5,25t-67,18.5Q600,432 561,436t-81,4q-42,0 -82,-4t-75.5,-11.5Q287,417 256,406t-56,-25v120q25,14 56,25t66.5,18.5Q358,552 398,556t82,4ZM480,760q46,0 93.5,-7t87.5,-18.5q40,-11.5 67,-26t32,-29.5v-98q-26,14 -57.5,25t-67,18.5Q600,632 561,636t-81,4q-42,0 -82,-4t-75.5,-11.5Q287,617 256,606t-56,-25v99q5,15 31.5,29t66.5,25.5q40,11.5 88,18.5t94,7Z"
android:fillColor="#e3e3e3"/>
</vector>
+7
View File
@@ -327,4 +327,11 @@
<string name="ping_target_template">Ping target: %1$s</string>
<string name="_true">True</string>
<string name="_false">False</string>
<string name="backup_success">Backup success. %1$s</string>
<string name="restore_success">Restore success. %1$s</string>
<string name="restarting_app">Restarting app to apply changes…</string>
<string name="restore_failed">Failed to restore from backup.</string>
<string name="backup_failed">Failed to create backup.</string>
<string name="backup_application">Backup application data</string>
<string name="restore_application">Restore from backup</string>
</resources>
-5
View File
@@ -1,6 +1,5 @@
object Constants {
const val VERSION_NAME = "3.9.4"
const val JVM_TARGET = "17"
const val VERSION_CODE = 39400
const val TARGET_SDK = 35
const val MIN_SDK = 26
@@ -10,8 +9,4 @@ object Constants {
// build types
const val RELEASE = "release"
const val NIGHTLY = "nightly"
val allowedLicenses = listOf("MIT", "Apache-2.0", "BSD-3-Clause")
val allowedLicenseUrls = listOf("https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING",
"https://github.com/RikkaApps/Shizuku-API/blob/master/LICENSE")
}
+9
View File
@@ -15,6 +15,15 @@ fun Project.languageList(): List<String> {
.toList() + "en"
}
fun allowedLicenses(): List<String> {
return listOf("MIT", "Apache-2.0", "BSD-3-Clause")
}
fun allowedLicenseUrls(): List<String> {
return listOf("https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING",
"https://github.com/RikkaApps/Shizuku-API/blob/master/LICENSE", "https://github.com/rafi0101/Android-Room-Database-Backup/blob/master/LICENSE",
"https://opensource.org/license/mit")
}
fun buildLanguagesArray(languages: List<String>): String {
return languages.joinToString(separator = ", ") { "\"$it\"" }
+16 -14
View File
@@ -1,38 +1,39 @@
[versions]
accompanist = "0.37.3"
activityCompose = "1.10.1"
amneziawgAndroid = "1.5.0"
androidx-junit = "1.2.1"
amneziawgAndroid = "1.4.0"
androidx-junit = "1.3.0"
icmp4a = "1.0.0"
roomdatabasebackup = "1.1.0"
shizuku = "13.1.5"
appcompat = "1.7.1"
biometricKtx = "1.2.0-alpha05"
coreKtx = "1.16.0"
datastorePreferences = "1.2.0-alpha02"
desugar_jdk_libs = "2.1.5"
espressoCore = "3.6.1"
hiltAndroid = "2.56.2"
espressoCore = "3.7.0"
hiltAndroid = "2.57"
hiltCompiler = "1.2.0"
junit = "4.13.2"
kotlinx-serialization-json = "1.8.1"
ktorClientCore = "3.1.3"
kotlinx-serialization-json = "1.9.0"
ktorClientCore = "3.2.3"
lifecycle-runtime-compose = "2.9.2"
material3 = "1.3.2"
navigationCompose = "2.9.0"
navigationCompose = "2.9.3"
pinLockCompose = "1.0.4"
qrose = "1.0.1"
roomVersion = "2.7.1"
roomVersion = "2.7.2"
semver4j = "3.1.0"
slf4jAndroid = "1.7.36"
timber = "5.0.1"
tunnel = "1.4.0"
androidGradlePlugin = "8.10.1"
androidGradlePlugin = "8.11.0"
kotlin = "2.2.0"
ksp = "2.2.0-2.0.2"
composeBom = "2025.06.00"
compose = "1.8.2"
composeBom = "2025.07.00"
compose = "1.8.3"
icons = "1.7.8"
workRuntimeKtxVersion = "2.10.1"
workRuntimeKtxVersion = "2.10.3"
zxingAndroidEmbedded = "4.3.0"
coreSplashscreen = "1.0.1"
gradlePlugins-grgit = "5.3.2"
@@ -40,8 +41,8 @@ reorderable = "2.5.1"
#plugins
material = "1.12.0"
storage = "1.5.0"
ktfmt = "0.22.0"
storage = "1.6.0"
ktfmt = "0.23.0"
licensee = "1.13.0"
@@ -104,6 +105,7 @@ material-icons-extended = { module = "androidx.compose.material:material-icons-e
# util
pin-lock-compose = { module = "com.zaneschepke:pin_lock_compose", version.ref = "pinLockCompose" }
roomdatabasebackup = { module = "de.raphaelebner:roomdatabasebackup", version.ref = "roomdatabasebackup" }
shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" }
shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" }
qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" }
+2 -2
View File
@@ -1,8 +1,8 @@
#Wed Oct 11 22:39:21 EDT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists