diff --git a/app/build.gradle b/app/build.gradle
index 081bc276605..a0e5a8953dc 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -9,6 +9,7 @@ plugins {
id "kotlin-parcelize"
id "checkstyle"
id "org.sonarqube" version "4.0.0.2929"
+ id "dagger.hilt.android.plugin"
}
android {
@@ -92,6 +93,7 @@ android {
buildFeatures {
viewBinding true
+ compose true
}
packagingOptions {
@@ -103,6 +105,10 @@ android {
'META-INF/COPYRIGHT']
}
}
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.3"
+ }
}
ext {
@@ -117,6 +123,8 @@ ext {
googleAutoServiceVersion = '1.1.1'
groupieVersion = '2.10.1'
markwonVersion = '4.6.2'
+ hiltVersion = '1.2.0'
+ daggerVersion = '2.51.1'
leakCanaryVersion = '2.12'
stethoVersion = '1.6.0'
@@ -284,6 +292,20 @@ dependencies {
// Date and time formatting
implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final"
+ // Jetpack Compose
+ implementation(platform('androidx.compose:compose-bom:2024.02.01'))
+ implementation 'androidx.compose.material3:material3'
+ implementation 'androidx.activity:activity-compose'
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+
+ // hilt
+ implementation("androidx.hilt:hilt-navigation-compose:${hiltVersion}")
+ kapt("androidx.hilt:hilt-compiler:${hiltVersion}")
+
+ // dagger
+ implementation("com.google.dagger:hilt-android:${daggerVersion}")
+ kapt("com.google.dagger:hilt-android-compiler:${daggerVersion}")
+
/** Debugging **/
// Memory leak detection
debugImplementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}"
@@ -293,6 +315,9 @@ dependencies {
debugImplementation "com.facebook.stetho:stetho:${stethoVersion}"
debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}"
+ // Jetpack Compose
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+
/** Testing **/
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.6.0'
@@ -301,6 +326,10 @@ dependencies {
androidTestImplementation "androidx.test:runner:1.5.2"
androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}"
androidTestImplementation "org.assertj:assertj-core:3.24.2"
+
+ // dagger
+ testImplementation("com.google.dagger:hilt-android-testing:${daggerVersion}")
+ androidTestImplementation("com.google.dagger:hilt-android-testing:${daggerVersion}")
}
static String getGitWorkingBranch() {
diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java
index d92425d200e..35be89489a7 100644
--- a/app/src/main/java/org/schabi/newpipe/App.java
+++ b/app/src/main/java/org/schabi/newpipe/App.java
@@ -32,6 +32,7 @@
import java.util.List;
import java.util.Objects;
+import dagger.hilt.android.HiltAndroidApp;
import io.reactivex.rxjava3.exceptions.CompositeException;
import io.reactivex.rxjava3.exceptions.MissingBackpressureException;
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
@@ -57,6 +58,7 @@
* along with NewPipe. If not, see .
*/
+@HiltAndroidApp
public class App extends Application {
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
private static final String TAG = App.class.toString();
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index 17569412572..a74d30c74de 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -98,6 +98,9 @@
import java.util.List;
import java.util.Objects;
+import dagger.hilt.android.AndroidEntryPoint;
+
+@AndroidEntryPoint
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
@SuppressWarnings("ConstantConditions")
diff --git a/app/src/main/java/org/schabi/newpipe/dependency_injection/AppModule.kt b/app/src/main/java/org/schabi/newpipe/dependency_injection/AppModule.kt
new file mode 100644
index 00000000000..1365f80f8d0
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/dependency_injection/AppModule.kt
@@ -0,0 +1,27 @@
+package org.schabi.newpipe.dependency_injection
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.preference.PreferenceManager
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import org.schabi.newpipe.error.usecases.OpenErrorActivity
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+ @Provides
+ @Singleton
+ fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
+ PreferenceManager.getDefaultSharedPreferences(context)
+
+ @Provides
+ @Singleton
+ fun provideOpenActivity(
+ @ApplicationContext context: Context,
+ ): OpenErrorActivity = OpenErrorActivity(context)
+}
diff --git a/app/src/main/java/org/schabi/newpipe/dependency_injection/DatabaseModule.kt b/app/src/main/java/org/schabi/newpipe/dependency_injection/DatabaseModule.kt
new file mode 100644
index 00000000000..4e20fe30c06
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/dependency_injection/DatabaseModule.kt
@@ -0,0 +1,56 @@
+package org.schabi.newpipe.dependency_injection
+
+import android.content.Context
+import androidx.room.Room
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import org.schabi.newpipe.database.AppDatabase
+import org.schabi.newpipe.database.AppDatabase.DATABASE_NAME
+import org.schabi.newpipe.database.Migrations.MIGRATION_1_2
+import org.schabi.newpipe.database.Migrations.MIGRATION_2_3
+import org.schabi.newpipe.database.Migrations.MIGRATION_3_4
+import org.schabi.newpipe.database.Migrations.MIGRATION_4_5
+import org.schabi.newpipe.database.Migrations.MIGRATION_5_6
+import org.schabi.newpipe.database.Migrations.MIGRATION_6_7
+import org.schabi.newpipe.database.Migrations.MIGRATION_7_8
+import org.schabi.newpipe.database.Migrations.MIGRATION_8_9
+import org.schabi.newpipe.database.history.dao.SearchHistoryDAO
+import org.schabi.newpipe.database.history.dao.StreamHistoryDAO
+import org.schabi.newpipe.database.stream.dao.StreamDAO
+import org.schabi.newpipe.database.stream.dao.StreamStateDAO
+import javax.inject.Singleton
+
+@InstallIn(SingletonComponent::class)
+@Module
+class DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun provideAppDatabase(@ApplicationContext appContext: Context): AppDatabase =
+ Room.databaseBuilder(
+ appContext,
+ AppDatabase::class.java,
+ DATABASE_NAME
+ ).addMigrations(
+ MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
+ MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9
+ ).build()
+
+ @Provides
+ fun provideStreamStateDao(appDatabase: AppDatabase): StreamStateDAO =
+ appDatabase.streamStateDAO()
+
+ @Provides
+ fun providesStreamDao(appDatabase: AppDatabase): StreamDAO = appDatabase.streamDAO()
+
+ @Provides
+ fun provideStreamHistoryDao(appDatabase: AppDatabase): StreamHistoryDAO =
+ appDatabase.streamHistoryDAO()
+
+ @Provides
+ fun provideSearchHistoryDao(appDatabase: AppDatabase): SearchHistoryDAO =
+ appDatabase.searchHistoryDAO()
+}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
index 42ef261a15d..5dcbd6e69c4 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
@@ -188,7 +188,7 @@ private void handleCookiesFromUrl(@Nullable final String url) {
String abuseCookie = url.substring(abuseStart + 13, abuseEnd);
abuseCookie = Utils.decodeUrlUtf8(abuseCookie);
handleCookies(abuseCookie);
- } catch (IllegalArgumentException | StringIndexOutOfBoundsException e) {
+ } catch (final StringIndexOutOfBoundsException e) {
if (MainActivity.DEBUG) {
Log.e(TAG, "handleCookiesFromUrl: invalid google abuse starting at "
+ abuseStart + " and ending at " + abuseEnd + " for url " + url, e);
diff --git a/app/src/main/java/org/schabi/newpipe/error/usecases/OpenErrorActivity.kt b/app/src/main/java/org/schabi/newpipe/error/usecases/OpenErrorActivity.kt
new file mode 100644
index 00000000000..5168c0d3c59
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/error/usecases/OpenErrorActivity.kt
@@ -0,0 +1,18 @@
+package org.schabi.newpipe.error.usecases
+
+import android.content.Context
+import android.content.Intent
+import org.schabi.newpipe.error.ErrorActivity
+import org.schabi.newpipe.error.ErrorInfo
+
+class OpenErrorActivity(
+ private val context: Context,
+) {
+ operator fun invoke(errorInfo: ErrorInfo) {
+ val intent = Intent(context, ErrorActivity::class.java)
+ intent.putExtra(ErrorActivity.ERROR_INFO, errorInfo)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ context.startActivity(intent)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
index 1fea7e1559c..4801936e615 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
@@ -13,6 +13,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
import androidx.viewbinding.ViewBinding;
import com.google.android.material.snackbar.Snackbar;
@@ -26,6 +27,7 @@
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo;
+import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
@@ -34,7 +36,6 @@
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
-import org.schabi.newpipe.settings.HistorySettingsFragment;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.PlayButtonHelper;
@@ -161,14 +162,72 @@ public void held(final LocalItem selectedItem) {
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == R.id.action_history_clear) {
- HistorySettingsFragment
- .openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables);
+ openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables);
} else {
return super.onOptionsItemSelected(item);
}
return true;
}
+ private static void openDeleteWatchHistoryDialog(
+ @NonNull final Context context,
+ final HistoryRecordManager recordManager,
+ final CompositeDisposable disposables
+ ) {
+ new AlertDialog.Builder(context)
+ .setTitle(R.string.delete_view_history_alert)
+ .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss()))
+ .setPositiveButton(R.string.delete, ((dialog, which) -> {
+ disposables.add(getDeletePlaybackStatesDisposable(context, recordManager));
+ disposables.add(getWholeStreamHistoryDisposable(context, recordManager));
+ disposables.add(getRemoveOrphanedRecordsDisposable(context, recordManager));
+ }))
+ .show();
+ }
+
+ private static Disposable getDeletePlaybackStatesDisposable(
+ @NonNull final Context context,
+ final HistoryRecordManager recordManager
+ ) {
+ return recordManager.deleteCompleteStreamStateHistory()
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ howManyDeleted -> Toast.makeText(context,
+ R.string.watch_history_states_deleted, Toast.LENGTH_SHORT).show(),
+ throwable -> ErrorUtil.openActivity(context,
+ new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
+ "Delete playback states"))
+ );
+ }
+
+ private static Disposable getWholeStreamHistoryDisposable(
+ @NonNull final Context context,
+ final HistoryRecordManager recordManager
+ ) {
+ return recordManager.deleteWholeStreamHistory()
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ howManyDeleted -> Toast.makeText(context,
+ R.string.watch_history_deleted, Toast.LENGTH_SHORT).show(),
+ throwable -> ErrorUtil.openActivity(context,
+ new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
+ "Delete from history"))
+ );
+ }
+
+ private static Disposable getRemoveOrphanedRecordsDisposable(
+ @NonNull final Context context, final HistoryRecordManager recordManager) {
+ return recordManager.removeOrphanedRecords()
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ howManyDeleted -> {
+ },
+ throwable -> ErrorUtil.openActivity(context,
+ new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY,
+ "Clear orphaned records"))
+ );
+ }
+
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Loading
///////////////////////////////////////////////////////////////////////////
@@ -307,7 +366,7 @@ private void toggleSortMode() {
sortMode = StatisticSortMode.LAST_PLAYED;
setTitle(getString(R.string.title_last_played));
headerBinding.sortButtonIcon.setImageResource(
- R.drawable.ic_filter_list);
+ R.drawable.ic_filter_list);
headerBinding.sortButtonText.setText(R.string.title_most_played);
}
startLoading(true);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java
index 76163b30aaa..02d34788512 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java
@@ -121,12 +121,8 @@ private void showPathInSummary(final String prefKey, @StringRes final int defaul
target.setSummary(new File(URI.create(rawUri)).getPath());
return;
}
+ rawUri = decodeUrlUtf8(rawUri);
- try {
- rawUri = decodeUrlUtf8(rawUri);
- } catch (final IllegalArgumentException e) {
- // nothing to do
- }
target.setSummary(rawUri);
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
index 529e5344220..0c349ec4056 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
@@ -41,6 +41,7 @@
import java.util.concurrent.TimeUnit;
+import dagger.hilt.android.AndroidEntryPoint;
import icepick.Icepick;
import icepick.State;
@@ -64,6 +65,7 @@
* along with NewPipe. If not, see .
*/
+@AndroidEntryPoint
public class SettingsActivity extends AppCompatActivity implements
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
PreferenceSearchResultListener {
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java
index 06e0a7c1eae..b41ad904b07 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java
@@ -3,9 +3,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.XmlRes;
import androidx.fragment.app.Fragment;
-
import org.schabi.newpipe.R;
-
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
@@ -35,7 +33,6 @@ private SettingsResourceRegistry() {
add(ContentSettingsFragment.class, R.xml.content_settings);
add(DebugSettingsFragment.class, R.xml.debug_settings).setSearchable(false);
add(DownloadSettingsFragment.class, R.xml.download_settings);
- add(HistorySettingsFragment.class, R.xml.history_settings);
add(NotificationSettingsFragment.class, R.xml.notifications_settings);
add(PlayerNotificationSettingsFragment.class, R.xml.player_notification_settings);
add(UpdateSettingsFragment.class, R.xml.update_settings);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/components/irreversible_preference/IrreversiblePreference.kt b/app/src/main/java/org/schabi/newpipe/settings/components/irreversible_preference/IrreversiblePreference.kt
new file mode 100644
index 00000000000..f9cc79099fe
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/components/irreversible_preference/IrreversiblePreference.kt
@@ -0,0 +1,85 @@
+package org.schabi.newpipe.settings.components.irreversible_preference
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.tooling.preview.Preview
+import org.schabi.newpipe.ui.theme.AppTheme
+import org.schabi.newpipe.ui.theme.SizeTokens.SpacingExtraSmall
+import org.schabi.newpipe.ui.theme.SizeTokens.SpacingMedium
+
+@Composable
+fun IrreversiblePreferenceComponent(
+ title: String,
+ summary: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+) {
+ val clickModifier = if (enabled) {
+ Modifier.clickable { onClick() }
+ } else {
+ Modifier
+ }
+ Row(
+ modifier = clickModifier.then(modifier),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val alpha by remember {
+ derivedStateOf {
+ if (enabled) 1f else 0.38f
+ }
+ }
+ Column(
+ modifier = Modifier.padding(SpacingMedium)
+ ) {
+ Text(
+ text = title,
+ modifier = Modifier.alpha(alpha),
+ )
+ Spacer(modifier = Modifier.padding(SpacingExtraSmall))
+ Text(
+ text = summary,
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.alpha(alpha * 0.6f),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun IrreversiblePreferenceComponentPreview() {
+ val title = "Wipe cached metadata"
+ val summary = "Remove all cached webpage data"
+ AppTheme {
+ Column {
+
+ IrreversiblePreferenceComponent(
+ title = title,
+ summary = summary,
+ onClick = {},
+ modifier = Modifier.fillMaxWidth()
+ )
+ IrreversiblePreferenceComponent(
+ title = title,
+ summary = summary,
+ onClick = {},
+ modifier = Modifier.fillMaxWidth(),
+ enabled = false
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/components/switch_preference/SwitchPreference.kt b/app/src/main/java/org/schabi/newpipe/settings/components/switch_preference/SwitchPreference.kt
new file mode 100644
index 00000000000..61a64e8633d
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/components/switch_preference/SwitchPreference.kt
@@ -0,0 +1,73 @@
+package org.schabi.newpipe.settings.components.switch_preference
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.tooling.preview.Preview
+import org.schabi.newpipe.ui.theme.AppTheme
+import org.schabi.newpipe.ui.theme.SizeTokens.SpacingExtraSmall
+import org.schabi.newpipe.ui.theme.SizeTokens.SpacingMedium
+
+@Composable
+fun SwitchPreferenceComponent(
+ title: String,
+ summary: String,
+ isChecked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(SpacingMedium)
+ ) {
+ Text(text = title)
+ Spacer(modifier = Modifier.padding(SpacingExtraSmall))
+ Text(
+ text = summary,
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.alpha(0.6f)
+ )
+ }
+
+ Switch(
+ checked = isChecked,
+ onCheckedChange = onCheckedChange,
+ modifier = Modifier.padding(SpacingMedium)
+ )
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun SwitchPreferenceComponentPreview() {
+ val title = "Watch history"
+ val subtitle = "Keep track of watched videos"
+ var isChecked = false
+ AppTheme {
+ SwitchPreferenceComponent(
+ title = title,
+ summary = subtitle,
+ isChecked = isChecked,
+ onCheckedChange = {
+ isChecked = it
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/dependency_injection/SettingsModule.kt b/app/src/main/java/org/schabi/newpipe/settings/dependency_injection/SettingsModule.kt
new file mode 100644
index 00000000000..d0f271025cc
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/dependency_injection/SettingsModule.kt
@@ -0,0 +1,138 @@
+package org.schabi.newpipe.settings.dependency_injection
+
+import android.content.Context
+import android.content.SharedPreferences
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import org.schabi.newpipe.database.history.dao.SearchHistoryDAO
+import org.schabi.newpipe.database.history.dao.StreamHistoryDAO
+import org.schabi.newpipe.database.stream.dao.StreamDAO
+import org.schabi.newpipe.database.stream.dao.StreamStateDAO
+import org.schabi.newpipe.error.usecases.OpenErrorActivity
+import org.schabi.newpipe.settings.domain.repositories.HistoryRecordRepository
+import org.schabi.newpipe.settings.domain.repositories.HistoryRecordRepositoryImpl
+import org.schabi.newpipe.settings.domain.usecases.DeleteCompleteSearchHistory
+import org.schabi.newpipe.settings.domain.usecases.DeleteCompleteStreamStateHistory
+import org.schabi.newpipe.settings.domain.usecases.DeletePlaybackStates
+import org.schabi.newpipe.settings.domain.usecases.DeleteWatchHistory
+import org.schabi.newpipe.settings.domain.usecases.RemoveOrphanedRecords
+import org.schabi.newpipe.settings.domain.usecases.get_preference.GetPreference
+import org.schabi.newpipe.settings.domain.usecases.get_preference.GetPreferenceImpl
+import org.schabi.newpipe.settings.domain.usecases.update_preference.UpdatePreference
+import org.schabi.newpipe.settings.domain.usecases.update_preference.UpdatePreferenceImpl
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object SettingsModule {
+
+ @Provides
+ @Singleton
+ fun provideGetBooleanPreference(
+ sharedPreferences: SharedPreferences,
+ @ApplicationContext context: Context,
+ ): GetPreference = GetPreferenceImpl(sharedPreferences, context)
+
+ @Provides
+ @Singleton
+ fun provideGetStringPreference(
+ sharedPreferences: SharedPreferences,
+ @ApplicationContext context: Context,
+ ): GetPreference = GetPreferenceImpl(sharedPreferences, context)
+
+ @Provides
+ @Singleton
+ fun provideUpdateBooleanPreference(
+ sharedPreferences: SharedPreferences,
+ @ApplicationContext context: Context,
+ ): UpdatePreference = UpdatePreferenceImpl(context, sharedPreferences) { key, value ->
+ putBoolean(
+ key,
+ value
+ )
+ }
+
+ @Provides
+ @Singleton
+ fun provideUpdateStringPreference(
+ sharedPreferences: SharedPreferences,
+ @ApplicationContext context: Context,
+ ): UpdatePreference = UpdatePreferenceImpl(context, sharedPreferences) { key, value ->
+ putString(
+ key,
+ value
+ )
+ }
+
+ @Provides
+ @Singleton
+ fun provideUpdateIntPreference(
+ sharedPreferences: SharedPreferences,
+ @ApplicationContext context: Context,
+ ): UpdatePreference = UpdatePreferenceImpl(context, sharedPreferences) { key, value ->
+ putInt(key, value)
+ }
+
+ @Provides
+ @Singleton
+ fun provideHistoryRecordRepository(
+ streamStateDao: StreamStateDAO,
+ streamHistoryDAO: StreamHistoryDAO,
+ streamDAO: StreamDAO,
+ searchHistoryDAO: SearchHistoryDAO,
+ ): HistoryRecordRepository = HistoryRecordRepositoryImpl(
+ streamStateDao = streamStateDao,
+ streamHistoryDAO = streamHistoryDAO,
+ streamDAO = streamDAO,
+ searchHistoryDAO = searchHistoryDAO,
+ )
+
+ @Provides
+ @Singleton
+ fun provideDeletePlaybackStatesUseCase(
+ historyRecordRepository: HistoryRecordRepository,
+ ): DeletePlaybackStates = DeletePlaybackStates(
+ historyRecordRepository = historyRecordRepository,
+ )
+
+ @Provides
+ @Singleton
+ fun provideDeleteWholeStreamHistoryUseCase(
+ historyRecordRepository: HistoryRecordRepository,
+ ): DeleteCompleteStreamStateHistory = DeleteCompleteStreamStateHistory(
+ historyRecordRepository = historyRecordRepository,
+ )
+
+ @Provides
+ @Singleton
+ fun provideRemoveOrphanedRecordsUseCase(
+ historyRecordRepository: HistoryRecordRepository,
+ ): RemoveOrphanedRecords = RemoveOrphanedRecords(
+ historyRecordRepository = historyRecordRepository,
+ )
+
+ @Provides
+ @Singleton
+ fun provideDeleteCompleteSearchHistoryUseCase(
+ historyRecordRepository: HistoryRecordRepository,
+ ): DeleteCompleteSearchHistory = DeleteCompleteSearchHistory(
+ historyRecordRepository = historyRecordRepository,
+ )
+
+ @Provides
+ @Singleton
+ fun provideDeleteWatchHistoryUseCase(
+ deletePlaybackStates: DeletePlaybackStates,
+ deleteCompleteStreamStateHistory: DeleteCompleteStreamStateHistory,
+ removeOrphanedRecords: RemoveOrphanedRecords,
+ openErrorActivity: OpenErrorActivity,
+ ): DeleteWatchHistory = DeleteWatchHistory(
+ deletePlaybackStates = deletePlaybackStates,
+ deleteCompleteStreamStateHistory = deleteCompleteStreamStateHistory,
+ removeOrphanedRecords = removeOrphanedRecords,
+ openErrorActivity = openErrorActivity
+ )
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/repositories/HistoryRecordRepository.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/repositories/HistoryRecordRepository.kt
new file mode 100644
index 00000000000..3b9edaa48ff
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/repositories/HistoryRecordRepository.kt
@@ -0,0 +1,11 @@
+package org.schabi.newpipe.settings.domain.repositories
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+
+interface HistoryRecordRepository {
+ fun deleteCompleteStreamState(dispatcher: CoroutineDispatcher): Flow
+ fun deleteWholeStreamHistory(dispatcher: CoroutineDispatcher): Flow
+ fun removeOrphanedRecords(dispatcher: CoroutineDispatcher): Flow
+ fun deleteCompleteSearchHistory(dispatcher: CoroutineDispatcher): Flow
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/repositories/HistoryRecordRepositoryFake.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/repositories/HistoryRecordRepositoryFake.kt
new file mode 100644
index 00000000000..6e03fec475d
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/repositories/HistoryRecordRepositoryFake.kt
@@ -0,0 +1,64 @@
+package org.schabi.newpipe.settings.domain.repositories
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.update
+import org.schabi.newpipe.database.history.model.SearchHistoryEntry
+import org.schabi.newpipe.database.history.model.StreamHistoryEntity
+import org.schabi.newpipe.database.stream.model.StreamEntity
+import org.schabi.newpipe.database.stream.model.StreamStateEntity
+
+class HistoryRecordRepositoryFake : HistoryRecordRepository {
+ private val _searchHistory: MutableStateFlow> = MutableStateFlow(
+ emptyList()
+ )
+ val searchHistory = _searchHistory.asStateFlow()
+ private val _streamHistory = MutableStateFlow>(emptyList())
+ val streamHistory = _streamHistory.asStateFlow()
+ private val _streams = MutableStateFlow>(emptyList())
+ val streams = _streams.asStateFlow()
+ private val _streamStates = MutableStateFlow>(emptyList())
+ val streamStates = _streamStates.asStateFlow()
+
+ override fun deleteCompleteStreamState(dispatcher: CoroutineDispatcher): Flow = flow {
+ val count = streamStates.value.size
+ _streamStates.update {
+ emptyList()
+ }
+ emit(count)
+ }.flowOn(dispatcher)
+
+ override fun deleteWholeStreamHistory(dispatcher: CoroutineDispatcher): Flow = flow {
+ val count = streamHistory.value.size
+ _streamHistory.update {
+ emptyList()
+ }
+ emit(count)
+ }.flowOn(dispatcher)
+
+ override fun removeOrphanedRecords(dispatcher: CoroutineDispatcher): Flow = flow {
+ val orphanedStreams = streams.value.filter { stream ->
+ !streamHistory.value.any { it.streamUid == stream.uid }
+ }
+
+ val deletedCount = orphanedStreams.size
+
+ _streams.update { oldStreams ->
+ oldStreams.filter { it !in orphanedStreams }
+ }
+
+ emit(deletedCount)
+ }.flowOn(dispatcher)
+
+ override fun deleteCompleteSearchHistory(dispatcher: CoroutineDispatcher): Flow = flow {
+ val count = searchHistory.value.size
+ _searchHistory.update {
+ emptyList()
+ }
+ emit(count)
+ }.flowOn(dispatcher)
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/repositories/HistoryRecordRepositoryImpl.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/repositories/HistoryRecordRepositoryImpl.kt
new file mode 100644
index 00000000000..b1d4abeebcb
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/repositories/HistoryRecordRepositoryImpl.kt
@@ -0,0 +1,39 @@
+package org.schabi.newpipe.settings.domain.repositories
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import org.schabi.newpipe.database.history.dao.SearchHistoryDAO
+import org.schabi.newpipe.database.history.dao.StreamHistoryDAO
+import org.schabi.newpipe.database.stream.dao.StreamDAO
+import org.schabi.newpipe.database.stream.dao.StreamStateDAO
+
+class HistoryRecordRepositoryImpl(
+ private val streamStateDao: StreamStateDAO,
+ private val streamHistoryDAO: StreamHistoryDAO,
+ private val streamDAO: StreamDAO,
+ private val searchHistoryDAO: SearchHistoryDAO,
+) : HistoryRecordRepository {
+ override fun deleteCompleteStreamState(dispatcher: CoroutineDispatcher): Flow =
+ flow {
+ val deletedCount = streamStateDao.deleteAll()
+ emit(deletedCount)
+ }.flowOn(dispatcher)
+
+ override fun deleteWholeStreamHistory(dispatcher: CoroutineDispatcher): Flow =
+ flow {
+ val deletedCount = streamHistoryDAO.deleteAll()
+ emit(deletedCount)
+ }.flowOn(dispatcher)
+
+ override fun removeOrphanedRecords(dispatcher: CoroutineDispatcher): Flow = flow {
+ val deletedCount = streamDAO.deleteOrphans()
+ emit(deletedCount)
+ }.flowOn(dispatcher)
+
+ override fun deleteCompleteSearchHistory(dispatcher: CoroutineDispatcher): Flow = flow {
+ val deletedCount = searchHistoryDAO.deleteAll()
+ emit(deletedCount)
+ }.flowOn(dispatcher)
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeleteCompleteSearchHistory.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeleteCompleteSearchHistory.kt
new file mode 100644
index 00000000000..7b2c2d99a88
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeleteCompleteSearchHistory.kt
@@ -0,0 +1,20 @@
+package org.schabi.newpipe.settings.domain.usecases
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.take
+import org.schabi.newpipe.settings.domain.repositories.HistoryRecordRepository
+
+class DeleteCompleteSearchHistory(
+ private val historyRecordRepository: HistoryRecordRepository,
+) {
+ suspend operator fun invoke(
+ dispatcher: CoroutineDispatcher,
+ onError: (Throwable) -> Unit,
+ onSuccess: () -> Unit,
+ ) = historyRecordRepository.deleteCompleteSearchHistory(dispatcher).catch { error ->
+ onError(error)
+ }.take(1).collect {
+ onSuccess()
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeleteCompleteStreamStateHistory.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeleteCompleteStreamStateHistory.kt
new file mode 100644
index 00000000000..0b516966dca
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeleteCompleteStreamStateHistory.kt
@@ -0,0 +1,20 @@
+package org.schabi.newpipe.settings.domain.usecases
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.take
+import org.schabi.newpipe.settings.domain.repositories.HistoryRecordRepository
+
+class DeleteCompleteStreamStateHistory(
+ private val historyRecordRepository: HistoryRecordRepository,
+) {
+ suspend operator fun invoke(
+ dispatcher: CoroutineDispatcher,
+ onError: (Throwable) -> Unit,
+ onSuccess: () -> Unit,
+ ) = historyRecordRepository.deleteWholeStreamHistory(dispatcher).catch {
+ onError(it)
+ }.take(1).collect {
+ onSuccess()
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeletePlaybackStates.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeletePlaybackStates.kt
new file mode 100644
index 00000000000..e8416a492e1
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeletePlaybackStates.kt
@@ -0,0 +1,20 @@
+package org.schabi.newpipe.settings.domain.usecases
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.take
+import org.schabi.newpipe.settings.domain.repositories.HistoryRecordRepository
+
+class DeletePlaybackStates(
+ private val historyRecordRepository: HistoryRecordRepository,
+) {
+ suspend operator fun invoke(
+ dispatcher: CoroutineDispatcher,
+ onError: (Throwable) -> Unit,
+ onSuccess: () -> Unit,
+ ) = historyRecordRepository.deleteCompleteStreamState(dispatcher).catch {
+ onError(it)
+ }.take(1).collect {
+ onSuccess()
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeleteWatchHistory.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeleteWatchHistory.kt
new file mode 100644
index 00000000000..403f6f1c026
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/DeleteWatchHistory.kt
@@ -0,0 +1,69 @@
+package org.schabi.newpipe.settings.domain.usecases
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import org.schabi.newpipe.error.ErrorInfo
+import org.schabi.newpipe.error.UserAction
+import org.schabi.newpipe.error.usecases.OpenErrorActivity
+
+class DeleteWatchHistory(
+ private val deletePlaybackStates: DeletePlaybackStates,
+ private val deleteCompleteStreamStateHistory: DeleteCompleteStreamStateHistory,
+ private val removeOrphanedRecords: RemoveOrphanedRecords,
+ private val openErrorActivity: OpenErrorActivity,
+) {
+ suspend operator fun invoke(
+ onDeletePlaybackStates: () -> Unit,
+ onDeleteWholeStreamHistory: () -> Unit,
+ onRemoveOrphanedRecords: () -> Unit,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
+ ) = coroutineScope {
+ launch {
+ deletePlaybackStates(
+ dispatcher,
+ onError = { error ->
+ openErrorActivity(
+ ErrorInfo(
+ error,
+ UserAction.DELETE_FROM_HISTORY,
+ "Delete playback states"
+ )
+ )
+ },
+ onSuccess = onDeletePlaybackStates
+ )
+ }
+ launch {
+ deleteCompleteStreamStateHistory(
+ dispatcher,
+ onError = { error ->
+ openErrorActivity(
+ ErrorInfo(
+ error,
+ UserAction.DELETE_FROM_HISTORY,
+ "Delete from history"
+ )
+ )
+ },
+ onSuccess = onDeleteWholeStreamHistory
+ )
+ }
+ launch {
+ removeOrphanedRecords(
+ dispatcher,
+ onError = { error ->
+ openErrorActivity(
+ ErrorInfo(
+ error,
+ UserAction.DELETE_FROM_HISTORY,
+ "Clear orphaned records"
+ )
+ )
+ },
+ onSuccess = onRemoveOrphanedRecords
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/RemoveOrphanedRecords.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/RemoveOrphanedRecords.kt
new file mode 100644
index 00000000000..42e9cd00d4d
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/RemoveOrphanedRecords.kt
@@ -0,0 +1,21 @@
+package org.schabi.newpipe.settings.domain.usecases
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.take
+import org.schabi.newpipe.settings.domain.repositories.HistoryRecordRepository
+
+class RemoveOrphanedRecords(
+ private val historyRecordRepository: HistoryRecordRepository,
+) {
+ suspend operator fun invoke(
+ dispatcher: CoroutineDispatcher,
+ onError: (Throwable) -> Unit,
+ onSuccess: () -> Unit,
+ ) =
+ historyRecordRepository.removeOrphanedRecords(dispatcher).catch {
+ onError(it)
+ }.take(1).collect {
+ onSuccess()
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/get_preference/GetPreference.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/get_preference/GetPreference.kt
new file mode 100644
index 00000000000..19ed9d226d8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/get_preference/GetPreference.kt
@@ -0,0 +1,7 @@
+package org.schabi.newpipe.settings.domain.usecases.get_preference
+
+import kotlinx.coroutines.flow.Flow
+
+fun interface GetPreference {
+ operator fun invoke(key: Int, defaultValue: T): Flow
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/get_preference/GetPreferenceFake.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/get_preference/GetPreferenceFake.kt
new file mode 100644
index 00000000000..805a3008356
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/get_preference/GetPreferenceFake.kt
@@ -0,0 +1,16 @@
+package org.schabi.newpipe.settings.domain.usecases.get_preference
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+
+class GetPreferenceFake(
+ private val preferences: MutableStateFlow>,
+) : GetPreference {
+ override fun invoke(key: Int, defaultValue: T): Flow {
+ return preferences.asStateFlow().map {
+ it[key] ?: defaultValue
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/get_preference/GetPreferenceImpl.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/get_preference/GetPreferenceImpl.kt
new file mode 100644
index 00000000000..bb87025b761
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/get_preference/GetPreferenceImpl.kt
@@ -0,0 +1,49 @@
+package org.schabi.newpipe.settings.domain.usecases.get_preference
+
+import android.content.Context
+import android.content.SharedPreferences
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+
+class GetPreferenceImpl(
+ private val sharedPreferences: SharedPreferences,
+ private val context: Context,
+) : GetPreference {
+ override fun invoke(key: Int, defaultValue: T): Flow {
+ val keyString = context.getString(key)
+ return sharedPreferences.getFlowForKey(keyString, defaultValue)
+ }
+
+ private fun SharedPreferences.getFlowForKey(key: String, defaultValue: T) = callbackFlow {
+ val listener =
+ SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey ->
+ if (key == changedKey) {
+ val updated = getPreferenceValue(key, defaultValue)
+ trySend(updated)
+ }
+ }
+ registerOnSharedPreferenceChangeListener(listener)
+ println("Current value for $key: ${getPreferenceValue(key, defaultValue)}")
+ if (contains(key)) {
+ send(getPreferenceValue(key, defaultValue))
+ }
+ awaitClose {
+ unregisterOnSharedPreferenceChangeListener(listener)
+ cancel()
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun SharedPreferences.getPreferenceValue(key: String, defaultValue: T): T {
+ return when (defaultValue) {
+ is Boolean -> getBoolean(key, defaultValue) as T
+ is Int -> getInt(key, defaultValue) as T
+ is Long -> getLong(key, defaultValue) as T
+ is Float -> getFloat(key, defaultValue) as T
+ is String -> getString(key, defaultValue) as T
+ else -> throw IllegalArgumentException("Unsupported type")
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/update_preference/UpdatePreference.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/update_preference/UpdatePreference.kt
new file mode 100644
index 00000000000..0a1b0e966f2
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/update_preference/UpdatePreference.kt
@@ -0,0 +1,5 @@
+package org.schabi.newpipe.settings.domain.usecases.update_preference
+
+fun interface UpdatePreference {
+ suspend operator fun invoke(key: Int, value: T)
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/update_preference/UpdatePreferenceFake.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/update_preference/UpdatePreferenceFake.kt
new file mode 100644
index 00000000000..a67677584e9
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/update_preference/UpdatePreferenceFake.kt
@@ -0,0 +1,16 @@
+package org.schabi.newpipe.settings.domain.usecases.update_preference
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+
+class UpdatePreferenceFake(
+ private val preferences: MutableStateFlow>,
+) : UpdatePreference {
+ override suspend fun invoke(key: Int, value: T) {
+ preferences.update {
+ it.apply {
+ put(key, value)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/update_preference/UpdatePreferenceImpl.kt b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/update_preference/UpdatePreferenceImpl.kt
new file mode 100644
index 00000000000..e51ec731b1c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/domain/usecases/update_preference/UpdatePreferenceImpl.kt
@@ -0,0 +1,18 @@
+package org.schabi.newpipe.settings.domain.usecases.update_preference
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+
+class UpdatePreferenceImpl(
+ private val context: Context,
+ private val sharedPreferences: SharedPreferences,
+ private val setter: SharedPreferences.Editor.(String, T) -> SharedPreferences.Editor,
+) : UpdatePreference {
+ override suspend operator fun invoke(key: Int, value: T) {
+ val stringKey = context.getString(key)
+ sharedPreferences.edit {
+ setter(stringKey, value)
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/HistoryCacheFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/HistoryCacheFragment.kt
new file mode 100644
index 00000000000..d9a2f49c555
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/HistoryCacheFragment.kt
@@ -0,0 +1,39 @@
+package org.schabi.newpipe.settings.presentation.history_cache
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import org.schabi.newpipe.fragments.list.comments.CommentsFragment
+import org.schabi.newpipe.ui.theme.AppTheme
+import org.schabi.newpipe.util.KEY_SERVICE_ID
+import org.schabi.newpipe.util.KEY_URL
+
+class HistoryCacheFragment : Fragment() {
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ) = ComposeView(requireContext()).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ AppTheme {
+ HistoryCacheSettingsScreen(
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ }
+
+ companion object {
+ @JvmStatic
+ fun getInstance(serviceId: Int, url: String?) = CommentsFragment().apply {
+ arguments = bundleOf(KEY_SERVICE_ID to serviceId, KEY_URL to url)
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/HistoryCacheSettingsScreen.kt b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/HistoryCacheSettingsScreen.kt
new file mode 100644
index 00000000000..9a246257a7e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/HistoryCacheSettingsScreen.kt
@@ -0,0 +1,137 @@
+package org.schabi.newpipe.settings.presentation.history_cache
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.hilt.navigation.compose.hiltViewModel
+import org.schabi.newpipe.R
+import org.schabi.newpipe.settings.presentation.history_cache.components.CachePreferencesComponent
+import org.schabi.newpipe.settings.presentation.history_cache.components.HistoryPreferencesComponent
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheEvent
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent.ShowClearWatchHistorySnackbar
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent.ShowDeletePlaybackSnackbar
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent.ShowDeleteSearchHistorySnackbar
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent.ShowReCaptchaCookiesSnackbar
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent.ShowWipeCachedMetadataSnackbar
+import org.schabi.newpipe.settings.presentation.history_cache.state.SwitchPreferencesUiState
+import org.schabi.newpipe.ui.theme.AppTheme
+
+@Composable
+fun HistoryCacheSettingsScreen(
+ modifier: Modifier = Modifier,
+ viewModel: HistoryCacheSettingsViewModel = hiltViewModel(),
+) {
+ val snackBarHostState = remember { SnackbarHostState() }
+ val playBackPositionsDeleted = stringResource(R.string.watch_history_states_deleted)
+ val watchHistoryDeleted = stringResource(R.string.watch_history_deleted)
+ val wipeCachedMetadataSnackbar = stringResource(R.string.metadata_cache_wipe_complete_notice)
+ val deleteSearchHistory = stringResource(R.string.search_history_deleted)
+ val clearReCaptchaCookiesSnackbar = stringResource(R.string.recaptcha_cookies_cleared)
+
+ LaunchedEffect(key1 = true) {
+ viewModel.onInit()
+ viewModel.eventFlow.collect { event ->
+ val message = when (event) {
+ is ShowDeletePlaybackSnackbar -> playBackPositionsDeleted
+ is ShowClearWatchHistorySnackbar -> watchHistoryDeleted
+ is ShowWipeCachedMetadataSnackbar -> wipeCachedMetadataSnackbar
+ is ShowDeleteSearchHistorySnackbar -> deleteSearchHistory
+ is ShowReCaptchaCookiesSnackbar -> clearReCaptchaCookiesSnackbar
+ }
+
+ snackBarHostState.showSnackbar(message)
+ }
+ }
+
+ val switchPreferencesUiState by viewModel.switchState.collectAsState()
+ val recaptchaCookiesEnabled by viewModel.captchaCookies.collectAsState()
+ HistoryCacheComponent(
+ switchPreferences = switchPreferencesUiState,
+ recaptchaCookiesEnabled = recaptchaCookiesEnabled,
+ onEvent = { viewModel.onEvent(it) },
+ snackBarHostState = snackBarHostState,
+ modifier = modifier
+ )
+}
+
+@Composable
+fun HistoryCacheComponent(
+ switchPreferences: SwitchPreferencesUiState,
+ recaptchaCookiesEnabled: Boolean,
+ onEvent: (HistoryCacheEvent) -> Unit,
+ snackBarHostState: SnackbarHostState,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ snackbarHost = {
+ SnackbarHost(snackBarHostState)
+ }
+ ) { padding ->
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .verticalScroll(scrollState),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ HistoryPreferencesComponent(
+ state = switchPreferences,
+ onEvent = { key, value ->
+ onEvent(HistoryCacheEvent.OnUpdateBooleanPreference(key, value))
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ HorizontalDivider(Modifier.fillMaxWidth())
+ CachePreferencesComponent(
+ recaptchaCookiesEnabled = recaptchaCookiesEnabled,
+ onEvent = { onEvent(it) },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun HistoryCacheComponentPreview() {
+ val state by remember {
+ mutableStateOf(
+ SwitchPreferencesUiState()
+ )
+ }
+ AppTheme(
+ useDarkTheme = false
+ ) {
+ Surface {
+ HistoryCacheComponent(
+ switchPreferences = state,
+ recaptchaCookiesEnabled = false,
+ onEvent = {
+ },
+ snackBarHostState = SnackbarHostState(),
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/HistoryCacheSettingsViewModel.kt b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/HistoryCacheSettingsViewModel.kt
new file mode 100644
index 00000000000..49a8d3f1f62
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/HistoryCacheSettingsViewModel.kt
@@ -0,0 +1,204 @@
+package org.schabi.newpipe.settings.presentation.history_cache
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.schabi.newpipe.DownloaderImpl
+import org.schabi.newpipe.R
+import org.schabi.newpipe.error.ErrorInfo
+import org.schabi.newpipe.error.ReCaptchaActivity
+import org.schabi.newpipe.error.UserAction
+import org.schabi.newpipe.error.usecases.OpenErrorActivity
+import org.schabi.newpipe.settings.domain.usecases.DeleteCompleteSearchHistory
+import org.schabi.newpipe.settings.domain.usecases.DeleteCompleteStreamStateHistory
+import org.schabi.newpipe.settings.domain.usecases.DeleteWatchHistory
+import org.schabi.newpipe.settings.domain.usecases.get_preference.GetPreference
+import org.schabi.newpipe.settings.domain.usecases.update_preference.UpdatePreference
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheEvent
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheEvent.OnClickClearSearchHistory
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheEvent.OnClickClearWatchHistory
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheEvent.OnClickDeletePlaybackPositions
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheEvent.OnClickReCaptchaCookies
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheEvent.OnClickWipeCachedMetadata
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheEvent.OnUpdateBooleanPreference
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent.ShowClearWatchHistorySnackbar
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent.ShowDeletePlaybackSnackbar
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent.ShowDeleteSearchHistorySnackbar
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheUiEvent.ShowWipeCachedMetadataSnackbar
+import org.schabi.newpipe.settings.presentation.history_cache.state.SwitchPreferencesUiState
+import org.schabi.newpipe.util.InfoCache
+import javax.inject.Inject
+
+@HiltViewModel
+class HistoryCacheSettingsViewModel @Inject constructor(
+ private val updateStringPreference: UpdatePreference,
+ private val updateBooleanPreference: UpdatePreference,
+ private val getStringPreference: GetPreference,
+ private val getBooleanPreference: GetPreference,
+ private val deleteWatchHistory: DeleteWatchHistory,
+ private val deleteCompleteStreamStateHistory: DeleteCompleteStreamStateHistory,
+ private val deleteCompleteSearchHistory: DeleteCompleteSearchHistory,
+ private val openErrorActivity: OpenErrorActivity,
+) : ViewModel() {
+ private val _switchState = MutableStateFlow(SwitchPreferencesUiState())
+ val switchState: StateFlow = _switchState.asStateFlow()
+
+ private val _captchaCookies = MutableStateFlow(false)
+ val captchaCookies: StateFlow = _captchaCookies.asStateFlow()
+
+ private val _eventFlow = MutableSharedFlow()
+ val eventFlow = _eventFlow.asSharedFlow()
+
+ fun onInit() {
+
+ viewModelScope.launch {
+ val flow = getStringPreference(R.string.recaptcha_cookies_key, "")
+ flow.collect { preference ->
+ _captchaCookies.update {
+ preference.isNotEmpty()
+ }
+ }
+ }
+
+ viewModelScope.launch {
+ getBooleanPreference(R.string.enable_watch_history_key, true).collect { preference ->
+ _switchState.update { oldState ->
+ oldState.copy(
+ watchHistoryEnabled = preference
+ )
+ }
+ }
+ }
+
+ viewModelScope.launch {
+ getBooleanPreference(R.string.enable_playback_resume_key, true).collect { preference ->
+ _switchState.update { oldState ->
+ oldState.copy(
+ resumePlaybackEnabled = preference
+ )
+ }
+ }
+ }
+
+ viewModelScope.launch {
+ getBooleanPreference(
+ R.string.enable_playback_state_lists_key,
+ true
+ ).collect { preference ->
+ _switchState.update { oldState ->
+ oldState.copy(
+ positionsInListsEnabled = preference
+ )
+ }
+ }
+ }
+ viewModelScope.launch {
+ getBooleanPreference(R.string.enable_search_history_key, true).collect { preference ->
+ _switchState.update { oldState ->
+ oldState.copy(
+ searchHistoryEnabled = preference
+ )
+ }
+ }
+ }
+ }
+
+ fun onEvent(event: HistoryCacheEvent) {
+ when (event) {
+ is OnUpdateBooleanPreference -> {
+ viewModelScope.launch {
+ updateBooleanPreference(event.key, event.isEnabled)
+ }
+ }
+
+ is OnClickWipeCachedMetadata -> {
+ InfoCache.getInstance().clearCache()
+ viewModelScope.launch {
+ _eventFlow.emit(ShowWipeCachedMetadataSnackbar)
+ }
+ }
+
+ is OnClickClearWatchHistory -> {
+ viewModelScope.launch {
+ deleteWatchHistory(
+ onDeletePlaybackStates = {
+ viewModelScope.launch {
+ _eventFlow.emit(ShowDeletePlaybackSnackbar)
+ }
+ },
+ onDeleteWholeStreamHistory = {
+ viewModelScope.launch {
+ _eventFlow.emit(ShowClearWatchHistorySnackbar)
+ }
+ },
+ onRemoveOrphanedRecords = {
+ // TODO: ask why original in android fragments did nothing
+ }
+ )
+ }
+ }
+
+ is OnClickDeletePlaybackPositions -> {
+ viewModelScope.launch {
+ deleteCompleteStreamStateHistory(
+ Dispatchers.IO,
+ onError = { error ->
+ openErrorActivity(
+ ErrorInfo(
+ error,
+ UserAction.DELETE_FROM_HISTORY,
+ "Delete playback states"
+ )
+ )
+ },
+ onSuccess = {
+ viewModelScope.launch {
+ _eventFlow.emit(ShowDeletePlaybackSnackbar)
+ }
+ }
+ )
+ }
+ }
+
+ is OnClickClearSearchHistory -> {
+ viewModelScope.launch {
+ deleteCompleteSearchHistory(
+ dispatcher = Dispatchers.IO,
+ onError = { error ->
+ openErrorActivity(
+ ErrorInfo(
+ error,
+ UserAction.DELETE_FROM_HISTORY,
+ "Delete search history"
+ )
+ )
+ },
+ onSuccess = {
+ viewModelScope.launch {
+ _eventFlow.emit(ShowDeleteSearchHistorySnackbar)
+ }
+ }
+ )
+ }
+ }
+
+ is OnClickReCaptchaCookies -> {
+ viewModelScope.launch {
+ updateStringPreference(event.key, "")
+ DownloaderImpl.getInstance()
+ .setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, "")
+ _eventFlow.emit(ShowWipeCachedMetadataSnackbar)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/components/CachePreferences.kt b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/components/CachePreferences.kt
new file mode 100644
index 00000000000..c525938141c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/components/CachePreferences.kt
@@ -0,0 +1,174 @@
+package org.schabi.newpipe.settings.presentation.history_cache.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import org.schabi.newpipe.R
+import org.schabi.newpipe.settings.components.irreversible_preference.IrreversiblePreferenceComponent
+import org.schabi.newpipe.settings.presentation.history_cache.events.HistoryCacheEvent
+import org.schabi.newpipe.ui.theme.AppTheme
+import org.schabi.newpipe.ui.theme.SizeTokens.SpacingMedium
+
+@Composable
+fun CachePreferencesComponent(
+ recaptchaCookiesEnabled: Boolean,
+ onEvent: (HistoryCacheEvent) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var dialogTitle by remember { mutableStateOf("") }
+ var dialogOnClick by remember { mutableStateOf({}) }
+ var isDialogVisible by remember { mutableStateOf(false) }
+
+ val deleteViewHistory = stringResource(id = R.string.delete_view_history_alert)
+ val deletePlayBacks = stringResource(id = R.string.delete_playback_states_alert)
+ val deleteSearchHistory = stringResource(id = R.string.delete_search_history_alert)
+
+ val onOpenDialog: (String, HistoryCacheEvent) -> Unit = { title, eventType ->
+ dialogTitle = title
+ isDialogVisible = true
+ dialogOnClick = {
+ onEvent(eventType)
+ isDialogVisible = false
+ }
+ }
+
+ Column(
+ modifier = modifier,
+ ) {
+ Text(
+ stringResource(id = R.string.settings_category_clear_data_title),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(SpacingMedium)
+ )
+ IrreversiblePreferenceComponent(
+ title = stringResource(id = R.string.metadata_cache_wipe_title),
+ summary = stringResource(id = R.string.metadata_cache_wipe_summary),
+ onClick = {
+ onEvent(HistoryCacheEvent.OnClickWipeCachedMetadata(R.string.metadata_cache_wipe_key))
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ IrreversiblePreferenceComponent(
+ title = stringResource(id = R.string.clear_views_history_title),
+ summary = stringResource(id = R.string.clear_views_history_summary),
+ onClick = {
+ onOpenDialog(
+ deleteViewHistory,
+ HistoryCacheEvent.OnClickClearWatchHistory(R.string.clear_views_history_key)
+ )
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ IrreversiblePreferenceComponent(
+ title = stringResource(id = R.string.clear_playback_states_title),
+ summary = stringResource(id = R.string.clear_playback_states_summary),
+ onClick = {
+ onOpenDialog(
+ deletePlayBacks,
+ HistoryCacheEvent.OnClickDeletePlaybackPositions(R.string.clear_playback_states_key)
+ )
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ IrreversiblePreferenceComponent(
+ title = stringResource(id = R.string.clear_search_history_title),
+ summary = stringResource(id = R.string.clear_search_history_summary),
+ onClick = {
+ onOpenDialog(
+ deleteSearchHistory,
+ HistoryCacheEvent.OnClickClearSearchHistory(R.string.clear_search_history_key)
+ )
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ IrreversiblePreferenceComponent(
+ title = stringResource(id = R.string.clear_cookie_title),
+ summary = stringResource(id = R.string.clear_cookie_summary),
+ onClick = {
+ onEvent(HistoryCacheEvent.OnClickReCaptchaCookies(R.string.recaptcha_cookies_key))
+ },
+ enabled = recaptchaCookiesEnabled,
+ modifier = Modifier.fillMaxWidth()
+ )
+ if (isDialogVisible) {
+ CacheAlertDialog(
+ dialogTitle = dialogTitle,
+ onClickCancel = { isDialogVisible = false },
+ onClick = dialogOnClick
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun CachePreferencesComponentPreview() {
+ AppTheme {
+ Scaffold { padding ->
+ CachePreferencesComponent(
+ recaptchaCookiesEnabled = false,
+ onEvent = {},
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(padding)
+ )
+ }
+ }
+}
+
+@Composable
+private fun CacheAlertDialog(
+ dialogTitle: String,
+ onClickCancel: () -> Unit,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ AlertDialog(
+ onDismissRequest = onClickCancel,
+ confirmButton = {
+ TextButton(onClick = onClick) {
+ Text(text = "Delete")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onClickCancel) {
+ Text(text = "Cancel")
+ }
+ },
+ title = {
+ Text(text = dialogTitle)
+ },
+ text = {
+ Text(text = "This is an irreversible action")
+ },
+ modifier = modifier
+ )
+}
+
+@Preview(backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun CacheAlertDialogPreview() {
+ AppTheme {
+ Scaffold { padding ->
+ CacheAlertDialog(
+ dialogTitle = "Delete view history",
+ onClickCancel = {},
+ onClick = {},
+ modifier = Modifier.padding(padding)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/components/HistoryPreferences.kt b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/components/HistoryPreferences.kt
new file mode 100644
index 00000000000..c9ae14e413f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/components/HistoryPreferences.kt
@@ -0,0 +1,94 @@
+package org.schabi.newpipe.settings.presentation.history_cache.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import org.schabi.newpipe.R
+import org.schabi.newpipe.settings.components.switch_preference.SwitchPreferenceComponent
+import org.schabi.newpipe.settings.presentation.history_cache.state.SwitchPreferencesUiState
+import org.schabi.newpipe.ui.theme.AppTheme
+
+@Composable
+fun HistoryPreferencesComponent(
+ state: SwitchPreferencesUiState,
+ onEvent: (Int, Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ ) {
+ SwitchPreferenceComponent(
+ title = stringResource(id = R.string.enable_watch_history_title),
+ summary = stringResource(id = R.string.enable_watch_history_summary),
+ isChecked = state.watchHistoryEnabled,
+ onCheckedChange = {
+ onEvent(R.string.enable_watch_history_key, it)
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ SwitchPreferenceComponent(
+ title = stringResource(id = R.string.enable_playback_resume_title),
+ summary = stringResource(id = R.string.enable_playback_resume_summary),
+ isChecked = state.resumePlaybackEnabled,
+ onCheckedChange = {
+ onEvent(R.string.enable_playback_resume_key, it)
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ SwitchPreferenceComponent(
+ title = stringResource(id = R.string.enable_playback_state_lists_title),
+ summary = stringResource(id = R.string.enable_playback_state_lists_summary),
+ isChecked = state.positionsInListsEnabled,
+ onCheckedChange = {
+ onEvent(R.string.enable_playback_state_lists_key, it)
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ SwitchPreferenceComponent(
+ title = stringResource(id = R.string.enable_search_history_title),
+ summary = stringResource(id = R.string.enable_search_history_summary),
+ isChecked = state.searchHistoryEnabled,
+ onCheckedChange = {
+ onEvent(R.string.enable_search_history_key, it)
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SwitchPreferencesComponentPreview() {
+ var state by remember {
+ mutableStateOf(
+ SwitchPreferencesUiState()
+ )
+ }
+ AppTheme(
+ useDarkTheme = false
+ ) {
+ Scaffold { padding ->
+ HistoryPreferencesComponent(
+ state = state,
+ onEvent = { _, _ ->
+ // Mock behaviour to preview
+ state = state.copy(
+ watchHistoryEnabled = !state.watchHistoryEnabled
+ )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(padding),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/events/HistoryCacheEvent.kt b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/events/HistoryCacheEvent.kt
new file mode 100644
index 00000000000..0bd5d49c596
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/events/HistoryCacheEvent.kt
@@ -0,0 +1,10 @@
+package org.schabi.newpipe.settings.presentation.history_cache.events
+
+sealed class HistoryCacheEvent {
+ data class OnUpdateBooleanPreference(val key: Int, val isEnabled: Boolean) : HistoryCacheEvent()
+ data class OnClickWipeCachedMetadata(val key: Int) : HistoryCacheEvent()
+ data class OnClickClearWatchHistory(val key: Int) : HistoryCacheEvent()
+ data class OnClickDeletePlaybackPositions(val key: Int) : HistoryCacheEvent()
+ data class OnClickClearSearchHistory(val key: Int) : HistoryCacheEvent()
+ data class OnClickReCaptchaCookies(val key: Int) : HistoryCacheEvent()
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/events/HistoryCacheUiEvent.kt b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/events/HistoryCacheUiEvent.kt
new file mode 100644
index 00000000000..c40c1b45fdc
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/events/HistoryCacheUiEvent.kt
@@ -0,0 +1,9 @@
+package org.schabi.newpipe.settings.presentation.history_cache.events
+
+sealed class HistoryCacheUiEvent {
+ data object ShowDeletePlaybackSnackbar : HistoryCacheUiEvent()
+ data object ShowDeleteSearchHistorySnackbar : HistoryCacheUiEvent()
+ data object ShowClearWatchHistorySnackbar : HistoryCacheUiEvent()
+ data object ShowReCaptchaCookiesSnackbar : HistoryCacheUiEvent()
+ data object ShowWipeCachedMetadataSnackbar : HistoryCacheUiEvent()
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/state/SwitchPreferencesUiState.kt b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/state/SwitchPreferencesUiState.kt
new file mode 100644
index 00000000000..887aaf5acb9
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/presentation/history_cache/state/SwitchPreferencesUiState.kt
@@ -0,0 +1,10 @@
+package org.schabi.newpipe.settings.presentation.history_cache.state
+
+import androidx.compose.runtime.Stable
+@Stable
+data class SwitchPreferencesUiState(
+ val watchHistoryEnabled: Boolean = false,
+ val resumePlaybackEnabled: Boolean = false,
+ val positionsInListsEnabled: Boolean = false,
+ val searchHistoryEnabled: Boolean = false,
+)
diff --git a/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt b/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt
new file mode 100644
index 00000000000..b788932a269
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt
@@ -0,0 +1,137 @@
+package org.schabi.newpipe.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SearchBar
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import org.schabi.newpipe.R
+import org.schabi.newpipe.ui.theme.AppTheme
+import org.schabi.newpipe.ui.theme.SizeTokens
+
+@Composable
+fun TextAction(text: String, modifier: Modifier = Modifier) {
+ Text(text = text, color = MaterialTheme.colorScheme.onSurface, modifier = modifier)
+}
+
+@Composable
+fun NavigationIcon() {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back",
+ modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall)
+ )
+}
+
+@Composable
+fun SearchSuggestionItem(text: String) {
+ // TODO: Add more components here to display all the required details of a search suggestion item.
+ Text(text = text)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun Toolbar(
+ title: String,
+ modifier: Modifier = Modifier,
+ hasNavigationIcon: Boolean = true,
+ hasSearch: Boolean = false,
+ onSearchQueryChange: ((String) -> List)? = null,
+ actions: @Composable RowScope.() -> Unit = {}
+) {
+ var isSearchActive by remember { mutableStateOf(false) }
+ var query by remember { mutableStateOf("") }
+
+ Column {
+ TopAppBar(
+ title = { Text(text = title) },
+ modifier = modifier,
+ navigationIcon = { if (hasNavigationIcon) NavigationIcon() },
+ actions = {
+ actions()
+ if (hasSearch) {
+ IconButton(onClick = { isSearchActive = true }) {
+ Icon(
+ painterResource(id = R.drawable.ic_search),
+ contentDescription = stringResource(id = R.string.search),
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ )
+ if (isSearchActive) {
+ SearchBar(
+ query = query,
+ onQueryChange = { query = it },
+ onSearch = {},
+ placeholder = {
+ Text(text = stringResource(id = R.string.search))
+ },
+ active = true,
+ onActiveChange = {
+ isSearchActive = it
+ },
+ colors = SearchBarDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.background,
+ inputFieldColors = SearchBarDefaults.inputFieldColors(
+ focusedTextColor = MaterialTheme.colorScheme.onBackground,
+ unfocusedTextColor = MaterialTheme.colorScheme.onBackground
+ )
+ )
+ ) {
+ onSearchQueryChange?.invoke(query)?.takeIf { it.isNotEmpty() }
+ ?.map { suggestionText -> SearchSuggestionItem(text = suggestionText) }
+ ?: run {
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column {
+ Text(text = "╰(°●°╰)")
+ Text(text = stringResource(id = R.string.search_no_results))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ToolbarPreview() {
+ AppTheme {
+ Toolbar(
+ title = "Title",
+ hasSearch = true,
+ onSearchQueryChange = { emptyList() },
+ actions = {
+ TextAction(text = "Action1")
+ TextAction(text = "Action2")
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/theme/Color.kt b/app/src/main/java/org/schabi/newpipe/ui/theme/Color.kt
new file mode 100644
index 00000000000..28b3e65c437
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/theme/Color.kt
@@ -0,0 +1,63 @@
+package org.schabi.newpipe.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val md_theme_light_primary = Color(0xFFBB171C)
+val md_theme_light_onPrimary = Color(0xFFFFFFFF)
+val md_theme_light_primaryContainer = Color(0xFFFFDAD6)
+val md_theme_light_onPrimaryContainer = Color(0xFF410002)
+val md_theme_light_secondary = Color(0xFF984061)
+val md_theme_light_onSecondary = Color(0xFFFFFFFF)
+val md_theme_light_secondaryContainer = Color(0xFFFFD9E2)
+val md_theme_light_onSecondaryContainer = Color(0xFF3E001D)
+val md_theme_light_tertiary = Color(0xFF006874)
+val md_theme_light_onTertiary = Color(0xFFFFFFFF)
+val md_theme_light_tertiaryContainer = Color(0xFF97F0FF)
+val md_theme_light_onTertiaryContainer = Color(0xFF001F24)
+val md_theme_light_error = Color(0xFFBA1A1A)
+val md_theme_light_errorContainer = Color(0xFFFFDAD6)
+val md_theme_light_onError = Color(0xFFFFFFFF)
+val md_theme_light_onErrorContainer = Color(0xFF410002)
+val md_theme_light_background = Color(0xFFEEEEEE)
+val md_theme_light_onBackground = Color(0xFF1B1B1B)
+val md_theme_light_surface = Color(0xFFFFFFFF)
+val md_theme_light_onSurface = Color(0xFFE53835)
+val md_theme_light_surfaceVariant = Color(0xFFF5DDDB)
+val md_theme_light_onSurfaceVariant = Color(0xFF534341)
+val md_theme_light_outline = Color(0xFF857371)
+val md_theme_light_inverseOnSurface = Color(0xFFD6F6FF)
+val md_theme_light_inverseSurface = Color(0xFF00363F)
+val md_theme_light_inversePrimary = Color(0xFFFFB4AC)
+val md_theme_light_surfaceTint = Color(0xFFBB171C)
+val md_theme_light_outlineVariant = Color(0xFFD8C2BF)
+val md_theme_light_scrim = Color(0xFF000000)
+
+val md_theme_dark_primary = Color(0xFFFFB4AC)
+val md_theme_dark_onPrimary = Color(0xFF690006)
+val md_theme_dark_primaryContainer = Color(0xFF93000D)
+val md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD6)
+val md_theme_dark_secondary = Color(0xFFFFB1C8)
+val md_theme_dark_onSecondary = Color(0xFF5E1133)
+val md_theme_dark_secondaryContainer = Color(0xFF7B2949)
+val md_theme_dark_onSecondaryContainer = Color(0xFFFFD9E2)
+val md_theme_dark_tertiary = Color(0xFF4FD8EB)
+val md_theme_dark_onTertiary = Color(0xFF00363D)
+val md_theme_dark_tertiaryContainer = Color(0xFF004F58)
+val md_theme_dark_onTertiaryContainer = Color(0xFF97F0FF)
+val md_theme_dark_error = Color(0xFFFFB4AB)
+val md_theme_dark_errorContainer = Color(0xFF93000A)
+val md_theme_dark_onError = Color(0xFF690005)
+val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
+val md_theme_dark_background = Color(0xFF212121)
+val md_theme_dark_onBackground = Color(0xFFFFFFFF)
+val md_theme_dark_surface = Color(0xFF992521)
+val md_theme_dark_onSurface = Color(0xFFFFFFFF)
+val md_theme_dark_surfaceVariant = Color(0xFF534341)
+val md_theme_dark_onSurfaceVariant = Color(0xFFD8C2BF)
+val md_theme_dark_outline = Color(0xFFA08C8A)
+val md_theme_dark_inverseOnSurface = Color(0xFF001F25)
+val md_theme_dark_inverseSurface = Color(0xFFA6EEFF)
+val md_theme_dark_inversePrimary = Color(0xFFBB171C)
+val md_theme_dark_surfaceTint = Color(0xFFFFB4AC)
+val md_theme_dark_outlineVariant = Color(0xFF534341)
+val md_theme_dark_scrim = Color(0xFF000000)
diff --git a/app/src/main/java/org/schabi/newpipe/ui/theme/SizeTokens.kt b/app/src/main/java/org/schabi/newpipe/ui/theme/SizeTokens.kt
new file mode 100644
index 00000000000..d8104d7aea8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/theme/SizeTokens.kt
@@ -0,0 +1,13 @@
+package org.schabi.newpipe.ui.theme
+
+import androidx.compose.ui.unit.dp
+
+internal object SizeTokens {
+ val SpacingExtraSmall = 4.dp
+ val SpacingSmall = 8.dp
+ val SpacingMedium = 16.dp
+ val SpacingLarge = 24.dp
+ val SpacingExtraLarge = 32.dp
+
+ val SpaceMinSize = 44.dp // Minimum tappable size required for accessibility
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt b/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt
new file mode 100644
index 00000000000..846794d725c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt
@@ -0,0 +1,79 @@
+package org.schabi.newpipe.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+
+private val LightColors = lightColorScheme(
+ primary = md_theme_light_primary,
+ onPrimary = md_theme_light_onPrimary,
+ primaryContainer = md_theme_light_primaryContainer,
+ onPrimaryContainer = md_theme_light_onPrimaryContainer,
+ secondary = md_theme_light_secondary,
+ onSecondary = md_theme_light_onSecondary,
+ secondaryContainer = md_theme_light_secondaryContainer,
+ onSecondaryContainer = md_theme_light_onSecondaryContainer,
+ tertiary = md_theme_light_tertiary,
+ onTertiary = md_theme_light_onTertiary,
+ tertiaryContainer = md_theme_light_tertiaryContainer,
+ onTertiaryContainer = md_theme_light_onTertiaryContainer,
+ error = md_theme_light_error,
+ errorContainer = md_theme_light_errorContainer,
+ onError = md_theme_light_onError,
+ onErrorContainer = md_theme_light_onErrorContainer,
+ background = md_theme_light_background,
+ onBackground = md_theme_light_onBackground,
+ surface = md_theme_light_surface,
+ onSurface = md_theme_light_onSurface,
+ surfaceVariant = md_theme_light_surfaceVariant,
+ onSurfaceVariant = md_theme_light_onSurfaceVariant,
+ outline = md_theme_light_outline,
+ inverseOnSurface = md_theme_light_inverseOnSurface,
+ inverseSurface = md_theme_light_inverseSurface,
+ inversePrimary = md_theme_light_inversePrimary,
+ surfaceTint = md_theme_light_surfaceTint,
+ outlineVariant = md_theme_light_outlineVariant,
+ scrim = md_theme_light_scrim,
+)
+
+private val DarkColors = darkColorScheme(
+ primary = md_theme_dark_primary,
+ onPrimary = md_theme_dark_onPrimary,
+ primaryContainer = md_theme_dark_primaryContainer,
+ onPrimaryContainer = md_theme_dark_onPrimaryContainer,
+ secondary = md_theme_dark_secondary,
+ onSecondary = md_theme_dark_onSecondary,
+ secondaryContainer = md_theme_dark_secondaryContainer,
+ onSecondaryContainer = md_theme_dark_onSecondaryContainer,
+ tertiary = md_theme_dark_tertiary,
+ onTertiary = md_theme_dark_onTertiary,
+ tertiaryContainer = md_theme_dark_tertiaryContainer,
+ onTertiaryContainer = md_theme_dark_onTertiaryContainer,
+ error = md_theme_dark_error,
+ errorContainer = md_theme_dark_errorContainer,
+ onError = md_theme_dark_onError,
+ onErrorContainer = md_theme_dark_onErrorContainer,
+ background = md_theme_dark_background,
+ onBackground = md_theme_dark_onBackground,
+ surface = md_theme_dark_surface,
+ onSurface = md_theme_dark_onSurface,
+ surfaceVariant = md_theme_dark_surfaceVariant,
+ onSurfaceVariant = md_theme_dark_onSurfaceVariant,
+ outline = md_theme_dark_outline,
+ inverseOnSurface = md_theme_dark_inverseOnSurface,
+ inverseSurface = md_theme_dark_inverseSurface,
+ inversePrimary = md_theme_dark_inversePrimary,
+ surfaceTint = md_theme_dark_surfaceTint,
+ outlineVariant = md_theme_dark_outlineVariant,
+ scrim = md_theme_dark_scrim,
+)
+
+@Composable
+fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = if (useDarkTheme) DarkColors else LightColors,
+ content = content
+ )
+}
diff --git a/app/src/main/res/xml/main_settings.xml b/app/src/main/res/xml/main_settings.xml
index 5f96989f979..7920b7a0644 100644
--- a/app/src/main/res/xml/main_settings.xml
+++ b/app/src/main/res/xml/main_settings.xml
@@ -23,7 +23,7 @@
app:iconSpaceReserved="false" />
diff --git a/build.gradle b/build.gradle
index 6d19a6f8a84..97a619d4d53 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,14 +2,15 @@
buildscript {
ext.kotlin_version = '1.9.10'
+ ext.dagger_version ='2.51.1'
repositories {
google()
mavenCentral()
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.2.0'
+ classpath 'com.android.tools.build:gradle:8.5.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
-
+ classpath("com.google.dagger:hilt-android-gradle-plugin:$dagger_version")
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
@@ -19,6 +20,7 @@ allprojects {
repositories {
google()
mavenCentral()
+ gradlePluginPortal()
maven { url "https://jitpack.io" }
maven { url "https://repo.clojars.org" }
}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index d022615ff6d..efe1dac97da 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=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists