diff --git a/app/build.gradle b/app/build.gradle index 24e3d32cf..b9e621aca 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -310,9 +310,11 @@ dependencies { // Coroutines interop implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.8.1' - // Hilt + // Hilt & Dagger implementation("com.google.dagger:hilt-android:2.51.1") kapt("com.google.dagger:hilt-compiler:2.51.1") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + kapt("androidx.hilt:hilt-compiler:1.2.0") // Scroll implementation 'com.github.nanihadesuka:LazyColumnScrollbar:2.2.0' diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 41abd10c1..5d956ff41 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -94,6 +94,9 @@ import java.util.ArrayList; 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 000000000..1365f80f8 --- /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 000000000..4e20fe30c --- /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/usecases/OpenErrorActivity.kt b/app/src/main/java/org/schabi/newpipe/error/usecases/OpenErrorActivity.kt new file mode 100644 index 000000000..5168c0d3c --- /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 fac358075..830fa9b8f 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 android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.viewbinding.ViewBinding; import com.evernote.android.state.State; @@ -27,6 +28,7 @@ import org.schabi.newpipe.database.stream.model.StreamEntity; 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; @@ -35,7 +37,6 @@ import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; 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 class StatisticsPlaylistFragment @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 @@ public class StatisticsPlaylistFragment 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/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java index 0d57ce174..0aab12114 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -43,6 +43,9 @@ import org.schabi.newpipe.views.FocusOverlayView; import java.util.concurrent.TimeUnit; +import dagger.hilt.android.AndroidEntryPoint; + + /* * Created by Christian Schabesberger on 31.08.15. * @@ -63,6 +66,7 @@ import java.util.concurrent.TimeUnit; * 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/components/irreversible_preference/IrreversiblePreference.kt b/app/src/main/java/org/schabi/newpipe/settings/components/irreversible_preference/IrreversiblePreference.kt new file mode 100644 index 000000000..f9cc79099 --- /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 000000000..61a64e863 --- /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 000000000..d0f271025 --- /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 000000000..3b9edaa48 --- /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 000000000..6e03fec47 --- /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 000000000..b1d4abeeb --- /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 000000000..7b2c2d99a --- /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 000000000..0b516966d --- /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 000000000..e8416a492 --- /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 000000000..403f6f1c0 --- /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 000000000..42e9cd00d --- /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 000000000..19ed9d226 --- /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 000000000..805a30083 --- /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 000000000..bb87025b7 --- /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 000000000..0a1b0e966 --- /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 000000000..a67677584 --- /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 000000000..e51ec731b --- /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 000000000..d9a2f49c5 --- /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 000000000..9a246257a --- /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 000000000..49a8d3f1f --- /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 000000000..c52593814 --- /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 000000000..c9ae14e41 --- /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 000000000..0bd5d49c5 --- /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 000000000..c40c1b45f --- /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 000000000..887aaf5ac --- /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/res/xml/main_settings.xml b/app/src/main/res/xml/main_settings.xml index 5f96989f9..7920b7a06 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" />