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