onCompletion) {
- disposables.clear();
- if (commentText != null) {
- TextLinkifier.fromDescription(itemContentView, commentText,
- HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables,
- onCompletion);
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java
index a84c9840416..80f62eed3d1 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java
@@ -12,10 +12,6 @@
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.Localization;
-import androidx.preference.PreferenceManager;
-
-import static org.schabi.newpipe.MainActivity.DEBUG;
-
/*
* Created by Christian Schabesberger on 01.08.16.
*
@@ -81,7 +77,9 @@ private String getStreamInfoDetailLine(final StreamInfoItem infoItem) {
}
}
- final String uploadDate = getFormattedRelativeUploadDate(infoItem);
+ final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(),
+ infoItem.getUploadDate(),
+ infoItem.getTextualUploadDate());
if (!TextUtils.isEmpty(uploadDate)) {
if (viewsAndDate.isEmpty()) {
return uploadDate;
@@ -92,20 +90,4 @@ private String getStreamInfoDetailLine(final StreamInfoItem infoItem) {
return viewsAndDate;
}
-
- private String getFormattedRelativeUploadDate(final StreamInfoItem infoItem) {
- if (infoItem.getUploadDate() != null) {
- String formattedRelativeTime = Localization
- .relativeTime(infoItem.getUploadDate().offsetDateTime());
-
- if (DEBUG && PreferenceManager.getDefaultSharedPreferences(itemBuilder.getContext())
- .getBoolean(itemBuilder.getContext()
- .getString(R.string.show_original_time_ago_key), false)) {
- formattedRelativeTime += " (" + infoItem.getTextualUploadDate() + ")";
- }
- return formattedRelativeTime;
- } else {
- return infoItem.getTextualUploadDate();
- }
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
new file mode 100644
index 00000000000..61721d5467c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
@@ -0,0 +1,9 @@
+package org.schabi.newpipe.ktx
+
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.core.os.BundleCompat
+
+inline fun Bundle.parcelableArrayList(key: String?): ArrayList? {
+ return BundleCompat.getParcelableArrayList(this, key, T::class.java)
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
index b9409cb9d7e..b33619dea7a 100644
--- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
@@ -14,6 +14,7 @@
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.local.history.HistoryRecordManager;
+import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.LocalItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
@@ -24,6 +25,7 @@
import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
+import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
@@ -73,10 +75,12 @@ public class LocalItemListAdapter extends RecyclerView.Adapter localItems;
@@ -87,6 +91,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter, Void> {
+public final class BookmarkFragment extends BaseLocalListFragment, Void>
+ implements DebounceSavable {
+
+ private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
- protected Parcelable itemsListState;
+ Parcelable itemsListState;
private Subscription databaseSubscription;
private CompositeDisposable disposables = new CompositeDisposable();
private LocalPlaylistManager localPlaylistManager;
private RemotePlaylistManager remotePlaylistManager;
+ private ItemTouchHelper itemTouchHelper;
+
+ /* Have the bookmarked playlists been fully loaded from db */
+ private AtomicBoolean isLoadingComplete;
+
+ /* Gives enough time to avoid interrupting user sorting operations */
+ @Nullable
+ private DebounceSaver debounceSaver;
+
+ private List> deletedItems;
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Creation
@@ -65,6 +86,11 @@ public void onCreate(final Bundle savedInstanceState) {
localPlaylistManager = new LocalPlaylistManager(database);
remotePlaylistManager = new RemotePlaylistManager(database);
disposables = new CompositeDisposable();
+
+ isLoadingComplete = new AtomicBoolean();
+ debounceSaver = new DebounceSaver(3000, this);
+
+ deletedItems = new ArrayList<>();
}
@Nullable
@@ -91,10 +117,20 @@ public void onResume() {
// Fragment LifeCycle - Views
///////////////////////////////////////////////////////////////////////////
+ @Override
+ protected void initViews(final View rootView, final Bundle savedInstanceState) {
+ super.initViews(rootView, savedInstanceState);
+
+ itemListAdapter.setUseItemHandle(true);
+ }
+
@Override
protected void initListeners() {
super.initListeners();
+ itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
+ itemTouchHelper.attachToRecyclerView(itemsList);
+
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
@@ -102,7 +138,7 @@ public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
- NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.uid,
+ NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(),
entry.name);
} else if (selectedItem instanceof PlaylistRemoteEntity) {
@@ -123,6 +159,14 @@ public void held(final LocalItem selectedItem) {
showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem);
}
}
+
+ @Override
+ public void drag(final LocalItem selectedItem,
+ final RecyclerView.ViewHolder viewHolder) {
+ if (itemTouchHelper != null) {
+ itemTouchHelper.startDrag(viewHolder);
+ }
+ }
});
}
@@ -134,8 +178,13 @@ public void held(final LocalItem selectedItem) {
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
- Flowable.combineLatest(localPlaylistManager.getPlaylists(),
- remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge)
+ if (debounceSaver != null) {
+ disposables.add(debounceSaver.getDebouncedSaver());
+ debounceSaver.setNoChangesToSave();
+ }
+ isLoadingComplete.set(false);
+
+ getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager)
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getPlaylistsSubscriber());
@@ -149,6 +198,9 @@ public void startLoading(final boolean forceLoad) {
public void onPause() {
super.onPause();
itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
+
+ // Save on exit
+ saveImmediate();
}
@Override
@@ -163,19 +215,27 @@ public void onDestroyView() {
}
databaseSubscription = null;
+ itemTouchHelper = null;
}
@Override
public void onDestroy() {
super.onDestroy();
+ if (debounceSaver != null) {
+ debounceSaver.getDebouncedSaveSignal().onComplete();
+ }
if (disposables != null) {
disposables.dispose();
}
+ debounceSaver = null;
disposables = null;
localPlaylistManager = null;
remotePlaylistManager = null;
itemsListState = null;
+
+ isLoadingComplete = null;
+ deletedItems = null;
}
///////////////////////////////////////////////////////////////////////////
@@ -183,10 +243,12 @@ public void onDestroy() {
///////////////////////////////////////////////////////////////////////////
private Subscriber> getPlaylistsSubscriber() {
- return new Subscriber>() {
+ return new Subscriber<>() {
@Override
public void onSubscribe(final Subscription s) {
showLoading();
+ isLoadingComplete.set(false);
+
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
@@ -196,7 +258,10 @@ public void onSubscribe(final Subscription s) {
@Override
public void onNext(final List subscriptions) {
- handleResult(subscriptions);
+ if (debounceSaver == null || !debounceSaver.getIsModified()) {
+ handleResult(subscriptions);
+ isLoadingComplete.set(true);
+ }
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
@@ -209,7 +274,8 @@ public void onError(final Throwable exception) {
}
@Override
- public void onComplete() { }
+ public void onComplete() {
+ }
};
}
@@ -244,12 +310,183 @@ protected void resetFragment() {
}
}
+ /*//////////////////////////////////////////////////////////////////////////
+ // Playlist Metadata Manipulation
+ //////////////////////////////////////////////////////////////////////////*/
+
+ private void changeLocalPlaylistName(final long id, final String name) {
+ if (localPlaylistManager == null) {
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(TAG, "Updating playlist id=[" + id + "] "
+ + "with new name=[" + name + "] items");
+ }
+
+ final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
+ new ErrorInfo(throwable,
+ UserAction.REQUESTED_BOOKMARK,
+ "Changing playlist name")));
+ disposables.add(disposable);
+ }
+
+ private void deleteItem(final PlaylistLocalItem item) {
+ if (itemListAdapter == null) {
+ return;
+ }
+ itemListAdapter.removeItem(item);
+
+ if (item instanceof PlaylistMetadataEntry) {
+ deletedItems.add(new Pair<>(item.getUid(),
+ LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM));
+ } else if (item instanceof PlaylistRemoteEntity) {
+ deletedItems.add(new Pair<>(item.getUid(),
+ LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM));
+ }
+
+ if (debounceSaver != null) {
+ debounceSaver.setHasChangesToSave();
+ saveImmediate();
+ }
+ }
+
+ @Override
+ public void saveImmediate() {
+ if (itemListAdapter == null) {
+ return;
+ }
+
+ // List must be loaded and modified in order to save
+ if (isLoadingComplete == null || debounceSaver == null
+ || !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
+ return;
+ }
+
+ final List items = itemListAdapter.getItemsList();
+ final List localItemsUpdate = new ArrayList<>();
+ final List localItemsDeleteUid = new ArrayList<>();
+ final List remoteItemsUpdate = new ArrayList<>();
+ final List remoteItemsDeleteUid = new ArrayList<>();
+
+ // Calculate display index
+ for (int i = 0; i < items.size(); i++) {
+ final LocalItem item = items.get(i);
+
+ if (item instanceof PlaylistMetadataEntry
+ && ((PlaylistMetadataEntry) item).getDisplayIndex() != i) {
+ ((PlaylistMetadataEntry) item).setDisplayIndex(i);
+ localItemsUpdate.add((PlaylistMetadataEntry) item);
+ } else if (item instanceof PlaylistRemoteEntity
+ && ((PlaylistRemoteEntity) item).getDisplayIndex() != i) {
+ ((PlaylistRemoteEntity) item).setDisplayIndex(i);
+ remoteItemsUpdate.add((PlaylistRemoteEntity) item);
+ }
+ }
+
+ // Find deleted items
+ for (final Pair item : deletedItems) {
+ if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)) {
+ localItemsDeleteUid.add(item.first);
+ } else if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)) {
+ remoteItemsDeleteUid.add(item.first);
+ }
+ }
+
+ deletedItems.clear();
+
+ // 1. Update local playlists
+ // 2. Update remote playlists
+ // 3. Set NoChangesToSave
+ disposables.add(localPlaylistManager.updatePlaylists(localItemsUpdate, localItemsDeleteUid)
+ .mergeWith(remotePlaylistManager.updatePlaylists(
+ remoteItemsUpdate, remoteItemsDeleteUid))
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(() -> {
+ if (debounceSaver != null) {
+ debounceSaver.setNoChangesToSave();
+ }
+ },
+ throwable -> showError(new ErrorInfo(throwable,
+ UserAction.REQUESTED_BOOKMARK, "Saving playlist"))
+ ));
+
+ }
+
+ private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
+ // if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
+ // with an `if (shouldUseGridLayout()) ...`
+ return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
+ ItemTouchHelper.ACTION_STATE_IDLE) {
+ @Override
+ public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
+ final int viewSize,
+ final int viewSizeOutOfBounds,
+ final int totalSize,
+ final long msSinceStartScroll) {
+ final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView,
+ viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll);
+ final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY,
+ Math.abs(standardSpeed));
+ return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds);
+ }
+
+ @Override
+ public boolean onMove(@NonNull final RecyclerView recyclerView,
+ @NonNull final RecyclerView.ViewHolder source,
+ @NonNull final RecyclerView.ViewHolder target) {
+
+ // Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder.
+ if (itemListAdapter == null
+ || source.getItemViewType() != target.getItemViewType()
+ && !(
+ (
+ (source instanceof LocalBookmarkPlaylistItemHolder)
+ || (source instanceof RemoteBookmarkPlaylistItemHolder)
+ )
+ && (
+ (target instanceof LocalBookmarkPlaylistItemHolder)
+ || (target instanceof RemoteBookmarkPlaylistItemHolder)
+ ))
+ ) {
+ return false;
+ }
+
+ final int sourceIndex = source.getBindingAdapterPosition();
+ final int targetIndex = target.getBindingAdapterPosition();
+ final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
+ if (isSwapped && debounceSaver != null) {
+ debounceSaver.setHasChangesToSave();
+ }
+ return isSwapped;
+ }
+
+ @Override
+ public boolean isLongPressDragEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean isItemViewSwipeEnabled() {
+ return false;
+ }
+
+ @Override
+ public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder,
+ final int swipeDir) {
+ // Do nothing.
+ }
+ };
+ }
+
///////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
- showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid()));
+ showDeleteDialog(item.getName(), item);
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
@@ -257,7 +494,7 @@ private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
final String delete = getString(R.string.delete);
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
final boolean isThumbnailPermanent = localPlaylistManager
- .getIsPlaylistThumbnailPermanent(selectedItem.uid);
+ .getIsPlaylistThumbnailPermanent(selectedItem.getUid());
final ArrayList items = new ArrayList<>();
items.add(rename);
@@ -270,13 +507,12 @@ private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
if (items.get(index).equals(rename)) {
showRenameDialog(selectedItem);
} else if (items.get(index).equals(delete)) {
- showDeleteDialog(selectedItem.name,
- localPlaylistManager.deletePlaylist(selectedItem.uid));
+ showDeleteDialog(selectedItem.name, selectedItem);
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
final long thumbnailStreamId = localPlaylistManager
- .getAutomaticPlaylistThumbnailStreamId(selectedItem.uid);
+ .getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid());
localPlaylistManager
- .changePlaylistThumbnail(selectedItem.uid, thumbnailStreamId, false)
+ .changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
@@ -298,13 +534,13 @@ private void showRenameDialog(final PlaylistMetadataEntry selectedItem) {
.setView(dialogBinding.getRoot())
.setPositiveButton(R.string.rename_playlist, (dialog, which) ->
changeLocalPlaylistName(
- selectedItem.uid,
+ selectedItem.getUid(),
dialogBinding.dialogEditText.getText().toString()))
.setNegativeButton(R.string.cancel, null)
.show();
}
- private void showDeleteDialog(final String name, final Single deleteReactor) {
+ private void showDeleteDialog(final String name, final PlaylistLocalItem item) {
if (activity == null || disposables == null) {
return;
}
@@ -313,35 +549,8 @@ private void showDeleteDialog(final String name, final Single deleteRea
.setTitle(name)
.setMessage(R.string.delete_playlist_prompt)
.setCancelable(true)
- .setPositiveButton(R.string.delete, (dialog, i) ->
- disposables.add(deleteReactor
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(ignored -> { /*Do nothing on success*/ }, throwable ->
- showError(new ErrorInfo(throwable,
- UserAction.REQUESTED_BOOKMARK,
- "Deleting playlist")))))
+ .setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item))
.setNegativeButton(R.string.cancel, null)
.show();
}
-
- private void changeLocalPlaylistName(final long id, final String name) {
- if (localPlaylistManager == null) {
- return;
- }
-
- if (DEBUG) {
- Log.d(TAG, "Updating playlist id=[" + id + "] "
- + "with new name=[" + name + "] items");
- }
-
- localPlaylistManager.renamePlaylist(id, name);
- final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
- new ErrorInfo(throwable,
- UserAction.REQUESTED_BOOKMARK,
- "Changing playlist name")));
- disposables.add(disposable);
- }
}
-
diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java
new file mode 100644
index 00000000000..25eb2f65226
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java
@@ -0,0 +1,95 @@
+package org.schabi.newpipe.local.bookmark;
+
+import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
+import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
+import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import io.reactivex.rxjava3.core.Flowable;
+
+/**
+ * Takes care of remote and local playlists at once, hence "merged".
+ */
+public final class MergedPlaylistManager {
+
+ private MergedPlaylistManager() {
+ }
+
+ public static Flowable> getMergedOrderedPlaylists(
+ final LocalPlaylistManager localPlaylistManager,
+ final RemotePlaylistManager remotePlaylistManager) {
+ return Flowable.combineLatest(
+ localPlaylistManager.getPlaylists(),
+ remotePlaylistManager.getPlaylists(),
+ MergedPlaylistManager::merge
+ );
+ }
+
+ /**
+ * Merge localPlaylists and remotePlaylists by the display index.
+ * If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}.
+ *
+ * @param localPlaylists local playlists, already sorted by display index
+ * @param remotePlaylists remote playlists, already sorted by display index
+ * @return merged playlists
+ */
+ public static List merge(
+ final List localPlaylists,
+ final List remotePlaylists) {
+
+ // This algorithm is similar to the merge operation in merge sort.
+ final List result = new ArrayList<>(
+ localPlaylists.size() + remotePlaylists.size());
+ final List itemsWithSameIndex = new ArrayList<>();
+
+ int i = 0;
+ int j = 0;
+ while (i < localPlaylists.size()) {
+ while (j < remotePlaylists.size()) {
+ if (remotePlaylists.get(j).getDisplayIndex()
+ <= localPlaylists.get(i).getDisplayIndex()) {
+ addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
+ j++;
+ } else {
+ break;
+ }
+ }
+ addItem(result, localPlaylists.get(i), itemsWithSameIndex);
+ i++;
+ }
+ while (j < remotePlaylists.size()) {
+ addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
+ j++;
+ }
+ addItemsWithSameIndex(result, itemsWithSameIndex);
+
+ return result;
+ }
+
+ private static void addItem(final List result,
+ final PlaylistLocalItem item,
+ final List itemsWithSameIndex) {
+ if (!itemsWithSameIndex.isEmpty()
+ && itemsWithSameIndex.get(0).getDisplayIndex() != item.getDisplayIndex()) {
+ // The new item has a different display index, add previous items with same
+ // index to the result.
+ addItemsWithSameIndex(result, itemsWithSameIndex);
+ itemsWithSameIndex.clear();
+ }
+ itemsWithSameIndex.add(item);
+ }
+
+ private static void addItemsWithSameIndex(final List result,
+ final List itemsWithSameIndex) {
+ Collections.sort(itemsWithSameIndex,
+ Comparator.comparing(PlaylistLocalItem::getOrderingName,
+ Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
+ result.addAll(itemsWithSameIndex);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
index b503c4e0569..e7f73079f43 100644
--- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
@@ -155,14 +155,14 @@ private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT);
- playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams)
+ playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> {
successToast.show();
if (playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
playlistDisposables.add(manager
- .changePlaylistThumbnail(playlist.uid, streams.get(0).getUid(),
+ .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(),
false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> successToast.show()));
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
index de640dbbbe1..a40bf35dc52 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
@@ -137,7 +137,7 @@ class NotificationWorker(
.enqueueUniquePeriodicWork(
WORK_TAG,
if (force) {
- ExistingPeriodicWorkPolicy.REPLACE
+ ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
} else {
ExistingPeriodicWorkPolicy.KEEP
},
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
index 3d19de9c693..1c2826e7a67 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
@@ -26,7 +26,7 @@ object FeedEventManager {
}
sealed class Event {
- object IdleEvent : Event()
+ data object IdleEvent : Event()
data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
index 84cd8ed59a9..b44eec35333 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
@@ -18,7 +18,7 @@ data class FeedUpdateInfo(
@NotificationMode
val notificationMode: Int,
val name: String,
- val avatarUrl: String,
+ val avatarUrl: String?,
val url: String,
val serviceId: Int,
// description and subscriberCount are null if the constructor info is from the fast feed method
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java
new file mode 100644
index 00000000000..16130009b6e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java
@@ -0,0 +1,54 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.LocalItem;
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
+import org.schabi.newpipe.local.LocalItemBuilder;
+import org.schabi.newpipe.local.history.HistoryRecordManager;
+
+import java.time.format.DateTimeFormatter;
+
+public class LocalBookmarkPlaylistItemHolder extends LocalPlaylistItemHolder {
+ private final View itemHandleView;
+
+ public LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
+ }
+
+ LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
+ final ViewGroup parent) {
+ super(infoItemBuilder, layoutId, parent);
+ itemHandleView = itemView.findViewById(R.id.itemHandle);
+ }
+
+ @Override
+ public void updateFromItem(final LocalItem localItem,
+ final HistoryRecordManager historyRecordManager,
+ final DateTimeFormatter dateTimeFormatter) {
+ if (!(localItem instanceof PlaylistMetadataEntry)) {
+ return;
+ }
+ final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem;
+
+ itemHandleView.setOnTouchListener(getOnTouchListener(item));
+
+ super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
+ }
+
+ private View.OnTouchListener getOnTouchListener(final PlaylistMetadataEntry item) {
+ return (view, motionEvent) -> {
+ view.performClick();
+ if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
+ && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ itemBuilder.getOnItemSelectedListener().drag(item,
+ LocalBookmarkPlaylistItemHolder.this);
+ }
+ return false;
+ };
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java
new file mode 100644
index 00000000000..6d61d1e08bf
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java
@@ -0,0 +1,54 @@
+package org.schabi.newpipe.local.holder;
+
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.LocalItem;
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
+import org.schabi.newpipe.local.LocalItemBuilder;
+import org.schabi.newpipe.local.history.HistoryRecordManager;
+
+import java.time.format.DateTimeFormatter;
+
+public class RemoteBookmarkPlaylistItemHolder extends RemotePlaylistItemHolder {
+ private final View itemHandleView;
+
+ public RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
+ final ViewGroup parent) {
+ this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
+ }
+
+ RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
+ final ViewGroup parent) {
+ super(infoItemBuilder, layoutId, parent);
+ itemHandleView = itemView.findViewById(R.id.itemHandle);
+ }
+
+ @Override
+ public void updateFromItem(final LocalItem localItem,
+ final HistoryRecordManager historyRecordManager,
+ final DateTimeFormatter dateTimeFormatter) {
+ if (!(localItem instanceof PlaylistRemoteEntity)) {
+ return;
+ }
+ final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem;
+
+ itemHandleView.setOnTouchListener(getOnTouchListener(item));
+
+ super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
+ }
+
+ private View.OnTouchListener getOnTouchListener(final PlaylistRemoteEntity item) {
+ return (view, motionEvent) -> {
+ view.performClick();
+ if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
+ && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ itemBuilder.getOnItemSelectedListener().drag(item,
+ RemoteBookmarkPlaylistItemHolder.this);
+ }
+ return false;
+ };
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
index d14c1a231bf..7657320634c 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
@@ -14,6 +14,7 @@
import java.time.format.DateTimeFormatter;
public class RemotePlaylistItemHolder extends PlaylistItemHolder {
+
public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, parent);
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
index 51da52ae08f..d5ae431fadd 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
@@ -49,6 +49,8 @@
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
+import org.schabi.newpipe.util.debounce.DebounceSavable;
+import org.schabi.newpipe.util.debounce.DebounceSaver;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
@@ -58,7 +60,6 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
-import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
@@ -68,12 +69,10 @@
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
-import io.reactivex.rxjava3.subjects.PublishSubject;
public class LocalPlaylistFragment extends BaseLocalListFragment, Void>
- implements PlaylistControlViewHolder {
- /** Save the list 10 seconds after the last change occurred. */
- private static final long SAVE_DEBOUNCE_MILLIS = 10000;
+ implements PlaylistControlViewHolder, DebounceSavable {
+
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
protected Long playlistId;
@@ -90,13 +89,12 @@ public class LocalPlaylistFragment extends BaseLocalListFragment debouncedSaveSignal;
private CompositeDisposable disposables;
/** Whether the playlist has been fully loaded from db. */
private AtomicBoolean isLoadingComplete;
- /** Whether the playlist has been modified (e.g. items reordered or deleted) */
- private AtomicBoolean isModified;
+ /** Used to debounce saving playlist edits to disk. */
+ private DebounceSaver debounceSaver;
/** Flag to prevent simultaneous rewrites of the playlist. */
private boolean isRewritingPlaylist = false;
@@ -121,12 +119,11 @@ public static LocalPlaylistFragment getInstance(final long playlistId, final Str
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext()));
- debouncedSaveSignal = PublishSubject.create();
disposables = new CompositeDisposable();
isLoadingComplete = new AtomicBoolean();
- isModified = new AtomicBoolean();
+ debounceSaver = new DebounceSaver(this);
}
@Override
@@ -166,17 +163,6 @@ protected ViewBinding getListHeader() {
return headerBinding;
}
- /**
- * Commit changes immediately if the playlist has been modified.
- * Delete operations and other modifications will be committed to ensure that the database
- * is up to date, e.g. when the user adds the just deleted stream from another fragment.
- */
- public void commitChanges() {
- if (isModified != null && isModified.get()) {
- saveImmediate();
- }
- }
-
@Override
protected void initListeners() {
super.initListeners();
@@ -243,10 +229,13 @@ public void startLoading(final boolean forceLoad) {
if (disposables != null) {
disposables.clear();
}
- disposables.add(getDebouncedSaver());
+
+ if (debounceSaver != null) {
+ disposables.add(debounceSaver.getDebouncedSaver());
+ debounceSaver.setNoChangesToSave();
+ }
isLoadingComplete.set(false);
- isModified.set(false);
playlistManager.getPlaylistStreams(playlistId)
.onBackpressureLatest()
@@ -304,8 +293,8 @@ public void onDestroyView() {
@Override
public void onDestroy() {
super.onDestroy();
- if (debouncedSaveSignal != null) {
- debouncedSaveSignal.onComplete();
+ if (debounceSaver != null) {
+ debounceSaver.getDebouncedSaveSignal().onComplete();
}
if (disposables != null) {
disposables.dispose();
@@ -314,12 +303,11 @@ public void onDestroy() {
tabsPagerAdapter.getLocalPlaylistFragments().remove(this);
}
- debouncedSaveSignal = null;
+ debounceSaver = null;
playlistManager = null;
disposables = null;
isLoadingComplete = null;
- isModified = null;
}
///////////////////////////////////////////////////////////////////////////
@@ -343,7 +331,7 @@ public void onSubscribe(final Subscription s) {
@Override
public void onNext(final List streams) {
// Skip handling the result after it has been modified
- if (isModified == null || !isModified.get()) {
+ if (debounceSaver == null || !debounceSaver.getIsModified()) {
handleResult(streams);
isLoadingComplete.set(true);
}
@@ -495,14 +483,14 @@ public void removeWatchedStreams(final boolean removePartiallyWatched) {
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
- saveChanges();
+ debounceSaver.setHasChangesToSave();
if (thumbnailVideoRemoved) {
updateThumbnailUrl();
}
final long videoCount = itemListAdapter.getItemsList().size();
- setVideoCount(videoCount);
+ setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
if (videoCount == 0) {
showEmptyState();
}
@@ -532,7 +520,7 @@ public void handleResult(@NonNull final List result) {
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
itemsListState = null;
}
- setVideoCount(itemListAdapter.getItemsList().size());
+ setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
@@ -665,8 +653,8 @@ private void removeDuplicatesInPlaylist() {
.subscribe(itemsToKeep -> {
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
- setVideoCount(itemListAdapter.getItemsList().size());
- saveChanges();
+ setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
+ debounceSaver.setHasChangesToSave();
hideLoading();
isRewritingPlaylist = false;
@@ -684,42 +672,24 @@ private void deleteItem(final PlaylistStreamEntry item) {
updateThumbnailUrl();
}
- setVideoCount(itemListAdapter.getItemsList().size());
- saveChanges();
- }
-
- private void saveChanges() {
- if (isModified == null || debouncedSaveSignal == null) {
- return;
- }
-
- isModified.set(true);
- debouncedSaveSignal.onNext(System.currentTimeMillis());
- }
-
- private Disposable getDebouncedSaver() {
- if (debouncedSaveSignal == null) {
- return Disposable.empty();
- }
-
- return debouncedSaveSignal
- .debounce(SAVE_DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(ignored -> saveImmediate(), throwable ->
- showError(new ErrorInfo(throwable, UserAction.SOMETHING_ELSE,
- "Debounced saver")));
+ setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
+ debounceSaver.setHasChangesToSave();
}
- private void saveImmediate() {
+ /**
+ * Commit changes immediately if the playlist has been modified.
+ * Delete operations and other modifications will be committed to ensure that the database
+ * is up to date, e.g. when the user adds the just deleted stream from another fragment.
+ */
+ @Override
+ public void saveImmediate() {
if (playlistManager == null || itemListAdapter == null) {
return;
}
// List must be loaded and modified in order to save
- if (isLoadingComplete == null || isModified == null
- || !isLoadingComplete.get() || !isModified.get()) {
- Log.w(TAG, "Attempting to save playlist when local playlist "
- + "is not loaded or not modified: playlist id=[" + playlistId + "]");
+ if (isLoadingComplete == null || debounceSaver == null
+ || !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
return;
}
@@ -740,8 +710,8 @@ private void saveImmediate() {
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
() -> {
- if (isModified != null) {
- isModified.set(false);
+ if (debounceSaver != null) {
+ debounceSaver.setNoChangesToSave();
}
},
throwable -> showError(new ErrorInfo(throwable,
@@ -784,7 +754,7 @@ public boolean onMove(@NonNull final RecyclerView recyclerView,
final int targetIndex = target.getBindingAdapterPosition();
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
if (isSwapped) {
- saveChanges();
+ debounceSaver.setHasChangesToSave();
}
return isSwapped;
}
@@ -855,10 +825,21 @@ private void setInitialData(final long pid, final String title) {
this.name = !TextUtils.isEmpty(title) ? title : "";
}
- private void setVideoCount(final long count) {
+ private void setStreamCountAndOverallDuration(final ArrayList itemsList) {
if (activity != null && headerBinding != null) {
- headerBinding.playlistStreamCount.setText(Localization
- .localizeStreamCount(activity, count));
+ final long streamCount = itemsList.size();
+ final long playlistOverallDurationSeconds = itemsList.stream()
+ .filter(PlaylistStreamEntry.class::isInstance)
+ .map(PlaylistStreamEntry.class::cast)
+ .map(PlaylistStreamEntry::getStreamEntity)
+ .mapToLong(StreamEntity::getDuration)
+ .sum();
+ headerBinding.playlistStreamCount.setText(
+ Localization.concatenateStrings(
+ Localization.localizeStreamCount(activity, streamCount),
+ Localization.getDurationString(playlistOverallDurationSeconds,
+ true, true))
+ );
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
index 27c14856123..dd9307675de 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
@@ -19,7 +19,6 @@
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Maybe;
-import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class LocalPlaylistManager {
@@ -43,10 +42,13 @@ public Maybe> createPlaylist(final String name, final List database.runInTransaction(() -> {
final List streamIds = streamTable.upsertAll(streams);
final PlaylistEntity newPlaylist = new PlaylistEntity(name, false,
- streamIds.get(0));
+ streamIds.get(0), -1);
return insertJoinEntities(playlistTable.insert(newPlaylist),
streamIds, 0);
@@ -89,8 +91,20 @@ public Completable updateJoin(final long playlistId, final List streamIds)
})).subscribeOn(Schedulers.io());
}
- public Flowable> getPlaylists() {
- return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io());
+ public Completable updatePlaylists(final List updateItems,
+ final List deletedItems) {
+ final List items = new ArrayList<>(updateItems.size());
+ for (final PlaylistMetadataEntry item : updateItems) {
+ items.add(new PlaylistEntity(item));
+ }
+ return Completable.fromRunnable(() -> database.runInTransaction(() -> {
+ for (final Long uid : deletedItems) {
+ playlistTable.deletePlaylist(uid);
+ }
+ for (final PlaylistEntity item : items) {
+ playlistTable.upsertPlaylist(item);
+ }
+ })).subscribeOn(Schedulers.io());
}
public Flowable> getDistinctPlaylistStreams(final long playlistId) {
@@ -110,13 +124,12 @@ public Flowable> getPlaylistDuplicates(final Strin
.subscribeOn(Schedulers.io());
}
- public Flowable> getPlaylistStreams(final long playlistId) {
- return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io());
+ public Flowable> getPlaylists() {
+ return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io());
}
- public Single deletePlaylist(final long playlistId) {
- return Single.fromCallable(() -> playlistTable.deletePlaylist(playlistId))
- .subscribeOn(Schedulers.io());
+ public Flowable> getPlaylistStreams(final long playlistId) {
+ return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io());
}
public Maybe renamePlaylist(final long playlistId, final String name) {
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java
index 5221139e34f..4cc51f7525e 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java
@@ -7,20 +7,23 @@
import java.util.List;
+import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class RemotePlaylistManager {
+ private final AppDatabase database;
private final PlaylistRemoteDAO playlistRemoteTable;
public RemotePlaylistManager(final AppDatabase db) {
+ database = db;
playlistRemoteTable = db.playlistRemoteDAO();
}
public Flowable> getPlaylists() {
- return playlistRemoteTable.getAll().subscribeOn(Schedulers.io());
+ return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
}
public Flowable> getPlaylist(final PlaylistInfo info) {
@@ -33,6 +36,18 @@ public Single deletePlaylist(final long playlistId) {
.subscribeOn(Schedulers.io());
}
+ public Completable updatePlaylists(final List updateItems,
+ final List deletedItems) {
+ return Completable.fromRunnable(() -> database.runInTransaction(() -> {
+ for (final Long uid: deletedItems) {
+ playlistRemoteTable.deletePlaylist(uid);
+ }
+ for (final PlaylistRemoteEntity item: updateItems) {
+ playlistRemoteTable.upsert(item);
+ }
+ })).subscribeOn(Schedulers.io());
+ }
+
public Single onBookmark(final PlaylistInfo playlistInfo) {
return Single.fromCallable(() -> {
final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo);
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
index 488d8b3d28d..474add4f41b 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
@@ -100,7 +100,9 @@ class SubscriptionManager(context: Context) {
val subscriptionEntity = subscriptionTable.getSubscription(info.uid)
subscriptionEntity.name = info.name
- subscriptionEntity.avatarUrl = info.avatarUrl
+
+ // some services do not provide an avatar URL
+ info.avatarUrl?.let { subscriptionEntity.avatarUrl = it }
// these two fields are null if the feed info was fetched using the fast feed method
info.description?.let { subscriptionEntity.description = it }
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
index 19c581c080f..41761fb0102 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
@@ -55,10 +55,10 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
private var groupSortOrder: Long = -1
sealed class ScreenState : Serializable {
- object InitialScreen : ScreenState()
- object IconPickerScreen : ScreenState()
- object SubscriptionsPickerScreen : ScreenState()
- object DeleteScreen : ScreenState()
+ data object InitialScreen : ScreenState()
+ data object IconPickerScreen : ScreenState()
+ data object SubscriptionsPickerScreen : ScreenState()
+ data object DeleteScreen : ScreenState()
}
@State @JvmField var selectedIcon: FeedGroupIcon? = null
@@ -370,7 +370,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
private fun setupIconPicker() {
val groupAdapter = GroupieAdapter()
- groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(it) })
+ groupAdapter.addAll(FeedGroupIcon.entries.map { PickerIconItem(it) })
feedGroupCreateBinding.iconSelector.apply {
layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false)
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
index eff1a4400bb..292bda394cc 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
@@ -110,8 +110,8 @@ class FeedGroupDialogViewModel(
}
sealed class DialogEvent {
- object ProcessingEvent : DialogEvent()
- object SuccessEvent : DialogEvent()
+ data object ProcessingEvent : DialogEvent()
+ data object SuccessEvent : DialogEvent()
}
data class Filter(val query: String, val showOnlyUngrouped: Boolean)
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
index d56d16f3cc5..54809068ac8 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
@@ -25,6 +25,7 @@
import android.net.Uri;
import android.util.Log;
+import androidx.core.content.IntentCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.reactivestreams.Subscriber;
@@ -65,7 +66,7 @@ public int onStartCommand(final Intent intent, final int flags, final int startI
return START_NOT_STICKY;
}
- final Uri path = intent.getParcelableExtra(KEY_FILE_PATH);
+ final Uri path = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri.class);
if (path == null) {
stopAndReportError(new IllegalStateException(
"Exporting to a file, but the path is null"),
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
index d624e1038e7..442c7fddb8b 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
@@ -30,6 +30,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.core.content.IntentCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.reactivestreams.Subscriber;
@@ -108,7 +109,7 @@ public int onStartCommand(final Intent intent, final int flags, final int startI
if (currentMode == CHANNEL_URL_MODE) {
channelUrl = intent.getStringExtra(KEY_VALUE);
} else {
- final Uri uri = intent.getParcelableExtra(KEY_VALUE);
+ final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class);
if (uri == null) {
stopAndReportError(new IllegalStateException(
"Importing from input stream, but file path is null"),
diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt
index 8acd7041374..ff0bb269d0a 100644
--- a/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt
@@ -160,13 +160,12 @@ class MainPlayerGestureListener(
}
override fun onScroll(
- initialEvent: MotionEvent,
+ initialEvent: MotionEvent?,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
-
- if (!playerUi.isFullscreen) {
+ if (initialEvent == null || !playerUi.isFullscreen) {
return false
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt
index 23edcaeb844..0b94bf364e0 100644
--- a/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt
@@ -167,7 +167,7 @@ class PopupPlayerGestureListener(
}
override fun onFling(
- e1: MotionEvent,
+ e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
@@ -218,11 +218,14 @@ class PopupPlayerGestureListener(
}
override fun onScroll(
- initialEvent: MotionEvent,
+ initialEvent: MotionEvent?,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
+ if (initialEvent == null) {
+ return false
+ }
if (isResizing) {
return super.onScroll(initialEvent, movingEvent, distanceX, distanceY)
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
index 6f76a91d1c0..737ebc5dd04 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
@@ -1,10 +1,12 @@
package org.schabi.newpipe.player.mediasession;
import static org.schabi.newpipe.MainActivity.DEBUG;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
+import android.os.Build;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
@@ -14,15 +16,23 @@
import androidx.media.session.MediaButtonReceiver;
import com.google.android.exoplayer2.ForwardingPlayer;
+import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.R;
+import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.Player;
+import org.schabi.newpipe.player.notification.NotificationActionData;
+import org.schabi.newpipe.player.notification.NotificationConstants;
import org.schabi.newpipe.player.ui.PlayerUi;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.StreamTypeUtil;
+import java.util.List;
+import java.util.Objects;
import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
public class MediaSessionPlayerUi extends PlayerUi
implements SharedPreferences.OnSharedPreferenceChangeListener {
@@ -34,6 +44,10 @@ public class MediaSessionPlayerUi extends PlayerUi
private final String ignoreHardwareMediaButtonsKey;
private boolean shouldIgnoreHardwareMediaButtons = false;
+ // used to check whether any notification action changed, before sending costly updates
+ private List prevNotificationActions = List.of();
+
+
public MediaSessionPlayerUi(@NonNull final Player player) {
super(player);
ignoreHardwareMediaButtonsKey =
@@ -63,6 +77,10 @@ public void initPlayer() {
sessionConnector.setMetadataDeduplicationEnabled(true);
sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata());
+
+ // force updating media session actions by resetting the previous ones
+ prevNotificationActions = List.of();
+ updateMediaSessionActions();
}
@Override
@@ -80,6 +98,7 @@ public void destroyPlayer() {
mediaSession.release();
mediaSession = null;
}
+ prevNotificationActions = List.of();
}
@Override
@@ -163,4 +182,109 @@ private MediaMetadataCompat buildMediaMetadata() {
return builder.build();
}
+
+
+ private void updateMediaSessionActions() {
+ // On Android 13+ (or Android T or API 33+) the actions in the player notification can't be
+ // controlled directly anymore, but are instead derived from custom media session actions.
+ // However the system allows customizing only two of these actions, since the other three
+ // are fixed to play-pause-buffering, previous, next.
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ // Although setting media session actions on older android versions doesn't seem to
+ // cause any trouble, it also doesn't seem to do anything, so we don't do anything to
+ // save battery. Check out NotificationUtil.updateActions() to see what happens on
+ // older android versions.
+ return;
+ }
+
+ // only use the fourth and fifth actions (the settings page also shows only the last 2 on
+ // Android 13+)
+ final List newNotificationActions = IntStream.of(3, 4)
+ .map(i -> player.getPrefs().getInt(
+ player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
+ NotificationConstants.SLOT_DEFAULTS[i]))
+ .mapToObj(action -> NotificationActionData
+ .fromNotificationActionEnum(player, action))
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+
+ // avoid costly notification actions update, if nothing changed from last time
+ if (!newNotificationActions.equals(prevNotificationActions)) {
+ prevNotificationActions = newNotificationActions;
+ sessionConnector.setCustomActionProviders(
+ newNotificationActions.stream()
+ .map(data -> new SessionConnectorActionProvider(data, context))
+ .toArray(SessionConnectorActionProvider[]::new));
+ }
+ }
+
+ @Override
+ public void onBlocked() {
+ super.onBlocked();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onPlaying() {
+ super.onPlaying();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onBuffering() {
+ super.onBuffering();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onPaused() {
+ super.onPaused();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onPausedSeek() {
+ super.onPausedSeek();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onCompleted() {
+ super.onCompleted();
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
+ super.onRepeatModeChanged(repeatMode);
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
+ super.onShuffleModeEnabledChanged(shuffleModeEnabled);
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onBroadcastReceived(final Intent intent) {
+ super.onBroadcastReceived(intent);
+ if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) {
+ // the notification actions changed
+ updateMediaSessionActions();
+ }
+ }
+
+ @Override
+ public void onMetadataChanged(@NonNull final StreamInfo info) {
+ super.onMetadataChanged(info);
+ updateMediaSessionActions();
+ }
+
+ @Override
+ public void onPlayQueueEdited() {
+ super.onPlayQueueEdited();
+ updateMediaSessionActions();
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java
new file mode 100644
index 00000000000..a5c9fccc9eb
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java
@@ -0,0 +1,47 @@
+package org.schabi.newpipe.player.mediasession;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v4.media.session.PlaybackStateCompat;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.android.exoplayer2.Player;
+import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
+
+import org.schabi.newpipe.player.notification.NotificationActionData;
+
+import java.lang.ref.WeakReference;
+
+public class SessionConnectorActionProvider implements MediaSessionConnector.CustomActionProvider {
+
+ private final NotificationActionData data;
+ @NonNull
+ private final WeakReference context;
+
+ public SessionConnectorActionProvider(final NotificationActionData notificationActionData,
+ @NonNull final Context context) {
+ this.data = notificationActionData;
+ this.context = new WeakReference<>(context);
+ }
+
+ @Override
+ public void onCustomAction(@NonNull final Player player,
+ @NonNull final String action,
+ @Nullable final Bundle extras) {
+ final Context actualContext = context.get();
+ if (actualContext != null) {
+ actualContext.sendBroadcast(new Intent(action));
+ }
+ }
+
+ @Nullable
+ @Override
+ public PlaybackStateCompat.CustomAction getCustomAction(@NonNull final Player player) {
+ return new PlaybackStateCompat.CustomAction.Builder(
+ data.action(), data.name(), data.icon()
+ ).build();
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java
new file mode 100644
index 00000000000..b3abcd0b514
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java
@@ -0,0 +1,187 @@
+package org.schabi.newpipe.player.notification;
+
+import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
+import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.player.Player;
+
+import java.util.Objects;
+
+public final class NotificationActionData {
+
+ @NonNull
+ private final String action;
+ @NonNull
+ private final String name;
+ @DrawableRes
+ private final int icon;
+
+
+ public NotificationActionData(@NonNull final String action, @NonNull final String name,
+ @DrawableRes final int icon) {
+ this.action = action;
+ this.name = name;
+ this.icon = icon;
+ }
+
+ @NonNull
+ public String action() {
+ return action;
+ }
+
+ @NonNull
+ public String name() {
+ return name;
+ }
+
+ @DrawableRes
+ public int icon() {
+ return icon;
+ }
+
+
+ @SuppressLint("PrivateResource") // we currently use Exoplayer's internal strings and icons
+ @Nullable
+ public static NotificationActionData fromNotificationActionEnum(
+ @NonNull final Player player,
+ @NotificationConstants.Action final int selectedAction
+ ) {
+
+ final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
+ final Context ctx = player.getContext();
+
+ switch (selectedAction) {
+ case NotificationConstants.PREVIOUS:
+ return new NotificationActionData(ACTION_PLAY_PREVIOUS,
+ ctx.getString(R.string.exo_controls_previous_description), baseActionIcon);
+
+ case NotificationConstants.NEXT:
+ return new NotificationActionData(ACTION_PLAY_NEXT,
+ ctx.getString(R.string.exo_controls_next_description), baseActionIcon);
+
+ case NotificationConstants.REWIND:
+ return new NotificationActionData(ACTION_FAST_REWIND,
+ ctx.getString(R.string.exo_controls_rewind_description), baseActionIcon);
+
+ case NotificationConstants.FORWARD:
+ return new NotificationActionData(ACTION_FAST_FORWARD,
+ ctx.getString(R.string.exo_controls_fastforward_description),
+ baseActionIcon);
+
+ case NotificationConstants.SMART_REWIND_PREVIOUS:
+ if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
+ return new NotificationActionData(ACTION_PLAY_PREVIOUS,
+ ctx.getString(R.string.exo_controls_previous_description),
+ R.drawable.exo_notification_previous);
+ } else {
+ return new NotificationActionData(ACTION_FAST_REWIND,
+ ctx.getString(R.string.exo_controls_rewind_description),
+ R.drawable.exo_controls_rewind);
+ }
+
+ case NotificationConstants.SMART_FORWARD_NEXT:
+ if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
+ return new NotificationActionData(ACTION_PLAY_NEXT,
+ ctx.getString(R.string.exo_controls_next_description),
+ R.drawable.exo_notification_next);
+ } else {
+ return new NotificationActionData(ACTION_FAST_FORWARD,
+ ctx.getString(R.string.exo_controls_fastforward_description),
+ R.drawable.exo_controls_fastforward);
+ }
+
+ case NotificationConstants.PLAY_PAUSE_BUFFERING:
+ if (player.getCurrentState() == Player.STATE_PREFLIGHT
+ || player.getCurrentState() == Player.STATE_BLOCKED
+ || player.getCurrentState() == Player.STATE_BUFFERING) {
+ return new NotificationActionData(ACTION_PLAY_PAUSE,
+ ctx.getString(R.string.notification_action_buffering),
+ R.drawable.ic_hourglass_top);
+ }
+
+ // fallthrough
+ case NotificationConstants.PLAY_PAUSE:
+ if (player.getCurrentState() == Player.STATE_COMPLETED) {
+ return new NotificationActionData(ACTION_PLAY_PAUSE,
+ ctx.getString(R.string.exo_controls_pause_description),
+ R.drawable.ic_replay);
+ } else if (player.isPlaying()
+ || player.getCurrentState() == Player.STATE_PREFLIGHT
+ || player.getCurrentState() == Player.STATE_BLOCKED
+ || player.getCurrentState() == Player.STATE_BUFFERING) {
+ return new NotificationActionData(ACTION_PLAY_PAUSE,
+ ctx.getString(R.string.exo_controls_pause_description),
+ R.drawable.exo_notification_pause);
+ } else {
+ return new NotificationActionData(ACTION_PLAY_PAUSE,
+ ctx.getString(R.string.exo_controls_play_description),
+ R.drawable.exo_notification_play);
+ }
+
+ case NotificationConstants.REPEAT:
+ if (player.getRepeatMode() == REPEAT_MODE_ALL) {
+ return new NotificationActionData(ACTION_REPEAT,
+ ctx.getString(R.string.exo_controls_repeat_all_description),
+ R.drawable.exo_media_action_repeat_all);
+ } else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
+ return new NotificationActionData(ACTION_REPEAT,
+ ctx.getString(R.string.exo_controls_repeat_one_description),
+ R.drawable.exo_media_action_repeat_one);
+ } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
+ return new NotificationActionData(ACTION_REPEAT,
+ ctx.getString(R.string.exo_controls_repeat_off_description),
+ R.drawable.exo_media_action_repeat_off);
+ }
+
+ case NotificationConstants.SHUFFLE:
+ if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
+ return new NotificationActionData(ACTION_SHUFFLE,
+ ctx.getString(R.string.exo_controls_shuffle_on_description),
+ R.drawable.exo_controls_shuffle_on);
+ } else {
+ return new NotificationActionData(ACTION_SHUFFLE,
+ ctx.getString(R.string.exo_controls_shuffle_off_description),
+ R.drawable.exo_controls_shuffle_off);
+ }
+
+ case NotificationConstants.CLOSE:
+ return new NotificationActionData(ACTION_CLOSE, ctx.getString(R.string.close),
+ R.drawable.ic_close);
+
+ case NotificationConstants.NOTHING:
+ default:
+ // do nothing
+ return null;
+ }
+ }
+
+
+ @Override
+ public boolean equals(@Nullable final Object obj) {
+ return (obj instanceof NotificationActionData other)
+ && this.action.equals(other.action)
+ && this.name.equals(other.name)
+ && this.icon == other.icon;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(action, name, icon);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java
index 89bf0b22ae2..b9607f7eabb 100644
--- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java
+++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java
@@ -13,7 +13,7 @@
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
-import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
@@ -65,10 +65,16 @@ private NotificationConstants() {
public static final int CLOSE = 11;
@Retention(RetentionPolicy.SOURCE)
- @IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT,
- PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, SHUFFLE, CLOSE})
+ @IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD,
+ SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT,
+ SHUFFLE, CLOSE})
public @interface Action { }
+ @Action
+ public static final int[] ALL_ACTIONS = {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD,
+ SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT,
+ SHUFFLE, CLOSE};
+
@DrawableRes
public static final int[] ACTION_ICONS = {
0,
@@ -95,16 +101,6 @@ private NotificationConstants() {
CLOSE,
};
- @Action
- public static final int[][] SLOT_ALLOWED_ACTIONS = {
- new int[] {PREVIOUS, REWIND, SMART_REWIND_PREVIOUS},
- new int[] {REWIND, PLAY_PAUSE, PLAY_PAUSE_BUFFERING},
- new int[] {NEXT, FORWARD, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING},
- new int[] {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS,
- SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE},
- new int[] {NOTHING, NEXT, FORWARD, SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE},
- };
-
public static final int[] SLOT_PREF_KEYS = {
R.string.notification_slot_0_key,
R.string.notification_slot_1_key,
@@ -165,14 +161,11 @@ public static String getActionName(@NonNull final Context context, @Action final
/**
* @param context the context to use
* @param sharedPreferences the shared preferences to query values from
- * @param slotCount remove indices >= than this value (set to {@code 5} to do nothing, or make
- * it lower if there are slots with empty actions)
* @return a sorted list of the indices of the slots to use as compact slots
*/
- public static List getCompactSlotsFromPreferences(
+ public static Collection getCompactSlotsFromPreferences(
@NonNull final Context context,
- final SharedPreferences sharedPreferences,
- final int slotCount) {
+ final SharedPreferences sharedPreferences) {
final SortedSet compactSlots = new TreeSet<>();
for (int i = 0; i < 3; i++) {
final int compactSlot = sharedPreferences.getInt(
@@ -180,14 +173,14 @@ public static List getCompactSlotsFromPreferences(
if (compactSlot == Integer.MAX_VALUE) {
// settings not yet populated, return default values
- return new ArrayList<>(SLOT_COMPACT_DEFAULTS);
+ return SLOT_COMPACT_DEFAULTS;
}
- // a negative value (-1) is set when the user does not want a particular compact slot
- if (compactSlot >= 0 && compactSlot < slotCount) {
+ if (compactSlot >= 0) {
+ // compact slot is < 0 if there are less than 3 checked checkboxes
compactSlots.add(compactSlot);
}
}
- return new ArrayList<>(compactSlots);
+ return compactSlots;
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
index 05c2e3af6dd..30420b0c7da 100644
--- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
+++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
@@ -1,16 +1,19 @@
package org.schabi.newpipe.player.notification;
+import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
+import static androidx.media.app.NotificationCompat.MediaStyle;
+import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
+
import android.annotation.SuppressLint;
+import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
import android.os.Build;
import android.util.Log;
-import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.PendingIntentCompat;
@@ -23,23 +26,12 @@
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.util.NavigationHelper;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
-import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
-import static androidx.media.app.NotificationCompat.MediaStyle;
-import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
-import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
-import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
-
/**
* This is a utility class for player notifications.
*/
@@ -100,29 +92,21 @@ private synchronized NotificationCompat.Builder createNotification() {
final NotificationCompat.Builder builder =
new NotificationCompat.Builder(player.getContext(),
player.getContext().getString(R.string.notification_channel_id));
-
- initializeNotificationSlots();
-
- // count the number of real slots, to make sure compact slots indices are not out of bound
- int nonNothingSlotCount = 5;
- if (notificationSlots[3] == NotificationConstants.NOTHING) {
- --nonNothingSlotCount;
+ final MediaStyle mediaStyle = new MediaStyle();
+
+ // setup media style (compact notification slots and media session)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ // notification actions are ignored on Android 13+, and are replaced by code in
+ // MediaSessionPlayerUi
+ final int[] compactSlots = initializeNotificationSlots();
+ mediaStyle.setShowActionsInCompactView(compactSlots);
}
- if (notificationSlots[4] == NotificationConstants.NOTHING) {
- --nonNothingSlotCount;
- }
-
- // build the compact slot indices array (need code to convert from Integer... because Java)
- final List compactSlotList = NotificationConstants.getCompactSlotsFromPreferences(
- player.getContext(), player.getPrefs(), nonNothingSlotCount);
- final int[] compactSlots = compactSlotList.stream().mapToInt(Integer::intValue).toArray();
-
- final MediaStyle mediaStyle = new MediaStyle().setShowActionsInCompactView(compactSlots);
player.UIs()
.get(MediaSessionPlayerUi.class)
.flatMap(MediaSessionPlayerUi::getSessionToken)
.ifPresent(mediaStyle::setMediaSession);
+ // setup notification builder
builder.setStyle(mediaStyle)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@@ -157,7 +141,11 @@ private synchronized void updateNotification() {
notificationBuilder.setContentText(player.getUploaderName());
notificationBuilder.setTicker(player.getVideoTitle());
- updateActions(notificationBuilder);
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ // notification actions are ignored on Android 13+, and are replaced by code in
+ // MediaSessionPlayerUi
+ updateActions(notificationBuilder);
+ }
}
@@ -209,12 +197,35 @@ public void cancelNotificationAndStopForeground() {
// ACTIONS
/////////////////////////////////////////////////////
- private void initializeNotificationSlots() {
+ /**
+ * The compact slots array from settings contains indices from 0 to 4, each referring to one of
+ * the five actions configurable by the user. However, if the user sets an action to "Nothing",
+ * then all of the actions coming after will have a "settings index" different than the index
+ * of the corresponding action when sent to the system.
+ *
+ * @return the indices of compact slots referred to the list of non-nothing actions that will be
+ * sent to the system
+ */
+ private int[] initializeNotificationSlots() {
+ final Collection settingsCompactSlots = NotificationConstants
+ .getCompactSlotsFromPreferences(player.getContext(), player.getPrefs());
+ final List adjustedCompactSlots = new ArrayList<>();
+
+ int nonNothingIndex = 0;
for (int i = 0; i < 5; ++i) {
notificationSlots[i] = player.getPrefs().getInt(
player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]);
+
+ if (notificationSlots[i] != NotificationConstants.NOTHING) {
+ if (settingsCompactSlots.contains(i)) {
+ adjustedCompactSlots.add(nonNothingIndex);
+ }
+ nonNothingIndex += 1;
+ }
}
+
+ return adjustedCompactSlots.stream().mapToInt(Integer::intValue).toArray();
}
@SuppressLint("RestrictedApi")
@@ -227,115 +238,15 @@ private void updateActions(final NotificationCompat.Builder builder) {
private void addAction(final NotificationCompat.Builder builder,
@NotificationConstants.Action final int slot) {
- final NotificationCompat.Action action = getAction(slot);
- if (action != null) {
- builder.addAction(action);
- }
- }
-
- @Nullable
- private NotificationCompat.Action getAction(
- @NotificationConstants.Action final int selectedAction) {
- final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
- switch (selectedAction) {
- case NotificationConstants.PREVIOUS:
- return getAction(baseActionIcon,
- R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
-
- case NotificationConstants.NEXT:
- return getAction(baseActionIcon,
- R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
-
- case NotificationConstants.REWIND:
- return getAction(baseActionIcon,
- R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
-
- case NotificationConstants.FORWARD:
- return getAction(baseActionIcon,
- R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
-
- case NotificationConstants.SMART_REWIND_PREVIOUS:
- if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
- return getAction(R.drawable.exo_notification_previous,
- R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
- } else {
- return getAction(R.drawable.exo_controls_rewind,
- R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
- }
-
- case NotificationConstants.SMART_FORWARD_NEXT:
- if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
- return getAction(R.drawable.exo_notification_next,
- R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
- } else {
- return getAction(R.drawable.exo_controls_fastforward,
- R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
- }
-
- case NotificationConstants.PLAY_PAUSE_BUFFERING:
- if (player.getCurrentState() == Player.STATE_PREFLIGHT
- || player.getCurrentState() == Player.STATE_BLOCKED
- || player.getCurrentState() == Player.STATE_BUFFERING) {
- // null intent -> show hourglass icon that does nothing when clicked
- return new NotificationCompat.Action(R.drawable.ic_hourglass_top,
- player.getContext().getString(R.string.notification_action_buffering),
- null);
- }
-
- // fallthrough
- case NotificationConstants.PLAY_PAUSE:
- if (player.getCurrentState() == Player.STATE_COMPLETED) {
- return getAction(R.drawable.ic_replay,
- R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
- } else if (player.isPlaying()
- || player.getCurrentState() == Player.STATE_PREFLIGHT
- || player.getCurrentState() == Player.STATE_BLOCKED
- || player.getCurrentState() == Player.STATE_BUFFERING) {
- return getAction(R.drawable.exo_notification_pause,
- R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
- } else {
- return getAction(R.drawable.exo_notification_play,
- R.string.exo_controls_play_description, ACTION_PLAY_PAUSE);
- }
-
- case NotificationConstants.REPEAT:
- if (player.getRepeatMode() == REPEAT_MODE_ALL) {
- return getAction(R.drawable.exo_media_action_repeat_all,
- R.string.exo_controls_repeat_all_description, ACTION_REPEAT);
- } else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
- return getAction(R.drawable.exo_media_action_repeat_one,
- R.string.exo_controls_repeat_one_description, ACTION_REPEAT);
- } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
- return getAction(R.drawable.exo_media_action_repeat_off,
- R.string.exo_controls_repeat_off_description, ACTION_REPEAT);
- }
-
- case NotificationConstants.SHUFFLE:
- if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
- return getAction(R.drawable.exo_controls_shuffle_on,
- R.string.exo_controls_shuffle_on_description, ACTION_SHUFFLE);
- } else {
- return getAction(R.drawable.exo_controls_shuffle_off,
- R.string.exo_controls_shuffle_off_description, ACTION_SHUFFLE);
- }
-
- case NotificationConstants.CLOSE:
- return getAction(R.drawable.ic_close,
- R.string.close, ACTION_CLOSE);
-
- case NotificationConstants.NOTHING:
- default:
- // do nothing
- return null;
+ @Nullable final NotificationActionData data =
+ NotificationActionData.fromNotificationActionEnum(player, slot);
+ if (data == null) {
+ return;
}
- }
- private NotificationCompat.Action getAction(@DrawableRes final int drawable,
- @StringRes final int title,
- final String intentAction) {
- return new NotificationCompat.Action(drawable, player.getContext().getString(title),
- PendingIntentCompat.getBroadcast(player.getContext(), NOTIFICATION_ID,
- new Intent(intentAction), FLAG_UPDATE_CURRENT, false));
+ final PendingIntent intent = PendingIntentCompat.getBroadcast(player.getContext(),
+ NOTIFICATION_ID, new Intent(data.action()), FLAG_UPDATE_CURRENT, false);
+ builder.addAction(new NotificationCompat.Action(data.icon(), data.name(), intent));
}
private Intent getIntentForNotification() {
@@ -364,7 +275,7 @@ private void setLargeIcon(final NotificationCompat.Builder builder) {
final Bitmap thumbnail = player.getThumbnail();
if (thumbnail == null || !showThumbnail) {
// since the builder is reused, make sure the thumbnail is unset if there is not one
- builder.setLargeIcon(null);
+ builder.setLargeIcon((Bitmap) null);
return;
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
new file mode 100644
index 00000000000..97df1549b2e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
@@ -0,0 +1,301 @@
+package org.schabi.newpipe.settings;
+
+import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
+import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceManager;
+
+import com.grack.nanojson.JsonParserException;
+
+import org.schabi.newpipe.NewPipeDatabase;
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.error.ErrorInfo;
+import org.schabi.newpipe.error.ErrorUtil;
+import org.schabi.newpipe.error.UserAction;
+import org.schabi.newpipe.settings.export.BackupFileLocator;
+import org.schabi.newpipe.settings.export.ImportExportManager;
+import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
+import org.schabi.newpipe.streams.io.StoredFileHelper;
+import org.schabi.newpipe.util.NavigationHelper;
+import org.schabi.newpipe.util.ZipHelper;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Objects;
+
+public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
+
+ private static final String ZIP_MIME_TYPE = "application/zip";
+
+ private final SimpleDateFormat exportDateFormat =
+ new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
+ private ImportExportManager manager;
+ private String importExportDataPathKey;
+ private final ActivityResultLauncher requestImportPathLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+ this::requestImportPathResult);
+ private final ActivityResultLauncher requestExportPathLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
+ this::requestExportPathResult);
+
+
+ @Override
+ public void onCreatePreferences(@Nullable final Bundle savedInstanceState,
+ @Nullable final String rootKey) {
+ final File homeDir = ContextCompat.getDataDir(requireContext());
+ Objects.requireNonNull(homeDir);
+ manager = new ImportExportManager(new BackupFileLocator(homeDir));
+
+ importExportDataPathKey = getString(R.string.import_export_data_path);
+
+
+ addPreferencesFromResourceRegistry();
+
+ final Preference importDataPreference = requirePreference(R.string.import_data);
+ importDataPreference.setOnPreferenceClickListener((Preference p) -> {
+ NoFileManagerSafeGuard.launchSafe(
+ requestImportPathLauncher,
+ StoredFileHelper.getPicker(requireContext(),
+ ZIP_MIME_TYPE, getImportExportDataUri()),
+ TAG,
+ getContext()
+ );
+
+ return true;
+ });
+
+ final Preference exportDataPreference = requirePreference(R.string.export_data);
+ exportDataPreference.setOnPreferenceClickListener((final Preference p) -> {
+ NoFileManagerSafeGuard.launchSafe(
+ requestExportPathLauncher,
+ StoredFileHelper.getNewPicker(requireContext(),
+ "NewPipeData-" + exportDateFormat.format(new Date()) + ".zip",
+ ZIP_MIME_TYPE, getImportExportDataUri()),
+ TAG,
+ getContext()
+ );
+
+ return true;
+ });
+
+ final Preference resetSettings = findPreference(getString(R.string.reset_settings));
+ // Resets all settings by deleting shared preference and restarting the app
+ // A dialogue will pop up to confirm if user intends to reset all settings
+ assert resetSettings != null;
+ resetSettings.setOnPreferenceClickListener(preference -> {
+ // Show Alert Dialogue
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
+ builder.setMessage(R.string.reset_all_settings);
+ builder.setCancelable(true);
+ builder.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
+ // Deletes all shared preferences xml files.
+ final SharedPreferences sharedPreferences =
+ PreferenceManager.getDefaultSharedPreferences(requireContext());
+ sharedPreferences.edit().clear().apply();
+ // Restarts the app
+ if (getActivity() == null) {
+ return;
+ }
+ NavigationHelper.restartApp(getActivity());
+ });
+ builder.setNegativeButton(R.string.cancel, (dialogInterface, i) -> {
+ });
+ final AlertDialog alertDialog = builder.create();
+ alertDialog.show();
+ return true;
+ });
+ }
+
+ private void requestExportPathResult(final ActivityResult result) {
+ assureCorrectAppLanguage(requireContext());
+ if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+ // will be saved only on success
+ final Uri lastExportDataUri = result.getData().getData();
+
+ final StoredFileHelper file = new StoredFileHelper(
+ requireContext(), result.getData().getData(), ZIP_MIME_TYPE);
+
+ exportDatabase(file, lastExportDataUri);
+ }
+ }
+
+ private void requestImportPathResult(final ActivityResult result) {
+ assureCorrectAppLanguage(requireContext());
+ if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+ // will be saved only on success
+ final Uri lastImportDataUri = result.getData().getData();
+
+ final StoredFileHelper file = new StoredFileHelper(
+ requireContext(), result.getData().getData(), ZIP_MIME_TYPE);
+
+ new androidx.appcompat.app.AlertDialog.Builder(requireActivity())
+ .setMessage(R.string.override_current_data)
+ .setPositiveButton(R.string.ok, (d, id) ->
+ importDatabase(file, lastImportDataUri))
+ .setNegativeButton(R.string.cancel, (d, id) ->
+ d.cancel())
+ .show();
+ }
+ }
+
+ private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) {
+ try {
+ //checkpoint before export
+ NewPipeDatabase.checkpoint();
+
+ final SharedPreferences preferences = PreferenceManager
+ .getDefaultSharedPreferences(requireContext());
+ manager.exportDatabase(preferences, file);
+
+ saveLastImportExportDataUri(exportDataUri); // save export path only on success
+ Toast.makeText(requireContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT)
+ .show();
+ } catch (final Exception e) {
+ showErrorSnackbar(e, "Exporting database and settings");
+ }
+ }
+
+ private void importDatabase(final StoredFileHelper file, final Uri importDataUri) {
+ // check if file is supported
+ if (!ZipHelper.isValidZipFile(file)) {
+ Toast.makeText(requireContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
+ .show();
+ return;
+ }
+
+ try {
+ if (!manager.ensureDbDirectoryExists()) {
+ throw new IOException("Could not create databases dir");
+ }
+
+ // replace the current database
+ if (!manager.extractDb(file)) {
+ Toast.makeText(requireContext(), R.string.could_not_import_all_files,
+ Toast.LENGTH_LONG)
+ .show();
+ }
+
+ // if settings file exist, ask if it should be imported.
+ final boolean hasJsonPrefs = manager.exportHasJsonPrefs(file);
+ if (hasJsonPrefs || manager.exportHasSerializedPrefs(file)) {
+ new androidx.appcompat.app.AlertDialog.Builder(requireContext())
+ .setTitle(R.string.import_settings)
+ .setMessage(hasJsonPrefs ? null : requireContext()
+ .getString(R.string.import_settings_vulnerable_format))
+ .setOnDismissListener(dialog -> finishImport(importDataUri))
+ .setNegativeButton(R.string.cancel, (dialog, which) -> {
+ dialog.dismiss();
+ finishImport(importDataUri);
+ })
+ .setPositiveButton(R.string.ok, (dialog, which) -> {
+ dialog.dismiss();
+ final Context context = requireContext();
+ final SharedPreferences prefs = PreferenceManager
+ .getDefaultSharedPreferences(context);
+ try {
+ if (hasJsonPrefs) {
+ manager.loadJsonPrefs(file, prefs);
+ } else {
+ manager.loadSerializedPrefs(file, prefs);
+ }
+ } catch (IOException | ClassNotFoundException | JsonParserException e) {
+ createErrorNotification(e, "Importing preferences");
+ return;
+ }
+ cleanImport(context, prefs);
+ finishImport(importDataUri);
+ })
+ .show();
+ } else {
+ finishImport(importDataUri);
+ }
+ } catch (final Exception e) {
+ showErrorSnackbar(e, "Importing database and settings");
+ }
+ }
+
+ /**
+ * Remove settings that are not supposed to be imported on different devices
+ * and reset them to default values.
+ * @param context the context used for the import
+ * @param prefs the preferences used while running the import
+ */
+ private void cleanImport(@NonNull final Context context,
+ @NonNull final SharedPreferences prefs) {
+ // Check if media tunnelling needs to be disabled automatically,
+ // if it was disabled automatically in the imported preferences.
+ final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key);
+ final String automaticTunnelingKey =
+ context.getString(R.string.disabled_media_tunneling_automatically_key);
+ // R.string.disable_media_tunneling_key should always be true
+ // if R.string.disabled_media_tunneling_automatically_key equals 1,
+ // but we double check here just to be sure and to avoid regressions
+ // caused by possible later modification of the media tunneling functionality.
+ // R.string.disabled_media_tunneling_automatically_key == 0:
+ // automatic value overridden by user in settings
+ // R.string.disabled_media_tunneling_automatically_key == -1: not set
+ final boolean wasMediaTunnelingDisabledAutomatically =
+ prefs.getInt(automaticTunnelingKey, -1) == 1
+ && prefs.getBoolean(tunnelingKey, false);
+ if (wasMediaTunnelingDisabledAutomatically) {
+ prefs.edit()
+ .putInt(automaticTunnelingKey, -1)
+ .putBoolean(tunnelingKey, false)
+ .apply();
+ NewPipeSettings.setMediaTunneling(context);
+ }
+ }
+
+ /**
+ * Save import path and restart app.
+ *
+ * @param importDataUri The import path to save
+ */
+ private void finishImport(final Uri importDataUri) {
+ // save import path only on success
+ saveLastImportExportDataUri(importDataUri);
+ // restart app to properly load db
+ NavigationHelper.restartApp(requireActivity());
+ }
+
+ private Uri getImportExportDataUri() {
+ final String path = defaultPreferences.getString(importExportDataPathKey, null);
+ return isBlank(path) ? null : Uri.parse(path);
+ }
+
+ private void saveLastImportExportDataUri(final Uri importExportDataUri) {
+ final SharedPreferences.Editor editor = defaultPreferences.edit()
+ .putString(importExportDataPathKey, importExportDataUri.toString());
+ editor.apply();
+ }
+
+ private void showErrorSnackbar(final Throwable e, final String request) {
+ ErrorUtil.showSnackbar(this, new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request));
+ }
+
+ private void createErrorNotification(final Throwable e, final String request) {
+ ErrorUtil.createNotification(
+ requireContext(),
+ new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request)
+ );
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
index c7d107acb2d..ec2bed67a44 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
@@ -1,106 +1,36 @@
package org.schabi.newpipe.settings;
-import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
-import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
-
-import android.app.Activity;
import android.content.Context;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;
-import androidx.activity.result.ActivityResult;
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
-import androidx.core.content.ContextCompat;
import androidx.preference.Preference;
-import androidx.preference.PreferenceManager;
import org.schabi.newpipe.DownloaderImpl;
-import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
-import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.localization.Localization;
-import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
-import org.schabi.newpipe.streams.io.StoredFileHelper;
-import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
-import org.schabi.newpipe.util.ZipHelper;
import org.schabi.newpipe.util.image.PreferredImageQuality;
-import java.io.File;
import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
-import java.util.Locale;
-import java.util.Objects;
public class ContentSettingsFragment extends BasePreferenceFragment {
- private static final String ZIP_MIME_TYPE = "application/zip";
-
- private final SimpleDateFormat exportDateFormat =
- new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
-
- private ContentSettingsManager manager;
-
- private String importExportDataPathKey;
private String youtubeRestrictedModeEnabledKey;
private Localization initialSelectedLocalization;
private ContentCountry initialSelectedContentCountry;
private String initialLanguage;
- private final ActivityResultLauncher requestImportPathLauncher =
- registerForActivityResult(new StartActivityForResult(), this::requestImportPathResult);
- private final ActivityResultLauncher requestExportPathLauncher =
- registerForActivityResult(new StartActivityForResult(), this::requestExportPathResult);
@Override
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
- final File homeDir = ContextCompat.getDataDir(requireContext());
- Objects.requireNonNull(homeDir);
- manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir));
- manager.deleteSettingsFile();
-
- importExportDataPathKey = getString(R.string.import_export_data_path);
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
addPreferencesFromResourceRegistry();
- final Preference importDataPreference = requirePreference(R.string.import_data);
- importDataPreference.setOnPreferenceClickListener((Preference p) -> {
- NoFileManagerSafeGuard.launchSafe(
- requestImportPathLauncher,
- StoredFileHelper.getPicker(requireContext(),
- ZIP_MIME_TYPE, getImportExportDataUri()),
- TAG,
- getContext()
- );
-
- return true;
- });
-
- final Preference exportDataPreference = requirePreference(R.string.export_data);
- exportDataPreference.setOnPreferenceClickListener((final Preference p) -> {
- NoFileManagerSafeGuard.launchSafe(
- requestExportPathLauncher,
- StoredFileHelper.getNewPicker(requireContext(),
- "NewPipeData-" + exportDateFormat.format(new Date()) + ".zip",
- ZIP_MIME_TYPE, getImportExportDataUri()),
- TAG,
- getContext()
- );
-
- return true;
- });
-
initialSelectedLocalization = org.schabi.newpipe.util.Localization
.getPreferredLocalization(requireContext());
initialSelectedContentCountry = org.schabi.newpipe.util.Localization
@@ -158,151 +88,4 @@ public void onDestroy() {
NewPipe.setupLocalization(selectedLocalization, selectedContentCountry);
}
}
-
- private void requestExportPathResult(final ActivityResult result) {
- assureCorrectAppLanguage(getContext());
- if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
- // will be saved only on success
- final Uri lastExportDataUri = result.getData().getData();
-
- final StoredFileHelper file =
- new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE);
-
- exportDatabase(file, lastExportDataUri);
- }
- }
-
- private void requestImportPathResult(final ActivityResult result) {
- assureCorrectAppLanguage(getContext());
- if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
- // will be saved only on success
- final Uri lastImportDataUri = result.getData().getData();
-
- final StoredFileHelper file =
- new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE);
-
- new AlertDialog.Builder(requireActivity())
- .setMessage(R.string.override_current_data)
- .setPositiveButton(R.string.ok, (d, id) ->
- importDatabase(file, lastImportDataUri))
- .setNegativeButton(R.string.cancel, (d, id) ->
- d.cancel())
- .show();
- }
- }
-
- private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) {
- try {
- //checkpoint before export
- NewPipeDatabase.checkpoint();
-
- final SharedPreferences preferences = PreferenceManager
- .getDefaultSharedPreferences(requireContext());
- manager.exportDatabase(preferences, file);
-
- saveLastImportExportDataUri(exportDataUri); // save export path only on success
- Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT).show();
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Exporting database", e);
- }
- }
-
- private void importDatabase(final StoredFileHelper file, final Uri importDataUri) {
- // check if file is supported
- if (!ZipHelper.isValidZipFile(file)) {
- Toast.makeText(getContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
- .show();
- return;
- }
-
- try {
- if (!manager.ensureDbDirectoryExists()) {
- throw new IOException("Could not create databases dir");
- }
-
- if (!manager.extractDb(file)) {
- Toast.makeText(getContext(), R.string.could_not_import_all_files, Toast.LENGTH_LONG)
- .show();
- }
-
- // if settings file exist, ask if it should be imported.
- if (manager.extractSettings(file)) {
- new AlertDialog.Builder(requireContext())
- .setTitle(R.string.import_settings)
- .setNegativeButton(R.string.cancel, (dialog, which) -> {
- dialog.dismiss();
- finishImport(importDataUri);
- })
- .setPositiveButton(R.string.ok, (dialog, which) -> {
- dialog.dismiss();
- final Context context = requireContext();
- final SharedPreferences prefs = PreferenceManager
- .getDefaultSharedPreferences(context);
- manager.loadSharedPreferences(prefs);
- cleanImport(context, prefs);
- finishImport(importDataUri);
- })
- .show();
- } else {
- finishImport(importDataUri);
- }
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Importing database", e);
- }
- }
-
- /**
- * Remove settings that are not supposed to be imported on different devices
- * and reset them to default values.
- * @param context the context used for the import
- * @param prefs the preferences used while running the import
- */
- private void cleanImport(@NonNull final Context context,
- @NonNull final SharedPreferences prefs) {
- // Check if media tunnelling needs to be disabled automatically,
- // if it was disabled automatically in the imported preferences.
- final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key);
- final String automaticTunnelingKey =
- context.getString(R.string.disabled_media_tunneling_automatically_key);
- // R.string.disable_media_tunneling_key should always be true
- // if R.string.disabled_media_tunneling_automatically_key equals 1,
- // but we double check here just to be sure and to avoid regressions
- // caused by possible later modification of the media tunneling functionality.
- // R.string.disabled_media_tunneling_automatically_key == 0:
- // automatic value overridden by user in settings
- // R.string.disabled_media_tunneling_automatically_key == -1: not set
- final boolean wasMediaTunnelingDisabledAutomatically =
- prefs.getInt(automaticTunnelingKey, -1) == 1
- && prefs.getBoolean(tunnelingKey, false);
- if (wasMediaTunnelingDisabledAutomatically) {
- prefs.edit()
- .putInt(automaticTunnelingKey, -1)
- .putBoolean(tunnelingKey, false)
- .apply();
- NewPipeSettings.setMediaTunneling(context);
- }
- }
-
- /**
- * Save import path and restart system.
- *
- * @param importDataUri The import path to save
- */
- private void finishImport(final Uri importDataUri) {
- // save import path only on success
- saveLastImportExportDataUri(importDataUri);
- // restart app to properly load db
- NavigationHelper.restartApp(requireActivity());
- }
-
- private Uri getImportExportDataUri() {
- final String path = defaultPreferences.getString(importExportDataPathKey, null);
- return isBlank(path) ? null : Uri.parse(path);
- }
-
- private void saveLastImportExportDataUri(final Uri importExportDataUri) {
- final SharedPreferences.Editor editor = defaultPreferences.edit()
- .putString(importExportDataPathKey, importExportDataUri.toString());
- editor.apply();
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt
deleted file mode 100644
index df56de51669..00000000000
--- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-package org.schabi.newpipe.settings
-
-import android.content.SharedPreferences
-import android.util.Log
-import org.schabi.newpipe.MainActivity.DEBUG
-import org.schabi.newpipe.streams.io.SharpOutputStream
-import org.schabi.newpipe.streams.io.StoredFileHelper
-import org.schabi.newpipe.util.ZipHelper
-import java.io.IOException
-import java.io.ObjectInputStream
-import java.io.ObjectOutputStream
-import java.util.zip.ZipOutputStream
-
-class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) {
- companion object {
- const val TAG = "ContentSetManager"
- }
-
- /**
- * Exports given [SharedPreferences] to the file in given outputPath.
- * It also creates the file.
- */
- @Throws(Exception::class)
- fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) {
- file.create()
- ZipOutputStream(SharpOutputStream(file.stream).buffered())
- .use { outZip ->
- ZipHelper.addFileToZip(outZip, fileLocator.db.path, "newpipe.db")
-
- try {
- ObjectOutputStream(fileLocator.settings.outputStream()).use { output ->
- output.writeObject(preferences.all)
- output.flush()
- }
- } catch (e: IOException) {
- if (DEBUG) {
- Log.e(TAG, "Unable to exportDatabase", e)
- }
- }
-
- ZipHelper.addFileToZip(outZip, fileLocator.settings.path, "newpipe.settings")
- }
- }
-
- fun deleteSettingsFile() {
- fileLocator.settings.delete()
- }
-
- /**
- * Tries to create database directory if it does not exist.
- *
- * @return Whether the directory exists afterwards.
- */
- fun ensureDbDirectoryExists(): Boolean {
- return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir()
- }
-
- fun extractDb(file: StoredFileHelper): Boolean {
- val success = ZipHelper.extractFileFromZip(file, fileLocator.db.path, "newpipe.db")
- if (success) {
- fileLocator.dbJournal.delete()
- fileLocator.dbWal.delete()
- fileLocator.dbShm.delete()
- }
-
- return success
- }
-
- fun extractSettings(file: StoredFileHelper): Boolean {
- return ZipHelper.extractFileFromZip(file, fileLocator.settings.path, "newpipe.settings")
- }
-
- /**
- * Remove all shared preferences from the app and load the preferences supplied to the manager.
- */
- fun loadSharedPreferences(preferences: SharedPreferences) {
- try {
- val preferenceEditor = preferences.edit()
-
- ObjectInputStream(fileLocator.settings.inputStream()).use { input ->
- preferenceEditor.clear()
- @Suppress("UNCHECKED_CAST")
- val entries = input.readObject() as Map
- for ((key, value) in entries) {
- when (value) {
- is Boolean -> {
- preferenceEditor.putBoolean(key, value)
- }
- is Float -> {
- preferenceEditor.putFloat(key, value)
- }
- is Int -> {
- preferenceEditor.putInt(key, value)
- }
- is Long -> {
- preferenceEditor.putLong(key, value)
- }
- is String -> {
- preferenceEditor.putString(key, value)
- }
- is Set<*> -> {
- // There are currently only Sets with type String possible
- @Suppress("UNCHECKED_CAST")
- preferenceEditor.putStringSet(key, value as Set?)
- }
- }
- }
- preferenceEditor.commit()
- }
- } catch (e: IOException) {
- if (DEBUG) {
- Log.e(TAG, "Unable to loadSharedPreferences", e)
- }
- } catch (e: ClassNotFoundException) {
- if (DEBUG) {
- Log.e(TAG, "Unable to loadSharedPreferences", e)
- }
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java
index 3776d78f679..32e33d55bf6 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java
@@ -23,7 +23,7 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro
setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called
// Check if the app is updatable
- if (!ReleaseVersionUtil.isReleaseApk()) {
+ if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
getPreferenceScreen().removePreference(
findPreference(getString(R.string.update_pref_screen_key)));
diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeFileLocator.kt b/app/src/main/java/org/schabi/newpipe/settings/NewPipeFileLocator.kt
deleted file mode 100644
index c2f93d15f0a..00000000000
--- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeFileLocator.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package org.schabi.newpipe.settings
-
-import java.io.File
-
-/**
- * Locates specific files of NewPipe based on the home directory of the app.
- */
-class NewPipeFileLocator(private val homeDir: File) {
-
- val dbDir by lazy { File(homeDir, "/databases") }
-
- val db by lazy { File(homeDir, "/databases/newpipe.db") }
-
- val dbJournal by lazy { File(homeDir, "/databases/newpipe.db-journal") }
-
- val dbShm by lazy { File(homeDir, "/databases/newpipe.db-shm") }
-
- val dbWal by lazy { File(homeDir, "/databases/newpipe.db-wal") }
-
- val settings by lazy { File(homeDir, "/databases/newpipe.settings") }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
index 4d8560a5971..052ef02cf82 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
@@ -11,6 +11,7 @@
import androidx.annotation.StringRes;
import androidx.preference.PreferenceManager;
+import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.util.DeviceUtils;
@@ -44,14 +45,8 @@ public final class NewPipeSettings {
private NewPipeSettings() { }
public static void initSettings(final Context context) {
- // check if the last used preference version is set
- // to determine whether this is the first app run
- final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(context)
- .getInt(context.getString(R.string.last_used_preferences_version), -1);
- final boolean isFirstRun = lastUsedPrefVersion == -1;
-
// first run migrations, then setDefaultValues, since the latter requires the correct types
- SettingMigrations.runMigrationsIfNeeded(context, isFirstRun);
+ SettingMigrations.runMigrationsIfNeeded(context);
// readAgain is true so that if new settings are added their default value is set
PreferenceManager.setDefaultValues(context, R.xml.main_settings, true);
@@ -64,11 +59,12 @@ public static void initSettings(final Context context) {
PreferenceManager.setDefaultValues(context, R.xml.update_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_category_settings, true);
+ PreferenceManager.setDefaultValues(context, R.xml.backup_restore_settings, true);
saveDefaultVideoDownloadDirectory(context);
saveDefaultAudioDownloadDirectory(context);
- disableMediaTunnelingIfNecessary(context, isFirstRun);
+ disableMediaTunnelingIfNecessary(context);
}
static void saveDefaultVideoDownloadDirectory(final Context context) {
@@ -146,8 +142,7 @@ public static boolean showRemoteSearchSuggestions(final Context context,
R.string.show_remote_search_suggestions_key);
}
- private static void disableMediaTunnelingIfNecessary(@NonNull final Context context,
- final boolean isFirstRun) {
+ private static void disableMediaTunnelingIfNecessary(@NonNull final Context context) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
final String disabledTunnelingKey = context.getString(R.string.disable_media_tunneling_key);
final String disabledTunnelingAutomaticallyKey =
@@ -162,7 +157,7 @@ private static void disableMediaTunnelingIfNecessary(@NonNull final Context cont
prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0
&& !prefs.getBoolean(disabledTunnelingKey, false);
- if (Boolean.TRUE.equals(isFirstRun)
+ if (App.getApp().isFirstRun()
|| (wasDeviceBlacklistUpdated && !wasMediaTunnelingEnabledByUser)) {
setMediaTunneling(context);
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
index 147d20a360d..36abef9e5ca 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.settings;
+import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists;
+
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -31,7 +33,6 @@
import java.util.Vector;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.disposables.Disposable;
public class SelectPlaylistFragment extends DialogFragment {
@@ -90,8 +91,7 @@ private void loadPlaylists() {
final LocalPlaylistManager localPlaylistManager = new LocalPlaylistManager(database);
final RemotePlaylistManager remotePlaylistManager = new RemotePlaylistManager(database);
- disposable = Flowable.combineLatest(localPlaylistManager.getPlaylists(),
- remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge)
+ disposable = getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::displayPlaylists, this::onError);
}
@@ -118,7 +118,7 @@ private void clickedItem(final int position) {
if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
- onSelectedListener.onLocalPlaylistSelected(entry.uid, entry.name);
+ onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.name);
} else if (selectedItem instanceof PlaylistRemoteEntity) {
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
index b7bafde7535..d731f2f5ec1 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
@@ -7,6 +7,7 @@
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
+import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
@@ -163,15 +164,14 @@ protected void migrate(@NonNull final Context context) {
private static final int VERSION = 6;
- public static void runMigrationsIfNeeded(@NonNull final Context context,
- final boolean isFirstRun) {
+ public static void runMigrationsIfNeeded(@NonNull final Context context) {
// setup migrations and check if there is something to do
sp = PreferenceManager.getDefaultSharedPreferences(context);
final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version);
final int lastPrefVersion = sp.getInt(lastPrefVersionKey, 0);
// no migration to run, already up to date
- if (isFirstRun) {
+ if (App.getApp().isFirstRun()) {
sp.edit().putInt(lastPrefVersionKey, VERSION).apply();
return;
} else if (lastPrefVersion == VERSION) {
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 3ee6668bf94..529e5344220 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
@@ -266,7 +266,7 @@ private void initSearch(
*/
private void ensureSearchRepresentsApplicationState() {
// Check if the update settings are available
- if (!ReleaseVersionUtil.isReleaseApk()) {
+ if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
SettingsResourceRegistry.getInstance()
.getEntryByPreferencesResId(R.xml.update_settings)
.setSearchable(false);
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 c71b4ca5336..a38a9423d2b 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java
@@ -41,6 +41,7 @@ private SettingsResourceRegistry() {
add(UpdateSettingsFragment.class, R.xml.update_settings);
add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings);
add(ExoPlayerSettingsFragment.class, R.xml.exoplayer_settings);
+ add(BackupRestoreSettingsFragment.class, R.xml.backup_restore_settings);
add(SponsorBlockSettingsFragment.class, R.xml.sponsor_block_settings);
add(SponsorBlockCategoriesSettingsFragment.class, R.xml.sponsor_block_category_settings);
add(ExtraSettingsFragment.class, R.xml.extra_settings);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java
index d1a379e66ea..b8d0aa556d3 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java
@@ -1,9 +1,12 @@
package org.schabi.newpipe.settings;
+import android.app.AlertDialog;
+import android.content.Context;
import android.os.Bundle;
import android.widget.Toast;
import androidx.preference.Preference;
+import androidx.preference.PreferenceManager;
import org.schabi.newpipe.NewVersionWorker;
import org.schabi.newpipe.R;
@@ -36,4 +39,38 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro
findPreference(getString(R.string.manual_update_key))
.setOnPreferenceClickListener(manualUpdateClick);
}
+
+ public static void askForConsentToUpdateChecks(final Context context) {
+ new AlertDialog.Builder(context)
+ .setTitle(context.getString(R.string.check_for_updates))
+ .setMessage(context.getString(R.string.auto_update_check_description))
+ .setPositiveButton(context.getString(R.string.yes), (d, w) -> {
+ d.dismiss();
+ setAutoUpdateCheckEnabled(context, true);
+ })
+ .setNegativeButton(R.string.no, (d, w) -> {
+ d.dismiss();
+ // set explicitly to false, since the default is true on previous versions
+ setAutoUpdateCheckEnabled(context, false);
+ })
+ .show();
+ }
+
+ private static void setAutoUpdateCheckEnabled(final Context context, final boolean enabled) {
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(context.getString(R.string.update_app_key), enabled)
+ .putBoolean(context.getString(R.string.update_check_consent_key), true)
+ .apply();
+ }
+
+ /**
+ * Whether the user was asked for consent to automatically check for app updates.
+ * @param context
+ * @return true if the user was asked for consent, false otherwise
+ */
+ public static boolean wasUserAskedForConsent(final Context context) {
+ return PreferenceManager.getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string.update_check_consent_key), false);
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java
index 3e92f297e6e..7dfddef20d3 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java
@@ -5,35 +5,22 @@
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
-import android.content.res.ColorStateList;
+import android.os.Build;
import android.util.AttributeSet;
-import android.view.LayoutInflater;
import android.view.View;
-import android.view.ViewGroup;
import android.widget.CheckBox;
-import android.widget.ImageView;
-import android.widget.RadioButton;
-import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.content.res.AppCompatResources;
-import androidx.core.widget.TextViewCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
-import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
-import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
import org.schabi.newpipe.player.notification.NotificationConstants;
-import org.schabi.newpipe.util.DeviceUtils;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.views.FocusOverlayView;
+import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
@@ -45,8 +32,9 @@ public NotificationActionsPreference(final Context context, final AttributeSet a
}
- @Nullable private NotificationSlot[] notificationSlots = null;
- @Nullable private List compactSlots = null;
+ private NotificationSlot[] notificationSlots;
+ private List compactSlots;
+
////////////////////////////////////////////////////////////////////////////
// Lifecycle
@@ -56,6 +44,11 @@ public NotificationActionsPreference(final Context context, final AttributeSet a
public void onBindViewHolder(@NonNull final PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ((TextView) holder.itemView.findViewById(R.id.summary))
+ .setText(R.string.notification_actions_summary_android13);
+ }
+
holder.itemView.setClickable(false);
setupActions(holder.itemView);
}
@@ -75,13 +68,29 @@ public void onDetached() {
////////////////////////////////////////////////////////////////////////////
private void setupActions(@NonNull final View view) {
- compactSlots = NotificationConstants.getCompactSlotsFromPreferences(getContext(),
- getSharedPreferences(), 5);
+ compactSlots = new ArrayList<>(NotificationConstants.getCompactSlotsFromPreferences(
+ getContext(), getSharedPreferences()));
notificationSlots = IntStream.range(0, 5)
- .mapToObj(i -> new NotificationSlot(i, view))
+ .mapToObj(i -> new NotificationSlot(getContext(), getSharedPreferences(), i, view,
+ compactSlots.contains(i), this::onToggleCompactSlot))
.toArray(NotificationSlot[]::new);
}
+ private void onToggleCompactSlot(final int i, final CheckBox checkBox) {
+ if (checkBox.isChecked()) {
+ compactSlots.remove((Integer) i);
+ } else if (compactSlots.size() < 3) {
+ compactSlots.add(i);
+ } else {
+ Toast.makeText(getContext(),
+ R.string.notification_actions_at_most_three,
+ Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ checkBox.toggle();
+ }
+
////////////////////////////////////////////////////////////////////////////
// Saving
@@ -99,143 +108,10 @@ private void saveChanges() {
for (int i = 0; i < 5; i++) {
editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
- notificationSlots[i].selectedAction);
+ notificationSlots[i].getSelectedAction());
}
editor.apply();
}
}
-
-
- ////////////////////////////////////////////////////////////////////////////
- // Notification action
- ////////////////////////////////////////////////////////////////////////////
-
- private static final int[] SLOT_ITEMS = {
- R.id.notificationAction0,
- R.id.notificationAction1,
- R.id.notificationAction2,
- R.id.notificationAction3,
- R.id.notificationAction4,
- };
-
- private static final int[] SLOT_TITLES = {
- R.string.notification_action_0_title,
- R.string.notification_action_1_title,
- R.string.notification_action_2_title,
- R.string.notification_action_3_title,
- R.string.notification_action_4_title,
- };
-
- private class NotificationSlot {
-
- final int i;
- @NotificationConstants.Action int selectedAction;
-
- ImageView icon;
- TextView summary;
-
- NotificationSlot(final int actionIndex, final View parentView) {
- this.i = actionIndex;
-
- final View view = parentView.findViewById(SLOT_ITEMS[i]);
- setupSelectedAction(view);
- setupTitle(view);
- setupCheckbox(view);
- }
-
- void setupTitle(final View view) {
- ((TextView) view.findViewById(R.id.notificationActionTitle))
- .setText(SLOT_TITLES[i]);
- view.findViewById(R.id.notificationActionClickableArea).setOnClickListener(
- v -> openActionChooserDialog());
- }
-
- void setupCheckbox(final View view) {
- final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox);
- compactSlotCheckBox.setChecked(compactSlots.contains(i));
- view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener(
- v -> {
- if (compactSlotCheckBox.isChecked()) {
- compactSlots.remove((Integer) i);
- } else if (compactSlots.size() < 3) {
- compactSlots.add(i);
- } else {
- Toast.makeText(getContext(),
- R.string.notification_actions_at_most_three,
- Toast.LENGTH_SHORT).show();
- return;
- }
-
- compactSlotCheckBox.toggle();
- });
- }
-
- void setupSelectedAction(final View view) {
- icon = view.findViewById(R.id.notificationActionIcon);
- summary = view.findViewById(R.id.notificationActionSummary);
- selectedAction = getSharedPreferences().getInt(
- getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
- NotificationConstants.SLOT_DEFAULTS[i]);
- updateInfo();
- }
-
- void updateInfo() {
- if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) {
- icon.setImageDrawable(null);
- } else {
- icon.setImageDrawable(AppCompatResources.getDrawable(getContext(),
- NotificationConstants.ACTION_ICONS[selectedAction]));
- }
-
- summary.setText(NotificationConstants.getActionName(getContext(), selectedAction));
- }
-
- void openActionChooserDialog() {
- final LayoutInflater inflater = LayoutInflater.from(getContext());
- final SingleChoiceDialogViewBinding binding =
- SingleChoiceDialogViewBinding.inflate(inflater);
-
- final AlertDialog alertDialog = new AlertDialog.Builder(getContext())
- .setTitle(SLOT_TITLES[i])
- .setView(binding.getRoot())
- .setCancelable(true)
- .create();
-
- final View.OnClickListener radioButtonsClickListener = v -> {
- selectedAction = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][v.getId()];
- updateInfo();
- alertDialog.dismiss();
- };
-
- for (int id = 0; id < NotificationConstants.SLOT_ALLOWED_ACTIONS[i].length; ++id) {
- final int action = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][id];
- final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater)
- .getRoot();
-
- // if present set action icon with correct color
- final int iconId = NotificationConstants.ACTION_ICONS[action];
- if (iconId != 0) {
- radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0);
-
- final var color = ColorStateList.valueOf(ThemeHelper
- .resolveColorFromAttr(getContext(), android.R.attr.textColorPrimary));
- TextViewCompat.setCompoundDrawableTintList(radioButton, color);
- }
-
- radioButton.setText(NotificationConstants.getActionName(getContext(), action));
- radioButton.setChecked(action == selectedAction);
- radioButton.setId(id);
- radioButton.setLayoutParams(new RadioGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
- radioButton.setOnClickListener(radioButtonsClickListener);
- binding.list.addView(radioButton);
- }
- alertDialog.show();
-
- if (DeviceUtils.isTv(getContext())) {
- FocusOverlayView.setupFocusObserver(alertDialog);
- }
- }
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java
new file mode 100644
index 00000000000..981ba3e7549
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java
@@ -0,0 +1,172 @@
+package org.schabi.newpipe.settings.custom;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.ColorStateList;
+import android.os.Build;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckBox;
+import android.widget.ImageView;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.core.widget.TextViewCompat;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
+import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
+import org.schabi.newpipe.player.notification.NotificationConstants;
+import org.schabi.newpipe.util.DeviceUtils;
+import org.schabi.newpipe.util.ThemeHelper;
+import org.schabi.newpipe.views.FocusOverlayView;
+
+import java.util.Objects;
+import java.util.function.BiConsumer;
+
+class NotificationSlot {
+
+ private static final int[] SLOT_ITEMS = {
+ R.id.notificationAction0,
+ R.id.notificationAction1,
+ R.id.notificationAction2,
+ R.id.notificationAction3,
+ R.id.notificationAction4,
+ };
+
+ private static final int[] SLOT_TITLES = {
+ R.string.notification_action_0_title,
+ R.string.notification_action_1_title,
+ R.string.notification_action_2_title,
+ R.string.notification_action_3_title,
+ R.string.notification_action_4_title,
+ };
+
+ private final int i;
+ private @NotificationConstants.Action int selectedAction;
+ private final Context context;
+ private final BiConsumer onToggleCompactSlot;
+
+ private ImageView icon;
+ private TextView summary;
+
+ NotificationSlot(final Context context,
+ final SharedPreferences prefs,
+ final int actionIndex,
+ final View parentView,
+ final boolean isCompactSlotChecked,
+ final BiConsumer onToggleCompactSlot) {
+ this.context = context;
+ this.i = actionIndex;
+ this.onToggleCompactSlot = onToggleCompactSlot;
+
+ selectedAction = Objects.requireNonNull(prefs).getInt(
+ context.getString(NotificationConstants.SLOT_PREF_KEYS[i]),
+ NotificationConstants.SLOT_DEFAULTS[i]);
+ final View view = parentView.findViewById(SLOT_ITEMS[i]);
+
+ // only show the last two notification slots on Android 13+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || i >= 3) {
+ setupSelectedAction(view);
+ setupTitle(view);
+ setupCheckbox(view, isCompactSlotChecked);
+ } else {
+ view.setVisibility(View.GONE);
+ }
+ }
+
+ void setupTitle(final View view) {
+ ((TextView) view.findViewById(R.id.notificationActionTitle))
+ .setText(SLOT_TITLES[i]);
+ view.findViewById(R.id.notificationActionClickableArea).setOnClickListener(
+ v -> openActionChooserDialog());
+ }
+
+ void setupCheckbox(final View view, final boolean isCompactSlotChecked) {
+ final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ // there are no compact slots to customize on Android 13+
+ compactSlotCheckBox.setVisibility(View.GONE);
+ view.findViewById(R.id.notificationActionCheckBoxClickableArea)
+ .setVisibility(View.GONE);
+ return;
+ }
+
+ compactSlotCheckBox.setChecked(isCompactSlotChecked);
+ view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener(
+ v -> onToggleCompactSlot.accept(i, compactSlotCheckBox));
+ }
+
+ void setupSelectedAction(final View view) {
+ icon = view.findViewById(R.id.notificationActionIcon);
+ summary = view.findViewById(R.id.notificationActionSummary);
+ updateInfo();
+ }
+
+ void updateInfo() {
+ if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) {
+ icon.setImageDrawable(null);
+ } else {
+ icon.setImageDrawable(AppCompatResources.getDrawable(context,
+ NotificationConstants.ACTION_ICONS[selectedAction]));
+ }
+
+ summary.setText(NotificationConstants.getActionName(context, selectedAction));
+ }
+
+ void openActionChooserDialog() {
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ final SingleChoiceDialogViewBinding binding =
+ SingleChoiceDialogViewBinding.inflate(inflater);
+
+ final AlertDialog alertDialog = new AlertDialog.Builder(context)
+ .setTitle(SLOT_TITLES[i])
+ .setView(binding.getRoot())
+ .setCancelable(true)
+ .create();
+
+ final View.OnClickListener radioButtonsClickListener = v -> {
+ selectedAction = NotificationConstants.ALL_ACTIONS[v.getId()];
+ updateInfo();
+ alertDialog.dismiss();
+ };
+
+ for (int id = 0; id < NotificationConstants.ALL_ACTIONS.length; ++id) {
+ final int action = NotificationConstants.ALL_ACTIONS[id];
+ final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater)
+ .getRoot();
+
+ // if present set action icon with correct color
+ final int iconId = NotificationConstants.ACTION_ICONS[action];
+ if (iconId != 0) {
+ radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0);
+
+ final var color = ColorStateList.valueOf(ThemeHelper
+ .resolveColorFromAttr(context, android.R.attr.textColorPrimary));
+ TextViewCompat.setCompoundDrawableTintList(radioButton, color);
+ }
+
+ radioButton.setText(NotificationConstants.getActionName(context, action));
+ radioButton.setChecked(action == selectedAction);
+ radioButton.setId(id);
+ radioButton.setLayoutParams(new RadioGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ radioButton.setOnClickListener(radioButtonsClickListener);
+ binding.list.addView(radioButton);
+ }
+ alertDialog.show();
+
+ if (DeviceUtils.isTv(context)) {
+ FocusOverlayView.setupFocusObserver(alertDialog);
+ }
+ }
+
+ @NotificationConstants.Action
+ public int getSelectedAction() {
+ return selectedAction;
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt b/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt
new file mode 100644
index 00000000000..c864e4a0df8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt
@@ -0,0 +1,28 @@
+package org.schabi.newpipe.settings.export
+
+import java.io.File
+
+/**
+ * Locates specific files of NewPipe based on the home directory of the app.
+ */
+class BackupFileLocator(private val homeDir: File) {
+ companion object {
+ const val FILE_NAME_DB = "newpipe.db"
+ @Deprecated(
+ "Serializing preferences with Java's ObjectOutputStream is vulnerable to injections",
+ replaceWith = ReplaceWith("FILE_NAME_JSON_PREFS")
+ )
+ const val FILE_NAME_SERIALIZED_PREFS = "newpipe.settings"
+ const val FILE_NAME_JSON_PREFS = "preferences.json"
+ }
+
+ val dbDir by lazy { File(homeDir, "/databases") }
+
+ val db by lazy { File(dbDir, FILE_NAME_DB) }
+
+ val dbJournal by lazy { File(dbDir, "$FILE_NAME_DB-journal") }
+
+ val dbShm by lazy { File(dbDir, "$FILE_NAME_DB-shm") }
+
+ val dbWal by lazy { File(dbDir, "$FILE_NAME_DB-wal") }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
new file mode 100644
index 00000000000..93c1bfb8100
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
@@ -0,0 +1,180 @@
+package org.schabi.newpipe.settings.export
+
+import android.content.SharedPreferences
+import com.grack.nanojson.JsonArray
+import com.grack.nanojson.JsonParser
+import com.grack.nanojson.JsonParserException
+import com.grack.nanojson.JsonWriter
+import org.schabi.newpipe.streams.io.SharpOutputStream
+import org.schabi.newpipe.streams.io.StoredFileHelper
+import org.schabi.newpipe.util.ZipHelper
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.ObjectOutputStream
+import java.util.zip.ZipOutputStream
+
+class ImportExportManager(private val fileLocator: BackupFileLocator) {
+ companion object {
+ const val TAG = "ImportExportManager"
+ }
+
+ /**
+ * Exports given [SharedPreferences] to the file in given outputPath.
+ * It also creates the file.
+ */
+ @Throws(Exception::class)
+ fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) {
+ file.create()
+ ZipOutputStream(SharpOutputStream(file.stream).buffered()).use { outZip ->
+ // add the database
+ ZipHelper.addFileToZip(
+ outZip,
+ BackupFileLocator.FILE_NAME_DB,
+ fileLocator.db.path,
+ )
+
+ // add the legacy vulnerable serialized preferences (will be removed in the future)
+ ZipHelper.addFileToZip(
+ outZip,
+ BackupFileLocator.FILE_NAME_SERIALIZED_PREFS
+ ) { byteOutput ->
+ ObjectOutputStream(byteOutput).use { output ->
+ output.writeObject(preferences.all)
+ output.flush()
+ }
+ }
+
+ // add the JSON preferences
+ ZipHelper.addFileToZip(
+ outZip,
+ BackupFileLocator.FILE_NAME_JSON_PREFS
+ ) { byteOutput ->
+ JsonWriter
+ .indent("")
+ .on(byteOutput)
+ .`object`(preferences.all)
+ .done()
+ }
+ }
+ }
+
+ /**
+ * Tries to create database directory if it does not exist.
+ *
+ * @return Whether the directory exists afterwards.
+ */
+ fun ensureDbDirectoryExists(): Boolean {
+ return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir()
+ }
+
+ /**
+ * Extracts the database from the given file to the app's database directory.
+ * The current app's database will be overwritten.
+ * @param file the .zip file to extract the database from
+ * @return true if the database was successfully extracted, false otherwise
+ */
+ fun extractDb(file: StoredFileHelper): Boolean {
+ val success = ZipHelper.extractFileFromZip(
+ file,
+ BackupFileLocator.FILE_NAME_DB,
+ fileLocator.db.path,
+ )
+
+ if (success) {
+ fileLocator.dbJournal.delete()
+ fileLocator.dbWal.delete()
+ fileLocator.dbShm.delete()
+ }
+
+ return success
+ }
+
+ @Deprecated(
+ "Serializing preferences with Java's ObjectOutputStream is vulnerable to injections",
+ replaceWith = ReplaceWith("exportHasJsonPrefs")
+ )
+ fun exportHasSerializedPrefs(zipFile: StoredFileHelper): Boolean {
+ return ZipHelper.zipContainsFile(zipFile, BackupFileLocator.FILE_NAME_SERIALIZED_PREFS)
+ }
+
+ fun exportHasJsonPrefs(zipFile: StoredFileHelper): Boolean {
+ return ZipHelper.zipContainsFile(zipFile, BackupFileLocator.FILE_NAME_JSON_PREFS)
+ }
+
+ /**
+ * Remove all shared preferences from the app and load the preferences supplied to the manager.
+ */
+ @Deprecated(
+ "Serializing preferences with Java's ObjectOutputStream is vulnerable to injections",
+ replaceWith = ReplaceWith("loadJsonPrefs")
+ )
+ @Throws(IOException::class, ClassNotFoundException::class)
+ fun loadSerializedPrefs(zipFile: StoredFileHelper, preferences: SharedPreferences) {
+ ZipHelper.extractFileFromZip(zipFile, BackupFileLocator.FILE_NAME_SERIALIZED_PREFS) {
+ PreferencesObjectInputStream(it).use { input ->
+ @Suppress("UNCHECKED_CAST")
+ val entries = input.readObject() as Map
+
+ val editor = preferences.edit()
+ editor.clear()
+
+ for ((key, value) in entries) {
+ when (value) {
+ is Boolean -> editor.putBoolean(key, value)
+ is Float -> editor.putFloat(key, value)
+ is Int -> editor.putInt(key, value)
+ is Long -> editor.putLong(key, value)
+ is String -> editor.putString(key, value)
+ is Set<*> -> {
+ // There are currently only Sets with type String possible
+ @Suppress("UNCHECKED_CAST")
+ editor.putStringSet(key, value as Set?)
+ }
+ }
+ }
+
+ if (!editor.commit()) {
+ throw IOException("Unable to commit loadSerializedPrefs")
+ }
+ }
+ }.let { fileExists ->
+ if (!fileExists) {
+ throw FileNotFoundException(BackupFileLocator.FILE_NAME_SERIALIZED_PREFS)
+ }
+ }
+ }
+
+ /**
+ * Remove all shared preferences from the app and load the preferences supplied to the manager.
+ */
+ @Throws(IOException::class, JsonParserException::class)
+ fun loadJsonPrefs(zipFile: StoredFileHelper, preferences: SharedPreferences) {
+ ZipHelper.extractFileFromZip(zipFile, BackupFileLocator.FILE_NAME_JSON_PREFS) {
+ val jsonObject = JsonParser.`object`().from(it)
+
+ val editor = preferences.edit()
+ editor.clear()
+
+ for ((key, value) in jsonObject) {
+ when (value) {
+ is Boolean -> editor.putBoolean(key, value)
+ is Float -> editor.putFloat(key, value)
+ is Int -> editor.putInt(key, value)
+ is Long -> editor.putLong(key, value)
+ is String -> editor.putString(key, value)
+ is JsonArray -> {
+ editor.putStringSet(key, value.mapNotNull { e -> e as? String }.toSet())
+ }
+ }
+ }
+
+ if (!editor.commit()) {
+ throw IOException("Unable to commit loadJsonPrefs")
+ }
+ }.let { fileExists ->
+ if (!fileExists) {
+ throw FileNotFoundException(BackupFileLocator.FILE_NAME_JSON_PREFS)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/PreferencesObjectInputStream.java b/app/src/main/java/org/schabi/newpipe/settings/export/PreferencesObjectInputStream.java
new file mode 100644
index 00000000000..0d11b0b61c8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/export/PreferencesObjectInputStream.java
@@ -0,0 +1,58 @@
+package org.schabi.newpipe.settings.export;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectStreamClass;
+import java.util.Set;
+
+/**
+ * An {@link ObjectInputStream} that only allows preferences-related types to be deserialized, to
+ * prevent injections. The only allowed types are: all primitive types, all boxed primitive types,
+ * null, strings. HashMap, HashSet and arrays of previously defined types are also allowed. Sources:
+ *
+ * cmu.edu
+ * ,
+ *
+ * OWASP cheatsheet
+ * ,
+ *
+ * Apache's {@code ValidatingObjectInputStream}
+ *
+ */
+public class PreferencesObjectInputStream extends ObjectInputStream {
+
+ /**
+ * Primitive types, strings and other built-in types do not pass through resolveClass() but
+ * instead have a custom encoding; see
+ *
+ * official docs.
+ */
+ private static final Set CLASS_WHITELIST = Set.of(
+ "java.lang.Boolean",
+ "java.lang.Byte",
+ "java.lang.Character",
+ "java.lang.Short",
+ "java.lang.Integer",
+ "java.lang.Long",
+ "java.lang.Float",
+ "java.lang.Double",
+ "java.lang.Void",
+ "java.util.HashMap",
+ "java.util.HashSet"
+ );
+
+ public PreferencesObjectInputStream(final InputStream in) throws IOException {
+ super(in);
+ }
+
+ @Override
+ protected Class> resolveClass(final ObjectStreamClass desc)
+ throws ClassNotFoundException, IOException {
+ if (CLASS_WHITELIST.contains(desc.getName())) {
+ return super.resolveClass(desc);
+ } else {
+ throw new ClassNotFoundException("Class not allowed: " + desc.getName());
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java
index 74fc74c76dd..bb47a4b9116 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java
@@ -1,11 +1,18 @@
package org.schabi.newpipe.streams.io;
+import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
+import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
+import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
+
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
+import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
+import android.system.Os;
+import android.system.StructStatVfs;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -15,6 +22,7 @@
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.FilePickerActivityHelper;
+import java.io.FileDescriptor;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
@@ -26,10 +34,6 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;
-import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
-import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
-import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
-
public class StoredDirectoryHelper {
private static final String TAG = StoredDirectoryHelper.class.getSimpleName();
public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION
@@ -38,6 +42,10 @@ public class StoredDirectoryHelper {
private Path ioTree;
private DocumentFile docTree;
+ /**
+ * Context is `null` for non-SAF files, i.e. files that use `ioTree`.
+ */
+ @Nullable
private Context context;
private final String tag;
@@ -168,6 +176,46 @@ public boolean isDirect() {
return docTree == null;
}
+ /**
+ * Get free memory of the storage partition this file belongs to (root of the directory).
+ * See StackOverflow and
+ *
+ * {@code statvfs()} and {@code fstatvfs()} docs
+ *
+ * @return amount of free memory in the volume of current directory (bytes), or {@link
+ * Long#MAX_VALUE} if an error occurred
+ */
+ public long getFreeStorageSpace() {
+ try {
+ final StructStatVfs stat;
+
+ if (ioTree != null) {
+ // non-SAF file, use statvfs with the path directly (also, `context` would be null
+ // for non-SAF files, so we wouldn't be able to call `getContentResolver` anyway)
+ stat = Os.statvfs(ioTree.toString());
+
+ } else {
+ // SAF file, we can't get a path directly, so obtain a file descriptor first
+ // and then use fstatvfs with the file descriptor
+ try (ParcelFileDescriptor parcelFileDescriptor =
+ context.getContentResolver().openFileDescriptor(getUri(), "r")) {
+ if (parcelFileDescriptor == null) {
+ return Long.MAX_VALUE;
+ }
+ final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
+ stat = Os.fstatvfs(fileDescriptor);
+ }
+ }
+
+ // this is the same formula used inside the FsStat class
+ return stat.f_bavail * stat.f_frsize;
+ } catch (final Throwable e) {
+ // ignore any error
+ Log.e(TAG, "Could not get free storage space", e);
+ return Long.MAX_VALUE;
+ }
+ }
+
/**
* Only using Java I/O. Creates the directory named by this abstract pathname, including any
* necessary but nonexistent parent directories.
diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
index 9dd38ddea14..100b52112f0 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
@@ -29,6 +29,7 @@
import org.schabi.newpipe.extractor.search.filter.FilterItem;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import androidx.preference.PreferenceManager;
@@ -115,14 +116,14 @@ public static Single> suggestionsFor(final int serviceId, final Str
public static Single getStreamInfo(final int serviceId, final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.STREAM,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.STREAM,
Single.fromCallable(() -> StreamInfo.getInfo(NewPipe.getService(serviceId), url)));
}
public static Single getChannelInfo(final int serviceId, final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.CHANNEL,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.CHANNEL,
Single.fromCallable(() ->
ChannelInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@@ -132,7 +133,7 @@ public static Single getChannelTab(final int serviceId,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId,
- listLinkHandler.getUrl(), InfoItem.InfoType.CHANNEL,
+ listLinkHandler.getUrl(), InfoCache.Type.CHANNEL_TAB,
Single.fromCallable(() ->
ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler)));
}
@@ -147,10 +148,11 @@ public static Single> getMoreChannelTabItems(
listLinkHandler, nextPage));
}
- public static Single getCommentsInfo(final int serviceId, final String url,
+ public static Single getCommentsInfo(final int serviceId,
+ final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.COMMENT,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.COMMENTS,
Single.fromCallable(() ->
CommentsInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@@ -177,7 +179,7 @@ public static Single getPlaylistInfo(final int serviceId,
final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.PLAYLIST,
Single.fromCallable(() ->
PlaylistInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@@ -190,9 +192,10 @@ public static Single> getMorePlaylistItems(final i
PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage));
}
- public static Single getKioskInfo(final int serviceId, final String url,
+ public static Single getKioskInfo(final int serviceId,
+ final String url,
final boolean forceLoad) {
- return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST,
+ return checkCache(forceLoad, serviceId, url, InfoCache.Type.KIOSK,
Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@@ -204,7 +207,7 @@ public static Single> getMoreKioskItems(final int
}
/*//////////////////////////////////////////////////////////////////////////
- // Utils
+ // Cache
//////////////////////////////////////////////////////////////////////////*/
/**
@@ -216,24 +219,25 @@ public static Single> getMoreKioskItems(final int
* @param forceLoad whether to force loading from the network instead of from the cache
* @param serviceId the service to load from
* @param url the URL to load
- * @param infoType the {@link InfoItem.InfoType} of the item
+ * @param cacheType the {@link InfoCache.Type} of the item
* @param loadFromNetwork the {@link Single} to load the item from the network
* @return a {@link Single} that loads the item
*/
private static Single checkCache(final boolean forceLoad,
- final int serviceId, final String url,
- final InfoItem.InfoType infoType,
- final Single loadFromNetwork) {
+ final int serviceId,
+ @NonNull final String url,
+ @NonNull final InfoCache.Type cacheType,
+ @NonNull final Single loadFromNetwork) {
checkServiceId(serviceId);
final Single actualLoadFromNetwork = loadFromNetwork
- .doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, infoType));
+ .doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, cacheType));
final Single load;
if (forceLoad) {
- CACHE.removeInfo(serviceId, url, infoType);
+ CACHE.removeInfo(serviceId, url, cacheType);
load = actualLoadFromNetwork;
} else {
- load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType),
+ load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, cacheType),
actualLoadFromNetwork.toMaybe())
.firstElement() // Take the first valid
.toSingle();
@@ -248,15 +252,17 @@ private static Single checkCache(final boolean forceLoad,
* @param the item type's class that extends {@link Info}
* @param serviceId the service to load from
* @param url the URL to load
- * @param infoType the {@link InfoItem.InfoType} of the item
+ * @param cacheType the {@link InfoCache.Type} of the item
* @return a {@link Single} that loads the item
*/
- private static Maybe loadFromCache(final int serviceId, final String url,
- final InfoItem.InfoType infoType) {
+ private static Maybe loadFromCache(
+ final int serviceId,
+ @NonNull final String url,
+ @NonNull final InfoCache.Type cacheType) {
checkServiceId(serviceId);
return Maybe.defer(() -> {
//noinspection unchecked
- final I info = (I) CACHE.getFromKey(serviceId, url, infoType);
+ final I info = (I) CACHE.getFromKey(serviceId, url, cacheType);
if (MainActivity.DEBUG) {
Log.d(TAG, "loadFromCache() called, info > " + info);
}
@@ -270,11 +276,17 @@ private static Maybe loadFromCache(final int serviceId, fina
});
}
- public static boolean isCached(final int serviceId, final String url,
- final InfoItem.InfoType infoType) {
- return null != loadFromCache(serviceId, url, infoType).blockingGet();
+ public static boolean isCached(final int serviceId,
+ @NonNull final String url,
+ @NonNull final InfoCache.Type cacheType) {
+ return null != loadFromCache(serviceId, url, cacheType).blockingGet();
}
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Utils
+ //////////////////////////////////////////////////////////////////////////*/
+
/**
* Formats the text contained in the meta info list as HTML and puts it into the text view,
* while also making the separator visible. If the list is null or empty, or the user chose not
diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java
index a07f05828fe..b9c91f8a5b0 100644
--- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java
+++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java
@@ -27,7 +27,6 @@
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.extractor.Info;
-import org.schabi.newpipe.extractor.InfoItem;
import java.util.Map;
@@ -48,14 +47,27 @@ private InfoCache() {
// no instance
}
+ /**
+ * Identifies the type of {@link Info} to put into the cache.
+ */
+ public enum Type {
+ STREAM,
+ CHANNEL,
+ CHANNEL_TAB,
+ COMMENTS,
+ PLAYLIST,
+ KIOSK,
+ }
+
public static InfoCache getInstance() {
return INSTANCE;
}
@NonNull
- private static String keyOf(final int serviceId, @NonNull final String url,
- @NonNull final InfoItem.InfoType infoType) {
- return serviceId + url + infoType.toString();
+ private static String keyOf(final int serviceId,
+ @NonNull final String url,
+ @NonNull final Type cacheType) {
+ return serviceId + ":" + cacheType.ordinal() + ":" + url;
}
private static void removeStaleCache() {
@@ -83,19 +95,22 @@ private static Info getInfo(@NonNull final String key) {
}
@Nullable
- public Info getFromKey(final int serviceId, @NonNull final String url,
- @NonNull final InfoItem.InfoType infoType) {
+ public Info getFromKey(final int serviceId,
+ @NonNull final String url,
+ @NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "getFromKey() called with: "
+ "serviceId = [" + serviceId + "], url = [" + url + "]");
}
synchronized (LRU_CACHE) {
- return getInfo(keyOf(serviceId, url, infoType));
+ return getInfo(keyOf(serviceId, url, cacheType));
}
}
- public void putInfo(final int serviceId, @NonNull final String url, @NonNull final Info info,
- @NonNull final InfoItem.InfoType infoType) {
+ public void putInfo(final int serviceId,
+ @NonNull final String url,
+ @NonNull final Info info,
+ @NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "putInfo() called with: info = [" + info + "]");
}
@@ -103,18 +118,19 @@ public void putInfo(final int serviceId, @NonNull final String url, @NonNull fin
final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId());
synchronized (LRU_CACHE) {
final CacheData data = new CacheData(info, expirationMillis);
- LRU_CACHE.put(keyOf(serviceId, url, infoType), data);
+ LRU_CACHE.put(keyOf(serviceId, url, cacheType), data);
}
}
- public void removeInfo(final int serviceId, @NonNull final String url,
- @NonNull final InfoItem.InfoType infoType) {
+ public void removeInfo(final int serviceId,
+ @NonNull final String url,
+ @NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "removeInfo() called with: "
+ "serviceId = [" + serviceId + "], url = [" + url + "]");
}
synchronized (LRU_CACHE) {
- LRU_CACHE.remove(keyOf(serviceId, url, infoType));
+ LRU_CACHE.remove(keyOf(serviceId, url, cacheType));
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java
index 71071d9977f..f1904565d3b 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java
@@ -643,6 +643,7 @@ private static int getDefaultResolutionWithDefaultFormat(@NonNull final Context
context.getString(R.string.best_resolution_key), defaultFormat, videoStreams);
}
+ @Nullable
private static MediaFormat getDefaultFormat(@NonNull final Context context,
@StringRes final int defaultFormatKey,
@StringRes final int defaultFormatValueKey) {
@@ -651,18 +652,14 @@ private static MediaFormat getDefaultFormat(@NonNull final Context context,
final String defaultFormat = context.getString(defaultFormatValueKey);
final String defaultFormatString = preferences.getString(
- context.getString(defaultFormatKey), defaultFormat);
+ context.getString(defaultFormatKey),
+ defaultFormat
+ );
- MediaFormat defaultMediaFormat = getMediaFormatFromKey(context, defaultFormatString);
- if (defaultMediaFormat == null) {
- preferences.edit().putString(context.getString(defaultFormatKey), defaultFormat)
- .apply();
- defaultMediaFormat = getMediaFormatFromKey(context, defaultFormat);
- }
-
- return defaultMediaFormat;
+ return getMediaFormatFromKey(context, defaultFormatString);
}
+ @Nullable
private static MediaFormat getMediaFormatFromKey(@NonNull final Context context,
@NonNull final String formatKey) {
MediaFormat format = null;
@@ -877,6 +874,7 @@ private static Comparator getAudioTrackNameComparator(
return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast(
Comparator.comparing(locale -> locale.getDisplayName(appLoc))))
- .thenComparing(AudioStream::getAudioTrackType);
+ .thenComparing(AudioStream::getAudioTrackType, Comparator.nullsLast(
+ Comparator.naturalOrder()));
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java
index c4034252de3..bc113e8f868 100644
--- a/app/src/main/java/org/schabi/newpipe/util/Localization.java
+++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.util;
+import static org.schabi.newpipe.MainActivity.DEBUG;
+
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
@@ -22,6 +24,7 @@
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.localization.ContentCountry;
+import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.AudioTrackType;
@@ -82,7 +85,7 @@ public static org.schabi.newpipe.extractor.localization.Localization getPreferre
.fromLocale(getPreferredLocale(context));
}
- public static ContentCountry getPreferredContentCountry(final Context context) {
+ public static ContentCountry getPreferredContentCountry(@NonNull final Context context) {
final String contentCountry = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.content_country_key),
context.getString(R.string.default_localization_key));
@@ -92,41 +95,43 @@ public static ContentCountry getPreferredContentCountry(final Context context) {
return new ContentCountry(contentCountry);
}
- public static Locale getPreferredLocale(final Context context) {
+ public static Locale getPreferredLocale(@NonNull final Context context) {
return getLocaleFromPrefs(context, R.string.content_language_key);
}
- public static Locale getAppLocale(final Context context) {
+ public static Locale getAppLocale(@NonNull final Context context) {
return getLocaleFromPrefs(context, R.string.app_language_key);
}
- public static String localizeNumber(final Context context, final long number) {
+ public static String localizeNumber(@NonNull final Context context, final long number) {
return localizeNumber(context, (double) number);
}
- public static String localizeNumber(final Context context, final double number) {
+ public static String localizeNumber(@NonNull final Context context, final double number) {
final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context));
return nf.format(number);
}
- public static String formatDate(final OffsetDateTime offsetDateTime, final Context context) {
+ public static String formatDate(@NonNull final Context context,
+ @NonNull final OffsetDateTime offsetDateTime) {
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(getAppLocale(context)).format(offsetDateTime
.atZoneSameInstant(ZoneId.systemDefault()));
}
@SuppressLint("StringFormatInvalid")
- public static String localizeUploadDate(final Context context,
- final OffsetDateTime offsetDateTime) {
- return context.getString(R.string.upload_date_text, formatDate(offsetDateTime, context));
+ public static String localizeUploadDate(@NonNull final Context context,
+ @NonNull final OffsetDateTime offsetDateTime) {
+ return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime));
}
- public static String localizeViewCount(final Context context, final long viewCount) {
+ public static String localizeViewCount(@NonNull final Context context, final long viewCount) {
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
localizeNumber(context, viewCount));
}
- public static String localizeStreamCount(final Context context, final long streamCount) {
+ public static String localizeStreamCount(@NonNull final Context context,
+ final long streamCount) {
switch ((int) streamCount) {
case (int) ListExtractor.ITEM_COUNT_UNKNOWN:
return "";
@@ -140,7 +145,8 @@ public static String localizeStreamCount(final Context context, final long strea
}
}
- public static String localizeStreamCountMini(final Context context, final long streamCount) {
+ public static String localizeStreamCountMini(@NonNull final Context context,
+ final long streamCount) {
switch ((int) streamCount) {
case (int) ListExtractor.ITEM_COUNT_UNKNOWN:
return "";
@@ -153,12 +159,13 @@ public static String localizeStreamCountMini(final Context context, final long s
}
}
- public static String localizeWatchingCount(final Context context, final long watchingCount) {
+ public static String localizeWatchingCount(@NonNull final Context context,
+ final long watchingCount) {
return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount,
localizeNumber(context, watchingCount));
}
- public static String shortCount(final Context context, final long count) {
+ public static String shortCount(@NonNull final Context context, final long count) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return CompactDecimalFormat.getInstance(getAppLocale(context),
CompactDecimalFormat.CompactStyle.SHORT).format(count);
@@ -179,37 +186,79 @@ public static String shortCount(final Context context, final long count) {
}
}
- public static String listeningCount(final Context context, final long listeningCount) {
+ public static String listeningCount(@NonNull final Context context, final long listeningCount) {
return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount,
shortCount(context, listeningCount));
}
- public static String shortWatchingCount(final Context context, final long watchingCount) {
+ public static String shortWatchingCount(@NonNull final Context context,
+ final long watchingCount) {
return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount,
shortCount(context, watchingCount));
}
- public static String shortViewCount(final Context context, final long viewCount) {
+ public static String shortViewCount(@NonNull final Context context, final long viewCount) {
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
shortCount(context, viewCount));
}
- public static String shortSubscriberCount(final Context context, final long subscriberCount) {
+ public static String shortSubscriberCount(@NonNull final Context context,
+ final long subscriberCount) {
return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount,
shortCount(context, subscriberCount));
}
- public static String downloadCount(final Context context, final int downloadCount) {
+ public static String downloadCount(@NonNull final Context context, final int downloadCount) {
return getQuantity(context, R.plurals.download_finished_notification, 0,
downloadCount, shortCount(context, downloadCount));
}
- public static String deletedDownloadCount(final Context context, final int deletedCount) {
+ public static String deletedDownloadCount(@NonNull final Context context,
+ final int deletedCount) {
return getQuantity(context, R.plurals.deleted_downloads_toast, 0,
deletedCount, shortCount(context, deletedCount));
}
+ public static String replyCount(@NonNull final Context context, final int replyCount) {
+ return getQuantity(context, R.plurals.replies, 0, replyCount,
+ String.valueOf(replyCount));
+ }
+
+ /**
+ * @param context the Android context
+ * @param likeCount the like count, possibly negative if unknown
+ * @return if {@code likeCount} is smaller than {@code 0}, the string {@code "-"}, otherwise
+ * the result of calling {@link #shortCount(Context, long)} on the like count
+ */
+ public static String likeCount(@NonNull final Context context, final int likeCount) {
+ if (likeCount < 0) {
+ return "-";
+ } else {
+ return shortCount(context, likeCount);
+ }
+ }
+
+ /**
+ * Get a readable text for a duration in the format {@code days:hours:minutes:seconds}.
+ * Prepended zeros are removed.
+ * @param duration the duration in seconds
+ * @return a formatted duration String or {@code 0:00} if the duration is zero.
+ */
public static String getDurationString(final long duration) {
+ return getDurationString(duration, true, false);
+ }
+
+ /**
+ * Get a readable text for a duration in the format {@code days:hours:minutes:seconds+}.
+ * Prepended zeros are removed. If the given duration is incomplete, a plus is appended to the
+ * duration string.
+ * @param duration the duration in seconds
+ * @param isDurationComplete whether the given duration is complete or whether info is missing
+ * @param showDurationPrefix whether the duration-prefix shall be shown
+ * @return a formatted duration String or {@code 0:00} if the duration is zero.
+ */
+ public static String getDurationString(final long duration, final boolean isDurationComplete,
+ final boolean showDurationPrefix) {
final String output;
final long days = duration / (24 * 60 * 60L); /* greater than a day */
@@ -227,7 +276,9 @@ public static String getDurationString(final long duration) {
} else {
output = String.format(Locale.US, "%d:%02d", minutes, seconds);
}
- return output;
+ final String durationPrefix = showDurationPrefix ? "⏱ " : "";
+ final String durationPostfix = isDurationComplete ? "" : "+";
+ return durationPrefix + output + durationPostfix;
}
/**
@@ -241,7 +292,8 @@ public static String getDurationString(final long duration) {
* @return duration in a human readable string.
*/
@NonNull
- public static String localizeDuration(final Context context, final int durationInSecs) {
+ public static String localizeDuration(@NonNull final Context context,
+ final int durationInSecs) {
if (durationInSecs < 0) {
throw new IllegalArgumentException("duration can not be negative");
}
@@ -278,7 +330,7 @@ public static String localizeDuration(final Context context, final int durationI
* @param track an {@link AudioStream} of the track
* @return the localized name of the audio track
*/
- public static String audioTrackName(final Context context, final AudioStream track) {
+ public static String audioTrackName(@NonNull final Context context, final AudioStream track) {
final String name;
if (track.getAudioLocale() != null) {
name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context));
@@ -298,7 +350,8 @@ public static String audioTrackName(final Context context, final AudioStream tra
}
@Nullable
- private static String audioTrackType(final Context context, final AudioTrackType trackType) {
+ private static String audioTrackType(@NonNull final Context context,
+ final AudioTrackType trackType) {
switch (trackType) {
case ORIGINAL:
return context.getString(R.string.audio_track_type_original);
@@ -314,20 +367,45 @@ private static String audioTrackType(final Context context, final AudioTrackType
// Pretty Time
//////////////////////////////////////////////////////////////////////////*/
- public static void initPrettyTime(final PrettyTime time) {
+ public static void initPrettyTime(@NonNull final PrettyTime time) {
prettyTime = time;
// Do not use decades as YouTube doesn't either.
prettyTime.removeUnit(Decade.class);
}
- public static PrettyTime resolvePrettyTime(final Context context) {
+ public static PrettyTime resolvePrettyTime(@NonNull final Context context) {
return new PrettyTime(getAppLocale(context));
}
- public static String relativeTime(final OffsetDateTime offsetDateTime) {
+ public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) {
return prettyTime.formatUnrounded(offsetDateTime);
}
+ /**
+ * @param context the Android context; if {@code null} then even if in debug mode and the
+ * setting is enabled, {@code textual} will not be shown next to {@code parsed}
+ * @param parsed the textual date or time ago parsed by NewPipeExtractor, or {@code null} if
+ * the extractor could not parse it
+ * @param textual the original textual date or time ago string as provided by services
+ * @return {@link #relativeTime(OffsetDateTime)} is used if {@code parsed != null}, otherwise
+ * {@code textual} is returned. If in debug mode, {@code context != null},
+ * {@code parsed != null} and the relevant setting is enabled, {@code textual} will
+ * be appended to the returned string for debugging purposes.
+ */
+ public static String relativeTimeOrTextual(@Nullable final Context context,
+ @Nullable final DateWrapper parsed,
+ final String textual) {
+ if (parsed == null) {
+ return textual;
+ } else if (DEBUG && context != null && PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string.show_original_time_ago_key), false)) {
+ return relativeTime(parsed.offsetDateTime()) + " (" + textual + ")";
+ } else {
+ return relativeTime(parsed.offsetDateTime());
+ }
+ }
+
public static void assureCorrectAppLanguage(final Context c) {
final Resources res = c.getResources();
final DisplayMetrics dm = res.getDisplayMetrics();
@@ -336,7 +414,8 @@ public static void assureCorrectAppLanguage(final Context c) {
res.updateConfiguration(conf, dm);
}
- private static Locale getLocaleFromPrefs(final Context context, @StringRes final int prefKey) {
+ private static Locale getLocaleFromPrefs(@NonNull final Context context,
+ @StringRes final int prefKey) {
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
final String defaultKey = context.getString(R.string.default_localization_key);
final String languageCode = sp.getString(context.getString(prefKey), defaultKey);
@@ -352,8 +431,10 @@ private static double round(final double value) {
return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue();
}
- private static String getQuantity(final Context context, @PluralsRes final int pluralId,
- @StringRes final int zeroCaseStringId, final long count,
+ private static String getQuantity(@NonNull final Context context,
+ @PluralsRes final int pluralId,
+ @StringRes final int zeroCaseStringId,
+ final long count,
final String formattedCount) {
if (count == 0) {
return context.getString(zeroCaseStringId);
diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
index b0d7dcf735a..5dee32371b5 100644
--- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
@@ -1,5 +1,6 @@
package org.schabi.newpipe.util;
+import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
import android.annotation.SuppressLint;
@@ -17,6 +18,7 @@
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
@@ -29,8 +31,10 @@
import org.schabi.newpipe.about.AboutActivity;
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.download.DownloadActivity;
+import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
+import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.DeliveryMethod;
@@ -41,6 +45,7 @@
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.channel.ChannelFragment;
+import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
import org.schabi.newpipe.fragments.list.kiosk.KioskFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment;
@@ -476,6 +481,35 @@ public static void openChannelFragment(@NonNull final Fragment fragment,
item.getServiceId(), uploaderUrl, item.getUploaderName());
}
+ /**
+ * Opens the comment author channel fragment, if the {@link CommentsInfoItem#getUploaderUrl()}
+ * of {@code comment} is non-null. Shows a UI-error snackbar if something goes wrong.
+ *
+ * @param activity the activity with the fragment manager and in which to show the snackbar
+ * @param comment the comment whose uploader/author will be opened
+ */
+ public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity activity,
+ @NonNull final CommentsInfoItem comment) {
+ if (isEmpty(comment.getUploaderUrl())) {
+ return;
+ }
+ try {
+ openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(),
+ comment.getUploaderUrl(), comment.getUploaderName());
+ } catch (final Exception e) {
+ ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e);
+ }
+ }
+
+ public static void openCommentRepliesFragment(@NonNull final FragmentActivity activity,
+ @NonNull final CommentsInfoItem comment) {
+ defaultTransaction(activity.getSupportFragmentManager())
+ .replace(R.id.fragment_holder, new CommentRepliesFragment(comment),
+ CommentRepliesFragment.TAG)
+ .addToBackStack(CommentRepliesFragment.TAG)
+ .commit();
+ }
+
public static void openPlaylistFragment(final FragmentManager fragmentManager,
final int serviceId, final String url,
@NonNull final String name) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java b/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java
deleted file mode 100644
index f96bb0d549f..00000000000
--- a/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.schabi.newpipe.util;
-
-import org.schabi.newpipe.extractor.InfoItem;
-import org.schabi.newpipe.extractor.ListInfo;
-import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-public class RelatedItemInfo extends ListInfo {
- public RelatedItemInfo(final int serviceId, final ListLinkHandler listUrlIdHandler,
- final String name) {
- super(serviceId, listUrlIdHandler, name);
- }
-
- public static RelatedItemInfo getInfo(final StreamInfo info) {
- final ListLinkHandler handler = new ListLinkHandler(
- info.getOriginalUrl(), info.getUrl(), info.getId(), Collections.emptyList(), null);
- final RelatedItemInfo relatedItemInfo = new RelatedItemInfo(
- info.getServiceId(), handler, info.getName());
- final List relatedItems = new ArrayList<>(info.getRelatedItems());
- relatedItemInfo.setRelatedItems(relatedItems);
- return relatedItemInfo;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
index 0a79fe2a0b2..1c315c694f3 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
+++ b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
@@ -1,97 +1,39 @@
package org.schabi.newpipe.util
import android.content.pm.PackageManager
-import android.content.pm.Signature
import androidx.core.content.pm.PackageInfoCompat
import org.schabi.newpipe.App
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification
import org.schabi.newpipe.error.UserAction
-import java.security.MessageDigest
-import java.security.NoSuchAlgorithmException
-import java.security.cert.CertificateEncodingException
-import java.security.cert.CertificateException
-import java.security.cert.CertificateFactory
-import java.security.cert.X509Certificate
import java.time.Instant
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
object ReleaseVersionUtil {
// Public key of the certificate that is used in NewPipe release versions
- private const val RELEASE_CERT_PUBLIC_KEY_SHA1 =
- "C3:96:13:CD:13:92:3F:37:EE:B6:9F:7A:0D:EA:7C:70:E0:7A:73:D8"
-
- @JvmStatic
- fun isReleaseApk(): Boolean {
- return certificateSHA1Fingerprint == RELEASE_CERT_PUBLIC_KEY_SHA1
- }
-
- /**
- * Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133.
- *
- * @return String with the APK's SHA1 fingerprint in hexadecimal
- */
- private val certificateSHA1Fingerprint: String
- get() {
- val app = App.getApp()
- val signatures: List = try {
- PackageInfoCompat.getSignatures(app.packageManager, app.packageName)
- } catch (e: PackageManager.NameNotFoundException) {
- showRequestError(app, e, "Could not find package info")
- return ""
- }
- if (signatures.isEmpty()) {
- return ""
- }
- val x509cert = try {
- val cf = CertificateFactory.getInstance("X509")
- cf.generateCertificate(signatures[0].toByteArray().inputStream()) as X509Certificate
- } catch (e: CertificateException) {
- showRequestError(app, e, "Certificate error")
- return ""
- }
-
- return try {
- val md = MessageDigest.getInstance("SHA1")
- val publicKey = md.digest(x509cert.encoded)
- byte2HexFormatted(publicKey)
- } catch (e: NoSuchAlgorithmException) {
- showRequestError(app, e, "Could not retrieve SHA1 key")
- ""
- } catch (e: CertificateEncodingException) {
- showRequestError(app, e, "Could not retrieve SHA1 key")
- ""
- }
- }
-
- private fun byte2HexFormatted(arr: ByteArray): String {
- val str = StringBuilder(arr.size * 2)
- for (i in arr.indices) {
- var h = Integer.toHexString(arr[i].toInt())
- val l = h.length
- if (l == 1) {
- h = "0$h"
- }
- if (l > 2) {
- h = h.substring(l - 2, l)
- }
- str.append(h.uppercase())
- if (i < arr.size - 1) {
- str.append(':')
- }
- }
- return str.toString()
- }
-
- private fun showRequestError(app: App, e: Exception, request: String) {
- createNotification(
- app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, request)
+ private const val RELEASE_CERT_PUBLIC_KEY_SHA256 =
+ "2f0c31d07f701416b2943376491cb16ebb718156defc2b1269aac04b94396c85"
+
+ @OptIn(ExperimentalStdlibApi::class)
+ val isReleaseApk by lazy {
+ @Suppress("NewApi")
+ val certificates = mapOf(
+ RELEASE_CERT_PUBLIC_KEY_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256
)
+ val app = App.getApp()
+ try {
+ PackageInfoCompat.hasSignatures(app.packageManager, app.packageName, certificates, false)
+ } catch (e: PackageManager.NameNotFoundException) {
+ createNotification(
+ app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info")
+ )
+ false
+ }
}
fun isLastUpdateCheckExpired(expiry: Long): Boolean {
- return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
+ return Instant.ofEpochSecond(expiry) < Instant.now()
}
/**
diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java
index 75d9a3892f2..69dc697fe89 100644
--- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java
@@ -11,7 +11,6 @@
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
-import java.util.Comparator;
import java.util.List;
public class SecondaryStreamHelper {
@@ -43,42 +42,27 @@ public static AudioStream getAudioStreamFor(@NonNull final Context context,
@NonNull final List audioStreams,
@NonNull final VideoStream videoStream) {
final MediaFormat mediaFormat = videoStream.getFormat();
- if (mediaFormat == null) {
- return null;
- }
-
- switch (mediaFormat) {
- case WEBM:
- case MPEG_4: // Is MPEG-4 DASH?
- break;
- default:
- return null;
- }
-
- final boolean m4v = mediaFormat == MediaFormat.MPEG_4;
- final boolean isLimitingDataUsage = ListHelper.isLimitingDataUsage(context);
- Comparator comparator = ListHelper.getAudioFormatComparator(
- m4v ? MediaFormat.M4A : MediaFormat.WEBMA, isLimitingDataUsage);
- int preferredAudioStreamIndex = ListHelper.getAudioIndexByHighestRank(
- audioStreams, comparator);
-
- if (preferredAudioStreamIndex == -1) {
- if (m4v) {
- return null;
- }
-
- comparator = ListHelper.getAudioFormatComparator(
- MediaFormat.WEBMA_OPUS, isLimitingDataUsage);
- preferredAudioStreamIndex = ListHelper.getAudioIndexByHighestRank(
- audioStreams, comparator);
-
- if (preferredAudioStreamIndex == -1) {
- return null;
- }
+ if (mediaFormat == MediaFormat.WEBM) {
+ return audioStreams
+ .stream()
+ .filter(audioStream -> audioStream.getFormat() == MediaFormat.WEBMA
+ || audioStream.getFormat() == MediaFormat.WEBMA_OPUS)
+ .max(ListHelper.getAudioFormatComparator(MediaFormat.WEBMA,
+ ListHelper.isLimitingDataUsage(context)))
+ .orElse(null);
+
+ } else if (mediaFormat == MediaFormat.MPEG_4) {
+ return audioStreams
+ .stream()
+ .filter(audioStream -> audioStream.getFormat() == MediaFormat.M4A)
+ .max(ListHelper.getAudioFormatComparator(MediaFormat.M4A,
+ ListHelper.isLimitingDataUsage(context)))
+ .orElse(null);
+
+ } else {
+ return null;
}
-
- return audioStreams.get(preferredAudioStreamIndex);
}
public T getStream() {
diff --git a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java
index 91dc5f35b93..61fdb602f28 100644
--- a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java
+++ b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java
@@ -27,6 +27,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.core.os.BundleCompat;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.MainActivity;
@@ -82,7 +83,8 @@ public static SavedState tryToRestore(final Bundle outState, final WriteRead wri
return null;
}
- final SavedState savedState = outState.getParcelable(KEY_SAVED_STATE);
+ final SavedState savedState = BundleCompat.getParcelable(
+ outState, KEY_SAVED_STATE, SavedState.class);
if (savedState == null) {
return null;
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
index bc08e6197fb..b2aebac426b 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
@@ -1,18 +1,21 @@
package org.schabi.newpipe.util;
import org.schabi.newpipe.streams.io.SharpInputStream;
+import org.schabi.newpipe.streams.io.StoredFileHelper;
import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
-import org.schabi.newpipe.streams.io.StoredFileHelper;
-
/**
* Created by Christian Schabesberger on 28.01.18.
* Copyright 2018 Christian Schabesberger
@@ -34,73 +37,154 @@
*/
public final class ZipHelper {
- private ZipHelper() { }
private static final int BUFFER_SIZE = 2048;
+ @FunctionalInterface
+ public interface InputStreamConsumer {
+ void acceptStream(InputStream inputStream) throws IOException;
+ }
+
+ @FunctionalInterface
+ public interface OutputStreamConsumer {
+ void acceptStream(OutputStream outputStream) throws IOException;
+ }
+
+
+ private ZipHelper() { }
+
+
/**
- * This function helps to create zip files.
- * Caution this will override the original file.
+ * This function helps to create zip files. Caution this will overwrite the original file.
*
- * @param outZip The ZipOutputStream where the data should be stored in
- * @param file The path of the file that should be added to zip.
- * @param name The path of the file inside the zip.
- * @throws Exception
+ * @param outZip the ZipOutputStream where the data should be stored in
+ * @param nameInZip the path of the file inside the zip
+ * @param fileOnDisk the path of the file on the disk that should be added to zip
*/
- public static void addFileToZip(final ZipOutputStream outZip, final String file,
- final String name) throws Exception {
+ public static void addFileToZip(final ZipOutputStream outZip,
+ final String nameInZip,
+ final String fileOnDisk) throws IOException {
+ try (FileInputStream fi = new FileInputStream(fileOnDisk)) {
+ addFileToZip(outZip, nameInZip, fi);
+ }
+ }
+
+ /**
+ * This function helps to create zip files. Caution this will overwrite the original file.
+ *
+ * @param outZip the ZipOutputStream where the data should be stored in
+ * @param nameInZip the path of the file inside the zip
+ * @param streamConsumer will be called with an output stream that will go to the output file
+ */
+ public static void addFileToZip(final ZipOutputStream outZip,
+ final String nameInZip,
+ final OutputStreamConsumer streamConsumer) throws IOException {
+ final byte[] bytes;
+ try (ByteArrayOutputStream byteOutput = new ByteArrayOutputStream()) {
+ streamConsumer.acceptStream(byteOutput);
+ bytes = byteOutput.toByteArray();
+ }
+
+ try (ByteArrayInputStream byteInput = new ByteArrayInputStream(bytes)) {
+ ZipHelper.addFileToZip(outZip, nameInZip, byteInput);
+ }
+ }
+
+ /**
+ * This function helps to create zip files. Caution this will overwrite the original file.
+ *
+ * @param outZip the ZipOutputStream where the data should be stored in
+ * @param nameInZip the path of the file inside the zip
+ * @param inputStream the content to put inside the file
+ */
+ public static void addFileToZip(final ZipOutputStream outZip,
+ final String nameInZip,
+ final InputStream inputStream) throws IOException {
final byte[] data = new byte[BUFFER_SIZE];
- try (FileInputStream fi = new FileInputStream(file);
- BufferedInputStream inputStream = new BufferedInputStream(fi, BUFFER_SIZE)) {
- final ZipEntry entry = new ZipEntry(name);
+ try (BufferedInputStream bufferedInputStream =
+ new BufferedInputStream(inputStream, BUFFER_SIZE)) {
+ final ZipEntry entry = new ZipEntry(nameInZip);
outZip.putNextEntry(entry);
int count;
- while ((count = inputStream.read(data, 0, BUFFER_SIZE)) != -1) {
+ while ((count = bufferedInputStream.read(data, 0, BUFFER_SIZE)) != -1) {
outZip.write(data, 0, count);
}
}
}
+ /**
+ * This will extract data from ZipInputStream. Caution this will overwrite the original file.
+ *
+ * @param zipFile the zip file to extract from
+ * @param nameInZip the path of the file inside the zip
+ * @param fileOnDisk the path of the file on the disk where the data should be extracted to
+ * @return will return true if the file was found within the zip file
+ */
+ public static boolean extractFileFromZip(final StoredFileHelper zipFile,
+ final String nameInZip,
+ final String fileOnDisk) throws IOException {
+ return extractFileFromZip(zipFile, nameInZip, input -> {
+ // delete old file first
+ final File oldFile = new File(fileOnDisk);
+ if (oldFile.exists()) {
+ if (!oldFile.delete()) {
+ throw new IOException("Could not delete " + fileOnDisk);
+ }
+ }
+
+ final byte[] data = new byte[BUFFER_SIZE];
+ try (FileOutputStream outFile = new FileOutputStream(fileOnDisk)) {
+ int count;
+ while ((count = input.read(data)) != -1) {
+ outFile.write(data, 0, count);
+ }
+ }
+ });
+ }
+
/**
* This will extract data from ZipInputStream.
- * Caution this will override the original file.
*
- * @param zipFile The zip file
- * @param file The path of the file on the disk where the data should be extracted to.
- * @param name The path of the file inside the zip.
+ * @param zipFile the zip file to extract from
+ * @param nameInZip the path of the file inside the zip
+ * @param streamConsumer will be called with the input stream from the file inside the zip
* @return will return true if the file was found within the zip file
- * @throws Exception
*/
- public static boolean extractFileFromZip(final StoredFileHelper zipFile, final String file,
- final String name) throws Exception {
+ public static boolean extractFileFromZip(final StoredFileHelper zipFile,
+ final String nameInZip,
+ final InputStreamConsumer streamConsumer)
+ throws IOException {
+ try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream(
+ new SharpInputStream(zipFile.getStream())))) {
+ ZipEntry ze;
+ while ((ze = inZip.getNextEntry()) != null) {
+ if (ze.getName().equals(nameInZip)) {
+ streamConsumer.acceptStream(inZip);
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ /**
+ * @param zipFile the zip file
+ * @param fileInZip the filename to check
+ * @return whether the provided filename is in the zip; only the first level is checked
+ */
+ public static boolean zipContainsFile(final StoredFileHelper zipFile, final String fileInZip)
+ throws Exception {
try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream(
new SharpInputStream(zipFile.getStream())))) {
- final byte[] data = new byte[BUFFER_SIZE];
- boolean found = false;
ZipEntry ze;
while ((ze = inZip.getNextEntry()) != null) {
- if (ze.getName().equals(name)) {
- found = true;
- // delete old file first
- final File oldFile = new File(file);
- if (oldFile.exists()) {
- if (!oldFile.delete()) {
- throw new Exception("Could not delete " + file);
- }
- }
-
- try (FileOutputStream outFile = new FileOutputStream(file)) {
- int count = 0;
- while ((count = inZip.read(data)) != -1) {
- outFile.write(data, 0, count);
- }
- }
-
- inZip.closeEntry();
+ if (ze.getName().equals(fileInZip)) {
+ return true;
}
}
- return found;
+ return false;
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java
new file mode 100644
index 00000000000..acc515dd670
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java
@@ -0,0 +1,15 @@
+package org.schabi.newpipe.util.debounce;
+
+import org.schabi.newpipe.error.ErrorInfo;
+
+public interface DebounceSavable {
+
+ /**
+ * Execute operations to save the data.
+ * Must set {@link DebounceSaver#setIsModified(boolean)} false in this method manually
+ * after the data has been saved.
+ */
+ void saveImmediate();
+
+ void showError(ErrorInfo errorInfo);
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java
new file mode 100644
index 00000000000..5bd5cdd55f4
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java
@@ -0,0 +1,81 @@
+package org.schabi.newpipe.util.debounce;
+
+import org.schabi.newpipe.error.ErrorInfo;
+import org.schabi.newpipe.error.UserAction;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.disposables.Disposable;
+import io.reactivex.rxjava3.subjects.PublishSubject;
+
+public class DebounceSaver {
+
+ private final long saveDebounceMillis;
+
+ private final PublishSubject debouncedSaveSignal;
+
+ private final DebounceSavable debounceSavable;
+
+ // Has the object been modified
+ private final AtomicBoolean isModified;
+
+ // Default 10 seconds
+ private static final long DEFAULT_SAVE_DEBOUNCE_MILLIS = 10000;
+
+
+ /**
+ * Creates a new {@code DebounceSaver}.
+ *
+ * @param saveDebounceMillis Save the object milliseconds later after the last change
+ * occurred.
+ * @param debounceSavable The object containing data to be saved.
+ */
+ public DebounceSaver(final long saveDebounceMillis, final DebounceSavable debounceSavable) {
+ this.saveDebounceMillis = saveDebounceMillis;
+ debouncedSaveSignal = PublishSubject.create();
+ this.debounceSavable = debounceSavable;
+ this.isModified = new AtomicBoolean();
+ }
+
+ /**
+ * Creates a new {@code DebounceSaver}. Save the object 10 seconds later after the last change
+ * occurred.
+ *
+ * @param debounceSavable The object containing data to be saved.
+ */
+ public DebounceSaver(final DebounceSavable debounceSavable) {
+ this(DEFAULT_SAVE_DEBOUNCE_MILLIS, debounceSavable);
+ }
+
+ public boolean getIsModified() {
+ return isModified.get();
+ }
+
+ public void setNoChangesToSave() {
+ isModified.set(false);
+ }
+
+ public PublishSubject getDebouncedSaveSignal() {
+ return debouncedSaveSignal;
+ }
+
+ public Disposable getDebouncedSaver() {
+ return debouncedSaveSignal
+ .debounce(saveDebounceMillis, TimeUnit.MILLISECONDS)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(ignored -> debounceSavable.saveImmediate(), throwable ->
+ debounceSavable.showError(new ErrorInfo(throwable,
+ UserAction.SOMETHING_ELSE, "Debounced saver")));
+ }
+
+ public void setHasChangesToSave() {
+ if (isModified == null || debouncedSaveSignal == null) {
+ return;
+ }
+
+ isModified.set(true);
+ debouncedSaveSignal.onNext(System.currentTimeMillis());
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java
new file mode 100644
index 00000000000..184b73304d8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java
@@ -0,0 +1,193 @@
+package org.schabi.newpipe.util.text;
+
+import android.graphics.Paint;
+import android.text.Layout;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.text.HtmlCompat;
+
+import org.schabi.newpipe.extractor.StreamingService;
+import org.schabi.newpipe.extractor.stream.Description;
+
+import java.util.function.Consumer;
+
+
+import io.reactivex.rxjava3.disposables.CompositeDisposable;
+
+/**
+ * Class to ellipsize text inside a {@link TextView}.
+ * This class provides all utils to automatically ellipsize and expand a text
+ */
+public final class TextEllipsizer {
+ private static final int EXPANDED_LINES = Integer.MAX_VALUE;
+ private static final String ELLIPSIS = "…";
+
+ @NonNull private final CompositeDisposable disposable = new CompositeDisposable();
+
+ @NonNull private final TextView view;
+ private final int maxLines;
+ @NonNull private Description content;
+ @Nullable private StreamingService streamingService;
+ @Nullable private String streamUrl;
+ private boolean isEllipsized = false;
+ @Nullable private Boolean canBeEllipsized = null;
+
+ @NonNull private final Paint paintAtContentSize = new Paint();
+ private final float ellipsisWidthPx;
+ @Nullable private Consumer stateChangeListener = null;
+ @Nullable private Consumer onContentChanged;
+
+ public TextEllipsizer(@NonNull final TextView view,
+ final int maxLines,
+ @Nullable final StreamingService streamingService) {
+ this.view = view;
+ this.maxLines = maxLines;
+ this.content = Description.EMPTY_DESCRIPTION;
+ this.streamingService = streamingService;
+
+ paintAtContentSize.setTextSize(view.getTextSize());
+ ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
+ }
+
+ public void setOnContentChanged(@Nullable final Consumer onContentChanged) {
+ this.onContentChanged = onContentChanged;
+ }
+
+ public void setContent(@NonNull final Description content) {
+ this.content = content;
+ canBeEllipsized = null;
+ linkifyContentView(v -> {
+ final int currentMaxLines = view.getMaxLines();
+ view.setMaxLines(EXPANDED_LINES);
+ canBeEllipsized = view.getLineCount() > maxLines;
+ view.setMaxLines(currentMaxLines);
+ if (onContentChanged != null) {
+ onContentChanged.accept(canBeEllipsized);
+ }
+ });
+ }
+
+ public void setStreamUrl(@Nullable final String streamUrl) {
+ this.streamUrl = streamUrl;
+ }
+
+ public void setStreamingService(@NonNull final StreamingService streamingService) {
+ this.streamingService = streamingService;
+ }
+
+ /**
+ * Expand the {@link TextEllipsizer#content} to its full length.
+ */
+ public void expand() {
+ view.setMaxLines(EXPANDED_LINES);
+ linkifyContentView(v -> isEllipsized = false);
+ }
+
+ /**
+ * Shorten the {@link TextEllipsizer#content} to the given number of
+ * {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code …}'
+ * if the text was shorted.
+ */
+ public void ellipsize() {
+ // expand text to see whether it is necessary to ellipsize the text
+ view.setMaxLines(EXPANDED_LINES);
+ linkifyContentView(v -> {
+ final CharSequence charSeqText = view.getText();
+ if (charSeqText != null && view.getLineCount() > maxLines) {
+ // Note that converting to String removes spans (i.e. links), but that's something
+ // we actually want since when the text is ellipsized we want all clicks on the
+ // comment to expand the comment, not to open links.
+ final String text = charSeqText.toString();
+
+ final Layout layout = view.getLayout();
+ final float lineWidth = layout.getLineWidth(maxLines - 1);
+ final float layoutWidth = layout.getWidth();
+ final int lineStart = layout.getLineStart(maxLines - 1);
+ final int lineEnd = layout.getLineEnd(maxLines - 1);
+
+ // remove characters up until there is enough space for the ellipsis
+ // (also summing 2 more pixels, just to be sure to avoid float rounding errors)
+ int end = lineEnd;
+ float removedCharactersWidth = 0.0f;
+ while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth
+ && end >= lineStart) {
+ end -= 1;
+ // recalculate each time to account for ligatures or other similar things
+ removedCharactersWidth = paintAtContentSize.measureText(
+ text.substring(end, lineEnd));
+ }
+
+ // remove trailing spaces and newlines
+ while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) {
+ end -= 1;
+ }
+
+ final String newVal = text.substring(0, end) + ELLIPSIS;
+ view.setText(newVal);
+ isEllipsized = true;
+ } else {
+ isEllipsized = false;
+ }
+ view.setMaxLines(maxLines);
+ });
+ }
+
+ /**
+ * Toggle the view between the ellipsized and expanded state.
+ */
+ public void toggle() {
+ if (isEllipsized) {
+ expand();
+ } else {
+ ellipsize();
+ }
+ }
+
+ /**
+ * Whether the {@link #view} can be ellipsized.
+ * This is only the case when the {@link #content} has more lines
+ * than allowed via {@link #maxLines}.
+ * @return {@code true} if the {@link #content} has more lines than allowed via
+ * {@link #maxLines} and thus can be shortened, {@code false} if the {@code content} fits into
+ * the {@link #view} without being shortened and {@code null} if the initialization is not
+ * completed yet.
+ */
+ @Nullable
+ public Boolean canBeEllipsized() {
+ return canBeEllipsized;
+ }
+
+ private void linkifyContentView(final Consumer consumer) {
+ final boolean oldState = isEllipsized;
+ disposable.clear();
+ TextLinkifier.fromDescription(view, content,
+ HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable,
+ v -> {
+ consumer.accept(v);
+ notifyStateChangeListener(oldState);
+ });
+
+ }
+
+ /**
+ * Add a listener which is called when the given content is changed,
+ * either from ellipsized to full or vice versa.
+ * @param listener The listener to be called, or {@code null} to remove it.
+ * The Boolean parameter is the new state.
+ * Ellipsized content is represented as {@code true},
+ * normal or full content by {@code false}.
+ */
+ public void setStateChangeListener(@Nullable final Consumer listener) {
+ this.stateChangeListener = listener;
+ }
+
+ private void notifyStateChangeListener(final boolean oldState) {
+ if (oldState != isEllipsized && stateChangeListener != null) {
+ stateChangeListener.accept(isEllipsized);
+ }
+ }
+
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java
index e59a3dc0577..1419ac85a04 100644
--- a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java
+++ b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java
@@ -92,7 +92,7 @@ public static void fromDescription(@NonNull final TextView textView,
* {@link HtmlCompat#fromHtml(String, int)}.
*
*
- * @param textView the {@link TextView} to set the the HTML string block linked
+ * @param textView the {@link TextView} to set the HTML string block linked
* @param htmlBlock the HTML string block to be linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String,
* int)} will be called
diff --git a/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt
index e3d14291694..8554e71943d 100644
--- a/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt
+++ b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt
@@ -80,10 +80,10 @@ class CircleClipTapView(context: Context?, attrs: AttributeSet) : View(context,
updatePathShape()
}
- override fun onDraw(canvas: Canvas?) {
+ override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
- canvas?.clipPath(shapePath)
- canvas?.drawPath(shapePath, backgroundPaint)
+ canvas.clipPath(shapePath)
+ canvas.drawPath(shapePath, backgroundPaint)
}
}
diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java
index dbef73f7059..6b57c36f894 100755
--- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java
+++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java
@@ -23,7 +23,6 @@
import android.os.Handler.Callback;
import android.os.IBinder;
import android.os.Message;
-import android.os.Parcelable;
import android.util.Log;
import android.widget.Toast;
@@ -36,6 +35,7 @@
import androidx.core.app.PendingIntentCompat;
import androidx.core.app.ServiceCompat;
import androidx.core.content.ContextCompat;
+import androidx.core.content.IntentCompat;
import androidx.preference.PreferenceManager;
import com.grack.nanojson.JsonStringWriter;
@@ -53,6 +53,7 @@
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import us.shandian.giga.get.DownloadMission;
import us.shandian.giga.get.MissionRecoveryInfo;
@@ -364,31 +365,31 @@ public void updateForegroundState(boolean state) {
*/
public static void startMission(Context context, String[] urls, StoredFileHelper storage,
char kind, int threads, String source, String psName,
- String[] psArgs, long nearLength, MissionRecoveryInfo[] recoveryInfo,
+ String[] psArgs, long nearLength,
+ ArrayList recoveryInfo,
VideoSegment[] segments) {
- Intent intent = new Intent(context, DownloadManagerService.class);
- intent.setAction(Intent.ACTION_RUN);
- intent.putExtra(EXTRA_URLS, urls);
- intent.putExtra(EXTRA_KIND, kind);
- intent.putExtra(EXTRA_THREADS, threads);
- intent.putExtra(EXTRA_SOURCE, source);
- intent.putExtra(EXTRA_POSTPROCESSING_NAME, psName);
- intent.putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs);
- intent.putExtra(EXTRA_NEAR_LENGTH, nearLength);
- intent.putExtra(EXTRA_RECOVERY_INFO, recoveryInfo);
-
- intent.putExtra(EXTRA_PARENT_PATH, storage.getParentUri());
- intent.putExtra(EXTRA_PATH, storage.getUri());
- intent.putExtra(EXTRA_STORAGE_TAG, storage.getTag());
- intent.putExtra(EXTRA_SEGMENTS, segments);
+ final Intent intent = new Intent(context, DownloadManagerService.class)
+ .setAction(Intent.ACTION_RUN)
+ .putExtra(EXTRA_URLS, urls)
+ .putExtra(EXTRA_KIND, kind)
+ .putExtra(EXTRA_THREADS, threads)
+ .putExtra(EXTRA_SOURCE, source)
+ .putExtra(EXTRA_POSTPROCESSING_NAME, psName)
+ .putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs)
+ .putExtra(EXTRA_NEAR_LENGTH, nearLength)
+ .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo)
+ .putExtra(EXTRA_PARENT_PATH, storage.getParentUri())
+ .putExtra(EXTRA_PATH, storage.getUri())
+ .putExtra(EXTRA_SEGMENTS, segments)
+ .putExtra(EXTRA_STORAGE_TAG, storage.getTag());
context.startService(intent);
}
private void startMission(Intent intent) {
String[] urls = intent.getStringArrayExtra(EXTRA_URLS);
- Uri path = intent.getParcelableExtra(EXTRA_PATH);
- Uri parentPath = intent.getParcelableExtra(EXTRA_PARENT_PATH);
+ Uri path = IntentCompat.getParcelableExtra(intent, EXTRA_PATH, Uri.class);
+ Uri parentPath = IntentCompat.getParcelableExtra(intent, EXTRA_PARENT_PATH, Uri.class);
int threads = intent.getIntExtra(EXTRA_THREADS, 1);
char kind = intent.getCharExtra(EXTRA_KIND, '?');
String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME);
@@ -396,7 +397,10 @@ private void startMission(Intent intent) {
String source = intent.getStringExtra(EXTRA_SOURCE);
long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0);
String tag = intent.getStringExtra(EXTRA_STORAGE_TAG);
- Parcelable[] parcelRecovery = intent.getParcelableArrayExtra(EXTRA_RECOVERY_INFO);
+ final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO,
+ MissionRecoveryInfo.class);
+ Objects.requireNonNull(recovery);
+
VideoSegment[] segments = (VideoSegment[]) intent.getSerializableExtra(EXTRA_SEGMENTS);
StoredFileHelper storage;
@@ -412,15 +416,11 @@ private void startMission(Intent intent) {
else
ps = Postprocessing.getAlgorithm(psName, psArgs);
- MissionRecoveryInfo[] recovery = new MissionRecoveryInfo[parcelRecovery.length];
- for (int i = 0; i < parcelRecovery.length; i++)
- recovery[i] = (MissionRecoveryInfo) parcelRecovery[i];
-
final DownloadMission mission = new DownloadMission(urls, storage, kind, ps);
mission.threadCount = threads;
mission.source = source;
mission.nearLength = nearLength;
- mission.recoveryInfo = recovery;
+ mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]);
if (segments != null && segments.length > 0) {
try {
diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java
index 90c29d6738c..c26d7afd520 100644
--- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java
+++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java
@@ -517,7 +517,7 @@ private void showError(@NonNull DownloadMission mission) {
showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed);
return;
case ERROR_INSUFFICIENT_STORAGE:
- msg = R.string.error_insufficient_storage;
+ msg = R.string.error_insufficient_storage_left;
break;
case ERROR_UNKNOWN_EXCEPTION:
if (mission.errObject != null) {
diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java
index 3cfa22bd9f6..86a08c57f8a 100644
--- a/app/src/main/java/us/shandian/giga/util/Utility.java
+++ b/app/src/main/java/us/shandian/giga/util/Utility.java
@@ -2,6 +2,8 @@
import android.content.Context;
import android.os.Build;
+import android.os.Environment;
+import android.os.StatFs;
import android.util.Log;
import androidx.annotation.ColorInt;
@@ -26,10 +28,8 @@
import java.io.Serializable;
import java.net.HttpURLConnection;
import java.util.Locale;
-import java.util.Random;
import okio.ByteString;
-import us.shandian.giga.get.DownloadMission;
public class Utility {
diff --git a/app/src/main/res/layout-land/list_stream_card_item.xml b/app/src/main/res/layout-land/list_stream_card_item.xml
new file mode 120000
index 00000000000..70228ee1d20
--- /dev/null
+++ b/app/src/main/res/layout-land/list_stream_card_item.xml
@@ -0,0 +1 @@
+../layout/list_stream_item.xml
\ No newline at end of file
diff --git a/app/src/main/res/layout/comment_replies_header.xml b/app/src/main/res/layout/comment_replies_header.xml
new file mode 100644
index 00000000000..ed5ba1a1084
--- /dev/null
+++ b/app/src/main/res/layout/comment_replies_header.xml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_comments.xml b/app/src/main/res/layout/fragment_comments.xml
index b1b644d8c05..2a8c747cd63 100644
--- a/app/src/main/res/layout/fragment_comments.xml
+++ b/app/src/main/res/layout/fragment_comments.xml
@@ -9,7 +9,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
- tools:listitem="@layout/list_comments_item" />
+ tools:listitem="@layout/list_comment_item" />
+ android:src="@drawable/ic_pin" />
+ tools:text="Author Name, Lorem ipsum • 5 months ago" />
-
+ tools:text="@tools:sample/lorem/random[1]" />
+ android:src="@drawable/ic_heart" />
-
+ android:layout_alignParentEnd="true"
+ android:layout_marginStart="@dimen/video_item_detail_heart_margin"
+ android:minHeight="0dp"
+ tools:text="543 replies" />
diff --git a/app/src/main/res/layout/list_comments_mini_item.xml b/app/src/main/res/layout/list_comments_mini_item.xml
deleted file mode 100644
index 606a237c5fc..00000000000
--- a/app/src/main/res/layout/list_comments_mini_item.xml
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/list_playlist_bookmark_item.xml b/app/src/main/res/layout/list_playlist_bookmark_item.xml
new file mode 100644
index 00000000000..6aabd4d0752
--- /dev/null
+++ b/app/src/main/res/layout/list_playlist_bookmark_item.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/playlist_header.xml b/app/src/main/res/layout/playlist_header.xml
index 9c038db3a66..c761240d978 100644
--- a/app/src/main/res/layout/playlist_header.xml
+++ b/app/src/main/res/layout/playlist_header.xml
@@ -80,10 +80,32 @@
tools:text="234 videos" />
+
+
+
+
+ android:layout_below="@id/playlist_description_read_more">
-
+ android:id="@+id/summary"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:clickable="false"
+ android:focusable="false"
+ android:gravity="center"
+ android:text="@string/notification_actions_summary"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.0"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
-
+
-
+
-
+
-
+
-
+
-
+
diff --git a/app/src/main/res/values-aeb/strings.xml b/app/src/main/res/values-aeb/strings.xml
new file mode 100644
index 00000000000..a6b3daec935
--- /dev/null
+++ b/app/src/main/res/values-aeb/strings.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-ar-rLY/strings.xml b/app/src/main/res/values-ar-rLY/strings.xml
index aca74e743c6..077cf1106ca 100644
--- a/app/src/main/res/values-ar-rLY/strings.xml
+++ b/app/src/main/res/values-ar-rLY/strings.xml
@@ -78,7 +78,7 @@
بليون
تعذر تحميل موجز \'%s\'.
؟
- التحقق من وجود تحديثات
+ التحقق من وجود تحديثات
مثيلات خوادم پيرتيوب
+100 فيديو
ألف
@@ -271,7 +271,7 @@
يلغي السجل الحالي والاشتراكات وقوائم التشغيل والإعدادات (اختياريًا)
تعطل التطبيق / واجهة المستخدم
إعادة التسمية
- لم يتبقى مساحة في الجهاز
+ لم يتبقى مساحة في الجهاز
تعذر إعداد قائمة التنزيل
اختر مجلد التنزيل لملفات الفيديو
تم تعطيل الإشعارات
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 6bfe11eaad0..82173d75802 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -407,7 +407,7 @@
لا يمكن الكتابة فوق الملف
هناك تنزيل معلق بهذا الاسم
تم إغلاق NewPipe أثناء العمل على الملف
- لم يتبقى مساحة في الجهاز
+ لم يتبقى مساحة في الجهاز
تم فقد التقدم بسبب حذف الملف
انتهى وقت الاتصال
هل تريد محو سجل التنزيل، أم تريد حذف جميع الملفات التي تم تنزيلها؟
@@ -584,7 +584,7 @@
خلط
تكرار
يمكنك تحديد ثلاثة إجراءات كحد أقصى لإظهارها في الإشعار المضغوط!
- قم بتحرير كل إشعار أدناه من خلال النقر عليه. حدد ما يصل إلى ثلاثة منها لتظهر في الإشعار المضغوط باستخدام مربعات الاختيار الموجودة على اليمين
+ قم بتحرير كل إجراء إعلام أدناه من خلال النقر عليه. حدد ما يصل إلى ثلاثة منها ليتم عرضها في الإشعار المضغوط باستخدام مربعات الاختيار الموجودة على اليمين.
زر الإجراء الخامس
زر الإجراء الرابع
زر الإجراء الثالث
@@ -700,7 +700,7 @@
وضع التالي على قائمة الانتظار
تم وضع التالي على قائمة الانتظار
جاري المعالجة ... قد يستغرق لحظة
- التحقق من وجود تحديثات
+ التحقق من وجود تحديثات
التحقق يدويا من وجود إصدارات جديدة
جاري التحقق من وجود تحديثات…
عناصر تغذية جديدة
@@ -858,4 +858,26 @@
مشاركة قائمة التشغيل
شارك تفاصيل قائمة التشغيل مثل اسم قائمة التشغيل وعناوين الفيديو أو كقائمة بسيطة من عناوين URL للفيديو
- %1$s: %2$s
+
+ - رد %s
+ - رد %s
+ - ردان%s
+ - ردود%s
+ - ردود %s
+ - ردود %s
+
+ عرض المزيد
+ عرض أقل
+ قم بتحرير كل إجراء إعلام أدناه من خلال النقر عليه. يتم تعيين الإجراءات الثلاثة الأولى (تشغيل/إيقاف مؤقت، السابق والتالي) بواسطة النظام ولا يمكن تخصيصها.
+ لا توجد مساحة خالية كافية على الجهاز
+ اعادة ضبط الإعداداتِ
+ النسخ الاحتياطيُّ والاستعادة
+ أعيدوا جميع الإعدادات إلى قيمهم الافتراضية
+ ستؤدي إعادة ضبط جميع الإعدادات إلى تجاهل جميع إعداداتك المفضلة وإعادة تشغيل التطبيق.
+\n
+\nهل انت متأكد انك تريد المتابعة؟
+ نعم
+ يمكن لـ NewPipe البحث تلقائيًا عن الإصدارات الجديدة من وقت لآخر وإعلامك بمجرد توفرها.
+\nهل تريد تمكين هذا؟
+ لا
\ No newline at end of file
diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml
index e3de4f4d236..66bfe75dee9 100644
--- a/app/src/main/res/values-az/strings.xml
+++ b/app/src/main/res/values-az/strings.xml
@@ -448,7 +448,7 @@
- %s video
- %s video
- Yeniləmələri yoxla
+ Yeniləmələri yoxla
Axtarış çubuğunun miniatür önizləməsi
Əməliyyat sistem tərəfindən ləğv edildi
Avto
@@ -548,7 +548,7 @@
İzləniləni sil
Sistem qovluğu seçicisini (SAF) istifadə et
Bağlantı fasiləsi
- Cihazda yer qalmayıb
+ Cihazda yer qalmayıb
Fayl üzərində işləyərkən NewPipe bağlandı
Emaldan sonra uğursuz oldu
Serverə qoşulmaq mümkün deyil
@@ -694,7 +694,7 @@
Bu video yalnız YouTube Music Premium üzvləri üçün əlçatandır, ona görə də NewPipe tərəfindən yayımlamaq və ya endirmək mümkün deyil.
İndi açıqlamadakı mətni seçə bilərsiniz. Nəzərə alın ki, seçim rejimində səhifə titrəyə və linklər kliklənməyə bilər.
Bildirişdə göstərilən video miniatürünü 16:9-dan 1:1 görünüş nisbətinə qədər kəs
- Aşağıdakı hər bir bildiriş fəaliyyətini üzərinə toxunaraq redaktə et. Sağdakı təsdiq qutularından istifadə edərək yığcam bildirişdə göstərmək üçün onların üçünü seç
+ Aşağıdakı hər bir bildiriş fəaliyyətini üzərinə toxunaraq düzəliş edin. Sağdakı təsdiq qutularından istifadə edərək yığcam bildirişdə göstərmək üçün onların üçünü seçin.
Belə fayl/məzmun mənbəyi yoxdur
Seçilən yayım xarici oynadıcılar tərəfindən dəstəklənmir
Yükləyici tərəfindən hələ dəstəklənməyən yayımlar göstərilmir
@@ -769,4 +769,5 @@
Axın yenilənərkən əldə edilən səhifələr.Kanal sürətli rejim istifadə edərək yenilənirsə, bu seçimin heç bir təsiri yoxdur.
Yükləyici avatarları
Miniatürlər
+ Aşağıdakı hər bildirişə vuraraq ona düzəliş edin. İlk üç əməl (oynatma/fasilə, əvvəlki və sonrakı) sistem tərəfindən təyin olunub və dəyişdirilə bilməz.
\ No newline at end of file
diff --git a/app/src/main/res/values-b+ast/strings.xml b/app/src/main/res/values-b+ast/strings.xml
index 626e3f284ff..5cc516f4619 100644
--- a/app/src/main/res/values-b+ast/strings.xml
+++ b/app/src/main/res/values-b+ast/strings.xml
@@ -171,7 +171,7 @@
Yá esiste un ficheru baxáu con esti nome
nun pue sobrecribise\'l ficheru
Hai una descarga pendiente con esti nome
- Nun queda espaciu nel preséu
+ Nun queda espaciu nel preséu
Escosó\'l tiempu d\'espera de la conexón
Nun pudieron importase les soscripciones
Sotítulos
diff --git a/app/src/main/res/values-b+uz+Latn/strings.xml b/app/src/main/res/values-b+uz+Latn/strings.xml
index 0ba69ae41ac..c3c18789180 100644
--- a/app/src/main/res/values-b+uz+Latn/strings.xml
+++ b/app/src/main/res/values-b+uz+Latn/strings.xml
@@ -58,7 +58,7 @@
Kodi bilan ijro etish
Faqat ba\'zi qurilmalar 2K / 4K videolarni ijro etishi mumkin
Yuqori o\'lchamlarni ko\'rsatish
- "Standart pop-up o\'lchamlari"
+ Standart pop-up o\'lchamlari
Standart o\'lchamlari
Audio fayllar uchun yuklab olish papkasini tanlash
Yuklab olingan videofayllar shu yerda saqlanadi
@@ -416,7 +416,7 @@
Ushbu yuklab olishni tiklab bo\'lmaydi
Ulanish vaqti tugadi
Siljish yo\'qoldi, chunki fayl o\'chirildi
- Qurilmada bo\'sh joy qolmadi
+ Qurilmada bo\'sh joy qolmadi
NewPipe fayl ustida ishlash paytida yopilgan
Keyingi ishlov berilmadi
Topilmadi
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
index 38905ed7fbc..dceaaf6c560 100644
--- a/app/src/main/res/values-be/strings.xml
+++ b/app/src/main/res/values-be/strings.xml
@@ -6,8 +6,8 @@
Патокавы плэер не знойдзены (вы можаце ўсталяваць VLC каб прайграць).
Усталяваць
Скасаваць
- Адкрыць у браўзеры
- Адкрыць у асобным акне
+ Адкрыць ў браўзеры
+ Адкрыць ў асобным акне
Падзяліцца
Спампаваць
Загрузка файла прамой трансляцыі
@@ -37,12 +37,12 @@
Загружаныя аўдыёфайлы захоўваюцца тут
Абярыце тэчку загрузкі для аўдыёфайлаў
Разрознянне па змаўчанні
- Разрозненне усплываючага акна
+ Разрозненне ўсплываючага акна
Высокія разрозненні
Толькі некаторыя прылады могуць прайграваць відэа ў 2K/4K
- Прайграць у Kodi
- Усталяваць адсутную праграму Kore\?
- Паказаць опцыю \"Прайграць у Kodi\"
+ Прайграць ў Kodi
+ Ўсталяваць адсутную праграму Kore?
+ Паказаць опцыю \"Прайграць ў Kodi\"
Паказаць опцыю прайгравання відэа праз медыяцэнтр Kodi
Аўдыё
Фармат аўдыё па змаўчанні
@@ -70,7 +70,7 @@
Узнавіць прайграванне
Працягваць прайграванне пасля перапынкаў (напрыклад, тэлефонных званкоў)
Загрузіць
- \"Наступнае\" и \"Прапанаванае\" відэа
+ \"Наступнае\" і \"Прапанаванае\" відэа
Паказаць падказку \"Утрымлівайце, каб паставіць у чаргу\"
Паказаць падказку пры націсканні фонавай або ўсплывальнай кнопкі ў відэа \"Падрабязнасці:\"
URL не падтрымліваецца
@@ -227,7 +227,7 @@
\nПалітыка прыватнасці NewPipe падрабязна тлумачыць, якія дадзеныя адпраўляюцца і захоўваюцца пры адпраўцы справаздачы аб збоях.