This commit is contained in:
wb9688 2024-04-01 14:25:43 +02:00 committed by GitHub
commit 6edd2f0771
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
695 changed files with 59474 additions and 67723 deletions

View File

@ -1,46 +0,0 @@
package org.schabi.newpipe.error;
import android.os.Parcel;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import java.util.Arrays;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/**
* Instrumented tests for {@link ErrorInfo}.
*/
@RunWith(AndroidJUnit4.class)
@LargeTest
public class ErrorInfoTest {
@Test
public void errorInfoTestParcelable() {
final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"),
UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId());
// Obtain a Parcel object and write the parcelable object to it:
final Parcel parcel = Parcel.obtain();
info.writeToParcel(parcel, 0);
parcel.setDataPosition(0);
final ErrorInfo infoFromParcel = (ErrorInfo) ErrorInfo.CREATOR.createFromParcel(parcel);
assertTrue(Arrays.toString(infoFromParcel.getStackTraces())
.contains(ErrorInfoTest.class.getSimpleName()));
assertEquals(UserAction.USER_REPORT, infoFromParcel.getUserAction());
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
infoFromParcel.getServiceName());
assertEquals("request", infoFromParcel.getRequest());
assertEquals(R.string.parsing_error, infoFromParcel.getMessageStringId());
parcel.recycle();
}
}

View File

@ -0,0 +1,36 @@
package org.schabi.newpipe.error
import android.os.Parcel
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.exceptions.ParsingException
/**
* Instrumented tests for [ErrorInfo].
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class ErrorInfoTest {
@Test
fun errorInfoTestParcelable() {
val info = ErrorInfo(ParsingException("Hello"),
UserAction.USER_REPORT, "request", ServiceList.YouTube.serviceId)
// Obtain a Parcel object and write the parcelable object to it:
val parcel = Parcel.obtain()
info.writeToParcel(parcel, 0)
parcel.setDataPosition(0)
val infoFromParcel = ErrorInfo.CREATOR.createFromParcel(parcel) as ErrorInfo
Assert.assertTrue(infoFromParcel.stackTraces.contentToString().contains(ErrorInfoTest::class.java.getSimpleName()))
Assert.assertEquals(UserAction.USER_REPORT, infoFromParcel.userAction)
Assert.assertEquals(ServiceList.YouTube.serviceInfo.name,
infoFromParcel.serviceName)
Assert.assertEquals("request", infoFromParcel.request)
Assert.assertEquals(R.string.parsing_error.toLong(), infoFromParcel.messageStringId.toLong())
parcel.recycle()
}
}

View File

@ -1,82 +0,0 @@
package org.schabi.newpipe.local.subscription;
import static org.junit.Assert.assertEquals;
import androidx.test.core.app.ApplicationProvider;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.testUtil.TestDatabase;
import org.schabi.newpipe.testUtil.TrampolineSchedulerRule;
import java.io.IOException;
import java.util.List;
public class SubscriptionManagerTest {
private AppDatabase database;
private SubscriptionManager manager;
@Rule
public TrampolineSchedulerRule trampolineScheduler = new TrampolineSchedulerRule();
private SubscriptionEntity getAssertOneSubscriptionEntity() {
final List<SubscriptionEntity> entities = manager
.getSubscriptions(FeedGroupEntity.GROUP_ALL_ID, "", false)
.blockingFirst();
assertEquals(1, entities.size());
return entities.get(0);
}
@Before
public void setup() {
database = TestDatabase.Companion.createReplacingNewPipeDatabase();
manager = new SubscriptionManager(ApplicationProvider.getApplicationContext());
}
@After
public void cleanUp() {
database.close();
}
@Test
public void testInsert() throws ExtractionException, IOException {
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown");
final SubscriptionEntity subscription = SubscriptionEntity.from(info);
manager.insertSubscription(subscription);
final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity();
// the uid has changed, since the uid is chosen upon inserting, but the rest should match
assertEquals(subscription.getServiceId(), readSubscription.getServiceId());
assertEquals(subscription.getUrl(), readSubscription.getUrl());
assertEquals(subscription.getName(), readSubscription.getName());
assertEquals(subscription.getAvatarUrl(), readSubscription.getAvatarUrl());
assertEquals(subscription.getSubscriberCount(), readSubscription.getSubscriberCount());
assertEquals(subscription.getDescription(), readSubscription.getDescription());
}
@Test
public void testUpdateNotificationMode() throws ExtractionException, IOException {
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/veritasium");
final SubscriptionEntity subscription = SubscriptionEntity.from(info);
subscription.setNotificationMode(0);
manager.insertSubscription(subscription);
manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1)
.blockingAwait();
final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity();
assertEquals(0, subscription.getNotificationMode());
assertEquals(subscription.getUrl(), anotherSubscription.getUrl());
assertEquals(1, anotherSubscription.getNotificationMode());
}
}

View File

@ -0,0 +1,76 @@
package org.schabi.newpipe.local.subscription
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.schabi.newpipe.database.AppDatabase
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.database.subscription.SubscriptionEntity.Companion.from
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.exceptions.ExtractionException
import org.schabi.newpipe.testUtil.TestDatabase.Companion.createReplacingNewPipeDatabase
import org.schabi.newpipe.testUtil.TrampolineSchedulerRule
import java.io.IOException
class SubscriptionManagerTest {
private var database: AppDatabase? = null
private var manager: SubscriptionManager? = null
@Rule
var trampolineScheduler = TrampolineSchedulerRule()
private val assertOneSubscriptionEntity: SubscriptionEntity
private get() {
val entities = manager
.getSubscriptions(FeedGroupEntity.GROUP_ALL_ID, "", false)
.blockingFirst()
Assert.assertEquals(1, entities.size.toLong())
return entities[0]
}
@Before
fun setup() {
database = createReplacingNewPipeDatabase()
manager = SubscriptionManager(ApplicationProvider.getApplicationContext())
}
@After
fun cleanUp() {
database!!.close()
}
@Test
@Throws(ExtractionException::class, IOException::class)
fun testInsert() {
val info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown")
val subscription = from(info)
manager!!.insertSubscription(subscription)
val readSubscription = assertOneSubscriptionEntity
// the uid has changed, since the uid is chosen upon inserting, but the rest should match
Assert.assertEquals(subscription.getServiceId().toLong(), readSubscription.getServiceId().toLong())
Assert.assertEquals(subscription.getUrl(), readSubscription.getUrl())
Assert.assertEquals(subscription.getName(), readSubscription.getName())
Assert.assertEquals(subscription.getAvatarUrl(), readSubscription.getAvatarUrl())
Assert.assertEquals(subscription.getSubscriberCount(), readSubscription.getSubscriberCount())
Assert.assertEquals(subscription.getDescription(), readSubscription.getDescription())
}
@Test
@Throws(ExtractionException::class, IOException::class)
fun testUpdateNotificationMode() {
val info = ChannelInfo.getInfo("https://www.youtube.com/c/veritasium")
val subscription = from(info)
subscription.setNotificationMode(0)
manager!!.insertSubscription(subscription)
manager!!.updateNotificationMode(subscription.getServiceId(), subscription.getUrl()!!, 1)
.blockingAwait()
val anotherSubscription = assertOneSubscriptionEntity
Assert.assertEquals(0, subscription.getNotificationMode().toLong())
Assert.assertEquals(subscription.getUrl(), anotherSubscription.getUrl())
Assert.assertEquals(1, anotherSubscription.getNotificationMode().toLong())
}
}

View File

@ -1,20 +0,0 @@
package org.schabi.newpipe.settings;
import android.content.Intent;
import leakcanary.LeakCanary;
/**
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
* This class is loaded via reflection by
* {@link DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI}.
*/
@SuppressWarnings("unused") // Class is used but loaded via reflection
public class DebugSettingsBVDLeakCanary
implements DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI {
@Override
public Intent getNewLeakDisplayActivityIntent() {
return LeakCanary.INSTANCE.newLeakDisplayActivityIntent();
}
}

View File

@ -0,0 +1,18 @@
package org.schabi.newpipe.settings
import android.content.Intent
import leakcanary.LeakCanary.newLeakDisplayActivityIntent
import org.schabi.newpipe.settings.DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI
/**
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
* This class is loaded via reflection by
* [DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI].
*/
@Suppress("unused") // Class is used but loaded via reflection
class DebugSettingsBVDLeakCanary : DebugSettingsBVDLeakCanaryAPI {
override fun getNewLeakDisplayActivityIntent(): Intent? {
return newLeakDisplayActivityIntent()
}
}

View File

@ -1,344 +0,0 @@
/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.fragment.app;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.BundleCompat;
import androidx.lifecycle.Lifecycle;
import androidx.viewpager.widget.PagerAdapter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
// TODO: Replace this deprecated class with its ViewPager2 counterpart
/**
* This is a copy from {@link androidx.fragment.app.FragmentStatePagerAdapter}.
* <p>
* It includes a workaround to fix the menu visibility when the adapter is restored.
* </p>
* <p>
* When restoring the state of this adapter, all the fragments' menu visibility were set to false,
* effectively disabling the menu from the user until he switched pages or another event
* that triggered the menu to be visible again happened.
* </p>
* <p>
* <b>Check out the changes in:</b>
* </p>
* <ul>
* <li>{@link #saveState()}</li>
* <li>{@link #restoreState(Parcelable, ClassLoader)}</li>
* </ul>
*
* @deprecated Switch to {@link androidx.viewpager2.widget.ViewPager2} and use
* {@link androidx.viewpager2.adapter.FragmentStateAdapter} instead.
*/
@SuppressWarnings("deprecation")
@Deprecated
public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapter {
private static final String TAG = "FragmentStatePagerAdapt";
private static final boolean DEBUG = false;
@Retention(RetentionPolicy.SOURCE)
@IntDef({BEHAVIOR_SET_USER_VISIBLE_HINT, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT})
private @interface Behavior { }
/**
* Indicates that {@link Fragment#setUserVisibleHint(boolean)} will be called when the current
* fragment changes.
*
* @deprecated This behavior relies on the deprecated
* {@link Fragment#setUserVisibleHint(boolean)} API. Use
* {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} to switch to its replacement,
* {@link FragmentTransaction#setMaxLifecycle}.
* @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)
*/
@Deprecated
public static final int BEHAVIOR_SET_USER_VISIBLE_HINT = 0;
/**
* Indicates that only the current fragment will be in the {@link Lifecycle.State#RESUMED}
* state. All other Fragments are capped at {@link Lifecycle.State#STARTED}.
*
* @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)
*/
public static final int BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT = 1;
private final FragmentManager mFragmentManager;
private final int mBehavior;
private FragmentTransaction mCurTransaction = null;
private final ArrayList<Fragment.SavedState> mSavedState = new ArrayList<>();
private final ArrayList<Fragment> mFragments = new ArrayList<>();
private Fragment mCurrentPrimaryItem = null;
private boolean mExecutingFinishUpdate;
/**
* Constructor for {@link FragmentStatePagerAdapterMenuWorkaround}
* that sets the fragment manager for the adapter. This is the equivalent of calling
* {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} and passing in
* {@link #BEHAVIOR_SET_USER_VISIBLE_HINT}.
*
* <p>Fragments will have {@link Fragment#setUserVisibleHint(boolean)} called whenever the
* current Fragment changes.</p>
*
* @param fm fragment manager that will interact with this adapter
* @deprecated use {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} with
* {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT}
*/
@Deprecated
public FragmentStatePagerAdapterMenuWorkaround(@NonNull final FragmentManager fm) {
this(fm, BEHAVIOR_SET_USER_VISIBLE_HINT);
}
/**
* Constructor for {@link FragmentStatePagerAdapterMenuWorkaround}.
*
* If {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} is passed in, then only the current
* Fragment is in the {@link Lifecycle.State#RESUMED} state, while all other fragments are
* capped at {@link Lifecycle.State#STARTED}. If {@link #BEHAVIOR_SET_USER_VISIBLE_HINT} is
* passed, all fragments are in the {@link Lifecycle.State#RESUMED} state and there will be
* callbacks to {@link Fragment#setUserVisibleHint(boolean)}.
*
* @param fm fragment manager that will interact with this adapter
* @param behavior determines if only current fragments are in a resumed state
*/
public FragmentStatePagerAdapterMenuWorkaround(@NonNull final FragmentManager fm,
@Behavior final int behavior) {
mFragmentManager = fm;
mBehavior = behavior;
}
/**
* @param position the position of the item you want
* @return the {@link Fragment} associated with a specified position
*/
@NonNull
public abstract Fragment getItem(int position);
@Override
public void startUpdate(@NonNull final ViewGroup container) {
if (container.getId() == View.NO_ID) {
throw new IllegalStateException("ViewPager with adapter " + this
+ " requires a view id");
}
}
@SuppressWarnings("deprecation")
@NonNull
@Override
public Object instantiateItem(@NonNull final ViewGroup container, final int position) {
// If we already have this item instantiated, there is nothing
// to do. This can happen when we are restoring the entire pager
// from its saved state, where the fragment manager has already
// taken care of restoring the fragments we previously had instantiated.
if (mFragments.size() > position) {
final Fragment f = mFragments.get(position);
if (f != null) {
return f;
}
}
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction();
}
final Fragment fragment = getItem(position);
if (DEBUG) {
Log.v(TAG, "Adding item #" + position + ": f=" + fragment);
}
if (mSavedState.size() > position) {
final Fragment.SavedState fss = mSavedState.get(position);
if (fss != null) {
fragment.setInitialSavedState(fss);
}
}
while (mFragments.size() <= position) {
mFragments.add(null);
}
fragment.setMenuVisibility(false);
if (mBehavior == BEHAVIOR_SET_USER_VISIBLE_HINT) {
fragment.setUserVisibleHint(false);
}
mFragments.set(position, fragment);
mCurTransaction.add(container.getId(), fragment);
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.STARTED);
}
return fragment;
}
@Override
public void destroyItem(@NonNull final ViewGroup container, final int position,
@NonNull final Object object) {
final Fragment fragment = (Fragment) object;
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction();
}
if (DEBUG) {
Log.v(TAG, "Removing item #" + position + ": f=" + object
+ " v=" + ((Fragment) object).getView());
}
while (mSavedState.size() <= position) {
mSavedState.add(null);
}
mSavedState.set(position, fragment.isAdded()
? mFragmentManager.saveFragmentInstanceState(fragment) : null);
mFragments.set(position, null);
mCurTransaction.remove(fragment);
if (fragment.equals(mCurrentPrimaryItem)) {
mCurrentPrimaryItem = null;
}
}
@Override
@SuppressWarnings({"ReferenceEquality", "deprecation"})
public void setPrimaryItem(@NonNull final ViewGroup container, final int position,
@NonNull final Object object) {
final Fragment fragment = (Fragment) object;
if (fragment != mCurrentPrimaryItem) {
if (mCurrentPrimaryItem != null) {
mCurrentPrimaryItem.setMenuVisibility(false);
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction();
}
mCurTransaction.setMaxLifecycle(mCurrentPrimaryItem, Lifecycle.State.STARTED);
} else {
mCurrentPrimaryItem.setUserVisibleHint(false);
}
}
fragment.setMenuVisibility(true);
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction();
}
mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED);
} else {
fragment.setUserVisibleHint(true);
}
mCurrentPrimaryItem = fragment;
}
}
@Override
public void finishUpdate(@NonNull final ViewGroup container) {
if (mCurTransaction != null) {
// We drop any transactions that attempt to be committed
// from a re-entrant call to finishUpdate(). We need to
// do this as a workaround for Robolectric running measure/layout
// calls inline rather than allowing them to be posted
// as they would on a real device.
if (!mExecutingFinishUpdate) {
try {
mExecutingFinishUpdate = true;
mCurTransaction.commitNowAllowingStateLoss();
} finally {
mExecutingFinishUpdate = false;
}
}
mCurTransaction = null;
}
}
@Override
public boolean isViewFromObject(@NonNull final View view, @NonNull final Object object) {
return ((Fragment) object).getView() == view;
}
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
private final String selectedFragment = "selected_fragment";
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
@Override
@Nullable
public Parcelable saveState() {
Bundle state = null;
if (!mSavedState.isEmpty()) {
state = new Bundle();
state.putParcelableArrayList("states", mSavedState);
}
for (int i = 0; i < mFragments.size(); i++) {
final Fragment f = mFragments.get(i);
if (f != null && f.isAdded()) {
if (state == null) {
state = new Bundle();
}
final String key = "f" + i;
mFragmentManager.putFragment(state, key, f);
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// Check if it's the same fragment instance
if (f == mCurrentPrimaryItem) {
state.putString(selectedFragment, key);
}
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
}
}
return state;
}
@Override
public void restoreState(@Nullable final Parcelable state, @Nullable final ClassLoader loader) {
if (state != null) {
final Bundle bundle = (Bundle) state;
bundle.setClassLoader(loader);
final var states = BundleCompat.getParcelableArrayList(bundle, "states",
Fragment.SavedState.class);
mSavedState.clear();
mFragments.clear();
if (states != null) {
mSavedState.addAll(states);
}
final Iterable<String> keys = bundle.keySet();
for (final String key : keys) {
if (key.startsWith("f")) {
final int index = Integer.parseInt(key.substring(1));
final Fragment f = mFragmentManager.getFragment(bundle, key);
if (f != null) {
while (mFragments.size() <= index) {
mFragments.add(null);
}
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
final boolean wasSelected = bundle.getString(selectedFragment, "")
.equals(key);
f.setMenuVisibility(wasSelected);
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
mFragments.set(index, f);
} else {
Log.w(TAG, "Bad fragment at key " + key);
}
}
}
}
}
}

View File

@ -0,0 +1,308 @@
/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.fragment.app
import android.os.Bundle
import android.os.Parcelable
import android.util.Log
import android.view.View
import android.view.ViewGroup
import androidx.annotation.IntDef
import androidx.core.os.BundleCompat
import androidx.lifecycle.Lifecycle
import androidx.viewpager.widget.PagerAdapter
// TODO: Replace this deprecated class with its ViewPager2 counterpart
/**
* This is a copy from [androidx.fragment.app.FragmentStatePagerAdapter].
*
*
* It includes a workaround to fix the menu visibility when the adapter is restored.
*
*
*
* When restoring the state of this adapter, all the fragments' menu visibility were set to false,
* effectively disabling the menu from the user until he switched pages or another event
* that triggered the menu to be visible again happened.
*
*
*
* **Check out the changes in:**
*
*
* * [.saveState]
* * [.restoreState]
*
*
*/
@Suppress("deprecation")
@Deprecated("""Switch to {@link androidx.viewpager2.widget.ViewPager2} and use
{@link androidx.viewpager2.adapter.FragmentStateAdapter} instead.""")
abstract class FragmentStatePagerAdapterMenuWorkaround
/**
* Constructor for [FragmentStatePagerAdapterMenuWorkaround].
*
* If [.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT] is passed in, then only the current
* Fragment is in the [Lifecycle.State.RESUMED] state, while all other fragments are
* capped at [Lifecycle.State.STARTED]. If [.BEHAVIOR_SET_USER_VISIBLE_HINT] is
* passed, all fragments are in the [Lifecycle.State.RESUMED] state and there will be
* callbacks to [Fragment.setUserVisibleHint].
*
* @param fm fragment manager that will interact with this adapter
* @param behavior determines if only current fragments are in a resumed state
*/(private val mFragmentManager: FragmentManager,
@param:Behavior private val mBehavior: Int) : PagerAdapter() {
@Retention(AnnotationRetention.SOURCE)
@IntDef([BEHAVIOR_SET_USER_VISIBLE_HINT, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT])
private annotation class Behavior
private var mCurTransaction: FragmentTransaction? = null
private val mSavedState = ArrayList<Fragment.SavedState?>()
private val mFragments = ArrayList<Fragment>()
private var mCurrentPrimaryItem: Fragment? = null
private var mExecutingFinishUpdate = false
/**
* Constructor for [FragmentStatePagerAdapterMenuWorkaround]
* that sets the fragment manager for the adapter. This is the equivalent of calling
* [.FragmentStatePagerAdapterMenuWorkaround] and passing in
* [.BEHAVIOR_SET_USER_VISIBLE_HINT].
*
*
* Fragments will have [Fragment.setUserVisibleHint] called whenever the
* current Fragment changes.
*
* @param fm fragment manager that will interact with this adapter
*/
@Deprecated("""use {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} with
{@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT}""")
constructor(fm: FragmentManager) : this(fm, BEHAVIOR_SET_USER_VISIBLE_HINT)
/**
* @param position the position of the item you want
* @return the [Fragment] associated with a specified position
*/
abstract fun getItem(position: Int): Fragment
override fun startUpdate(container: ViewGroup) {
check(container.id != View.NO_ID) {
("ViewPager with adapter " + this
+ " requires a view id")
}
}
@Suppress("deprecation")
override fun instantiateItem(container: ViewGroup, position: Int): Any {
// If we already have this item instantiated, there is nothing
// to do. This can happen when we are restoring the entire pager
// from its saved state, where the fragment manager has already
// taken care of restoring the fragments we previously had instantiated.
if (mFragments.size > position) {
val f = mFragments[position]
if (f != null) {
return f
}
}
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction()
}
val fragment = getItem(position)
if (DEBUG) {
Log.v(TAG, "Adding item #$position: f=$fragment")
}
if (mSavedState.size > position) {
val fss = mSavedState[position]
if (fss != null) {
fragment.setInitialSavedState(fss)
}
}
while (mFragments.size <= position) {
mFragments.add(null)
}
fragment.setMenuVisibility(false)
if (mBehavior == BEHAVIOR_SET_USER_VISIBLE_HINT) {
fragment.setUserVisibleHint(false)
}
mFragments[position] = fragment
mCurTransaction!!.add(container.id, fragment)
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
mCurTransaction!!.setMaxLifecycle(fragment, Lifecycle.State.STARTED)
}
return fragment
}
override fun destroyItem(container: ViewGroup, position: Int,
`object`: Any) {
val fragment = `object` as Fragment
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction()
}
if (DEBUG) {
Log.v(TAG, "Removing item #" + position + ": f=" + `object`
+ " v=" + `object`.view)
}
while (mSavedState.size <= position) {
mSavedState.add(null)
}
mSavedState[position] = if (fragment.isAdded) mFragmentManager.saveFragmentInstanceState(fragment) else null
mFragments.set(position, null)
mCurTransaction!!.remove(fragment)
if (fragment == mCurrentPrimaryItem) {
mCurrentPrimaryItem = null
}
}
@Suppress("deprecation")
override fun setPrimaryItem(container: ViewGroup, position: Int,
`object`: Any) {
val fragment = `object` as Fragment
if (fragment !== mCurrentPrimaryItem) {
if (mCurrentPrimaryItem != null) {
mCurrentPrimaryItem!!.setMenuVisibility(false)
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction()
}
mCurTransaction!!.setMaxLifecycle(mCurrentPrimaryItem!!, Lifecycle.State.STARTED)
} else {
mCurrentPrimaryItem!!.setUserVisibleHint(false)
}
}
fragment.setMenuVisibility(true)
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
if (mCurTransaction == null) {
mCurTransaction = mFragmentManager.beginTransaction()
}
mCurTransaction!!.setMaxLifecycle(fragment, Lifecycle.State.RESUMED)
} else {
fragment.setUserVisibleHint(true)
}
mCurrentPrimaryItem = fragment
}
}
override fun finishUpdate(container: ViewGroup) {
if (mCurTransaction != null) {
// We drop any transactions that attempt to be committed
// from a re-entrant call to finishUpdate(). We need to
// do this as a workaround for Robolectric running measure/layout
// calls inline rather than allowing them to be posted
// as they would on a real device.
if (!mExecutingFinishUpdate) {
try {
mExecutingFinishUpdate = true
mCurTransaction!!.commitNowAllowingStateLoss()
} finally {
mExecutingFinishUpdate = false
}
}
mCurTransaction = null
}
}
override fun isViewFromObject(view: View, `object`: Any): Boolean {
return (`object` as Fragment).view === view
}
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
private val selectedFragment = "selected_fragment"
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
override fun saveState(): Parcelable? {
var state: Bundle? = null
if (!mSavedState.isEmpty()) {
state = Bundle()
state.putParcelableArrayList("states", mSavedState)
}
for (i in mFragments.indices) {
val f = mFragments[i]
if (f != null && f.isAdded) {
if (state == null) {
state = Bundle()
}
val key = "f$i"
mFragmentManager.putFragment(state, key, f)
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// Check if it's the same fragment instance
if (f === mCurrentPrimaryItem) {
state.putString(selectedFragment, key)
}
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
}
}
return state
}
override fun restoreState(state: Parcelable?, loader: ClassLoader?) {
if (state != null) {
val bundle = state as Bundle
bundle.classLoader = loader
val states = BundleCompat.getParcelableArrayList(bundle, "states",
Fragment.SavedState::class.java)
mSavedState.clear()
mFragments.clear()
if (states != null) {
mSavedState.addAll(states)
}
val keys: Iterable<String> = bundle.keySet()
for (key in keys) {
if (key.startsWith("f")) {
val index = key.substring(1).toInt()
val f = mFragmentManager.getFragment(bundle, key)
if (f != null) {
while (mFragments.size <= index) {
mFragments.add(null)
}
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
val wasSelected = (bundle.getString(selectedFragment, "")
== key)
f.setMenuVisibility(wasSelected)
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
mFragments[index] = f
} else {
Log.w(TAG, "Bad fragment at key $key")
}
}
}
}
}
companion object {
private const val TAG = "FragmentStatePagerAdapt"
private const val DEBUG = false
/**
* Indicates that [Fragment.setUserVisibleHint] will be called when the current
* fragment changes.
*
* @see .FragmentStatePagerAdapterMenuWorkaround
*/
@Deprecated("""This behavior relies on the deprecated
{@link Fragment#setUserVisibleHint(boolean)} API. Use
{@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} to switch to its replacement,
{@link FragmentTransaction#setMaxLifecycle}.
""")
val BEHAVIOR_SET_USER_VISIBLE_HINT = 0
/**
* Indicates that only the current fragment will be in the [Lifecycle.State.RESUMED]
* state. All other Fragments are capped at [Lifecycle.State.STARTED].
*
* @see .FragmentStatePagerAdapterMenuWorkaround
*/
const val BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT = 1
}
}

View File

@ -1,165 +0,0 @@
package com.google.android.material.appbar;
import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.OverScroller;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import org.schabi.newpipe.R;
import java.lang.reflect.Field;
import java.util.List;
// See https://stackoverflow.com/questions/56849221#57997489
public final class FlingBehavior extends AppBarLayout.Behavior {
private final Rect focusScrollRect = new Rect();
public FlingBehavior(final Context context, final AttributeSet attrs) {
super(context, attrs);
}
private boolean allowScroll = true;
private final Rect globalRect = new Rect();
private final List<Integer> skipInterceptionOfElements = List.of(
R.id.itemsListPanel, R.id.playbackSeekBar,
R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton);
@Override
public boolean onRequestChildRectangleOnScreen(
@NonNull final CoordinatorLayout coordinatorLayout, @NonNull final AppBarLayout child,
@NonNull final Rect rectangle, final boolean immediate) {
focusScrollRect.set(rectangle);
coordinatorLayout.offsetDescendantRectToMyCoords(child, focusScrollRect);
final int height = coordinatorLayout.getHeight();
if (focusScrollRect.top <= 0 && focusScrollRect.bottom >= height) {
// the child is too big to fit inside ourselves completely, ignore request
return false;
}
final int dy;
if (focusScrollRect.bottom > height) {
dy = focusScrollRect.top;
} else if (focusScrollRect.top < 0) {
// scrolling up
dy = -(height - focusScrollRect.bottom);
} else {
// nothing to do
return false;
}
final int consumed = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), 0);
return consumed == dy;
}
@Override
public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent,
@NonNull final AppBarLayout child,
@NonNull final MotionEvent ev) {
for (final int element : skipInterceptionOfElements) {
final View view = child.findViewById(element);
if (view != null) {
final boolean visible = view.getGlobalVisibleRect(globalRect);
if (visible && globalRect.contains((int) ev.getRawX(), (int) ev.getRawY())) {
allowScroll = false;
return false;
}
}
}
allowScroll = true;
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
// remove reference to old nested scrolling child
resetNestedScrollingChild();
// Stop fling when your finger touches the screen
stopAppBarLayoutFling();
break;
default:
break;
}
return super.onInterceptTouchEvent(parent, child, ev);
}
@Override
public boolean onStartNestedScroll(@NonNull final CoordinatorLayout parent,
@NonNull final AppBarLayout child,
@NonNull final View directTargetChild,
final View target,
final int nestedScrollAxes,
final int type) {
return allowScroll && super.onStartNestedScroll(
parent, child, directTargetChild, target, nestedScrollAxes, type);
}
@Override
public boolean onNestedFling(@NonNull final CoordinatorLayout coordinatorLayout,
@NonNull final AppBarLayout child,
@NonNull final View target, final float velocityX,
final float velocityY, final boolean consumed) {
return allowScroll && super.onNestedFling(
coordinatorLayout, child, target, velocityX, velocityY, consumed);
}
@Nullable
private OverScroller getScrollerField() {
try {
final Class<?> headerBehaviorType = this.getClass()
.getSuperclass().getSuperclass().getSuperclass();
if (headerBehaviorType != null) {
final Field field = headerBehaviorType.getDeclaredField("scroller");
field.setAccessible(true);
return ((OverScroller) field.get(this));
}
} catch (final NoSuchFieldException | IllegalAccessException e) {
// ?
}
return null;
}
@Nullable
private Field getLastNestedScrollingChildRefField() {
try {
final Class<?> headerBehaviorType = this.getClass().getSuperclass().getSuperclass();
if (headerBehaviorType != null) {
final Field field =
headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef");
field.setAccessible(true);
return field;
}
} catch (final NoSuchFieldException e) {
// ?
}
return null;
}
private void resetNestedScrollingChild() {
final Field field = getLastNestedScrollingChildRefField();
if (field != null) {
try {
final Object value = field.get(this);
if (value != null) {
field.set(this, null);
}
} catch (final IllegalAccessException e) {
// ?
}
}
}
private void stopAppBarLayoutFling() {
final OverScroller scroller = getScrollerField();
if (scroller != null) {
scroller.forceFinished(true);
}
}
}

View File

@ -0,0 +1,141 @@
package com.google.android.material.appbar
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.widget.OverScroller
import androidx.coordinatorlayout.widget.CoordinatorLayout
import org.schabi.newpipe.R
import java.lang.reflect.Field
import java.util.List
// See https://stackoverflow.com/questions/56849221#57997489
class FlingBehavior(context: Context?, attrs: AttributeSet?) : AppBarLayout.Behavior(context, attrs) {
private val focusScrollRect = Rect()
private var allowScroll = true
private val globalRect = Rect()
private val skipInterceptionOfElements = List.of(
R.id.itemsListPanel, R.id.playbackSeekBar,
R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton)
override fun onRequestChildRectangleOnScreen(
coordinatorLayout: CoordinatorLayout, child: AppBarLayout,
rectangle: Rect, immediate: Boolean): Boolean {
focusScrollRect.set(rectangle)
coordinatorLayout.offsetDescendantRectToMyCoords(child, focusScrollRect)
val height = coordinatorLayout.height
if (focusScrollRect.top <= 0 && focusScrollRect.bottom >= height) {
// the child is too big to fit inside ourselves completely, ignore request
return false
}
val dy: Int
dy = if (focusScrollRect.bottom > height) {
focusScrollRect.top
} else if (focusScrollRect.top < 0) {
// scrolling up
-(height - focusScrollRect.bottom)
} else {
// nothing to do
return false
}
val consumed = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), 0)
return consumed == dy
}
override fun onInterceptTouchEvent(parent: CoordinatorLayout,
child: AppBarLayout,
ev: MotionEvent): Boolean {
for (element in skipInterceptionOfElements) {
val view = child.findViewById<View>(element)
if (view != null) {
val visible = view.getGlobalVisibleRect(globalRect)
if (visible && globalRect.contains(ev.rawX.toInt(), ev.rawY.toInt())) {
allowScroll = false
return false
}
}
}
allowScroll = true
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// remove reference to old nested scrolling child
resetNestedScrollingChild()
// Stop fling when your finger touches the screen
stopAppBarLayoutFling()
}
else -> {}
}
return super.onInterceptTouchEvent(parent, child, ev)
}
override fun onStartNestedScroll(parent: CoordinatorLayout,
child: AppBarLayout,
directTargetChild: View,
target: View,
nestedScrollAxes: Int,
type: Int): Boolean {
return allowScroll && super.onStartNestedScroll(
parent, child, directTargetChild, target, nestedScrollAxes, type)
}
override fun onNestedFling(coordinatorLayout: CoordinatorLayout,
child: AppBarLayout,
target: View, velocityX: Float,
velocityY: Float, consumed: Boolean): Boolean {
return allowScroll && super.onNestedFling(
coordinatorLayout, child, target, velocityX, velocityY, consumed)
}
private val scrollerField: OverScroller?
private get() {
try {
val headerBehaviorType: Class<*>? = this.javaClass
.superclass.superclass.superclass
if (headerBehaviorType != null) {
val field = headerBehaviorType.getDeclaredField("scroller")
field.isAccessible = true
return field[this] as OverScroller
}
} catch (e: NoSuchFieldException) {
// ?
} catch (e: IllegalAccessException) {
}
return null
}
private val lastNestedScrollingChildRefField: Field?
private get() {
try {
val headerBehaviorType: Class<*>? = this.javaClass.superclass.superclass
if (headerBehaviorType != null) {
val field = headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef")
field.isAccessible = true
return field
}
} catch (e: NoSuchFieldException) {
// ?
}
return null
}
private fun resetNestedScrollingChild() {
val field = lastNestedScrollingChildRefField
if (field != null) {
try {
val value = field[this]
if (value != null) {
field[this] = null
}
} catch (e: IllegalAccessException) {
// ?
}
}
}
private fun stopAppBarLayoutFling() {
val scroller = scrollerField
scroller?.forceFinished(true)
}
}

View File

@ -14,51 +14,55 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.commons.text.similarity;
package org.apache.commons.text.similarity
import java.util.Locale;
import org.apache.commons.text.similarity.FuzzyScore
import java.util.Locale
/**
* A matching algorithm that is similar to the searching algorithms implemented in editors such
* as Sublime Text, TextMate, Atom and others.
*
* <p>
*
*
* One point is given for every matched character. Subsequent matches yield two bonus points.
* A higher score indicates a higher similarity.
* </p>
*
* <p>
*
*
*
* This code has been adapted from Apache Commons Lang 3.3.
* </p>
*
*
* @since 1.0
*
* Note: This class was forked from
* <a href="https://git.io/JyYJg">
* apache/commons-text (8cfdafc) FuzzyScore.java
* </a>
* [
* apache/commons-text (8cfdafc) FuzzyScore.java
](https://git.io/JyYJg) *
*/
public class FuzzyScore {
class FuzzyScore(locale: Locale?) {
/**
* Gets the locale.
*
* @return The locale
*/
/**
* Locale used to change the case of text.
*/
private final Locale locale;
val locale: Locale
/**
* This returns a {@link Locale}-specific {@link FuzzyScore}.
* This returns a [Locale]-specific [FuzzyScore].
*
* @param locale The string matching logic is case insensitive.
A {@link Locale} is necessary to normalize both Strings to lower case.
* A [Locale] is necessary to normalize both Strings to lower case.
* @throws IllegalArgumentException
* This is thrown if the {@link Locale} parameter is {@code null}.
* This is thrown if the [Locale] parameter is `null`.
*/
public FuzzyScore(final Locale locale) {
if (locale == null) {
throw new IllegalArgumentException("Locale must not be null");
}
this.locale = locale;
init {
requireNotNull(locale) { "Locale must not be null" }
this.locale = locale
}
/**
@ -76,73 +80,57 @@ public class FuzzyScore {
* score.fuzzyScore("Workshop", "ws") = 2
* score.fuzzyScore("Workshop", "wo") = 4
* score.fuzzyScore("Apache Software Foundation", "asf") = 3
* </pre>
</pre> *
*
* @param term a full term that should be matched against, must not be null
* @param query the query that will be matched against a term, must not be
* null
* null
* @return result score
* @throws IllegalArgumentException if the term or query is {@code null}
* @throws IllegalArgumentException if the term or query is `null`
*/
public Integer fuzzyScore(final CharSequence term, final CharSequence query) {
if (term == null || query == null) {
throw new IllegalArgumentException("CharSequences must not be null");
}
fun fuzzyScore(term: CharSequence?, query: CharSequence?): Int {
require(!(term == null || query == null)) { "CharSequences must not be null" }
// fuzzy logic is case insensitive. We normalize the Strings to lower
// case right from the start. Turning characters to lower case
// via Character.toLowerCase(char) is unfortunately insufficient
// as it does not accept a locale.
final String termLowerCase = term.toString().toLowerCase(locale);
final String queryLowerCase = query.toString().toLowerCase(locale);
val termLowerCase = term.toString().lowercase(locale)
val queryLowerCase = query.toString().lowercase(locale)
// the resulting score
int score = 0;
var score = 0
// the position in the term which will be scanned next for potential
// query character matches
int termIndex = 0;
var termIndex = 0
// index of the previously matched character in the term
int previousMatchingCharacterIndex = Integer.MIN_VALUE;
for (int queryIndex = 0; queryIndex < queryLowerCase.length(); queryIndex++) {
final char queryChar = queryLowerCase.charAt(queryIndex);
boolean termCharacterMatchFound = false;
for (; termIndex < termLowerCase.length()
&& !termCharacterMatchFound; termIndex++) {
final char termChar = termLowerCase.charAt(termIndex);
var previousMatchingCharacterIndex = Int.MIN_VALUE
for (queryIndex in 0 until queryLowerCase.length) {
val queryChar = queryLowerCase[queryIndex]
var termCharacterMatchFound = false
while (termIndex < termLowerCase.length
&& !termCharacterMatchFound) {
val termChar = termLowerCase[termIndex]
if (queryChar == termChar) {
// simple character matches result in one point
score++;
score++
// subsequent character matches further improve
// the score.
if (previousMatchingCharacterIndex + 1 == termIndex) {
score += 2;
score += 2
}
previousMatchingCharacterIndex = termIndex;
previousMatchingCharacterIndex = termIndex
// we can leave the nested loop. Every character in the
// query can match at most one character in the term.
termCharacterMatchFound = true;
termCharacterMatchFound = true
}
termIndex++
}
}
return score;
return score
}
/**
* Gets the locale.
*
* @return The locale
*/
public Locale getLocale() {
return locale;
}
}

View File

@ -1,269 +0,0 @@
package org.schabi.newpipe;
import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationChannelCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.preference.PreferenceManager;
import com.jakewharton.processphoenix.ProcessPhoenix;
import org.acra.ACRA;
import org.acra.config.CoreConfigurationBuilder;
import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.SocketException;
import java.util.List;
import java.util.Objects;
import io.reactivex.rxjava3.exceptions.CompositeException;
import io.reactivex.rxjava3.exceptions.MissingBackpressureException;
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
import io.reactivex.rxjava3.exceptions.UndeliverableException;
import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* App.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class App extends Application {
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
private static final String TAG = App.class.toString();
private boolean isFirstRun = false;
private static App app;
@NonNull
public static App getApp() {
return app;
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(base);
initACRA();
}
@Override
public void onCreate() {
super.onCreate();
app = this;
if (ProcessPhoenix.isPhoenixProcess(this)) {
Log.i(TAG, "This is a phoenix process! "
+ "Aborting initialization of App[onCreate]");
return;
}
// check if the last used preference version is set
// to determine whether this is the first app run
final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(this)
.getInt(getString(R.string.last_used_preferences_version), -1);
isFirstRun = lastUsedPrefVersion == -1;
// Initialize settings first because other initializations can use its values
NewPipeSettings.initSettings(this);
NewPipe.init(getDownloader(),
Localization.getPreferredLocalization(this),
Localization.getPreferredContentCountry(this));
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
StateSaver.init(this);
initNotificationChannels();
ServiceHelper.initServices(this);
// Initialize image loader
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
PicassoHelper.init(this);
ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this,
prefs.getString(getString(R.string.image_quality_key),
getString(R.string.image_quality_default))));
PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false));
configureRxJavaErrorHandler();
}
@Override
public void onTerminate() {
super.onTerminate();
PicassoHelper.terminate();
}
protected Downloader getDownloader() {
final DownloaderImpl downloader = DownloaderImpl.init(null);
setCookiesToDownloader(downloader);
return downloader;
}
protected void setCookiesToDownloader(final DownloaderImpl downloader) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
getApplicationContext());
final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key);
downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null));
downloader.updateYoutubeRestrictedModeCookies(getApplicationContext());
}
private void configureRxJavaErrorHandler() {
// https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling
RxJavaPlugins.setErrorHandler(new Consumer<Throwable>() {
@Override
public void accept(@NonNull final Throwable throwable) {
Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : "
+ "throwable = [" + throwable.getClass().getName() + "]");
final Throwable actualThrowable;
if (throwable instanceof UndeliverableException) {
// As UndeliverableException is a wrapper,
// get the cause of it to get the "real" exception
actualThrowable = Objects.requireNonNull(throwable.getCause());
} else {
actualThrowable = throwable;
}
final List<Throwable> errors;
if (actualThrowable instanceof CompositeException) {
errors = ((CompositeException) actualThrowable).getExceptions();
} else {
errors = List.of(actualThrowable);
}
for (final Throwable error : errors) {
if (isThrowableIgnored(error)) {
return;
}
if (isThrowableCritical(error)) {
reportException(error);
return;
}
}
// Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
// When exception is not reported, log it
if (isDisposedRxExceptionsReported()) {
reportException(actualThrowable);
} else {
Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable);
}
}
private boolean isThrowableIgnored(@NonNull final Throwable throwable) {
// Don't crash the application over a simple network problem
return ExceptionUtils.hasAssignableCause(throwable,
// network api cancellation
IOException.class, SocketException.class,
// blocking code disposed
InterruptedException.class, InterruptedIOException.class);
}
private boolean isThrowableCritical(@NonNull final Throwable throwable) {
// Though these exceptions cannot be ignored
return ExceptionUtils.hasAssignableCause(throwable,
NullPointerException.class, IllegalArgumentException.class, // bug in app
OnErrorNotImplementedException.class, MissingBackpressureException.class,
IllegalStateException.class); // bug in operator
}
private void reportException(@NonNull final Throwable throwable) {
// Throw uncaught exception that will trigger the report system
Thread.currentThread().getUncaughtExceptionHandler()
.uncaughtException(Thread.currentThread(), throwable);
}
});
}
/**
* Called in {@link #attachBaseContext(Context)} after calling the {@code super} method.
* Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA.
*/
protected void initACRA() {
if (ACRA.isACRASenderServiceProcess()) {
return;
}
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder()
.withBuildConfigClass(BuildConfig.class);
ACRA.init(this, acraConfig);
}
private void initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels
final List<NotificationChannelCompat> notificationChannelCompats = List.of(
new NotificationChannelCompat.Builder(getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
.build(),
new NotificationChannelCompat
.Builder(getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.app_update_notification_channel_name))
.setDescription(
getString(R.string.app_update_notification_channel_description))
.build(),
new NotificationChannelCompat.Builder(getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
.build(),
new NotificationChannelCompat.Builder(getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
.build(),
new NotificationChannelCompat
.Builder(getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(getString(R.string.streams_notification_channel_name))
.setDescription(
getString(R.string.streams_notification_channel_description))
.build()
);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
}
protected boolean isDisposedRxExceptionsReported() {
return false;
}
public boolean isFirstRun() {
return isFirstRun;
}
}

View File

@ -0,0 +1,240 @@
package org.schabi.newpipe
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import androidx.preference.PreferenceManager
import com.jakewharton.processphoenix.ProcessPhoenix
import io.reactivex.rxjava3.exceptions.CompositeException
import io.reactivex.rxjava3.exceptions.MissingBackpressureException
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException
import io.reactivex.rxjava3.exceptions.UndeliverableException
import io.reactivex.rxjava3.functions.Consumer
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.acra.ACRA.init
import org.acra.ACRA.isACRASenderServiceProcess
import org.acra.config.CoreConfigurationBuilder
import org.schabi.newpipe.error.ReCaptchaActivity
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.ktx.hasAssignableCause
import org.schabi.newpipe.settings.NewPipeSettings
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.StateSaver
import org.schabi.newpipe.util.image.ImageStrategy
import org.schabi.newpipe.util.image.PicassoHelper
import org.schabi.newpipe.util.image.PreferredImageQuality
import java.io.IOException
import java.io.InterruptedIOException
import java.net.SocketException
import java.util.Objects
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* App.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
open class App() : Application() {
private var isFirstRun: Boolean = false
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
initACRA()
}
public override fun onCreate() {
super.onCreate()
app = this
if (ProcessPhoenix.isPhoenixProcess(this)) {
Log.i(TAG, ("This is a phoenix process! "
+ "Aborting initialization of App[onCreate]"))
return
}
// check if the last used preference version is set
// to determine whether this is the first app run
val lastUsedPrefVersion: Int = PreferenceManager.getDefaultSharedPreferences(this)
.getInt(getString(R.string.last_used_preferences_version), -1)
isFirstRun = lastUsedPrefVersion == -1
// Initialize settings first because other initializations can use its values
NewPipeSettings.initSettings(this)
NewPipe.init(getDownloader(),
Localization.getPreferredLocalization(this),
Localization.getPreferredContentCountry(this))
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()))
StateSaver.init(this)
initNotificationChannels()
ServiceHelper.initServices(this)
// Initialize image loader
val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
PicassoHelper.init(this)
ImageStrategy.setPreferredImageQuality(PreferredImageQuality.Companion.fromPreferenceKey(this,
prefs.getString(getString(R.string.image_quality_key),
getString(R.string.image_quality_default))))
PicassoHelper.setIndicatorsEnabled((MainActivity.Companion.DEBUG
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false)))
configureRxJavaErrorHandler()
}
public override fun onTerminate() {
super.onTerminate()
PicassoHelper.terminate()
}
protected open fun getDownloader(): Downloader? {
val downloader: DownloaderImpl? = DownloaderImpl.Companion.init(null)
setCookiesToDownloader(downloader)
return downloader
}
protected fun setCookiesToDownloader(downloader: DownloaderImpl?) {
val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(
getApplicationContext())
val key: String = getApplicationContext().getString(R.string.recaptcha_cookies_key)
downloader!!.setCookie(ReCaptchaActivity.Companion.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null))
downloader.updateYoutubeRestrictedModeCookies(getApplicationContext())
}
private fun configureRxJavaErrorHandler() {
// https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling
RxJavaPlugins.setErrorHandler(object : Consumer<Throwable> {
public override fun accept(throwable: Throwable) {
Log.e(TAG, ("RxJavaPlugins.ErrorHandler called with -> : "
+ "throwable = [" + throwable.javaClass.getName() + "]"))
val actualThrowable: Throwable
if (throwable is UndeliverableException) {
// As UndeliverableException is a wrapper,
// get the cause of it to get the "real" exception
actualThrowable = Objects.requireNonNull(throwable.cause)
} else {
actualThrowable = throwable
}
val errors: List<Throwable>
if (actualThrowable is CompositeException) {
errors = actualThrowable.getExceptions()
} else {
errors = java.util.List.of(actualThrowable)
}
for (error: Throwable in errors) {
if (isThrowableIgnored(error)) {
return
}
if (isThrowableCritical(error)) {
reportException(error)
return
}
}
// Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
// When exception is not reported, log it
if (isDisposedRxExceptionsReported()) {
reportException(actualThrowable)
} else {
Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable)
}
}
private fun isThrowableIgnored(throwable: Throwable): Boolean {
// Don't crash the application over a simple network problem
return throwable.hasAssignableCause( // network api cancellation
IOException::class.java, SocketException::class.java, // blocking code disposed
InterruptedException::class.java, InterruptedIOException::class.java)
}
private fun isThrowableCritical(throwable: Throwable): Boolean {
// Though these exceptions cannot be ignored
return throwable.hasAssignableCause(NullPointerException::class.java, IllegalArgumentException::class.java, // bug in app
OnErrorNotImplementedException::class.java, MissingBackpressureException::class.java, IllegalStateException::class.java) // bug in operator
}
private fun reportException(throwable: Throwable) {
// Throw uncaught exception that will trigger the report system
Thread.currentThread().getUncaughtExceptionHandler()
.uncaughtException(Thread.currentThread(), throwable)
}
})
}
/**
* Called in [.attachBaseContext] after calling the `super` method.
* Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA.
*/
protected fun initACRA() {
if (isACRASenderServiceProcess()) {
return
}
val acraConfig: CoreConfigurationBuilder = CoreConfigurationBuilder()
.withBuildConfigClass(BuildConfig::class.java)
init(this, acraConfig)
}
private fun initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels
val notificationChannelCompats: List<NotificationChannelCompat> = java.util.List.of(
NotificationChannelCompat.Builder(getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
.build(),
NotificationChannelCompat.Builder(getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.app_update_notification_channel_name))
.setDescription(
getString(R.string.app_update_notification_channel_description))
.build(),
NotificationChannelCompat.Builder(getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
.build(),
NotificationChannelCompat.Builder(getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
.build(),
NotificationChannelCompat.Builder(getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(getString(R.string.streams_notification_channel_name))
.setDescription(
getString(R.string.streams_notification_channel_description))
.build()
)
val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannelsCompat(notificationChannelCompats)
}
protected open fun isDisposedRxExceptionsReported(): Boolean {
return false
}
fun isFirstRun(): Boolean {
return isFirstRun
}
companion object {
val PACKAGE_NAME: String = BuildConfig.APPLICATION_ID
private val TAG: String = App::class.java.toString()
private var app: App? = null
fun getApp(): App {
return (app)!!
}
}
}

View File

@ -1,139 +0,0 @@
package org.schabi.newpipe;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import icepick.Icepick;
import icepick.State;
public abstract class BaseFragment extends Fragment {
protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode());
protected static final boolean DEBUG = MainActivity.DEBUG;
protected AppCompatActivity activity;
//These values are used for controlling fragments when they are part of the frontpage
@State
protected boolean useAsFrontPage = false;
public void useAsFrontPage(final boolean value) {
useAsFrontPage = value;
}
/*//////////////////////////////////////////////////////////////////////////
// Fragment's Lifecycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onAttach(@NonNull final Context context) {
super.onAttach(context);
activity = (AppCompatActivity) context;
}
@Override
public void onDetach() {
super.onDetach();
activity = null;
}
@Override
public void onCreate(final Bundle savedInstanceState) {
if (DEBUG) {
Log.d(TAG, "onCreate() called with: "
+ "savedInstanceState = [" + savedInstanceState + "]");
}
super.onCreate(savedInstanceState);
Icepick.restoreInstanceState(this, savedInstanceState);
if (savedInstanceState != null) {
onRestoreInstanceState(savedInstanceState);
}
}
@Override
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState);
if (DEBUG) {
Log.d(TAG, "onViewCreated() called with: "
+ "rootView = [" + rootView + "], "
+ "savedInstanceState = [" + savedInstanceState + "]");
}
initViews(rootView, savedInstanceState);
initListeners();
}
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
}
protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
}
/*//////////////////////////////////////////////////////////////////////////
// Init
//////////////////////////////////////////////////////////////////////////*/
/**
* This method is called in {@link #onViewCreated(View, Bundle)} to initialize the views.
*
* <p>
* {@link #initListeners()} is called after this method to initialize the corresponding
* listeners.
* </p>
* @param rootView The inflated view for this fragment
* (provided by {@link #onViewCreated(View, Bundle)})
* @param savedInstanceState The saved state of this fragment
* (provided by {@link #onViewCreated(View, Bundle)})
*/
protected void initViews(final View rootView, final Bundle savedInstanceState) {
}
/**
* Initialize the listeners for this fragment.
*
* <p>
* This method is called after {@link #initViews(View, Bundle)}
* in {@link #onViewCreated(View, Bundle)}.
* </p>
*/
protected void initListeners() {
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
public void setTitle(final String title) {
if (DEBUG) {
Log.d(TAG, "setTitle() called with: title = [" + title + "]");
}
if (!useAsFrontPage && activity != null && activity.getSupportActionBar() != null) {
activity.getSupportActionBar().setDisplayShowTitleEnabled(true);
activity.getSupportActionBar().setTitle(title);
}
}
/**
* Finds the root fragment by looping through all of the parent fragments. The root fragment
* is supposed to be {@link org.schabi.newpipe.fragments.MainFragment}, and is the fragment that
* handles keeping the backstack of opened fragments in NewPipe, and also the player bottom
* sheet. This function therefore returns the fragment manager of said fragment.
*
* @return the fragment manager of the root fragment, i.e.
* {@link org.schabi.newpipe.fragments.MainFragment}
*/
protected FragmentManager getFM() {
Fragment current = this;
while (current.getParentFragment() != null) {
current = current.getParentFragment();
}
return current.getFragmentManager();
}
}

View File

@ -0,0 +1,129 @@
package org.schabi.newpipe
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import icepick.Icepick
import icepick.State
abstract class BaseFragment() : Fragment() {
protected val TAG: String = javaClass.getSimpleName() + "@" + Integer.toHexString(hashCode())
protected var activity: AppCompatActivity? = null
//These values are used for controlling fragments when they are part of the frontpage
@State
protected var useAsFrontPage: Boolean = false
fun useAsFrontPage(value: Boolean) {
useAsFrontPage = value
}
/*//////////////////////////////////////////////////////////////////////////
// Fragment's Lifecycle
////////////////////////////////////////////////////////////////////////// */
public override fun onAttach(context: Context) {
super.onAttach(context)
activity = context as AppCompatActivity?
}
public override fun onDetach() {
super.onDetach()
activity = null
}
public override fun onCreate(savedInstanceState: Bundle?) {
if (DEBUG) {
Log.d(TAG, ("onCreate() called with: "
+ "savedInstanceState = [" + savedInstanceState + "]"))
}
super.onCreate(savedInstanceState)
Icepick.restoreInstanceState(this, savedInstanceState)
if (savedInstanceState != null) {
onRestoreInstanceState(savedInstanceState)
}
}
public override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
super.onViewCreated(rootView, savedInstanceState)
if (DEBUG) {
Log.d(TAG, ("onViewCreated() called with: "
+ "rootView = [" + rootView + "], "
+ "savedInstanceState = [" + savedInstanceState + "]"))
}
initViews(rootView, savedInstanceState)
initListeners()
}
public override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Icepick.saveInstanceState(this, outState)
}
protected open fun onRestoreInstanceState(savedInstanceState: Bundle) {}
/*//////////////////////////////////////////////////////////////////////////
// Init
////////////////////////////////////////////////////////////////////////// */
/**
* This method is called in [.onViewCreated] to initialize the views.
*
*
*
* [.initListeners] is called after this method to initialize the corresponding
* listeners.
*
* @param rootView The inflated view for this fragment
* (provided by [.onViewCreated])
* @param savedInstanceState The saved state of this fragment
* (provided by [.onViewCreated])
*/
protected open fun initViews(rootView: View, savedInstanceState: Bundle?) {}
/**
* Initialize the listeners for this fragment.
*
*
*
* This method is called after [.initViews]
* in [.onViewCreated].
*
*/
protected open fun initListeners() {}
/*//////////////////////////////////////////////////////////////////////////
// Utils
////////////////////////////////////////////////////////////////////////// */
open fun setTitle(title: String?) {
if (DEBUG) {
Log.d(TAG, "setTitle() called with: title = [" + title + "]")
}
if (!useAsFrontPage && (activity != null) && (activity!!.getSupportActionBar() != null)) {
activity!!.getSupportActionBar()!!.setDisplayShowTitleEnabled(true)
activity!!.getSupportActionBar()!!.setTitle(title)
}
}
protected val fM: FragmentManager?
/**
* Finds the root fragment by looping through all of the parent fragments. The root fragment
* is supposed to be [org.schabi.newpipe.fragments.MainFragment], and is the fragment that
* handles keeping the backstack of opened fragments in NewPipe, and also the player bottom
* sheet. This function therefore returns the fragment manager of said fragment.
*
* @return the fragment manager of the root fragment, i.e.
* [org.schabi.newpipe.fragments.MainFragment]
*/
protected get() {
var current: Fragment? = this
while (current!!.getParentFragment() != null) {
current = current.getParentFragment()
}
return current.getFragmentManager()
}
companion object {
protected val DEBUG: Boolean = MainActivity.Companion.DEBUG
}
}

View File

@ -1,182 +0,0 @@
package org.schabi.newpipe;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.extractor.downloader.Request;
import org.schabi.newpipe.extractor.downloader.Response;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.util.InfoCache;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import okhttp3.OkHttpClient;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
public final class DownloaderImpl extends Downloader {
public static final String USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY =
"youtube_restricted_mode_key";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
public static final String YOUTUBE_DOMAIN = "youtube.com";
private static DownloaderImpl instance;
private final Map<String, String> mCookies;
private final OkHttpClient client;
private DownloaderImpl(final OkHttpClient.Builder builder) {
this.client = builder
.readTimeout(30, TimeUnit.SECONDS)
// .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"),
// 16 * 1024 * 1024))
.build();
this.mCookies = new HashMap<>();
}
/**
* It's recommended to call exactly once in the entire lifetime of the application.
*
* @param builder if null, default builder will be used
* @return a new instance of {@link DownloaderImpl}
*/
public static DownloaderImpl init(@Nullable final OkHttpClient.Builder builder) {
instance = new DownloaderImpl(
builder != null ? builder : new OkHttpClient.Builder());
return instance;
}
public static DownloaderImpl getInstance() {
return instance;
}
public String getCookies(final String url) {
final String youtubeCookie = url.contains(YOUTUBE_DOMAIN)
? getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) : null;
// Recaptcha cookie is always added TODO: not sure if this is necessary
return Stream.of(youtubeCookie, getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY))
.filter(Objects::nonNull)
.flatMap(cookies -> Arrays.stream(cookies.split("; *")))
.distinct()
.collect(Collectors.joining("; "));
}
public String getCookie(final String key) {
return mCookies.get(key);
}
public void setCookie(final String key, final String cookie) {
mCookies.put(key, cookie);
}
public void removeCookie(final String key) {
mCookies.remove(key);
}
public void updateYoutubeRestrictedModeCookies(final Context context) {
final String restrictedModeEnabledKey =
context.getString(R.string.youtube_restricted_mode_enabled);
final boolean restrictedModeEnabled = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(restrictedModeEnabledKey, false);
updateYoutubeRestrictedModeCookies(restrictedModeEnabled);
}
public void updateYoutubeRestrictedModeCookies(final boolean youtubeRestrictedModeEnabled) {
if (youtubeRestrictedModeEnabled) {
setCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY,
YOUTUBE_RESTRICTED_MODE_COOKIE);
} else {
removeCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY);
}
InfoCache.getInstance().clearCache();
}
/**
* Get the size of the content that the url is pointing by firing a HEAD request.
*
* @param url an url pointing to the content
* @return the size of the content, in bytes
*/
public long getContentLength(final String url) throws IOException {
try {
final Response response = head(url);
return Long.parseLong(response.getHeader("Content-Length"));
} catch (final NumberFormatException e) {
throw new IOException("Invalid content length", e);
} catch (final ReCaptchaException e) {
throw new IOException(e);
}
}
@Override
public Response execute(@NonNull final Request request)
throws IOException, ReCaptchaException {
final String httpMethod = request.httpMethod();
final String url = request.url();
final Map<String, List<String>> headers = request.headers();
final byte[] dataToSend = request.dataToSend();
RequestBody requestBody = null;
if (dataToSend != null) {
requestBody = RequestBody.create(dataToSend);
}
final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder()
.method(httpMethod, requestBody).url(url)
.addHeader("User-Agent", USER_AGENT);
final String cookies = getCookies(url);
if (!cookies.isEmpty()) {
requestBuilder.addHeader("Cookie", cookies);
}
for (final Map.Entry<String, List<String>> pair : headers.entrySet()) {
final String headerName = pair.getKey();
final List<String> headerValueList = pair.getValue();
if (headerValueList.size() > 1) {
requestBuilder.removeHeader(headerName);
for (final String headerValue : headerValueList) {
requestBuilder.addHeader(headerName, headerValue);
}
} else if (headerValueList.size() == 1) {
requestBuilder.header(headerName, headerValueList.get(0));
}
}
final okhttp3.Response response = client.newCall(requestBuilder.build()).execute();
if (response.code() == 429) {
response.close();
throw new ReCaptchaException("reCaptcha Challenge requested", url);
}
final ResponseBody body = response.body();
String responseBodyToReturn = null;
if (body != null) {
responseBodyToReturn = body.string();
}
final String latestUrl = response.request().url().toString();
return new Response(response.code(), response.message(), response.headers().toMultimap(),
responseBodyToReturn, latestUrl);
}
}

View File

@ -0,0 +1,170 @@
package org.schabi.newpipe
import android.content.Context
import androidx.preference.PreferenceManager
import androidx.room.RoomDatabase.Builder.build
import okhttp3.OkHttpClient
import okhttp3.OkHttpClient.Builder.build
import okhttp3.OkHttpClient.Builder.readTimeout
import okhttp3.Request.Builder.addHeader
import okhttp3.Request.Builder.build
import okhttp3.Request.Builder.header
import okhttp3.Request.Builder.method
import okhttp3.Request.Builder.removeHeader
import okhttp3.Request.Builder.url
import okhttp3.RequestBody
import okhttp3.ResponseBody
import org.schabi.newpipe.database.stream.model.StreamEntity.url
import org.schabi.newpipe.error.ReCaptchaActivity
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.util.InfoCache
import java.io.IOException
import java.util.Arrays
import java.util.Objects
import java.util.concurrent.TimeUnit
import java.util.function.Function
import java.util.function.Predicate
import java.util.stream.Collectors
import java.util.stream.Stream
class DownloaderImpl private constructor(builder: Builder) : Downloader() {
private val mCookies: MutableMap<String, String?>
private val client: OkHttpClient
init {
client = builder
.readTimeout(30, TimeUnit.SECONDS) // .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"),
// 16 * 1024 * 1024))
.build()
mCookies = HashMap()
}
fun getCookies(url: String): String {
val youtubeCookie: String? = if (url.contains(YOUTUBE_DOMAIN)) getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) else null
// Recaptcha cookie is always added TODO: not sure if this is necessary
return Stream.of<String?>(youtubeCookie, getCookie(ReCaptchaActivity.Companion.RECAPTCHA_COOKIES_KEY))
.filter(Predicate<String?>({ obj: String? -> Objects.nonNull(obj) }))
.flatMap<String?>(Function<String?, Stream<out String?>>({ cookies: String? -> Arrays.stream<String?>(cookies!!.split("; *".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()) }))
.distinct()
.collect(Collectors.joining("; "))
}
fun getCookie(key: String): String? {
return mCookies.get(key)
}
fun setCookie(key: String, cookie: String?) {
mCookies.put(key, cookie)
}
fun removeCookie(key: String) {
mCookies.remove(key)
}
fun updateYoutubeRestrictedModeCookies(context: Context) {
val restrictedModeEnabledKey: String = context.getString(R.string.youtube_restricted_mode_enabled)
val restrictedModeEnabled: Boolean = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(restrictedModeEnabledKey, false)
updateYoutubeRestrictedModeCookies(restrictedModeEnabled)
}
fun updateYoutubeRestrictedModeCookies(youtubeRestrictedModeEnabled: Boolean) {
if (youtubeRestrictedModeEnabled) {
setCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY,
YOUTUBE_RESTRICTED_MODE_COOKIE)
} else {
removeCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY)
}
InfoCache.Companion.getInstance().clearCache()
}
/**
* Get the size of the content that the url is pointing by firing a HEAD request.
*
* @param url an url pointing to the content
* @return the size of the content, in bytes
*/
@Throws(IOException::class)
fun getContentLength(url: String?): Long {
try {
val response: Response = head(url)
return response.getHeader("Content-Length")!!.toLong()
} catch (e: NumberFormatException) {
throw IOException("Invalid content length", e)
} catch (e: ReCaptchaException) {
throw IOException(e)
}
}
@Throws(IOException::class, ReCaptchaException::class)
public override fun execute(request: Request): Response {
val httpMethod: String = request.httpMethod()
val url: String = request.url()
val headers: Map<String, List<String>> = request.headers()
val dataToSend: ByteArray? = request.dataToSend()
var requestBody: RequestBody? = null
if (dataToSend != null) {
requestBody = RequestBody.create(dataToSend)
}
val requestBuilder: Builder = Builder()
.method(httpMethod, requestBody).url(url)
.addHeader("User-Agent", USER_AGENT)
val cookies: String = getCookies(url)
if (!cookies.isEmpty()) {
requestBuilder.addHeader("Cookie", cookies)
}
for (pair: Map.Entry<String, List<String>> in headers.entries) {
val headerName: String = pair.key
val headerValueList: List<String> = pair.value
if (headerValueList.size > 1) {
requestBuilder.removeHeader(headerName)
for (headerValue: String? in headerValueList) {
requestBuilder.addHeader(headerName, headerValue)
}
} else if (headerValueList.size == 1) {
requestBuilder.header(headerName, headerValueList.get(0))
}
}
val response: okhttp3.Response = client.newCall(requestBuilder.build()).execute()
if (response.code() == 429) {
response.close()
throw ReCaptchaException("reCaptcha Challenge requested", url)
}
val body: ResponseBody? = response.body()
var responseBodyToReturn: String? = null
if (body != null) {
responseBodyToReturn = body.string()
}
val latestUrl: String = response.request().url().toString()
return Response(response.code(), response.message(), response.headers().toMultimap(),
responseBodyToReturn, latestUrl)
}
companion object {
val USER_AGENT: String = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
val YOUTUBE_RESTRICTED_MODE_COOKIE_KEY: String = "youtube_restricted_mode_key"
val YOUTUBE_RESTRICTED_MODE_COOKIE: String = "PREF=f2=8000000"
val YOUTUBE_DOMAIN: String = "youtube.com"
private var instance: DownloaderImpl? = null
/**
* It's recommended to call exactly once in the entire lifetime of the application.
*
* @param builder if null, default builder will be used
* @return a new instance of [DownloaderImpl]
*/
fun init(builder: Builder?): DownloaderImpl? {
instance = DownloaderImpl(
if (builder != null) builder else Builder())
return instance
}
fun getInstance(): DownloaderImpl? {
return instance
}
}
}

View File

@ -1,50 +0,0 @@
package org.schabi.newpipe;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import org.schabi.newpipe.util.NavigationHelper;
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* ExitActivity.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class ExitActivity extends Activity {
public static void exitAndRemoveFromRecentApps(final Activity activity) {
final Intent intent = new Intent(activity, ExitActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
| Intent.FLAG_ACTIVITY_CLEAR_TASK
| Intent.FLAG_ACTIVITY_NO_ANIMATION);
activity.startActivity(intent);
}
@SuppressLint("NewApi")
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
finishAndRemoveTask();
NavigationHelper.restartApp(this);
}
}

View File

@ -0,0 +1,44 @@
package org.schabi.newpipe
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import org.schabi.newpipe.util.NavigationHelper
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* ExitActivity.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
class ExitActivity() : Activity() {
@SuppressLint("NewApi")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
finishAndRemoveTask()
NavigationHelper.restartApp(this)
}
companion object {
fun exitAndRemoveFromRecentApps(activity: Activity) {
val intent: Intent = Intent(activity, ExitActivity::class.java)
intent.addFlags((Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
or Intent.FLAG_ACTIVITY_CLEAR_TASK
or Intent.FLAG_ACTIVITY_NO_ANIMATION))
activity.startActivity(intent)
}
}
}

View File

@ -1,927 +0,0 @@
/*
* Created by Christian Schabesberger on 02.08.16.
* <p>
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
* DownloadActivity.java is part of NewPipe.
* <p>
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.FrameLayout;
import android.widget.Spinner;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentContainerView;
import androidx.fragment.app.FragmentManager;
import androidx.preference.PreferenceManager;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import org.schabi.newpipe.databinding.ActivityMainBinding;
import org.schabi.newpipe.databinding.DrawerHeaderBinding;
import org.schabi.newpipe.databinding.DrawerLayoutBinding;
import org.schabi.newpipe.databinding.InstanceSpinnerLayoutBinding;
import org.schabi.newpipe.databinding.ToolbarLayoutBinding;
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.services.peertube.PeertubeInstance;
import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment;
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.settings.UpdateSettingsFragment;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.KioskTranslator;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PeertubeHelper;
import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.ReleaseVersionUtil;
import org.schabi.newpipe.util.SerializedCache;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.FocusOverlayView;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
@SuppressWarnings("ConstantConditions")
public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release");
private ActivityMainBinding mainBinding;
private DrawerHeaderBinding drawerHeaderBinding;
private DrawerLayoutBinding drawerLayoutBinding;
private ToolbarLayoutBinding toolbarLayoutBinding;
private ActionBarDrawerToggle toggle;
private boolean servicesShown = false;
private BroadcastReceiver broadcastReceiver;
private static final int ITEM_ID_SUBSCRIPTIONS = -1;
private static final int ITEM_ID_FEED = -2;
private static final int ITEM_ID_BOOKMARKS = -3;
private static final int ITEM_ID_DOWNLOADS = -4;
private static final int ITEM_ID_HISTORY = -5;
private static final int ITEM_ID_SETTINGS = 0;
private static final int ITEM_ID_ABOUT = 1;
private static final int ORDER = 0;
/*//////////////////////////////////////////////////////////////////////////
// Activity's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
protected void onCreate(final Bundle savedInstanceState) {
if (DEBUG) {
Log.d(TAG, "onCreate() called with: "
+ "savedInstanceState = [" + savedInstanceState + "]");
}
ThemeHelper.setDayNightMode(this);
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceState);
mainBinding = ActivityMainBinding.inflate(getLayoutInflater());
drawerLayoutBinding = mainBinding.drawerLayout;
drawerHeaderBinding = DrawerHeaderBinding.bind(drawerLayoutBinding.navigation
.getHeaderView(0));
toolbarLayoutBinding = mainBinding.toolbarLayout;
setContentView(mainBinding.getRoot());
if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
initFragments();
}
setSupportActionBar(toolbarLayoutBinding.toolbar);
try {
setupDrawer();
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
}
if (DeviceUtils.isTv(this)) {
FocusOverlayView.setupFocusObserver(this);
}
openMiniPlayerUponPlayerStarted();
if (PermissionHelper.checkPostNotificationsPermission(this,
PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE)) {
// Schedule worker for checking for new streams and creating corresponding notifications
// if this is enabled by the user.
NotificationWorker.initialize(this);
}
if (!UpdateSettingsFragment.wasUserAskedForConsent(this)
&& !App.getApp().isFirstRun()
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
}
}
@Override
protected void onPostCreate(final Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
final App app = App.getApp();
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
if (prefs.getBoolean(app.getString(R.string.update_app_key), false)
&& prefs.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
// Start the worker which is checking all conditions
// and eventually searching for a new version.
NewVersionWorker.enqueueNewVersionCheckingWork(app, false);
}
}
private void setupDrawer() throws ExtractionException {
addDrawerMenuForCurrentService();
toggle = new ActionBarDrawerToggle(this, mainBinding.getRoot(),
toolbarLayoutBinding.toolbar, R.string.drawer_open, R.string.drawer_close);
toggle.syncState();
mainBinding.getRoot().addDrawerListener(toggle);
mainBinding.getRoot().addDrawerListener(new DrawerLayout.SimpleDrawerListener() {
private int lastService;
@Override
public void onDrawerOpened(final View drawerView) {
lastService = ServiceHelper.getSelectedServiceId(MainActivity.this);
}
@Override
public void onDrawerClosed(final View drawerView) {
if (servicesShown) {
toggleServices();
}
if (lastService != ServiceHelper.getSelectedServiceId(MainActivity.this)) {
ActivityCompat.recreate(MainActivity.this);
}
}
});
drawerLayoutBinding.navigation.setNavigationItemSelectedListener(this::drawerItemSelected);
setupDrawerHeader();
}
/**
* Builds the drawer menu for the current service.
*
* @throws ExtractionException if the service didn't provide available kiosks
*/
private void addDrawerMenuForCurrentService() throws ExtractionException {
//Tabs
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
final StreamingService service = NewPipe.getService(currentServiceId);
int kioskMenuItemId = 0;
for (final String ks : service.getKioskList().getAvailableKiosks()) {
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator
.getTranslatedKioskName(ks, this))
.setIcon(KioskTranslator.getKioskIcon(ks));
kioskMenuItemId++;
}
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER,
R.string.tab_subscriptions)
.setIcon(R.drawable.ic_tv);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
.setIcon(R.drawable.ic_subscriptions);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
.setIcon(R.drawable.ic_bookmark);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_DOWNLOADS, ORDER, R.string.downloads)
.setIcon(R.drawable.ic_file_download);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history)
.setIcon(R.drawable.ic_history);
//Settings and About
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings)
.setIcon(R.drawable.ic_settings);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about)
.setIcon(R.drawable.ic_info_outline);
}
private boolean drawerItemSelected(final MenuItem item) {
switch (item.getGroupId()) {
case R.id.menu_services_group:
changeService(item);
break;
case R.id.menu_tabs_group:
try {
tabSelected(item);
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Selecting main page tab", e);
}
break;
case R.id.menu_options_about_group:
optionsAboutSelected(item);
break;
default:
return false;
}
mainBinding.getRoot().closeDrawers();
return true;
}
private void changeService(final MenuItem item) {
drawerLayoutBinding.navigation.getMenu()
.getItem(ServiceHelper.getSelectedServiceId(this))
.setChecked(false);
ServiceHelper.setSelectedServiceId(this, item.getItemId());
drawerLayoutBinding.navigation.getMenu()
.getItem(ServiceHelper.getSelectedServiceId(this))
.setChecked(true);
}
private void tabSelected(final MenuItem item) throws ExtractionException {
switch (item.getItemId()) {
case ITEM_ID_SUBSCRIPTIONS:
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
break;
case ITEM_ID_FEED:
NavigationHelper.openFeedFragment(getSupportFragmentManager());
break;
case ITEM_ID_BOOKMARKS:
NavigationHelper.openBookmarksFragment(getSupportFragmentManager());
break;
case ITEM_ID_DOWNLOADS:
NavigationHelper.openDownloads(this);
break;
case ITEM_ID_HISTORY:
NavigationHelper.openStatisticFragment(getSupportFragmentManager());
break;
default:
final StreamingService currentService = ServiceHelper.getSelectedService(this);
int kioskMenuItemId = 0;
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
if (kioskMenuItemId == item.getItemId()) {
NavigationHelper.openKioskFragment(getSupportFragmentManager(),
currentService.getServiceId(), kioskId);
break;
}
kioskMenuItemId++;
}
break;
}
}
private void optionsAboutSelected(final MenuItem item) {
switch (item.getItemId()) {
case ITEM_ID_SETTINGS:
NavigationHelper.openSettings(this);
break;
case ITEM_ID_ABOUT:
NavigationHelper.openAbout(this);
break;
}
}
private void setupDrawerHeader() {
drawerHeaderBinding.drawerHeaderActionButton.setOnClickListener(view -> toggleServices());
// If the current app name is bigger than the default "NewPipe" (7 chars),
// let the text view grow a little more as well.
if (getString(R.string.app_name).length() > "NewPipe".length()) {
final ViewGroup.LayoutParams layoutParams =
drawerHeaderBinding.drawerHeaderNewpipeTitle.getLayoutParams();
layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
drawerHeaderBinding.drawerHeaderNewpipeTitle.setLayoutParams(layoutParams);
drawerHeaderBinding.drawerHeaderNewpipeTitle.setMaxLines(2);
drawerHeaderBinding.drawerHeaderNewpipeTitle.setMinWidth(getResources()
.getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_default_width));
drawerHeaderBinding.drawerHeaderNewpipeTitle.setMaxWidth(getResources()
.getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_max_width));
}
}
private void toggleServices() {
servicesShown = !servicesShown;
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_services_group);
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_tabs_group);
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_options_about_group);
// Show up or down arrow
drawerHeaderBinding.drawerArrow.setImageResource(
servicesShown ? R.drawable.ic_arrow_drop_up : R.drawable.ic_arrow_drop_down);
if (servicesShown) {
showServices();
} else {
try {
addDrawerMenuForCurrentService();
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Showing main page tabs", e);
}
}
}
private void showServices() {
for (final StreamingService s : NewPipe.getServices()) {
final String title = s.getServiceInfo().getName();
final MenuItem menuItem = drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_services_group, s.getServiceId(), ORDER, title)
.setIcon(ServiceHelper.getIcon(s.getServiceId()));
// peertube specifics
if (s.getServiceId() == 3) {
enhancePeertubeMenu(menuItem);
}
}
drawerLayoutBinding.navigation.getMenu()
.getItem(ServiceHelper.getSelectedServiceId(this))
.setChecked(true);
}
private void enhancePeertubeMenu(final MenuItem menuItem) {
final PeertubeInstance currentInstance = PeertubeHelper.getCurrentInstance();
menuItem.setTitle(currentInstance.getName());
final Spinner spinner = InstanceSpinnerLayoutBinding.inflate(LayoutInflater.from(this))
.getRoot();
final List<PeertubeInstance> instances = PeertubeHelper.getInstanceList(this);
final List<String> items = new ArrayList<>();
int defaultSelect = 0;
for (final PeertubeInstance instance : instances) {
items.add(instance.getName());
if (instance.getUrl().equals(currentInstance.getUrl())) {
defaultSelect = items.size() - 1;
}
}
final ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
R.layout.instance_spinner_item, items);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spinner.setAdapter(adapter);
spinner.setSelection(defaultSelect, false);
spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(final AdapterView<?> parent, final View view,
final int position, final long id) {
final PeertubeInstance newInstance = instances.get(position);
if (newInstance.getUrl().equals(PeertubeHelper.getCurrentInstance().getUrl())) {
return;
}
PeertubeHelper.selectInstance(newInstance, getApplicationContext());
changeService(menuItem);
mainBinding.getRoot().closeDrawers();
new Handler(Looper.getMainLooper()).postDelayed(() -> {
getSupportFragmentManager().popBackStack(null,
FragmentManager.POP_BACK_STACK_INCLUSIVE);
ActivityCompat.recreate(MainActivity.this);
}, 300);
}
@Override
public void onNothingSelected(final AdapterView<?> parent) {
}
});
menuItem.setActionView(spinner);
}
@Override
protected void onDestroy() {
super.onDestroy();
if (!isChangingConfigurations()) {
StateSaver.clearStateFiles();
}
if (broadcastReceiver != null) {
unregisterReceiver(broadcastReceiver);
}
}
@Override
protected void onResume() {
assureCorrectAppLanguage(this);
// Change the date format to match the selected language on resume
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
super.onResume();
// Close drawer on return, and don't show animation,
// so it looks like the drawer isn't open when the user returns to MainActivity
mainBinding.getRoot().closeDrawer(GravityCompat.START, false);
try {
final int selectedServiceId = ServiceHelper.getSelectedServiceId(this);
final String selectedServiceName = NewPipe.getService(selectedServiceId)
.getServiceInfo().getName();
drawerHeaderBinding.drawerHeaderServiceView.setText(selectedServiceName);
drawerHeaderBinding.drawerHeaderServiceIcon.setImageResource(ServiceHelper
.getIcon(selectedServiceId));
drawerHeaderBinding.drawerHeaderServiceView.post(() -> drawerHeaderBinding
.drawerHeaderServiceView.setSelected(true));
drawerHeaderBinding.drawerHeaderActionButton.setContentDescription(
getString(R.string.drawer_header_description) + selectedServiceName);
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e);
}
final SharedPreferences sharedPreferences =
PreferenceManager.getDefaultSharedPreferences(this);
if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) {
if (DEBUG) {
Log.d(TAG, "Theme has changed, recreating activity...");
}
sharedPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
ActivityCompat.recreate(this);
}
if (sharedPreferences.getBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false)) {
if (DEBUG) {
Log.d(TAG, "main page has changed, recreating main fragment...");
}
sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
NavigationHelper.openMainActivity(this);
}
final boolean isHistoryEnabled = sharedPreferences.getBoolean(
getString(R.string.enable_watch_history_key), true);
drawerLayoutBinding.navigation.getMenu().findItem(ITEM_ID_HISTORY)
.setVisible(isHistoryEnabled);
}
@Override
protected void onNewIntent(final Intent intent) {
if (DEBUG) {
Log.d(TAG, "onNewIntent() called with: intent = [" + intent + "]");
}
if (intent != null) {
// Return if launched from a launcher (e.g. Nova Launcher, Pixel Launcher ...)
// to not destroy the already created backstack
final String action = intent.getAction();
if ((action != null && action.equals(Intent.ACTION_MAIN))
&& intent.hasCategory(Intent.CATEGORY_LAUNCHER)) {
return;
}
}
super.onNewIntent(intent);
setIntent(intent);
handleIntent(intent);
}
@Override
public boolean onKeyDown(final int keyCode, final KeyEvent event) {
final Fragment fragment = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder);
if (fragment instanceof OnKeyDownListener
&& !bottomSheetHiddenOrCollapsed()) {
// Provide keyDown event to fragment which then sends this event
// to the main player service
return ((OnKeyDownListener) fragment).onKeyDown(keyCode)
|| super.onKeyDown(keyCode, event);
}
return super.onKeyDown(keyCode, event);
}
@Override
public void onBackPressed() {
if (DEBUG) {
Log.d(TAG, "onBackPressed() called");
}
if (DeviceUtils.isTv(this)) {
if (mainBinding.getRoot().isDrawerOpen(drawerLayoutBinding.navigation)) {
mainBinding.getRoot().closeDrawers();
return;
}
}
// In case bottomSheet is not visible on the screen or collapsed we can assume that the user
// interacts with a fragment inside fragment_holder so all back presses should be
// handled by it
if (bottomSheetHiddenOrCollapsed()) {
final FragmentManager fm = getSupportFragmentManager();
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragment instanceof BackPressable) {
if (((BackPressable) fragment).onBackPressed()) {
return;
}
} else if (fragment instanceof CommentRepliesFragment) {
// expand DetailsFragment if CommentRepliesFragment was opened
// to show the top level comments again
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, false);
}
} else {
final Fragment fragmentPlayer = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragmentPlayer instanceof BackPressable) {
if (!((BackPressable) fragmentPlayer).onBackPressed()) {
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
return;
}
}
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
finish();
} else {
super.onBackPressed();
}
}
@Override
public void onRequestPermissionsResult(final int requestCode,
@NonNull final String[] permissions,
@NonNull final int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
for (final int i : grantResults) {
if (i == PackageManager.PERMISSION_DENIED) {
return;
}
}
switch (requestCode) {
case PermissionHelper.DOWNLOADS_REQUEST_CODE:
NavigationHelper.openDownloads(this);
break;
case PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE:
final Fragment fragment = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder);
if (fragment instanceof VideoDetailFragment) {
((VideoDetailFragment) fragment).openDownloadDialog();
}
break;
case PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE:
NotificationWorker.initialize(this);
break;
}
}
/**
* Implement the following diagram behavior for the up button:
* <pre>
* +---------------+
* | Main Screen +----+
* +-------+-------+ |
* | |
* Up | Search Button
* | |
* +----+-----+ |
* +------------+ Search |-----+
* | +----+-----+
* | Open |
* | something Up
* | |
* | +------------+-------------+
* | | |
* | | Video <-> Channel |
* +---| Channel <-> Playlist |
* | Video <-> .... |
* | |
* +--------------------------+
* </pre>
*/
private void onHomeButtonPressed() {
final FragmentManager fm = getSupportFragmentManager();
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
if (fragment instanceof CommentRepliesFragment) {
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, true);
} else if (!NavigationHelper.tryGotoSearchFragment(fm)) {
// If search fragment wasn't found in the backstack go to the main fragment
NavigationHelper.gotoMainFragment(fm);
}
}
/*//////////////////////////////////////////////////////////////////////////
// Menu
//////////////////////////////////////////////////////////////////////////*/
@Override
public boolean onCreateOptionsMenu(final Menu menu) {
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "]");
}
super.onCreateOptionsMenu(menu);
final Fragment fragment =
getSupportFragmentManager().findFragmentById(R.id.fragment_holder);
if (!(fragment instanceof SearchFragment)) {
toolbarLayoutBinding.toolbarSearchContainer.getRoot().setVisibility(View.GONE);
}
final ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(false);
}
updateDrawerNavigation();
return true;
}
@Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
if (DEBUG) {
Log.d(TAG, "onOptionsItemSelected() called with: item = [" + item + "]");
}
if (item.getItemId() == android.R.id.home) {
onHomeButtonPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
/*//////////////////////////////////////////////////////////////////////////
// Init
//////////////////////////////////////////////////////////////////////////*/
private void initFragments() {
if (DEBUG) {
Log.d(TAG, "initFragments() called");
}
StateSaver.clearStateFiles();
if (getIntent() != null && getIntent().hasExtra(Constants.KEY_LINK_TYPE)) {
// When user watch a video inside popup and then tries to open the video in main player
// while the app is closed he will see a blank fragment on place of kiosk.
// Let's open it first
if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
NavigationHelper.openMainFragment(getSupportFragmentManager());
}
handleIntent(getIntent());
} else {
NavigationHelper.gotoMainFragment(getSupportFragmentManager());
}
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
private void updateDrawerNavigation() {
if (getSupportActionBar() == null) {
return;
}
final Fragment fragment = getSupportFragmentManager()
.findFragmentById(R.id.fragment_holder);
if (fragment instanceof MainFragment) {
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
if (toggle != null) {
toggle.syncState();
toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot()
.open());
mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED);
}
} else {
mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> onHomeButtonPressed());
}
}
private void handleIntent(final Intent intent) {
try {
if (DEBUG) {
Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]");
}
if (intent.hasExtra(Constants.KEY_LINK_TYPE)) {
final String url = intent.getStringExtra(Constants.KEY_URL);
final int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0);
String title = intent.getStringExtra(Constants.KEY_TITLE);
if (title == null) {
title = "";
}
final StreamingService.LinkType linkType = ((StreamingService.LinkType) intent
.getSerializableExtra(Constants.KEY_LINK_TYPE));
assert linkType != null;
switch (linkType) {
case STREAM:
final String intentCacheKey = intent.getStringExtra(
Player.PLAY_QUEUE_KEY);
final PlayQueue playQueue = intentCacheKey != null
? SerializedCache.getInstance()
.take(intentCacheKey, PlayQueue.class)
: null;
final boolean switchingPlayers = intent.getBooleanExtra(
VideoDetailFragment.KEY_SWITCHING_PLAYERS, false);
NavigationHelper.openVideoDetailFragment(
getApplicationContext(), getSupportFragmentManager(),
serviceId, url, title, playQueue, switchingPlayers);
break;
case CHANNEL:
NavigationHelper.openChannelFragment(getSupportFragmentManager(),
serviceId, url, title);
break;
case PLAYLIST:
NavigationHelper.openPlaylistFragment(getSupportFragmentManager(),
serviceId, url, title);
break;
}
} else if (intent.hasExtra(Constants.KEY_OPEN_SEARCH)) {
String searchString = intent.getStringExtra(Constants.KEY_SEARCH_STRING);
if (searchString == null) {
searchString = "";
}
final int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0);
NavigationHelper.openSearchFragment(
getSupportFragmentManager(),
serviceId,
searchString);
} else {
NavigationHelper.gotoMainFragment(getSupportFragmentManager());
}
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Handling intent", e);
}
}
private void openMiniPlayerIfMissing() {
final Fragment fragmentPlayer = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder);
if (fragmentPlayer == null) {
// We still don't have a fragment attached to the activity. It can happen when a user
// started popup or background players without opening a stream inside the fragment.
// Adding it in a collapsed state (only mini player will be visible).
NavigationHelper.showMiniPlayer(getSupportFragmentManager());
}
}
private void openMiniPlayerUponPlayerStarted() {
if (getIntent().getSerializableExtra(Constants.KEY_LINK_TYPE)
== StreamingService.LinkType.STREAM) {
// handleIntent() already takes care of opening video detail fragment
// due to an intent containing a STREAM link
return;
}
if (PlayerHolder.getInstance().isPlayerOpen()) {
// if the player is already open, no need for a broadcast receiver
openMiniPlayerIfMissing();
} else {
// listen for player start intent being sent around
broadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context context, final Intent intent) {
if (Objects.equals(intent.getAction(),
VideoDetailFragment.ACTION_PLAYER_STARTED)) {
openMiniPlayerIfMissing();
// At this point the player is added 100%, we can unregister. Other actions
// are useless since the fragment will not be removed after that.
unregisterReceiver(broadcastReceiver);
broadcastReceiver = null;
}
}
};
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
registerReceiver(broadcastReceiver, intentFilter);
}
}
private void openDetailFragmentFromCommentReplies(
@NonNull final FragmentManager fm,
final boolean popBackStack
) {
// obtain the name of the fragment under the replies fragment that's going to be popped
@Nullable final String fragmentUnderEntryName;
if (fm.getBackStackEntryCount() < 2) {
fragmentUnderEntryName = null;
} else {
fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2)
.getName();
}
// the root comment is the comment for which the user opened the replies page
@Nullable final CommentRepliesFragment repliesFragment =
(CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG);
@Nullable final CommentsInfoItem rootComment =
repliesFragment == null ? null : repliesFragment.getCommentsInfoItem();
// sometimes this function pops the backstack, other times it's handled by the system
if (popBackStack) {
fm.popBackStackImmediate();
}
// only expand the bottom sheet back if there are no more nested comment replies fragments
// stacked under the one that is currently being popped
if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) {
return;
}
final BottomSheetBehavior<FragmentContainerView> behavior = BottomSheetBehavior
.from(mainBinding.fragmentPlayerHolder);
// do not return to the comment if the details fragment was closed
if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
return;
}
// scroll to the root comment once the bottom sheet expansion animation is finished
behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull final View bottomSheet,
final int newState) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
final Fragment detailFragment = fm.findFragmentById(
R.id.fragment_player_holder);
if (detailFragment instanceof VideoDetailFragment && rootComment != null) {
// should always be the case
((VideoDetailFragment) detailFragment).scrollToComment(rootComment);
}
behavior.removeBottomSheetCallback(this);
}
}
@Override
public void onSlide(@NonNull final View bottomSheet, final float slideOffset) {
// not needed, listener is removed once the sheet is expanded
}
});
behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
}
private boolean bottomSheetHiddenOrCollapsed() {
final BottomSheetBehavior<FrameLayout> bottomSheetBehavior =
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder);
final int sheetState = bottomSheetBehavior.getState();
return sheetState == BottomSheetBehavior.STATE_HIDDEN
|| sheetState == BottomSheetBehavior.STATE_COLLAPSED;
}
}

View File

@ -0,0 +1,820 @@
/*
* Created by Christian Schabesberger on 02.08.16.
* <p>
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
* DownloadActivity.java is part of NewPipe.
* <p>
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.Spinner
import androidx.appcompat.app.ActionBar
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager
import androidx.preference.PreferenceManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.navigation.NavigationView
import org.schabi.newpipe.NewVersionWorker.Companion.enqueueNewVersionCheckingWork
import org.schabi.newpipe.databinding.ActivityMainBinding
import org.schabi.newpipe.databinding.DrawerHeaderBinding
import org.schabi.newpipe.databinding.DrawerLayoutBinding
import org.schabi.newpipe.databinding.InstanceSpinnerLayoutBinding
import org.schabi.newpipe.databinding.ToolbarLayoutBinding
import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.StreamingService
import org.schabi.newpipe.extractor.StreamingService.LinkType
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.extractor.exceptions.ExtractionException
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance
import org.schabi.newpipe.fragments.BackPressable
import org.schabi.newpipe.fragments.MainFragment
import org.schabi.newpipe.fragments.detail.VideoDetailFragment
import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment
import org.schabi.newpipe.fragments.list.search.SearchFragment
import org.schabi.newpipe.local.feed.notifications.NotificationWorker.Companion.initialize
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.event.OnKeyDownListener
import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.settings.UpdateSettingsFragment
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.KioskTranslator
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.PeertubeHelper
import org.schabi.newpipe.util.PermissionHelper
import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
import org.schabi.newpipe.util.SerializedCache
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.StateSaver
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.views.FocusOverlayView
import java.util.Objects
class MainActivity() : AppCompatActivity() {
private var mainBinding: ActivityMainBinding? = null
private var drawerHeaderBinding: DrawerHeaderBinding? = null
private var drawerLayoutBinding: DrawerLayoutBinding? = null
private var toolbarLayoutBinding: ToolbarLayoutBinding? = null
private var toggle: ActionBarDrawerToggle? = null
private var servicesShown: Boolean = false
private var broadcastReceiver: BroadcastReceiver? = null
/*//////////////////////////////////////////////////////////////////////////
// Activity's LifeCycle
////////////////////////////////////////////////////////////////////////// */
override fun onCreate(savedInstanceState: Bundle?) {
if (DEBUG) {
Log.d(TAG, ("onCreate() called with: "
+ "savedInstanceState = [" + savedInstanceState + "]"))
}
ThemeHelper.setDayNightMode(this)
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this))
Localization.assureCorrectAppLanguage(this)
super.onCreate(savedInstanceState)
mainBinding = ActivityMainBinding.inflate(getLayoutInflater())
drawerLayoutBinding = mainBinding!!.drawerLayout
drawerHeaderBinding = DrawerHeaderBinding.bind(drawerLayoutBinding!!.navigation
.getHeaderView(0))
toolbarLayoutBinding = mainBinding!!.toolbarLayout
setContentView(mainBinding!!.getRoot())
if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
initFragments()
}
setSupportActionBar(toolbarLayoutBinding!!.toolbar)
try {
setupDrawer()
} catch (e: Exception) {
showUiErrorSnackbar(this, "Setting up drawer", e)
}
if (DeviceUtils.isTv(this)) {
FocusOverlayView.Companion.setupFocusObserver(this)
}
openMiniPlayerUponPlayerStarted()
if (PermissionHelper.checkPostNotificationsPermission(this,
PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE)) {
// Schedule worker for checking for new streams and creating corresponding notifications
// if this is enabled by the user.
initialize(this)
}
if ((!UpdateSettingsFragment.Companion.wasUserAskedForConsent(this)
&& !App.Companion.getApp().isFirstRun()
&& isReleaseApk)) {
UpdateSettingsFragment.Companion.askForConsentToUpdateChecks(this)
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
val app: App = App.Companion.getApp()
val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(app)
if ((prefs.getBoolean(app.getString(R.string.update_app_key), false)
&& prefs.getBoolean(app.getString(R.string.update_check_consent_key), false))) {
// Start the worker which is checking all conditions
// and eventually searching for a new version.
enqueueNewVersionCheckingWork(app, false)
}
}
@Throws(ExtractionException::class)
private fun setupDrawer() {
addDrawerMenuForCurrentService()
toggle = ActionBarDrawerToggle(this, mainBinding!!.getRoot(),
toolbarLayoutBinding!!.toolbar, R.string.drawer_open, R.string.drawer_close)
toggle!!.syncState()
mainBinding!!.getRoot().addDrawerListener(toggle!!)
mainBinding!!.getRoot().addDrawerListener(object : SimpleDrawerListener() {
private var lastService: Int = 0
public override fun onDrawerOpened(drawerView: View) {
lastService = ServiceHelper.getSelectedServiceId(this@MainActivity)
}
public override fun onDrawerClosed(drawerView: View) {
if (servicesShown) {
toggleServices()
}
if (lastService != ServiceHelper.getSelectedServiceId(this@MainActivity)) {
ActivityCompat.recreate(this@MainActivity)
}
}
})
drawerLayoutBinding!!.navigation.setNavigationItemSelectedListener(NavigationView.OnNavigationItemSelectedListener({ item: MenuItem -> drawerItemSelected(item) }))
setupDrawerHeader()
}
/**
* Builds the drawer menu for the current service.
*
* @throws ExtractionException if the service didn't provide available kiosks
*/
@Throws(ExtractionException::class)
private fun addDrawerMenuForCurrentService() {
//Tabs
val currentServiceId: Int = ServiceHelper.getSelectedServiceId(this)
val service: StreamingService = NewPipe.getService(currentServiceId)
var kioskMenuItemId: Int = 0
for (ks: String in service.getKioskList().getAvailableKiosks()) {
drawerLayoutBinding!!.navigation.getMenu()
.add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator.getTranslatedKioskName(ks, this))
.setIcon(KioskTranslator.getKioskIcon(ks))
kioskMenuItemId++
}
drawerLayoutBinding!!.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER,
R.string.tab_subscriptions)
.setIcon(R.drawable.ic_tv)
drawerLayoutBinding!!.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
.setIcon(R.drawable.ic_subscriptions)
drawerLayoutBinding!!.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
.setIcon(R.drawable.ic_bookmark)
drawerLayoutBinding!!.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_DOWNLOADS, ORDER, R.string.downloads)
.setIcon(R.drawable.ic_file_download)
drawerLayoutBinding!!.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history)
.setIcon(R.drawable.ic_history)
//Settings and About
drawerLayoutBinding!!.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings)
.setIcon(R.drawable.ic_settings)
drawerLayoutBinding!!.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about)
.setIcon(R.drawable.ic_info_outline)
}
private fun drawerItemSelected(item: MenuItem): Boolean {
when (item.getGroupId()) {
R.id.menu_services_group -> changeService(item)
R.id.menu_tabs_group -> try {
tabSelected(item)
} catch (e: Exception) {
showUiErrorSnackbar(this, "Selecting main page tab", e)
}
R.id.menu_options_about_group -> optionsAboutSelected(item)
else -> return false
}
mainBinding!!.getRoot().closeDrawers()
return true
}
private fun changeService(item: MenuItem) {
drawerLayoutBinding!!.navigation.getMenu()
.getItem(ServiceHelper.getSelectedServiceId(this))
.setChecked(false)
ServiceHelper.setSelectedServiceId(this, item.getItemId())
drawerLayoutBinding!!.navigation.getMenu()
.getItem(ServiceHelper.getSelectedServiceId(this))
.setChecked(true)
}
@Throws(ExtractionException::class)
private fun tabSelected(item: MenuItem) {
when (item.getItemId()) {
ITEM_ID_SUBSCRIPTIONS -> NavigationHelper.openSubscriptionFragment(getSupportFragmentManager())
ITEM_ID_FEED -> openFeedFragment(getSupportFragmentManager())
ITEM_ID_BOOKMARKS -> NavigationHelper.openBookmarksFragment(getSupportFragmentManager())
ITEM_ID_DOWNLOADS -> NavigationHelper.openDownloads(this)
ITEM_ID_HISTORY -> NavigationHelper.openStatisticFragment(getSupportFragmentManager())
else -> {
val currentService: StreamingService? = ServiceHelper.getSelectedService(this)
var kioskMenuItemId: Int = 0
for (kioskId: String in currentService!!.getKioskList().getAvailableKiosks()) {
if (kioskMenuItemId == item.getItemId()) {
NavigationHelper.openKioskFragment(getSupportFragmentManager(),
currentService.getServiceId(), kioskId)
break
}
kioskMenuItemId++
}
}
}
}
private fun optionsAboutSelected(item: MenuItem) {
when (item.getItemId()) {
ITEM_ID_SETTINGS -> NavigationHelper.openSettings(this)
ITEM_ID_ABOUT -> NavigationHelper.openAbout(this)
}
}
private fun setupDrawerHeader() {
drawerHeaderBinding!!.drawerHeaderActionButton.setOnClickListener(View.OnClickListener({ view: View? -> toggleServices() }))
// If the current app name is bigger than the default "NewPipe" (7 chars),
// let the text view grow a little more as well.
if (getString(R.string.app_name).length > "NewPipe".length) {
val layoutParams: ViewGroup.LayoutParams = drawerHeaderBinding!!.drawerHeaderNewpipeTitle.getLayoutParams()
layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT
drawerHeaderBinding!!.drawerHeaderNewpipeTitle.setLayoutParams(layoutParams)
drawerHeaderBinding!!.drawerHeaderNewpipeTitle.setMaxLines(2)
drawerHeaderBinding!!.drawerHeaderNewpipeTitle.setMinWidth(getResources()
.getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_default_width))
drawerHeaderBinding!!.drawerHeaderNewpipeTitle.setMaxWidth(getResources()
.getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_max_width))
}
}
private fun toggleServices() {
servicesShown = !servicesShown
drawerLayoutBinding!!.navigation.getMenu().removeGroup(R.id.menu_services_group)
drawerLayoutBinding!!.navigation.getMenu().removeGroup(R.id.menu_tabs_group)
drawerLayoutBinding!!.navigation.getMenu().removeGroup(R.id.menu_options_about_group)
// Show up or down arrow
drawerHeaderBinding!!.drawerArrow.setImageResource(
if (servicesShown) R.drawable.ic_arrow_drop_up else R.drawable.ic_arrow_drop_down)
if (servicesShown) {
showServices()
} else {
try {
addDrawerMenuForCurrentService()
} catch (e: Exception) {
showUiErrorSnackbar(this, "Showing main page tabs", e)
}
}
}
private fun showServices() {
for (s: StreamingService in NewPipe.getServices()) {
val title: String = s.getServiceInfo().getName()
val menuItem: MenuItem = drawerLayoutBinding!!.navigation.getMenu()
.add(R.id.menu_services_group, s.getServiceId(), ORDER, title)
.setIcon(ServiceHelper.getIcon(s.getServiceId()))
// peertube specifics
if (s.getServiceId() == 3) {
enhancePeertubeMenu(menuItem)
}
}
drawerLayoutBinding!!.navigation.getMenu()
.getItem(ServiceHelper.getSelectedServiceId(this))
.setChecked(true)
}
private fun enhancePeertubeMenu(menuItem: MenuItem) {
val currentInstance: PeertubeInstance? = PeertubeHelper.getCurrentInstance()
menuItem.setTitle(currentInstance!!.getName())
val spinner: Spinner = InstanceSpinnerLayoutBinding.inflate(LayoutInflater.from(this))
.getRoot()
val instances: List<PeertubeInstance?>? = PeertubeHelper.getInstanceList(this)
val items: MutableList<String> = ArrayList()
var defaultSelect: Int = 0
for (instance: PeertubeInstance? in instances!!) {
items.add(instance!!.getName())
if ((instance.getUrl() == currentInstance.getUrl())) {
defaultSelect = items.size - 1
}
}
val adapter: ArrayAdapter<String> = ArrayAdapter(this,
R.layout.instance_spinner_item, items)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.setAdapter(adapter)
spinner.setSelection(defaultSelect, false)
spinner.setOnItemSelectedListener(object : AdapterView.OnItemSelectedListener {
public override fun onItemSelected(parent: AdapterView<*>?, view: View,
position: Int, id: Long) {
val newInstance: PeertubeInstance? = instances.get(position)
if ((newInstance!!.getUrl() == PeertubeHelper.getCurrentInstance().getUrl())) {
return
}
PeertubeHelper.selectInstance(newInstance, getApplicationContext())
changeService(menuItem)
mainBinding!!.getRoot().closeDrawers()
Handler(Looper.getMainLooper()).postDelayed(Runnable({
getSupportFragmentManager().popBackStack(null,
FragmentManager.POP_BACK_STACK_INCLUSIVE)
ActivityCompat.recreate(this@MainActivity)
}), 300)
}
public override fun onNothingSelected(parent: AdapterView<*>?) {}
})
menuItem.setActionView(spinner)
}
override fun onDestroy() {
super.onDestroy()
if (!isChangingConfigurations()) {
StateSaver.clearStateFiles()
}
if (broadcastReceiver != null) {
unregisterReceiver(broadcastReceiver)
}
}
override fun onResume() {
Localization.assureCorrectAppLanguage(this)
// Change the date format to match the selected language on resume
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()))
super.onResume()
// Close drawer on return, and don't show animation,
// so it looks like the drawer isn't open when the user returns to MainActivity
mainBinding!!.getRoot().closeDrawer(GravityCompat.START, false)
try {
val selectedServiceId: Int = ServiceHelper.getSelectedServiceId(this)
val selectedServiceName: String = NewPipe.getService(selectedServiceId)
.getServiceInfo().getName()
drawerHeaderBinding!!.drawerHeaderServiceView.setText(selectedServiceName)
drawerHeaderBinding!!.drawerHeaderServiceIcon.setImageResource(ServiceHelper.getIcon(selectedServiceId))
drawerHeaderBinding!!.drawerHeaderServiceView.post(Runnable({ drawerHeaderBinding!!.drawerHeaderServiceView.setSelected(true) }))
drawerHeaderBinding!!.drawerHeaderActionButton.setContentDescription(
getString(R.string.drawer_header_description) + selectedServiceName)
} catch (e: Exception) {
showUiErrorSnackbar(this, "Setting up service toggle", e)
}
val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
if (sharedPreferences.getBoolean(KEY_THEME_CHANGE, false)) {
if (DEBUG) {
Log.d(TAG, "Theme has changed, recreating activity...")
}
sharedPreferences.edit().putBoolean(KEY_THEME_CHANGE, false).apply()
ActivityCompat.recreate(this)
}
if (sharedPreferences.getBoolean(KEY_MAIN_PAGE_CHANGE, false)) {
if (DEBUG) {
Log.d(TAG, "main page has changed, recreating main fragment...")
}
sharedPreferences.edit().putBoolean(KEY_MAIN_PAGE_CHANGE, false).apply()
NavigationHelper.openMainActivity(this)
}
val isHistoryEnabled: Boolean = sharedPreferences.getBoolean(
getString(R.string.enable_watch_history_key), true)
drawerLayoutBinding!!.navigation.getMenu().findItem(ITEM_ID_HISTORY)
.setVisible(isHistoryEnabled)
}
override fun onNewIntent(intent: Intent) {
if (DEBUG) {
Log.d(TAG, "onNewIntent() called with: intent = [" + intent + "]")
}
if (intent != null) {
// Return if launched from a launcher (e.g. Nova Launcher, Pixel Launcher ...)
// to not destroy the already created backstack
val action: String? = intent.getAction()
if (((action != null && (action == Intent.ACTION_MAIN))
&& intent.hasCategory(Intent.CATEGORY_LAUNCHER))) {
return
}
}
super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent)
}
public override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
val fragment: Fragment? = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder)
if ((fragment is OnKeyDownListener
&& !bottomSheetHiddenOrCollapsed())) {
// Provide keyDown event to fragment which then sends this event
// to the main player service
return ((fragment as OnKeyDownListener).onKeyDown(keyCode)
|| super.onKeyDown(keyCode, event))
}
return super.onKeyDown(keyCode, event)
}
public override fun onBackPressed() {
if (DEBUG) {
Log.d(TAG, "onBackPressed() called")
}
if (DeviceUtils.isTv(this)) {
if (mainBinding!!.getRoot().isDrawerOpen(drawerLayoutBinding!!.navigation)) {
mainBinding!!.getRoot().closeDrawers()
return
}
}
// In case bottomSheet is not visible on the screen or collapsed we can assume that the user
// interacts with a fragment inside fragment_holder so all back presses should be
// handled by it
if (bottomSheetHiddenOrCollapsed()) {
val fm: FragmentManager = getSupportFragmentManager()
val fragment: Fragment? = fm.findFragmentById(R.id.fragment_holder)
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragment is BackPressable) {
if ((fragment as BackPressable).onBackPressed()) {
return
}
} else if (fragment is CommentRepliesFragment) {
// expand DetailsFragment if CommentRepliesFragment was opened
// to show the top level comments again
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, false)
}
} else {
val fragmentPlayer: Fragment? = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder)
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragmentPlayer is BackPressable) {
if (!(fragmentPlayer as BackPressable).onBackPressed()) {
BottomSheetBehavior.from(mainBinding!!.fragmentPlayerHolder)
.setState(BottomSheetBehavior.STATE_COLLAPSED)
}
return
}
}
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
finish()
} else {
super.onBackPressed()
}
}
public override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<String>,
grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
for (i: Int in grantResults) {
if (i == PackageManager.PERMISSION_DENIED) {
return
}
}
when (requestCode) {
PermissionHelper.DOWNLOADS_REQUEST_CODE -> NavigationHelper.openDownloads(this)
PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE -> {
val fragment: Fragment? = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder)
if (fragment is VideoDetailFragment) {
fragment.openDownloadDialog()
}
}
PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE -> initialize(this)
}
}
/**
* Implement the following diagram behavior for the up button:
* <pre>
* +---------------+
* | Main Screen +----+
* +-------+-------+ |
* | |
* Up | Search Button
* | |
* +----+-----+ |
* +------------+ Search |-----+
* | +----+-----+
* | Open |
* | something Up
* | |
* | +------------+-------------+
* | | |
* | | Video <-> Channel |
* +---| Channel <-> Playlist |
* | Video <-> .... |
* | |
* +--------------------------+
</pre> *
*/
private fun onHomeButtonPressed() {
val fm: FragmentManager = getSupportFragmentManager()
val fragment: Fragment? = fm.findFragmentById(R.id.fragment_holder)
if (fragment is CommentRepliesFragment) {
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, true)
} else if (!NavigationHelper.tryGotoSearchFragment(fm)) {
// If search fragment wasn't found in the backstack go to the main fragment
NavigationHelper.gotoMainFragment(fm)
}
}
/*//////////////////////////////////////////////////////////////////////////
// Menu
////////////////////////////////////////////////////////////////////////// */
public override fun onCreateOptionsMenu(menu: Menu): Boolean {
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "]")
}
super.onCreateOptionsMenu(menu)
val fragment: Fragment? = getSupportFragmentManager().findFragmentById(R.id.fragment_holder)
if (!(fragment is SearchFragment)) {
toolbarLayoutBinding!!.toolbarSearchContainer.getRoot().setVisibility(View.GONE)
}
val actionBar: ActionBar? = getSupportActionBar()
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(false)
}
updateDrawerNavigation()
return true
}
public override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (DEBUG) {
Log.d(TAG, "onOptionsItemSelected() called with: item = [" + item + "]")
}
if (item.getItemId() == android.R.id.home) {
onHomeButtonPressed()
return true
}
return super.onOptionsItemSelected(item)
}
/*//////////////////////////////////////////////////////////////////////////
// Init
////////////////////////////////////////////////////////////////////////// */
private fun initFragments() {
if (DEBUG) {
Log.d(TAG, "initFragments() called")
}
StateSaver.clearStateFiles()
if (getIntent() != null && getIntent().hasExtra(KEY_LINK_TYPE)) {
// When user watch a video inside popup and then tries to open the video in main player
// while the app is closed he will see a blank fragment on place of kiosk.
// Let's open it first
if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
NavigationHelper.openMainFragment(getSupportFragmentManager())
}
handleIntent(getIntent())
} else {
NavigationHelper.gotoMainFragment(getSupportFragmentManager())
}
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
////////////////////////////////////////////////////////////////////////// */
private fun updateDrawerNavigation() {
if (getSupportActionBar() == null) {
return
}
val fragment: Fragment? = getSupportFragmentManager()
.findFragmentById(R.id.fragment_holder)
if (fragment is MainFragment) {
getSupportActionBar()!!.setDisplayHomeAsUpEnabled(false)
if (toggle != null) {
toggle!!.syncState()
toolbarLayoutBinding!!.toolbar.setNavigationOnClickListener(View.OnClickListener({ v: View? ->
mainBinding!!.getRoot()
.open()
}))
mainBinding!!.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED)
}
} else {
mainBinding!!.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
getSupportActionBar()!!.setDisplayHomeAsUpEnabled(true)
toolbarLayoutBinding!!.toolbar.setNavigationOnClickListener(View.OnClickListener({ v: View? -> onHomeButtonPressed() }))
}
}
private fun handleIntent(intent: Intent) {
try {
if (DEBUG) {
Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]")
}
if (intent.hasExtra(KEY_LINK_TYPE)) {
val url: String? = intent.getStringExtra(KEY_URL)
val serviceId: Int = intent.getIntExtra(KEY_SERVICE_ID, 0)
var title: String? = intent.getStringExtra(KEY_TITLE)
if (title == null) {
title = ""
}
val linkType: LinkType? = (intent
.getSerializableExtra(KEY_LINK_TYPE) as LinkType?)
assert(linkType != null)
when (linkType) {
LinkType.STREAM -> {
val intentCacheKey: String? = intent.getStringExtra(
Player.Companion.PLAY_QUEUE_KEY)
val playQueue: PlayQueue? = if (intentCacheKey != null) SerializedCache.Companion.getInstance()
.take<PlayQueue>(intentCacheKey, PlayQueue::class.java) else null
val switchingPlayers: Boolean = intent.getBooleanExtra(
VideoDetailFragment.Companion.KEY_SWITCHING_PLAYERS, false)
NavigationHelper.openVideoDetailFragment(
getApplicationContext(), getSupportFragmentManager(),
serviceId, url, title, playQueue, switchingPlayers)
}
LinkType.CHANNEL -> NavigationHelper.openChannelFragment(getSupportFragmentManager(),
serviceId, url, title)
LinkType.PLAYLIST -> NavigationHelper.openPlaylistFragment(getSupportFragmentManager(),
serviceId, url, title)
}
} else if (intent.hasExtra(KEY_OPEN_SEARCH)) {
var searchString: String? = intent.getStringExtra(KEY_SEARCH_STRING)
if (searchString == null) {
searchString = ""
}
val serviceId: Int = intent.getIntExtra(KEY_SERVICE_ID, 0)
NavigationHelper.openSearchFragment(
getSupportFragmentManager(),
serviceId,
searchString)
} else {
NavigationHelper.gotoMainFragment(getSupportFragmentManager())
}
} catch (e: Exception) {
showUiErrorSnackbar(this, "Handling intent", e)
}
}
private fun openMiniPlayerIfMissing() {
val fragmentPlayer: Fragment? = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder)
if (fragmentPlayer == null) {
// We still don't have a fragment attached to the activity. It can happen when a user
// started popup or background players without opening a stream inside the fragment.
// Adding it in a collapsed state (only mini player will be visible).
NavigationHelper.showMiniPlayer(getSupportFragmentManager())
}
}
private fun openMiniPlayerUponPlayerStarted() {
if ((getIntent().getSerializableExtra(KEY_LINK_TYPE)
=== LinkType.STREAM)) {
// handleIntent() already takes care of opening video detail fragment
// due to an intent containing a STREAM link
return
}
if (PlayerHolder.Companion.getInstance().isPlayerOpen()) {
// if the player is already open, no need for a broadcast receiver
openMiniPlayerIfMissing()
} else {
// listen for player start intent being sent around
broadcastReceiver = object : BroadcastReceiver() {
public override fun onReceive(context: Context, intent: Intent) {
if (Objects.equals(intent.getAction(),
VideoDetailFragment.Companion.ACTION_PLAYER_STARTED)) {
openMiniPlayerIfMissing()
// At this point the player is added 100%, we can unregister. Other actions
// are useless since the fragment will not be removed after that.
unregisterReceiver(broadcastReceiver)
broadcastReceiver = null
}
}
}
val intentFilter: IntentFilter = IntentFilter()
intentFilter.addAction(VideoDetailFragment.Companion.ACTION_PLAYER_STARTED)
registerReceiver(broadcastReceiver, intentFilter)
}
}
private fun openDetailFragmentFromCommentReplies(
fm: FragmentManager,
popBackStack: Boolean
) {
// obtain the name of the fragment under the replies fragment that's going to be popped
val fragmentUnderEntryName: String?
if (fm.getBackStackEntryCount() < 2) {
fragmentUnderEntryName = null
} else {
fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2)
.getName()
}
// the root comment is the comment for which the user opened the replies page
val repliesFragment: CommentRepliesFragment? = fm.findFragmentByTag(CommentRepliesFragment.Companion.TAG) as CommentRepliesFragment?
val rootComment: CommentsInfoItem? = if (repliesFragment == null) null else repliesFragment.getCommentsInfoItem()
// sometimes this function pops the backstack, other times it's handled by the system
if (popBackStack) {
fm.popBackStackImmediate()
}
// only expand the bottom sheet back if there are no more nested comment replies fragments
// stacked under the one that is currently being popped
if ((CommentRepliesFragment.Companion.TAG == fragmentUnderEntryName)) {
return
}
val behavior: BottomSheetBehavior<FragmentContainerView> = BottomSheetBehavior
.from(mainBinding!!.fragmentPlayerHolder)
// do not return to the comment if the details fragment was closed
if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
return
}
// scroll to the root comment once the bottom sheet expansion animation is finished
behavior.addBottomSheetCallback(object : BottomSheetCallback() {
public override fun onStateChanged(bottomSheet: View,
newState: Int) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
val detailFragment: Fragment? = fm.findFragmentById(
R.id.fragment_player_holder)
if (detailFragment is VideoDetailFragment && rootComment != null) {
// should always be the case
detailFragment.scrollToComment(rootComment)
}
behavior.removeBottomSheetCallback(this)
}
}
public override fun onSlide(bottomSheet: View, slideOffset: Float) {
// not needed, listener is removed once the sheet is expanded
}
})
behavior.setState(BottomSheetBehavior.STATE_EXPANDED)
}
private fun bottomSheetHiddenOrCollapsed(): Boolean {
val bottomSheetBehavior: BottomSheetBehavior<FrameLayout> = BottomSheetBehavior.from(mainBinding!!.fragmentPlayerHolder)
val sheetState: Int = bottomSheetBehavior.getState()
return (sheetState == BottomSheetBehavior.STATE_HIDDEN
|| sheetState == BottomSheetBehavior.STATE_COLLAPSED)
}
companion object {
private val TAG: String = "MainActivity"
val DEBUG: Boolean = !BuildConfig.BUILD_TYPE.equals("release")
private val ITEM_ID_SUBSCRIPTIONS: Int = -1
private val ITEM_ID_FEED: Int = -2
private val ITEM_ID_BOOKMARKS: Int = -3
private val ITEM_ID_DOWNLOADS: Int = -4
private val ITEM_ID_HISTORY: Int = -5
private val ITEM_ID_SETTINGS: Int = 0
private val ITEM_ID_ABOUT: Int = 1
private val ORDER: Int = 0
}
}

View File

@ -1,72 +0,0 @@
package org.schabi.newpipe;
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7;
import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8;
import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.room.Room;
import org.schabi.newpipe.database.AppDatabase;
public final class NewPipeDatabase {
private static volatile AppDatabase databaseInstance;
private NewPipeDatabase() {
//no instance
}
private static AppDatabase getDatabase(final Context context) {
return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
.build();
}
@NonNull
public static AppDatabase getInstance(@NonNull final Context context) {
AppDatabase result = databaseInstance;
if (result == null) {
synchronized (NewPipeDatabase.class) {
result = databaseInstance;
if (result == null) {
databaseInstance = getDatabase(context);
result = databaseInstance;
}
}
}
return result;
}
public static void checkpoint() {
if (databaseInstance == null) {
throw new IllegalStateException("database is not initialized");
}
final Cursor c = databaseInstance.query("pragma wal_checkpoint(full)", null);
if (c.moveToFirst() && c.getInt(0) == 1) {
throw new RuntimeException("Checkpoint was blocked from completing");
}
}
public static void close() {
if (databaseInstance != null) {
synchronized (NewPipeDatabase.class) {
if (databaseInstance != null) {
databaseInstance.close();
databaseInstance = null;
}
}
}
}
}

View File

@ -0,0 +1,54 @@
package org.schabi.newpipe
import android.content.Context
import android.database.Cursor
import androidx.room.Room.databaseBuilder
import org.schabi.newpipe.database.AppDatabase
import org.schabi.newpipe.database.Migrations
import kotlin.concurrent.Volatile
object NewPipeDatabase {
@Volatile
private var databaseInstance: AppDatabase? = null
private fun getDatabase(context: Context): AppDatabase {
return databaseBuilder<AppDatabase>(context.getApplicationContext(), AppDatabase::class.java, AppDatabase.Companion.DATABASE_NAME)
.addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4, Migrations.MIGRATION_4_5,
Migrations.MIGRATION_5_6, Migrations.MIGRATION_6_7, Migrations.MIGRATION_7_8, Migrations.MIGRATION_8_9)
.build()
}
fun getInstance(context: Context): AppDatabase {
var result: AppDatabase? = databaseInstance
if (result == null) {
synchronized(NewPipeDatabase::class.java, {
result = databaseInstance
if (result == null) {
databaseInstance = getDatabase(context)
result = databaseInstance
}
})
}
return (result)!!
}
fun checkpoint() {
if (databaseInstance == null) {
throw IllegalStateException("database is not initialized")
}
val c: Cursor = databaseInstance!!.query("pragma wal_checkpoint(full)", null)
if (c.moveToFirst() && c.getInt(0) == 1) {
throw RuntimeException("Checkpoint was blocked from completing")
}
}
fun close() {
if (databaseInstance != null) {
synchronized(NewPipeDatabase::class.java, {
if (databaseInstance != null) {
databaseInstance!!.close()
databaseInstance = null
}
})
}
}
}

View File

@ -1,44 +0,0 @@
package org.schabi.newpipe;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* PanicResponderActivity.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class PanicResponderActivity extends Activity {
public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER";
@SuppressLint("NewApi")
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Intent intent = getIntent();
if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) {
// TODO: Explicitly clear the search results
// once they are restored when the app restarts
// or if the app reloads the current video after being killed,
// that should be cleared also
ExitActivity.exitAndRemoveFromRecentApps(this);
}
finishAndRemoveTask();
}
}

View File

@ -0,0 +1,43 @@
package org.schabi.newpipe
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.os.Bundle
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* PanicResponderActivity.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
class PanicResponderActivity() : Activity() {
@SuppressLint("NewApi")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent: Intent? = getIntent()
if (intent != null && (PANIC_TRIGGER_ACTION == intent.getAction())) {
// TODO: Explicitly clear the search results
// once they are restored when the app restarts
// or if the app reloads the current video after being killed,
// that should be cleared also
ExitActivity.Companion.exitAndRemoveFromRecentApps(this)
}
finishAndRemoveTask()
}
companion object {
val PANIC_TRIGGER_ACTION: String = "info.guardianproject.panic.action.TRIGGER"
}
}

View File

@ -1,94 +0,0 @@
package org.schabi.newpipe;
import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase;
import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText;
import android.content.Context;
import android.view.ContextThemeWrapper;
import android.view.View;
import android.widget.PopupMenu;
import androidx.fragment.app.FragmentManager;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.download.DownloadDialog;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.SparseItemUtil;
import java.util.List;
public final class QueueItemMenuUtil {
private QueueItemMenuUtil() {
}
public static void openPopupMenu(final PlayQueue playQueue,
final PlayQueueItem item,
final View view,
final boolean hideDetails,
final FragmentManager fragmentManager,
final Context context) {
final ContextThemeWrapper themeWrapper =
new ContextThemeWrapper(context, R.style.DarkPopupMenu);
final PopupMenu popupMenu = new PopupMenu(themeWrapper, view);
popupMenu.inflate(R.menu.menu_play_queue_item);
if (hideDetails) {
popupMenu.getMenu().findItem(R.id.menu_item_details).setVisible(false);
}
popupMenu.setOnMenuItemClickListener(menuItem -> {
switch (menuItem.getItemId()) {
case R.id.menu_item_remove:
final int index = playQueue.indexOf(item);
playQueue.remove(index);
return true;
case R.id.menu_item_details:
// playQueue is null since we don't want any queue change
NavigationHelper.openVideoDetail(context, item.getServiceId(),
item.getUrl(), item.getTitle(), null,
false);
return true;
case R.id.menu_item_append_playlist:
PlaylistDialog.createCorrespondingDialog(
context,
List.of(new StreamEntity(item)),
dialog -> dialog.show(
fragmentManager,
"QueueItemMenuUtil@append_playlist"
)
);
return true;
case R.id.menu_item_channel_details:
SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(),
item.getUrl(), item.getUploaderUrl(),
// An intent must be used here.
// Opening with FragmentManager transactions is not working,
// as PlayQueueActivity doesn't use fragments.
uploaderUrl -> NavigationHelper.openChannelFragmentUsingIntent(
context, item.getServiceId(), uploaderUrl, item.getUploader()
));
return true;
case R.id.menu_item_share:
shareText(context, item.getTitle(), item.getUrl(),
item.getThumbnails());
return true;
case R.id.menu_item_download:
fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(),
info -> {
final DownloadDialog downloadDialog = new DownloadDialog(context,
info);
downloadDialog.show(fragmentManager, "downloadDialog");
});
return true;
}
return false;
});
popupMenu.show();
}
}

View File

@ -0,0 +1,97 @@
package org.schabi.newpipe
import android.content.Context
import android.view.ContextThemeWrapper
import android.view.MenuItem
import android.view.View
import android.widget.PopupMenu
import androidx.fragment.app.FragmentManager
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.download.DownloadDialog
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.local.dialog.PlaylistDialog
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.player.playqueue.PlayQueueItem
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.SparseItemUtil
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.util.List
import java.util.function.Consumer
object QueueItemMenuUtil {
fun openPopupMenu(playQueue: PlayQueue?,
item: PlayQueueItem,
view: View?,
hideDetails: Boolean,
fragmentManager: FragmentManager?,
context: Context) {
val themeWrapper: ContextThemeWrapper = ContextThemeWrapper(context, R.style.DarkPopupMenu)
val popupMenu: PopupMenu = PopupMenu(themeWrapper, view)
popupMenu.inflate(R.menu.menu_play_queue_item)
if (hideDetails) {
popupMenu.getMenu().findItem(R.id.menu_item_details).setVisible(false)
}
popupMenu.setOnMenuItemClickListener(PopupMenu.OnMenuItemClickListener({ menuItem: MenuItem ->
when (menuItem.getItemId()) {
R.id.menu_item_remove -> {
val index: Int = playQueue!!.indexOf(item)
playQueue.remove(index)
return@setOnMenuItemClickListener true
}
R.id.menu_item_details -> {
// playQueue is null since we don't want any queue change
NavigationHelper.openVideoDetail(context, item.getServiceId(),
item.getUrl(), item.getTitle(), null,
false)
return@setOnMenuItemClickListener true
}
R.id.menu_item_append_playlist -> {
PlaylistDialog.Companion.createCorrespondingDialog(
context,
List.of<StreamEntity?>(StreamEntity(item)),
Consumer<PlaylistDialog>({ dialog: PlaylistDialog ->
dialog.show(
(fragmentManager)!!,
"QueueItemMenuUtil@append_playlist"
)
})
)
return@setOnMenuItemClickListener true
}
R.id.menu_item_channel_details -> {
SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(),
item.getUrl(), item.getUploaderUrl(), // An intent must be used here.
// Opening with FragmentManager transactions is not working,
// as PlayQueueActivity doesn't use fragments.
Consumer({ uploaderUrl: String? ->
NavigationHelper.openChannelFragmentUsingIntent(
context, item.getServiceId(), uploaderUrl, item.getUploader()
)
}))
return@setOnMenuItemClickListener true
}
R.id.menu_item_share -> {
ShareUtils.shareText(context, item.getTitle(), item.getUrl(),
item.getThumbnails())
return@setOnMenuItemClickListener true
}
R.id.menu_item_download -> {
SparseItemUtil.fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(),
Consumer({ info: StreamInfo ->
val downloadDialog: DownloadDialog = DownloadDialog(context,
info)
downloadDialog.show((fragmentManager)!!, "downloadDialog")
}))
return@setOnMenuItemClickListener true
}
}
false
}))
popupMenu.show()
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,65 +0,0 @@
package org.schabi.newpipe.database;
import static org.schabi.newpipe.database.Migrations.DB_VER_9;
import androidx.room.Database;
import androidx.room.RoomDatabase;
import androidx.room.TypeConverters;
import org.schabi.newpipe.database.feed.dao.FeedDAO;
import org.schabi.newpipe.database.feed.dao.FeedGroupDAO;
import org.schabi.newpipe.database.feed.model.FeedEntity;
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity;
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity;
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO;
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
import org.schabi.newpipe.database.playlist.dao.PlaylistDAO;
import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO;
import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO;
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
import org.schabi.newpipe.database.stream.dao.StreamDAO;
import org.schabi.newpipe.database.stream.dao.StreamStateDAO;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
@TypeConverters({Converters.class})
@Database(
entities = {
SubscriptionEntity.class, SearchHistoryEntry.class,
StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class,
PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class,
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class
},
version = DB_VER_9
)
public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db";
public abstract SearchHistoryDAO searchHistoryDAO();
public abstract StreamDAO streamDAO();
public abstract StreamHistoryDAO streamHistoryDAO();
public abstract StreamStateDAO streamStateDAO();
public abstract PlaylistDAO playlistDAO();
public abstract PlaylistStreamDAO playlistStreamDAO();
public abstract PlaylistRemoteDAO playlistRemoteDAO();
public abstract FeedDAO feedDAO();
public abstract FeedGroupDAO feedGroupDAO();
public abstract SubscriptionDAO subscriptionDAO();
}

View File

@ -0,0 +1,46 @@
package org.schabi.newpipe.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import org.schabi.newpipe.database.feed.dao.FeedDAO
import org.schabi.newpipe.database.feed.dao.FeedGroupDAO
import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO
import org.schabi.newpipe.database.history.model.SearchHistoryEntry
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.playlist.dao.PlaylistDAO
import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO
import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
import org.schabi.newpipe.database.stream.dao.StreamDAO
import org.schabi.newpipe.database.stream.dao.StreamStateDAO
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity
@TypeConverters([Converters::class])
@Database(entities = [SubscriptionEntity::class, SearchHistoryEntry::class, StreamEntity::class, StreamHistoryEntity::class, StreamStateEntity::class, PlaylistEntity::class, PlaylistStreamEntity::class, PlaylistRemoteEntity::class, FeedEntity::class, FeedGroupEntity::class, FeedGroupSubscriptionEntity::class, FeedLastUpdatedEntity::class], version = Migrations.DB_VER_9)
abstract class AppDatabase() : RoomDatabase() {
abstract fun searchHistoryDAO(): SearchHistoryDAO?
abstract fun streamDAO(): StreamDAO
abstract fun streamHistoryDAO(): StreamHistoryDAO?
abstract fun streamStateDAO(): StreamStateDAO?
abstract fun playlistDAO(): PlaylistDAO?
abstract fun playlistStreamDAO(): PlaylistStreamDAO?
abstract fun playlistRemoteDAO(): PlaylistRemoteDAO?
abstract fun feedDAO(): FeedDAO?
abstract fun feedGroupDAO(): FeedGroupDAO?
abstract fun subscriptionDAO(): SubscriptionDAO?
companion object {
val DATABASE_NAME: String = "newpipe.db"
}
}

View File

@ -1,39 +0,0 @@
package org.schabi.newpipe.database;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Update;
import java.util.Collection;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
@Dao
public interface BasicDAO<Entity> {
/* Inserts */
@Insert
long insert(Entity entity);
@Insert
List<Long> insertAll(Collection<Entity> entities);
/* Searches */
Flowable<List<Entity>> getAll();
Flowable<List<Entity>> listByService(int serviceId);
/* Deletes */
@Delete
void delete(Entity entity);
int deleteAll();
/* Updates */
@Update
int update(Entity entity);
@Update
void update(Collection<Entity> entities);
}

View File

@ -0,0 +1,33 @@
package org.schabi.newpipe.database
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Update
import io.reactivex.rxjava3.core.Flowable
@Dao
open interface BasicDAO<Entity> {
/* Inserts */
@Insert
fun insert(entity: Entity): Long
@Insert
fun insertAll(entities: Collection<Entity>?): List<Long?>?
/* Searches */
fun getAll(): Flowable<List<Entity>?>?
fun listByService(serviceId: Int): Flowable<List<Entity>?>?
/* Deletes */
@Delete
fun delete(entity: Entity)
fun deleteAll(): Int
/* Updates */
@Update
fun update(entity: Entity): Int
@Update
fun update(entities: Collection<Entity>?)
}

View File

@ -1,13 +0,0 @@
package org.schabi.newpipe.database;
public interface LocalItem {
LocalItemType getLocalItemType();
enum LocalItemType {
PLAYLIST_LOCAL_ITEM,
PLAYLIST_REMOTE_ITEM,
PLAYLIST_STREAM_ITEM,
STATISTIC_STREAM_ITEM,
}
}

View File

@ -0,0 +1,11 @@
package org.schabi.newpipe.database
open interface LocalItem {
fun getLocalItemType(): LocalItemType
enum class LocalItemType {
PLAYLIST_LOCAL_ITEM,
PLAYLIST_REMOTE_ITEM,
PLAYLIST_STREAM_ITEM,
STATISTIC_STREAM_ITEM
}
}

View File

@ -1,15 +1,11 @@
package org.schabi.newpipe.database;
package org.schabi.newpipe.database
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import org.schabi.newpipe.MainActivity;
public final class Migrations {
import android.util.Log
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.schabi.newpipe.MainActivity
object Migrations {
/////////////////////////////////////////////////////////////////////////////
// Test new migrations manually by importing a database from daily usage //
// and checking if the migration works (Use the Database Inspector //
@ -17,25 +13,21 @@ public final class Migrations {
// If you add a migration point it out in the pull request, so that //
// others remember to test it themselves. //
/////////////////////////////////////////////////////////////////////////////
public static final int DB_VER_1 = 1;
public static final int DB_VER_2 = 2;
public static final int DB_VER_3 = 3;
public static final int DB_VER_4 = 4;
public static final int DB_VER_5 = 5;
public static final int DB_VER_6 = 6;
public static final int DB_VER_7 = 7;
public static final int DB_VER_8 = 8;
public static final int DB_VER_9 = 9;
private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG;
public static final Migration MIGRATION_1_2 = new Migration(DB_VER_1, DB_VER_2) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
val DB_VER_1: Int = 1
val DB_VER_2: Int = 2
val DB_VER_3: Int = 3
val DB_VER_4: Int = 4
val DB_VER_5: Int = 5
val DB_VER_6: Int = 6
val DB_VER_7: Int = 7
val DB_VER_8: Int = 8
val DB_VER_9: Int = 9
private val TAG: String = Migrations::class.java.getName()
val DEBUG: Boolean = MainActivity.Companion.DEBUG
val MIGRATION_1_2: Migration = object : Migration(DB_VER_1, DB_VER_2) {
public override fun migrate(database: SupportSQLiteDatabase) {
if (DEBUG) {
Log.d(TAG, "Start migrating database");
Log.d(TAG, "Start migrating database")
}
/*
* Unfortunately these queries must be hardcoded due to the possibility of
@ -45,170 +37,152 @@ public final class Migrations {
// Not much we can do about this, since room doesn't create tables before migration.
// It's either this or blasting the entire database anew.
database.execSQL("CREATE INDEX `index_search_history_search` "
+ "ON `search_history` (`search`)");
database.execSQL("CREATE TABLE IF NOT EXISTS `streams` "
database.execSQL(("CREATE INDEX `index_search_history_search` "
+ "ON `search_history` (`search`)"))
database.execSQL(("CREATE TABLE IF NOT EXISTS `streams` "
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, "
+ "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, "
+ "`thumbnail_url` TEXT)");
database.execSQL("CREATE UNIQUE INDEX `index_streams_service_id_url` "
+ "ON `streams` (`service_id`, `url`)");
database.execSQL("CREATE TABLE IF NOT EXISTS `stream_history` "
+ "`thumbnail_url` TEXT)"))
database.execSQL(("CREATE UNIQUE INDEX `index_streams_service_id_url` "
+ "ON `streams` (`service_id`, `url`)"))
database.execSQL(("CREATE TABLE IF NOT EXISTS `stream_history` "
+ "(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, "
+ "`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), "
+ "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) "
+ "ON UPDATE CASCADE ON DELETE CASCADE )");
database.execSQL("CREATE INDEX `index_stream_history_stream_id` "
+ "ON `stream_history` (`stream_id`)");
database.execSQL("CREATE TABLE IF NOT EXISTS `stream_state` "
+ "ON UPDATE CASCADE ON DELETE CASCADE )"))
database.execSQL(("CREATE INDEX `index_stream_history_stream_id` "
+ "ON `stream_history` (`stream_id`)"))
database.execSQL(("CREATE TABLE IF NOT EXISTS `stream_state` "
+ "(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, "
+ "PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) "
+ "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )");
database.execSQL("CREATE TABLE IF NOT EXISTS `playlists` "
+ "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"))
database.execSQL(("CREATE TABLE IF NOT EXISTS `playlists` "
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "`name` TEXT, `thumbnail_url` TEXT)");
database.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)");
database.execSQL("CREATE TABLE IF NOT EXISTS `playlist_stream_join` "
+ "`name` TEXT, `thumbnail_url` TEXT)"))
database.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)")
database.execSQL(("CREATE TABLE IF NOT EXISTS `playlist_stream_join` "
+ "(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, "
+ "`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), "
+ "FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "
+ "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
database.execSQL("CREATE UNIQUE INDEX "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"))
database.execSQL(("CREATE UNIQUE INDEX "
+ "`index_playlist_stream_join_playlist_id_join_index` "
+ "ON `playlist_stream_join` (`playlist_id`, `join_index`)");
database.execSQL("CREATE INDEX `index_playlist_stream_join_stream_id` "
+ "ON `playlist_stream_join` (`stream_id`)");
database.execSQL("CREATE TABLE IF NOT EXISTS `remote_playlists` "
+ "ON `playlist_stream_join` (`playlist_id`, `join_index`)"))
database.execSQL(("CREATE INDEX `index_playlist_stream_join_stream_id` "
+ "ON `playlist_stream_join` (`stream_id`)"))
database.execSQL(("CREATE TABLE IF NOT EXISTS `remote_playlists` "
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
+ "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)");
database.execSQL("CREATE INDEX `index_remote_playlists_name` "
+ "ON `remote_playlists` (`name`)");
database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
+ "ON `remote_playlists` (`service_id`, `url`)");
+ "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"))
database.execSQL(("CREATE INDEX `index_remote_playlists_name` "
+ "ON `remote_playlists` (`name`)"))
database.execSQL(("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
+ "ON `remote_playlists` (`service_id`, `url`)"))
// Populate streams table with existing entries in watch history
// Latest data first, thus ignoring older entries with the same indices
database.execSQL("INSERT OR IGNORE INTO streams (service_id, url, title, "
database.execSQL(("INSERT OR IGNORE INTO streams (service_id, url, title, "
+ "stream_type, duration, uploader, thumbnail_url) "
+ "SELECT service_id, url, title, 'VIDEO_STREAM', duration, "
+ "uploader, thumbnail_url "
+ "FROM watch_history "
+ "ORDER BY creation_date DESC");
+ "ORDER BY creation_date DESC"))
// Once the streams have PKs, join them with the normalized history table
// and populate it with the remaining data from watch history
database.execSQL("INSERT INTO stream_history (stream_id, access_date, repeat_count)"
database.execSQL(("INSERT INTO stream_history (stream_id, access_date, repeat_count)"
+ "SELECT uid, creation_date, 1 "
+ "FROM watch_history INNER JOIN streams "
+ "ON watch_history.service_id == streams.service_id "
+ "AND watch_history.url == streams.url "
+ "ORDER BY creation_date DESC");
database.execSQL("DROP TABLE IF EXISTS watch_history");
+ "ORDER BY creation_date DESC"))
database.execSQL("DROP TABLE IF EXISTS watch_history")
if (DEBUG) {
Log.d(TAG, "Stop migrating database");
Log.d(TAG, "Stop migrating database")
}
}
};
public static final Migration MIGRATION_2_3 = new Migration(DB_VER_2, DB_VER_3) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
}
val MIGRATION_2_3: Migration = object : Migration(DB_VER_2, DB_VER_3) {
public override fun migrate(database: SupportSQLiteDatabase) {
// Add NOT NULLs and new fields
database.execSQL("CREATE TABLE IF NOT EXISTS streams_new "
database.execSQL(("CREATE TABLE IF NOT EXISTS streams_new "
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, "
+ "stream_type TEXT NOT NULL, duration INTEGER NOT NULL, "
+ "uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, "
+ "textual_upload_date TEXT, upload_date INTEGER, "
+ "is_upload_date_approximation INTEGER)");
database.execSQL("INSERT INTO streams_new (uid, service_id, url, title, stream_type, "
+ "is_upload_date_approximation INTEGER)"))
database.execSQL(("INSERT INTO streams_new (uid, service_id, url, title, stream_type, "
+ "duration, uploader, thumbnail_url, view_count, textual_upload_date, "
+ "upload_date, is_upload_date_approximation) "
+ "SELECT uid, service_id, url, ifnull(title, ''), "
+ "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), "
+ "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL "
+ "FROM streams WHERE url IS NOT NULL");
database.execSQL("DROP TABLE streams");
database.execSQL("ALTER TABLE streams_new RENAME TO streams");
database.execSQL("CREATE UNIQUE INDEX index_streams_service_id_url "
+ "ON streams (service_id, url)");
+ "FROM streams WHERE url IS NOT NULL"))
database.execSQL("DROP TABLE streams")
database.execSQL("ALTER TABLE streams_new RENAME TO streams")
database.execSQL(("CREATE UNIQUE INDEX index_streams_service_id_url "
+ "ON streams (service_id, url)"))
// Tables for feed feature
database.execSQL("CREATE TABLE IF NOT EXISTS feed "
database.execSQL(("CREATE TABLE IF NOT EXISTS feed "
+ "(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, "
+ "PRIMARY KEY(stream_id, subscription_id), "
+ "FOREIGN KEY(stream_id) REFERENCES streams(uid) "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "
+ "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)");
database.execSQL("CREATE TABLE IF NOT EXISTS feed_group "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"))
database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)")
database.execSQL(("CREATE TABLE IF NOT EXISTS feed_group "
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, "
+ "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)");
database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)");
database.execSQL("CREATE TABLE IF NOT EXISTS feed_group_subscription_join "
+ "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"))
database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)")
database.execSQL(("CREATE TABLE IF NOT EXISTS feed_group_subscription_join "
+ "(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, "
+ "PRIMARY KEY(group_id, subscription_id), "
+ "FOREIGN KEY(group_id) REFERENCES feed_group(uid) "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "
+ "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
database.execSQL("CREATE INDEX index_feed_group_subscription_join_subscription_id "
+ "ON feed_group_subscription_join (subscription_id)");
database.execSQL("CREATE TABLE IF NOT EXISTS feed_last_updated "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"))
database.execSQL(("CREATE INDEX index_feed_group_subscription_join_subscription_id "
+ "ON feed_group_subscription_join (subscription_id)"))
database.execSQL(("CREATE TABLE IF NOT EXISTS feed_last_updated "
+ "(subscription_id INTEGER NOT NULL, last_updated INTEGER, "
+ "PRIMARY KEY(subscription_id), "
+ "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) "
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"))
}
};
public static final Migration MIGRATION_3_4 = new Migration(DB_VER_3, DB_VER_4) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
}
val MIGRATION_3_4: Migration = object : Migration(DB_VER_3, DB_VER_4) {
public override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE streams ADD COLUMN uploader_url TEXT"
);
)
}
};
public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
+ "INTEGER NOT NULL DEFAULT 0");
}
val MIGRATION_4_5: Migration = object : Migration(DB_VER_4, DB_VER_5) {
public override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
+ "INTEGER NOT NULL DEFAULT 0"))
}
};
public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` "
+ "INTEGER NOT NULL DEFAULT 0");
}
val MIGRATION_5_6: Migration = object : Migration(DB_VER_5, DB_VER_6) {
public override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` "
+ "INTEGER NOT NULL DEFAULT 0"))
}
};
public static final Migration MIGRATION_6_7 = new Migration(DB_VER_6, DB_VER_7) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
}
val MIGRATION_6_7: Migration = object : Migration(DB_VER_6, DB_VER_7) {
public override fun migrate(database: SupportSQLiteDatabase) {
// Create a new column thumbnail_stream_id
database.execSQL("ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` "
+ "INTEGER NOT NULL DEFAULT -1");
database.execSQL(("ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` "
+ "INTEGER NOT NULL DEFAULT -1"))
// Migrate the thumbnail_url to the thumbnail_stream_id
database.execSQL("UPDATE playlists SET thumbnail_stream_id = ("
database.execSQL(("UPDATE playlists SET thumbnail_stream_id = ("
+ " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END"
+ " FROM ("
+ " SELECT p.uid AS playlist_uid, s.uid AS stream_uid"
@ -216,92 +190,81 @@ public final class Migrations {
+ " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id"
+ " LEFT JOIN streams s ON s.uid = ps.stream_id"
+ " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table"
+ " WHERE playlist_uid = playlists.uid)");
+ " WHERE playlist_uid = playlists.uid)"))
// Remove the thumbnail_url field in the playlist table
database.execSQL("CREATE TABLE IF NOT EXISTS `playlists_new`"
database.execSQL(("CREATE TABLE IF NOT EXISTS `playlists_new`"
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "name TEXT, "
+ "is_thumbnail_permanent INTEGER NOT NULL, "
+ "thumbnail_stream_id INTEGER NOT NULL)");
database.execSQL("INSERT INTO playlists_new"
+ "thumbnail_stream_id INTEGER NOT NULL)"))
database.execSQL(("INSERT INTO playlists_new"
+ " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id "
+ " FROM playlists");
database.execSQL("DROP TABLE playlists");
database.execSQL("ALTER TABLE playlists_new RENAME TO playlists");
database.execSQL("CREATE INDEX IF NOT EXISTS "
+ "`index_playlists_name` ON `playlists` (`name`)");
+ " FROM playlists"))
database.execSQL("DROP TABLE playlists")
database.execSQL("ALTER TABLE playlists_new RENAME TO playlists")
database.execSQL(("CREATE INDEX IF NOT EXISTS "
+ "`index_playlists_name` ON `playlists` (`name`)"))
}
};
public static final Migration MIGRATION_7_8 = new Migration(DB_VER_7, DB_VER_8) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT "
+ "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)");
database.execSQL("UPDATE search_history SET search = trim(search)");
}
val MIGRATION_7_8: Migration = object : Migration(DB_VER_7, DB_VER_8) {
public override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(("DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT "
+ "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)"))
database.execSQL("UPDATE search_history SET search = trim(search)")
}
};
public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
}
val MIGRATION_8_9: Migration = object : Migration(DB_VER_8, DB_VER_9) {
public override fun migrate(database: SupportSQLiteDatabase) {
try {
database.beginTransaction();
database.beginTransaction()
// Update playlists.
// Create a temp table to initialize display_index.
database.execSQL("CREATE TABLE `playlists_tmp` "
database.execSQL(("CREATE TABLE `playlists_tmp` "
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, "
+ "`thumbnail_stream_id` INTEGER NOT NULL, "
+ "`display_index` INTEGER NOT NULL)");
database.execSQL("INSERT INTO `playlists_tmp` "
+ "`display_index` INTEGER NOT NULL)"))
database.execSQL(("INSERT INTO `playlists_tmp` "
+ "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
+ "`display_index`) "
+ "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
+ "-1 "
+ "FROM `playlists`");
+ "FROM `playlists`"))
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
database.execSQL("DROP TABLE `playlists`");
database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`");
database.execSQL("DROP TABLE `playlists`")
database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`")
// Update remote_playlists.
// Create a temp table to initialize display_index.
database.execSQL("CREATE TABLE `remote_playlists_tmp` "
database.execSQL(("CREATE TABLE `remote_playlists_tmp` "
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
+ "`thumbnail_url` TEXT, `uploader` TEXT, "
+ "`display_index` INTEGER NOT NULL,"
+ "`stream_count` INTEGER)");
database.execSQL("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, "
+ "`stream_count` INTEGER)"))
database.execSQL(("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, "
+ "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, "
+ "`stream_count`)"
+ "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, "
+ "-1, `stream_count` FROM `remote_playlists`");
+ "-1, `stream_count` FROM `remote_playlists`"))
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
database.execSQL("DROP TABLE `remote_playlists`");
database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`");
database.execSQL("DROP TABLE `remote_playlists`")
database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`")
// Create index on the new table.
database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
+ "ON `remote_playlists` (`service_id`, `url`)");
database.setTransactionSuccessful();
database.execSQL(("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
+ "ON `remote_playlists` (`service_id`, `url`)"))
database.setTransactionSuccessful()
} finally {
database.endTransaction();
database.endTransaction()
}
}
};
private Migrations() {
}
}

View File

@ -1,7 +0,0 @@
package org.schabi.newpipe.database.history.dao;
import org.schabi.newpipe.database.BasicDAO;
public interface HistoryDAO<T> extends BasicDAO<T> {
T getLatestEntry();
}

View File

@ -0,0 +1,7 @@
package org.schabi.newpipe.database.history.dao
import org.schabi.newpipe.database.BasicDAO
open interface HistoryDAO<T> : BasicDAO<T> {
fun getLatestEntry(): T
}

View File

@ -1,52 +0,0 @@
package org.schabi.newpipe.database.history.dao;
import androidx.annotation.Nullable;
import androidx.room.Dao;
import androidx.room.Query;
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.CREATION_DATE;
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.ID;
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SEARCH;
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SERVICE_ID;
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.TABLE_NAME;
@Dao
public interface SearchHistoryDAO extends HistoryDAO<SearchHistoryEntry> {
String ORDER_BY_CREATION_DATE = " ORDER BY " + CREATION_DATE + " DESC";
String ORDER_BY_MAX_CREATION_DATE = " ORDER BY MAX(" + CREATION_DATE + ") DESC";
@Query("SELECT * FROM " + TABLE_NAME
+ " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")")
@Nullable
SearchHistoryEntry getLatestEntry();
@Query("DELETE FROM " + TABLE_NAME)
@Override
int deleteAll();
@Query("DELETE FROM " + TABLE_NAME + " WHERE " + SEARCH + " = :query")
int deleteAllWhereQuery(String query);
@Query("SELECT * FROM " + TABLE_NAME + ORDER_BY_CREATION_DATE)
@Override
Flowable<List<SearchHistoryEntry>> getAll();
@Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " GROUP BY " + SEARCH
+ ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
Flowable<List<String>> getUniqueEntries(int limit);
@Query("SELECT * FROM " + TABLE_NAME
+ " WHERE " + SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE)
@Override
Flowable<List<SearchHistoryEntry>> listByService(int serviceId);
@Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " WHERE " + SEARCH + " LIKE :query || '%'"
+ " GROUP BY " + SEARCH + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
Flowable<List<String>> getSimilarEntries(String query, int limit);
}

View File

@ -0,0 +1,37 @@
package org.schabi.newpipe.database.history.dao
import androidx.room.Dao
import androidx.room.Query
import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.history.model.SearchHistoryEntry
@Dao
open interface SearchHistoryDAO : HistoryDAO<SearchHistoryEntry?> {
@Query(("SELECT * FROM " + SearchHistoryEntry.TABLE_NAME
+ " WHERE " + SearchHistoryEntry.ID + " = (SELECT MAX(" + SearchHistoryEntry.ID + ") FROM " + SearchHistoryEntry.TABLE_NAME + ")"))
public override fun getLatestEntry(): SearchHistoryEntry?
@Query("DELETE FROM " + SearchHistoryEntry.TABLE_NAME)
public override fun deleteAll(): Int
@Query("DELETE FROM " + SearchHistoryEntry.TABLE_NAME + " WHERE " + SearchHistoryEntry.SEARCH + " = :query")
fun deleteAllWhereQuery(query: String?): Int
@Query("SELECT * FROM " + SearchHistoryEntry.TABLE_NAME + ORDER_BY_CREATION_DATE)
public override fun getAll(): Flowable<MutableList<SearchHistoryEntry?>>?
@Query(("SELECT " + SearchHistoryEntry.SEARCH + " FROM " + SearchHistoryEntry.TABLE_NAME + " GROUP BY " + SearchHistoryEntry.SEARCH
+ ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit"))
fun getUniqueEntries(limit: Int): Flowable<List<String?>?>?
@Query(("SELECT * FROM " + SearchHistoryEntry.TABLE_NAME
+ " WHERE " + SearchHistoryEntry.SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE))
public override fun listByService(serviceId: Int): Flowable<MutableList<SearchHistoryEntry?>>?
@Query(("SELECT " + SearchHistoryEntry.SEARCH + " FROM " + SearchHistoryEntry.TABLE_NAME + " WHERE " + SearchHistoryEntry.SEARCH + " LIKE :query || '%'"
+ " GROUP BY " + SearchHistoryEntry.SEARCH + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit"))
fun getSimilarEntries(query: String?, limit: Int): Flowable<List<String?>?>?
companion object {
val ORDER_BY_CREATION_DATE: String = " ORDER BY " + SearchHistoryEntry.CREATION_DATE + " DESC"
val ORDER_BY_MAX_CREATION_DATE: String = " ORDER BY MAX(" + SearchHistoryEntry.CREATION_DATE + ") DESC"
}
}

View File

@ -1,89 +0,0 @@
package org.schabi.newpipe.database.history.dao;
import androidx.annotation.Nullable;
import androidx.room.Dao;
import androidx.room.Query;
import androidx.room.RewriteQueriesToDropUnusedColumns;
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE;
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE;
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_REPEAT_COUNT;
import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_LATEST_DATE;
import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WATCH_COUNT;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@Dao
public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity> {
@Query("SELECT * FROM " + STREAM_HISTORY_TABLE
+ " WHERE " + STREAM_ACCESS_DATE + " = "
+ "(SELECT MAX(" + STREAM_ACCESS_DATE + ") FROM " + STREAM_HISTORY_TABLE + ")")
@Override
@Nullable
public abstract StreamHistoryEntity getLatestEntry();
@Override
@Query("SELECT * FROM " + STREAM_HISTORY_TABLE)
public abstract Flowable<List<StreamHistoryEntity>> getAll();
@Override
@Query("DELETE FROM " + STREAM_HISTORY_TABLE)
public abstract int deleteAll();
@Override
public Flowable<List<StreamHistoryEntity>> listByService(final int serviceId) {
throw new UnsupportedOperationException();
}
@Query("SELECT * FROM " + STREAM_TABLE
+ " INNER JOIN " + STREAM_HISTORY_TABLE
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
+ " ORDER BY " + STREAM_ACCESS_DATE + " DESC")
public abstract Flowable<List<StreamHistoryEntry>> getHistory();
@Query("SELECT * FROM " + STREAM_TABLE
+ " INNER JOIN " + STREAM_HISTORY_TABLE
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
+ " ORDER BY " + STREAM_ID + " ASC")
public abstract Flowable<List<StreamHistoryEntry>> getHistorySortedById();
@Query("SELECT * FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID
+ " = :streamId ORDER BY " + STREAM_ACCESS_DATE + " DESC LIMIT 1")
@Nullable
public abstract StreamHistoryEntity getLatestEntry(long streamId);
@Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
public abstract int deleteStreamHistory(long streamId);
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM " + STREAM_TABLE
// Select the latest entry and watch count for each stream id on history table
+ " INNER JOIN "
+ "(SELECT " + JOIN_STREAM_ID + ", "
+ " MAX(" + STREAM_ACCESS_DATE + ") AS " + STREAM_LATEST_DATE + ", "
+ " SUM(" + STREAM_REPEAT_COUNT + ") AS " + STREAM_WATCH_COUNT
+ " FROM " + STREAM_HISTORY_TABLE + " GROUP BY " + JOIN_STREAM_ID + ")"
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
+ " LEFT JOIN "
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
+ STREAM_PROGRESS_MILLIS
+ " FROM " + STREAM_STATE_TABLE + " )"
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS)
public abstract Flowable<List<StreamStatisticsEntry>> getStatistics();
}

View File

@ -0,0 +1,59 @@
package org.schabi.newpipe.database.history.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
import org.schabi.newpipe.database.stream.StreamStatisticsEntry
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
@Dao
abstract class StreamHistoryDAO() : HistoryDAO<StreamHistoryEntity?> {
@Query(("SELECT * FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
+ " WHERE " + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + " = "
+ "(SELECT MAX(" + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + ") FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + ")"))
abstract override fun getLatestEntry(): StreamHistoryEntity?
@Query("SELECT * FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE)
abstract override fun getAll(): Flowable<List<StreamHistoryEntity>?>?
@Query("DELETE FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE)
abstract override fun deleteAll(): Int
public override fun listByService(serviceId: Int): Flowable<MutableList<StreamHistoryEntity?>>? {
throw UnsupportedOperationException()
}
@Query(("SELECT * FROM " + StreamEntity.STREAM_TABLE
+ " INNER JOIN " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
+ " ON " + StreamEntity.STREAM_ID + " = " + StreamHistoryEntity.Companion.JOIN_STREAM_ID
+ " ORDER BY " + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + " DESC"))
abstract fun getHistory(): Flowable<List<StreamHistoryEntry?>?>?
@Query(("SELECT * FROM " + StreamEntity.STREAM_TABLE
+ " INNER JOIN " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
+ " ON " + StreamEntity.STREAM_ID + " = " + StreamHistoryEntity.Companion.JOIN_STREAM_ID
+ " ORDER BY " + StreamEntity.STREAM_ID + " ASC"))
abstract fun getHistorySortedById(): Flowable<List<StreamHistoryEntry?>?>
@Query(("SELECT * FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + " WHERE " + StreamHistoryEntity.Companion.JOIN_STREAM_ID
+ " = :streamId ORDER BY " + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + " DESC LIMIT 1"))
abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity?
@Query("DELETE FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + " WHERE " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + " = :streamId")
abstract fun deleteStreamHistory(streamId: Long): Int
@RewriteQueriesToDropUnusedColumns
@Query(("SELECT * FROM " + StreamEntity.STREAM_TABLE // Select the latest entry and watch count for each stream id on history table
+ " INNER JOIN "
+ "(SELECT " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + ", "
+ " MAX(" + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + ") AS " + StreamStatisticsEntry.STREAM_LATEST_DATE + ", "
+ " SUM(" + StreamHistoryEntity.Companion.STREAM_REPEAT_COUNT + ") AS " + StreamStatisticsEntry.STREAM_WATCH_COUNT
+ " FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + " GROUP BY " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + ")"
+ " ON " + StreamEntity.STREAM_ID + " = " + StreamHistoryEntity.Companion.JOIN_STREAM_ID
+ " LEFT JOIN "
+ "(SELECT " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + " AS " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS + ", "
+ StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
+ " FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " )"
+ " ON " + StreamEntity.STREAM_ID + " = " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS))
abstract fun getStatistics(): Flowable<List<StreamStatisticsEntry?>?>
}

View File

@ -1,81 +0,0 @@
package org.schabi.newpipe.database.history.model;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import java.time.OffsetDateTime;
import static androidx.room.ForeignKey.CASCADE;
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE;
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE;
@Entity(tableName = STREAM_HISTORY_TABLE,
primaryKeys = {JOIN_STREAM_ID, STREAM_ACCESS_DATE},
// No need to index for timestamp as they will almost always be unique
indices = {@Index(value = {JOIN_STREAM_ID})},
foreignKeys = {
@ForeignKey(entity = StreamEntity.class,
parentColumns = StreamEntity.STREAM_ID,
childColumns = JOIN_STREAM_ID,
onDelete = CASCADE, onUpdate = CASCADE)
})
public class StreamHistoryEntity {
public static final String STREAM_HISTORY_TABLE = "stream_history";
public static final String JOIN_STREAM_ID = "stream_id";
public static final String STREAM_ACCESS_DATE = "access_date";
public static final String STREAM_REPEAT_COUNT = "repeat_count";
@ColumnInfo(name = JOIN_STREAM_ID)
private long streamUid;
@NonNull
@ColumnInfo(name = STREAM_ACCESS_DATE)
private OffsetDateTime accessDate;
@ColumnInfo(name = STREAM_REPEAT_COUNT)
private long repeatCount;
/**
* @param streamUid the stream id this history item will refer to
* @param accessDate the last time the stream was accessed
* @param repeatCount the total number of views this stream received
*/
public StreamHistoryEntity(final long streamUid,
@NonNull final OffsetDateTime accessDate,
final long repeatCount) {
this.streamUid = streamUid;
this.accessDate = accessDate;
this.repeatCount = repeatCount;
}
public long getStreamUid() {
return streamUid;
}
public void setStreamUid(final long streamUid) {
this.streamUid = streamUid;
}
@NonNull
public OffsetDateTime getAccessDate() {
return accessDate;
}
public void setAccessDate(@NonNull final OffsetDateTime accessDate) {
this.accessDate = accessDate;
}
public long getRepeatCount() {
return repeatCount;
}
public void setRepeatCount(final long repeatCount) {
this.repeatCount = repeatCount;
}
}

View File

@ -0,0 +1,50 @@
package org.schabi.newpipe.database.history.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import java.time.OffsetDateTime
@Entity(tableName = StreamHistoryEntity.STREAM_HISTORY_TABLE, primaryKeys = [StreamHistoryEntity.JOIN_STREAM_ID, StreamHistoryEntity.STREAM_ACCESS_DATE], indices = [Index(value = [StreamHistoryEntity.JOIN_STREAM_ID])], foreignKeys = [ForeignKey(entity = StreamEntity::class, parentColumns = StreamEntity.STREAM_ID, childColumns = StreamHistoryEntity.JOIN_STREAM_ID, onDelete = CASCADE, onUpdate = CASCADE)])
class StreamHistoryEntity
/**
* @param streamUid the stream id this history item will refer to
* @param accessDate the last time the stream was accessed
* @param repeatCount the total number of views this stream received
*/(@field:ColumnInfo(name = JOIN_STREAM_ID) private var streamUid: Long,
@field:ColumnInfo(name = STREAM_ACCESS_DATE) private var accessDate: OffsetDateTime,
@field:ColumnInfo(name = STREAM_REPEAT_COUNT) private var repeatCount: Long) {
fun getStreamUid(): Long {
return streamUid
}
fun setStreamUid(streamUid: Long) {
this.streamUid = streamUid
}
fun getAccessDate(): OffsetDateTime {
return accessDate
}
fun setAccessDate(accessDate: OffsetDateTime) {
this.accessDate = accessDate
}
fun getRepeatCount(): Long {
return repeatCount
}
fun setRepeatCount(repeatCount: Long) {
this.repeatCount = repeatCount
}
companion object {
val STREAM_HISTORY_TABLE: String = "stream_history"
val JOIN_STREAM_ID: String = "stream_id"
val STREAM_ACCESS_DATE: String = "access_date"
val STREAM_REPEAT_COUNT: String = "repeat_count"
}
}

View File

@ -1,29 +0,0 @@
package org.schabi.newpipe.database.playlist;
import androidx.room.ColumnInfo;
/**
* This class adds a field to {@link PlaylistMetadataEntry} that contains an integer representing
* how many times a specific stream is already contained inside a local playlist. Used to be able
* to grey out playlists which already contain the current stream in the playlist append dialog.
* @see org.schabi.newpipe.local.playlist.LocalPlaylistManager#getPlaylistDuplicates(String)
*/
public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
public static final String PLAYLIST_TIMES_STREAM_IS_CONTAINED = "timesStreamIsContained";
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
public final long timesStreamIsContained;
@SuppressWarnings("checkstyle:ParameterNumber")
public PlaylistDuplicatesEntry(final long uid,
final String name,
final String thumbnailUrl,
final boolean isThumbnailPermanent,
final long thumbnailStreamId,
final long displayIndex,
final long streamCount,
final long timesStreamIsContained) {
super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex,
streamCount);
this.timesStreamIsContained = timesStreamIsContained;
}
}

View File

@ -0,0 +1,23 @@
package org.schabi.newpipe.database.playlist
import androidx.room.ColumnInfo
/**
* This class adds a field to [PlaylistMetadataEntry] that contains an integer representing
* how many times a specific stream is already contained inside a local playlist. Used to be able
* to grey out playlists which already contain the current stream in the playlist append dialog.
* @see org.schabi.newpipe.local.playlist.LocalPlaylistManager.getPlaylistDuplicates
*/
class PlaylistDuplicatesEntry(uid: Long,
name: String,
thumbnailUrl: String,
isThumbnailPermanent: Boolean,
thumbnailStreamId: Long,
displayIndex: Long,
streamCount: Long,
@field:ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED) val timesStreamIsContained: Long) : PlaylistMetadataEntry(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex,
streamCount) {
companion object {
val PLAYLIST_TIMES_STREAM_IS_CONTAINED: String = "timesStreamIsContained"
}
}

View File

@ -1,13 +0,0 @@
package org.schabi.newpipe.database.playlist;
import org.schabi.newpipe.database.LocalItem;
public interface PlaylistLocalItem extends LocalItem {
String getOrderingName();
long getDisplayIndex();
long getUid();
void setDisplayIndex(long displayIndex);
}

View File

@ -0,0 +1,10 @@
package org.schabi.newpipe.database.playlist
import org.schabi.newpipe.database.LocalItem
open interface PlaylistLocalItem : LocalItem {
fun getOrderingName(): String
fun getDisplayIndex(): Long
fun getUid(): Long
fun setDisplayIndex(displayIndex: Long)
}

View File

@ -1,74 +0,0 @@
package org.schabi.newpipe.database.playlist;
import androidx.room.ColumnInfo;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
public class PlaylistMetadataEntry implements PlaylistLocalItem {
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
@ColumnInfo(name = PLAYLIST_ID)
private final long uid;
@ColumnInfo(name = PLAYLIST_NAME)
public final String name;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
private final boolean isThumbnailPermanent;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
private final long thumbnailStreamId;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
public final String thumbnailUrl;
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
private long displayIndex;
@ColumnInfo(name = PLAYLIST_STREAM_COUNT)
public final long streamCount;
public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl,
final boolean isThumbnailPermanent, final long thumbnailStreamId,
final long displayIndex, final long streamCount) {
this.uid = uid;
this.name = name;
this.thumbnailUrl = thumbnailUrl;
this.isThumbnailPermanent = isThumbnailPermanent;
this.thumbnailStreamId = thumbnailStreamId;
this.displayIndex = displayIndex;
this.streamCount = streamCount;
}
@Override
public LocalItemType getLocalItemType() {
return LocalItemType.PLAYLIST_LOCAL_ITEM;
}
@Override
public String getOrderingName() {
return name;
}
public boolean isThumbnailPermanent() {
return isThumbnailPermanent;
}
public long getThumbnailStreamId() {
return thumbnailStreamId;
}
@Override
public long getDisplayIndex() {
return displayIndex;
}
@Override
public long getUid() {
return uid;
}
@Override
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
}

View File

@ -0,0 +1,41 @@
package org.schabi.newpipe.database.playlist
import androidx.room.ColumnInfo
import org.schabi.newpipe.database.LocalItem.LocalItemType
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
open class PlaylistMetadataEntry(@field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_ID) private val uid: Long, @JvmField @field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_NAME) val name: String, @field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_URL) val thumbnailUrl: String,
@field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_PERMANENT) private val isThumbnailPermanent: Boolean, @field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID) private val thumbnailStreamId: Long,
@field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX) private var displayIndex: Long, @field:ColumnInfo(name = PLAYLIST_STREAM_COUNT) val streamCount: Long) : PlaylistLocalItem {
public override fun getLocalItemType(): LocalItemType {
return LocalItemType.PLAYLIST_LOCAL_ITEM
}
public override fun getOrderingName(): String {
return name
}
fun isThumbnailPermanent(): Boolean {
return isThumbnailPermanent
}
fun getThumbnailStreamId(): Long {
return thumbnailStreamId
}
public override fun getDisplayIndex(): Long {
return displayIndex
}
public override fun getUid(): Long {
return uid
}
public override fun setDisplayIndex(displayIndex: Long) {
this.displayIndex = displayIndex
}
companion object {
val PLAYLIST_STREAM_COUNT: String = "streamCount"
}
}

View File

@ -1,53 +0,0 @@
package org.schabi.newpipe.database.playlist.dao;
import androidx.room.Dao;
import androidx.room.Query;
import androidx.room.Transaction;
import org.schabi.newpipe.database.BasicDAO;
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
@Dao
public interface PlaylistDAO extends BasicDAO<PlaylistEntity> {
@Override
@Query("SELECT * FROM " + PLAYLIST_TABLE)
Flowable<List<PlaylistEntity>> getAll();
@Override
@Query("DELETE FROM " + PLAYLIST_TABLE)
int deleteAll();
@Override
default Flowable<List<PlaylistEntity>> listByService(final int serviceId) {
throw new UnsupportedOperationException();
}
@Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId")
Flowable<List<PlaylistEntity>> getPlaylist(long playlistId);
@Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId")
int deletePlaylist(long playlistId);
@Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE)
Flowable<Long> getCount();
@Transaction
default long upsertPlaylist(final PlaylistEntity playlist) {
final long playlistId = playlist.getUid();
if (playlistId == -1) {
// This situation is probably impossible.
return insert(playlist);
} else {
update(playlist);
return playlistId;
}
}
}

View File

@ -0,0 +1,40 @@
package org.schabi.newpipe.database.playlist.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
@Dao
open interface PlaylistDAO : BasicDAO<PlaylistEntity?> {
@Query("SELECT * FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE)
public override fun getAll(): Flowable<List<PlaylistEntity?>?>?
@Query("DELETE FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE)
public override fun deleteAll(): Int
public override fun listByService(serviceId: Int): Flowable<List<PlaylistEntity?>?>? {
throw UnsupportedOperationException()
}
@Query("SELECT * FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE + " WHERE " + PlaylistEntity.Companion.PLAYLIST_ID + " = :playlistId")
fun getPlaylist(playlistId: Long): Flowable<List<PlaylistEntity?>>
@Query("DELETE FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE + " WHERE " + PlaylistEntity.Companion.PLAYLIST_ID + " = :playlistId")
fun deletePlaylist(playlistId: Long): Int
@Query("SELECT COUNT(*) FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE)
fun getCount(): Flowable<Long?>
@Transaction
fun upsertPlaylist(playlist: PlaylistEntity): Long {
val playlistId: Long = playlist.getUid()
if (playlistId == -1L) {
// This situation is probably impossible.
return insert(playlist)
} else {
update(playlist)
return playlistId
}
}
}

View File

@ -1,68 +0,0 @@
package org.schabi.newpipe.database.playlist.dao;
import androidx.room.Dao;
import androidx.room.Query;
import androidx.room.Transaction;
import org.schabi.newpipe.database.BasicDAO;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL;
@Dao
public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
@Override
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE)
Flowable<List<PlaylistRemoteEntity>> getAll();
@Override
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE)
int deleteAll();
@Override
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_ID + " = :playlistId")
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long playlistId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistRemoteEntity>> getPlaylists();
@Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_URL + " = :url "
+ "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Long getPlaylistIdInternal(long serviceId, String url);
@Transaction
default long upsert(final PlaylistRemoteEntity playlist) {
final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl());
if (playlistId == null) {
return insert(playlist);
} else {
playlist.setUid(playlistId);
update(playlist);
return playlistId;
}
}
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId")
int deletePlaylist(long playlistId);
}

View File

@ -0,0 +1,53 @@
package org.schabi.newpipe.database.playlist.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
@Dao
open interface PlaylistRemoteDAO : BasicDAO<PlaylistRemoteEntity?> {
@Query("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE)
public override fun getAll(): Flowable<List<PlaylistRemoteEntity?>?>?
@Query("DELETE FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE)
public override fun deleteAll(): Int
@Query(("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE
+ " WHERE " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId"))
public override fun listByService(serviceId: Int): Flowable<List<PlaylistRemoteEntity?>?>?
@Query(("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE + " WHERE "
+ PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_ID + " = :playlistId"))
fun getPlaylist(playlistId: Long): Flowable<List<PlaylistRemoteEntity?>?>?
@Query(("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE + " WHERE "
+ PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL + " = :url AND " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId"))
fun getPlaylist(serviceId: Long, url: String?): Flowable<List<PlaylistRemoteEntity?>?>
@Query(("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE
+ " ORDER BY " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_DISPLAY_INDEX))
fun getPlaylists(): Flowable<List<PlaylistRemoteEntity?>?>
@Query(("SELECT " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_ID + " FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE
+ " WHERE " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL + " = :url "
+ "AND " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId"))
fun getPlaylistIdInternal(serviceId: Long, url: String?): Long
@Transaction
fun upsert(playlist: PlaylistRemoteEntity): Long {
val playlistId: Long = getPlaylistIdInternal(playlist.getServiceId().toLong(), playlist.getUrl())
if (playlistId == null) {
return insert(playlist)
} else {
playlist.setUid(playlistId)
update(playlist)
return playlistId
}
}
@Query(("DELETE FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE
+ " WHERE " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_ID + " = :playlistId"))
fun deletePlaylist(playlistId: Long): Int
}

View File

@ -1,159 +0,0 @@
package org.schabi.newpipe.database.playlist.dao;
import androidx.room.Dao;
import androidx.room.Query;
import androidx.room.RewriteQueriesToDropUnusedColumns;
import androidx.room.Transaction;
import org.schabi.newpipe.database.BasicDAO;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED;
import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@Dao
public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
@Override
@Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE)
Flowable<List<PlaylistStreamEntity>> getAll();
@Override
@Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE)
int deleteAll();
@Override
default Flowable<List<PlaylistStreamEntity>> listByService(final int serviceId) {
throw new UnsupportedOperationException();
}
@Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
void deleteBatch(long playlistId);
@Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)"
+ " FROM " + PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
Flowable<Integer> getMaximumIndexOf(long playlistId);
@Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_ID
+ " ELSE " + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " END"
+ " FROM " + STREAM_TABLE
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId "
+ " LIMIT 1"
)
Flowable<Long> getAutomaticThumbnailStreamId(long playlistId);
@RewriteQueriesToDropUnusedColumns
@Transaction
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
// get ids of streams of the given playlist
+ "(SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX
+ " FROM " + PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)"
// then merge with the stream metadata
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
+ " LEFT JOIN "
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
+ STREAM_PROGRESS_MILLIS
+ " FROM " + STREAM_STATE_TABLE + " )"
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS
+ " ORDER BY " + JOIN_INDEX + " ASC")
Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId);
@Transaction
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
+ PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
+ " ELSE (SELECT " + STREAM_THUMBNAIL_URL
+ " FROM " + STREAM_TABLE
+ " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID
+ " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", "
+ "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT
+ " FROM " + PLAYLIST_TABLE
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
+ " GROUP BY " + PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
@RewriteQueriesToDropUnusedColumns
@Transaction
@Query("SELECT *, MIN(" + JOIN_INDEX + ")"
+ " FROM " + STREAM_TABLE + " INNER JOIN"
+ " (SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX
+ " FROM " + PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)"
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
+ " LEFT JOIN "
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
+ STREAM_PROGRESS_MILLIS
+ " FROM " + STREAM_STATE_TABLE + " )"
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS
+ " GROUP BY " + STREAM_ID
+ " ORDER BY MIN(" + JOIN_INDEX + ") ASC")
Flowable<List<PlaylistStreamEntry>> getStreamsWithoutDuplicates(long playlistId);
@Transaction
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
+ PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
+ " ELSE (SELECT " + STREAM_THUMBNAIL_URL
+ " FROM " + STREAM_TABLE
+ " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID
+ " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", "
+ "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + ", "
+ "COALESCE(SUM(" + STREAM_URL + " = :streamUrl), 0) AS "
+ PLAYLIST_TIMES_STREAM_IS_CONTAINED
+ " FROM " + PLAYLIST_TABLE
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
+ " LEFT JOIN " + STREAM_TABLE
+ " ON " + STREAM_TABLE + "." + STREAM_ID + " = " + JOIN_STREAM_ID
+ " AND :streamUrl = :streamUrl"
+ " GROUP BY " + JOIN_PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
}

View File

@ -0,0 +1,117 @@
package org.schabi.newpipe.database.playlist.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
@Dao
open interface PlaylistStreamDAO : BasicDAO<PlaylistStreamEntity?> {
@Query("SELECT * FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE)
public override fun getAll(): Flowable<List<PlaylistStreamEntity?>?>?
@Query("DELETE FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE)
public override fun deleteAll(): Int
public override fun listByService(serviceId: Int): Flowable<List<PlaylistStreamEntity?>?>? {
throw UnsupportedOperationException()
}
@Query(("DELETE FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId"))
fun deleteBatch(playlistId: Long)
@Query(("SELECT COALESCE(MAX(" + PlaylistStreamEntity.Companion.JOIN_INDEX + "), -1)"
+ " FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId"))
fun getMaximumIndexOf(playlistId: Long): Flowable<Int?>
@Query(("SELECT CASE WHEN COUNT(*) != 0 then " + StreamEntity.STREAM_ID
+ " ELSE " + PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID + " END"
+ " FROM " + StreamEntity.STREAM_TABLE
+ " LEFT JOIN " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + StreamEntity.STREAM_ID + " = " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID
+ " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId "
+ " LIMIT 1"))
fun getAutomaticThumbnailStreamId(playlistId: Long): Flowable<Long>
@RewriteQueriesToDropUnusedColumns
@Transaction
@Query(("SELECT * FROM " + StreamEntity.STREAM_TABLE + " INNER JOIN " // get ids of streams of the given playlist
+ "(SELECT " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + "," + PlaylistStreamEntity.Companion.JOIN_INDEX
+ " FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId)" // then merge with the stream metadata
+ " ON " + StreamEntity.STREAM_ID + " = " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID
+ " LEFT JOIN "
+ "(SELECT " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + " AS " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS + ", "
+ StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
+ " FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " )"
+ " ON " + StreamEntity.STREAM_ID + " = " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS
+ " ORDER BY " + PlaylistStreamEntity.Companion.JOIN_INDEX + " ASC"))
fun getOrderedStreamsOf(playlistId: Long): Flowable<List<PlaylistStreamEntry?>?>
@Transaction
@Query(("SELECT " + PlaylistEntity.Companion.PLAYLIST_ID + ", " + PlaylistEntity.Companion.PLAYLIST_NAME + ", "
+ PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_PERMANENT + ", " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + ", "
+ PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + PlaylistEntity.Companion.DEFAULT_THUMBNAIL + "'"
+ " ELSE (SELECT " + StreamEntity.STREAM_THUMBNAIL_URL
+ " FROM " + StreamEntity.STREAM_TABLE
+ " WHERE " + StreamEntity.STREAM_TABLE + "." + StreamEntity.STREAM_ID + " = " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID
+ " ) END AS " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_URL + ", "
+ "COALESCE(COUNT(" + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + "), 0) AS " + PlaylistMetadataEntry.Companion.PLAYLIST_STREAM_COUNT
+ " FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE
+ " LEFT JOIN " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PlaylistEntity.Companion.PLAYLIST_TABLE + "." + PlaylistEntity.Companion.PLAYLIST_ID + " = " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID
+ " GROUP BY " + PlaylistEntity.Companion.PLAYLIST_ID
+ " ORDER BY " + PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX))
fun getPlaylistMetadata(): Flowable<List<PlaylistMetadataEntry?>?>
@RewriteQueriesToDropUnusedColumns
@Transaction
@Query(("SELECT *, MIN(" + PlaylistStreamEntity.Companion.JOIN_INDEX + ")"
+ " FROM " + StreamEntity.STREAM_TABLE + " INNER JOIN"
+ " (SELECT " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + "," + PlaylistStreamEntity.Companion.JOIN_INDEX
+ " FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
+ " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId)"
+ " ON " + StreamEntity.STREAM_ID + " = " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID
+ " LEFT JOIN "
+ "(SELECT " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + " AS " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS + ", "
+ StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
+ " FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " )"
+ " ON " + StreamEntity.STREAM_ID + " = " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS
+ " GROUP BY " + StreamEntity.STREAM_ID
+ " ORDER BY MIN(" + PlaylistStreamEntity.Companion.JOIN_INDEX + ") ASC"))
fun getStreamsWithoutDuplicates(playlistId: Long): Flowable<List<PlaylistStreamEntry?>?>
@Transaction
@Query(("SELECT " + PlaylistEntity.Companion.PLAYLIST_TABLE + "." + PlaylistEntity.Companion.PLAYLIST_ID + ", " + PlaylistEntity.Companion.PLAYLIST_NAME + ", "
+ PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_PERMANENT + ", " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + ", "
+ PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + PlaylistEntity.Companion.DEFAULT_THUMBNAIL + "'"
+ " ELSE (SELECT " + StreamEntity.STREAM_THUMBNAIL_URL
+ " FROM " + StreamEntity.STREAM_TABLE
+ " WHERE " + StreamEntity.STREAM_TABLE + "." + StreamEntity.STREAM_ID + " = " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID
+ " ) END AS " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_URL + ", "
+ "COALESCE(COUNT(" + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + "), 0) AS " + PlaylistMetadataEntry.Companion.PLAYLIST_STREAM_COUNT + ", "
+ "COALESCE(SUM(" + StreamEntity.STREAM_URL + " = :streamUrl), 0) AS "
+ PlaylistDuplicatesEntry.Companion.PLAYLIST_TIMES_STREAM_IS_CONTAINED
+ " FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE
+ " LEFT JOIN " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PlaylistEntity.Companion.PLAYLIST_TABLE + "." + PlaylistEntity.Companion.PLAYLIST_ID + " = " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID
+ " LEFT JOIN " + StreamEntity.STREAM_TABLE
+ " ON " + StreamEntity.STREAM_TABLE + "." + StreamEntity.STREAM_ID + " = " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID
+ " AND :streamUrl = :streamUrl"
+ " GROUP BY " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID
+ " ORDER BY " + PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX))
fun getPlaylistDuplicatesMetadata(streamUrl: String?): Flowable<List<PlaylistDuplicatesEntry?>?>
}

View File

@ -1,100 +0,0 @@
package org.schabi.newpipe.database.playlist.model;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
@Entity(tableName = PLAYLIST_TABLE)
public class PlaylistEntity {
public static final String DEFAULT_THUMBNAIL = "drawable://"
+ R.drawable.placeholder_thumbnail_playlist;
public static final long DEFAULT_THUMBNAIL_ID = -1;
public static final String PLAYLIST_TABLE = "playlists";
public static final String PLAYLIST_ID = "uid";
public static final String PLAYLIST_NAME = "name";
public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
public static final String PLAYLIST_DISPLAY_INDEX = "display_index";
public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id";
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = PLAYLIST_ID)
private long uid = 0;
@ColumnInfo(name = PLAYLIST_NAME)
private String name;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
private boolean isThumbnailPermanent;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
private long thumbnailStreamId;
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
private long displayIndex;
public PlaylistEntity(final String name, final boolean isThumbnailPermanent,
final long thumbnailStreamId, final long displayIndex) {
this.name = name;
this.isThumbnailPermanent = isThumbnailPermanent;
this.thumbnailStreamId = thumbnailStreamId;
this.displayIndex = displayIndex;
}
@Ignore
public PlaylistEntity(final PlaylistMetadataEntry item) {
this.uid = item.getUid();
this.name = item.name;
this.isThumbnailPermanent = item.isThumbnailPermanent();
this.thumbnailStreamId = item.getThumbnailStreamId();
this.displayIndex = item.getDisplayIndex();
}
public long getUid() {
return uid;
}
public void setUid(final long uid) {
this.uid = uid;
}
public String getName() {
return name;
}
public void setName(final String name) {
this.name = name;
}
public long getThumbnailStreamId() {
return thumbnailStreamId;
}
public void setThumbnailStreamId(final long thumbnailStreamId) {
this.thumbnailStreamId = thumbnailStreamId;
}
public boolean getIsThumbnailPermanent() {
return isThumbnailPermanent;
}
public void setIsThumbnailPermanent(final boolean isThumbnailSet) {
this.isThumbnailPermanent = isThumbnailSet;
}
public long getDisplayIndex() {
return displayIndex;
}
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
}

View File

@ -0,0 +1,98 @@
package org.schabi.newpipe.database.playlist.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import org.schabi.newpipe.R
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
@Entity(tableName = PlaylistEntity.PLAYLIST_TABLE)
class PlaylistEntity {
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = PLAYLIST_ID)
private var uid: Long = 0
@ColumnInfo(name = PLAYLIST_NAME)
private var name: String?
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
private var isThumbnailPermanent: Boolean
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
private var thumbnailStreamId: Long
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
private var displayIndex: Long
constructor(name: String?, isThumbnailPermanent: Boolean,
thumbnailStreamId: Long, displayIndex: Long) {
this.name = name
this.isThumbnailPermanent = isThumbnailPermanent
this.thumbnailStreamId = thumbnailStreamId
this.displayIndex = displayIndex
}
@Ignore
constructor(item: PlaylistMetadataEntry) {
uid = item.getUid()
name = item.name
isThumbnailPermanent = item.isThumbnailPermanent()
thumbnailStreamId = item.getThumbnailStreamId()
displayIndex = item.getDisplayIndex()
}
fun getUid(): Long {
return uid
}
fun setUid(uid: Long) {
this.uid = uid
}
fun getName(): String? {
return name
}
fun setName(name: String?) {
this.name = name
}
fun getThumbnailStreamId(): Long {
return thumbnailStreamId
}
fun setThumbnailStreamId(thumbnailStreamId: Long) {
this.thumbnailStreamId = thumbnailStreamId
}
fun getIsThumbnailPermanent(): Boolean {
return isThumbnailPermanent
}
fun setIsThumbnailPermanent(isThumbnailSet: Boolean) {
isThumbnailPermanent = isThumbnailSet
}
fun getDisplayIndex(): Long {
return displayIndex
}
fun setDisplayIndex(displayIndex: Long) {
this.displayIndex = displayIndex
}
companion object {
val DEFAULT_THUMBNAIL: String = ("drawable://"
+ R.drawable.placeholder_thumbnail_playlist)
val DEFAULT_THUMBNAIL_ID: Long = -1
val PLAYLIST_TABLE: String = "playlists"
val PLAYLIST_ID: String = "uid"
val PLAYLIST_NAME: String = "name"
val PLAYLIST_THUMBNAIL_URL: String = "thumbnail_url"
val PLAYLIST_DISPLAY_INDEX: String = "display_index"
val PLAYLIST_THUMBNAIL_PERMANENT: String = "is_thumbnail_permanent"
val PLAYLIST_THUMBNAIL_STREAM_ID: String = "thumbnail_stream_id"
}
}

View File

@ -1,188 +0,0 @@
package org.schabi.newpipe.database.playlist.model;
import android.text.TextUtils;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.image.ImageStrategy;
import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL;
@Entity(tableName = REMOTE_PLAYLIST_TABLE,
indices = {
@Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true)
})
public class PlaylistRemoteEntity implements PlaylistLocalItem {
public static final String REMOTE_PLAYLIST_TABLE = "remote_playlists";
public static final String REMOTE_PLAYLIST_ID = "uid";
public static final String REMOTE_PLAYLIST_SERVICE_ID = "service_id";
public static final String REMOTE_PLAYLIST_NAME = "name";
public static final String REMOTE_PLAYLIST_URL = "url";
public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader";
public static final String REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index";
public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count";
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = REMOTE_PLAYLIST_ID)
private long uid = 0;
@ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID)
private int serviceId = Constants.NO_SERVICE_ID;
@ColumnInfo(name = REMOTE_PLAYLIST_NAME)
private String name;
@ColumnInfo(name = REMOTE_PLAYLIST_URL)
private String url;
@ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL)
private String thumbnailUrl;
@ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
private String uploader;
@ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
private long displayIndex = -1; // Make sure the new item is on the top
@ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
private Long streamCount;
public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
final String thumbnailUrl, final String uploader,
final Long streamCount) {
this.serviceId = serviceId;
this.name = name;
this.url = url;
this.thumbnailUrl = thumbnailUrl;
this.uploader = uploader;
this.streamCount = streamCount;
}
@Ignore
public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
final String thumbnailUrl, final String uploader,
final long displayIndex, final Long streamCount) {
this.serviceId = serviceId;
this.name = name;
this.url = url;
this.thumbnailUrl = thumbnailUrl;
this.uploader = uploader;
this.displayIndex = displayIndex;
this.streamCount = streamCount;
}
@Ignore
public PlaylistRemoteEntity(final PlaylistInfo info) {
this(info.getServiceId(), info.getName(), info.getUrl(),
// use uploader avatar when no thumbnail is available
ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty()
? info.getUploaderAvatars() : info.getThumbnails()),
info.getUploaderName(), info.getStreamCount());
}
@Ignore
public boolean isIdenticalTo(final PlaylistInfo info) {
/*
* Returns boolean comparing the online playlist and the local copy.
* (False if info changed such as playlist name or track count)
*/
return getServiceId() == info.getServiceId()
&& getStreamCount() == info.getStreamCount()
&& TextUtils.equals(getName(), info.getName())
&& TextUtils.equals(getUrl(), info.getUrl())
// we want to update the local playlist data even when either the remote thumbnail
// URL changes, or the preferred image quality setting is changed by the user
&& TextUtils.equals(getThumbnailUrl(),
ImageStrategy.imageListToDbUrl(info.getThumbnails()))
&& TextUtils.equals(getUploader(), info.getUploaderName());
}
@Override
public long getUid() {
return uid;
}
public void setUid(final long uid) {
this.uid = uid;
}
public int getServiceId() {
return serviceId;
}
public void setServiceId(final int serviceId) {
this.serviceId = serviceId;
}
public String getName() {
return name;
}
public void setName(final String name) {
this.name = name;
}
public String getThumbnailUrl() {
return thumbnailUrl;
}
public void setThumbnailUrl(final String thumbnailUrl) {
this.thumbnailUrl = thumbnailUrl;
}
public String getUrl() {
return url;
}
public void setUrl(final String url) {
this.url = url;
}
public String getUploader() {
return uploader;
}
public void setUploader(final String uploader) {
this.uploader = uploader;
}
@Override
public long getDisplayIndex() {
return displayIndex;
}
@Override
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
public Long getStreamCount() {
return streamCount;
}
public void setStreamCount(final Long streamCount) {
this.streamCount = streamCount;
}
@Override
public LocalItemType getLocalItemType() {
return PLAYLIST_REMOTE_ITEM;
}
@Override
public String getOrderingName() {
return name;
}
}

View File

@ -0,0 +1,170 @@
package org.schabi.newpipe.database.playlist.model
import android.text.TextUtils
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import org.schabi.newpipe.database.LocalItem.LocalItemType
import org.schabi.newpipe.database.playlist.PlaylistLocalItem
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
import org.schabi.newpipe.util.image.ImageStrategy
@Entity(tableName = PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE, indices = [Index(value = [PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID, PlaylistRemoteEntity.REMOTE_PLAYLIST_URL], unique = true)])
class PlaylistRemoteEntity : PlaylistLocalItem {
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = REMOTE_PLAYLIST_ID)
private var uid: Long = 0
@ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID)
private var serviceId: Int = NO_SERVICE_ID
@ColumnInfo(name = REMOTE_PLAYLIST_NAME)
private var name: String
@ColumnInfo(name = REMOTE_PLAYLIST_URL)
private var url: String
@ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL)
private var thumbnailUrl: String?
@ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
private var uploader: String
@ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
private var displayIndex: Long = -1 // Make sure the new item is on the top
@ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
private var streamCount: Long
constructor(serviceId: Int, name: String, url: String,
thumbnailUrl: String?, uploader: String,
streamCount: Long) {
this.serviceId = serviceId
this.name = name
this.url = url
this.thumbnailUrl = thumbnailUrl
this.uploader = uploader
this.streamCount = streamCount
}
@Ignore
constructor(serviceId: Int, name: String, url: String,
thumbnailUrl: String?, uploader: String,
displayIndex: Long, streamCount: Long) {
this.serviceId = serviceId
this.name = name
this.url = url
this.thumbnailUrl = thumbnailUrl
this.uploader = uploader
this.displayIndex = displayIndex
this.streamCount = streamCount
}
@Ignore
constructor(info: PlaylistInfo) : this(info.getServiceId(), info.getName(), info.getUrl(), // use uploader avatar when no thumbnail is available
ImageStrategy.imageListToDbUrl(if (info.getThumbnails().isEmpty()) info.getUploaderAvatars() else info.getThumbnails()),
info.getUploaderName(), info.getStreamCount())
@Ignore
fun isIdenticalTo(info: PlaylistInfo): Boolean {
/*
* Returns boolean comparing the online playlist and the local copy.
* (False if info changed such as playlist name or track count)
*/
return ((getServiceId() == info.getServiceId()
) && (getStreamCount() == info.getStreamCount()
) && TextUtils.equals(getName(), info.getName())
&& TextUtils.equals(getUrl(), info.getUrl()) // we want to update the local playlist data even when either the remote thumbnail
// URL changes, or the preferred image quality setting is changed by the user
&& TextUtils.equals(getThumbnailUrl(),
ImageStrategy.imageListToDbUrl(info.getThumbnails()))
&& TextUtils.equals(getUploader(), info.getUploaderName()))
}
public override fun getUid(): Long {
return uid
}
fun setUid(uid: Long) {
this.uid = uid
}
fun getServiceId(): Int {
return serviceId
}
fun setServiceId(serviceId: Int) {
this.serviceId = serviceId
}
fun getName(): String {
return name
}
fun setName(name: String) {
this.name = name
}
fun getThumbnailUrl(): String? {
return thumbnailUrl
}
fun setThumbnailUrl(thumbnailUrl: String?) {
this.thumbnailUrl = thumbnailUrl
}
fun getUrl(): String {
return url
}
fun setUrl(url: String) {
this.url = url
}
fun getUploader(): String {
return uploader
}
fun setUploader(uploader: String) {
this.uploader = uploader
}
public override fun getDisplayIndex(): Long {
return displayIndex
}
public override fun setDisplayIndex(displayIndex: Long) {
this.displayIndex = displayIndex
}
fun getStreamCount(): Long {
return streamCount
}
fun setStreamCount(streamCount: Long) {
this.streamCount = streamCount
}
public override fun getLocalItemType(): LocalItemType {
return LocalItemType.PLAYLIST_REMOTE_ITEM
}
public override fun getOrderingName(): String {
return name
}
companion object {
val REMOTE_PLAYLIST_TABLE: String = "remote_playlists"
val REMOTE_PLAYLIST_ID: String = "uid"
val REMOTE_PLAYLIST_SERVICE_ID: String = "service_id"
val REMOTE_PLAYLIST_NAME: String = "name"
val REMOTE_PLAYLIST_URL: String = "url"
val REMOTE_PLAYLIST_THUMBNAIL_URL: String = "thumbnail_url"
val REMOTE_PLAYLIST_UPLOADER_NAME: String = "uploader"
val REMOTE_PLAYLIST_DISPLAY_INDEX: String = "display_index"
val REMOTE_PLAYLIST_STREAM_COUNT: String = "stream_count"
}
}

View File

@ -1,76 +0,0 @@
package org.schabi.newpipe.database.playlist.model;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import static androidx.room.ForeignKey.CASCADE;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
@Entity(tableName = PLAYLIST_STREAM_JOIN_TABLE,
primaryKeys = {JOIN_PLAYLIST_ID, JOIN_INDEX},
indices = {
@Index(value = {JOIN_PLAYLIST_ID, JOIN_INDEX}, unique = true),
@Index(value = {JOIN_STREAM_ID})
},
foreignKeys = {
@ForeignKey(entity = PlaylistEntity.class,
parentColumns = PlaylistEntity.PLAYLIST_ID,
childColumns = JOIN_PLAYLIST_ID,
onDelete = CASCADE, onUpdate = CASCADE, deferred = true),
@ForeignKey(entity = StreamEntity.class,
parentColumns = StreamEntity.STREAM_ID,
childColumns = JOIN_STREAM_ID,
onDelete = CASCADE, onUpdate = CASCADE, deferred = true)
})
public class PlaylistStreamEntity {
public static final String PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join";
public static final String JOIN_PLAYLIST_ID = "playlist_id";
public static final String JOIN_STREAM_ID = "stream_id";
public static final String JOIN_INDEX = "join_index";
@ColumnInfo(name = JOIN_PLAYLIST_ID)
private long playlistUid;
@ColumnInfo(name = JOIN_STREAM_ID)
private long streamUid;
@ColumnInfo(name = JOIN_INDEX)
private int index;
public PlaylistStreamEntity(final long playlistUid, final long streamUid, final int index) {
this.playlistUid = playlistUid;
this.streamUid = streamUid;
this.index = index;
}
public long getPlaylistUid() {
return playlistUid;
}
public void setPlaylistUid(final long playlistUid) {
this.playlistUid = playlistUid;
}
public long getStreamUid() {
return streamUid;
}
public void setStreamUid(final long streamUid) {
this.streamUid = streamUid;
}
public int getIndex() {
return index;
}
public void setIndex(final int index) {
this.index = index;
}
}

View File

@ -0,0 +1,43 @@
package org.schabi.newpipe.database.playlist.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
@Entity(tableName = PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE, primaryKeys = [PlaylistStreamEntity.JOIN_PLAYLIST_ID, PlaylistStreamEntity.JOIN_INDEX], indices = [Index(value = [PlaylistStreamEntity.JOIN_PLAYLIST_ID, PlaylistStreamEntity.JOIN_INDEX], unique = true), Index(value = [PlaylistStreamEntity.JOIN_STREAM_ID])], foreignKeys = [ForeignKey(entity = PlaylistEntity::class, parentColumns = PlaylistEntity.Companion.PLAYLIST_ID, childColumns = PlaylistStreamEntity.JOIN_PLAYLIST_ID, onDelete = CASCADE, onUpdate = CASCADE, deferred = true), ForeignKey(entity = StreamEntity::class, parentColumns = StreamEntity.STREAM_ID, childColumns = PlaylistStreamEntity.JOIN_STREAM_ID, onDelete = CASCADE, onUpdate = CASCADE, deferred = true)])
class PlaylistStreamEntity(@field:ColumnInfo(name = JOIN_PLAYLIST_ID) private var playlistUid: Long, @field:ColumnInfo(name = JOIN_STREAM_ID) private var streamUid: Long, @field:ColumnInfo(name = JOIN_INDEX) private var index: Int) {
fun getPlaylistUid(): Long {
return playlistUid
}
fun setPlaylistUid(playlistUid: Long) {
this.playlistUid = playlistUid
}
fun getStreamUid(): Long {
return streamUid
}
fun setStreamUid(streamUid: Long) {
this.streamUid = streamUid
}
fun getIndex(): Int {
return index
}
fun setIndex(index: Int) {
this.index = index
}
companion object {
val PLAYLIST_STREAM_JOIN_TABLE: String = "playlist_stream_join"
val JOIN_PLAYLIST_ID: String = "playlist_id"
val JOIN_STREAM_ID: String = "stream_id"
val JOIN_INDEX: String = "join_index"
}
}

View File

@ -1,48 +0,0 @@
package org.schabi.newpipe.database.stream.dao;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.Transaction;
import org.schabi.newpipe.database.BasicDAO;
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@Dao
public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
@Override
@Query("SELECT * FROM " + STREAM_STATE_TABLE)
Flowable<List<StreamStateEntity>> getAll();
@Override
@Query("DELETE FROM " + STREAM_STATE_TABLE)
int deleteAll();
@Override
default Flowable<List<StreamStateEntity>> listByService(final int serviceId) {
throw new UnsupportedOperationException();
}
@Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
Flowable<List<StreamStateEntity>> getState(long streamId);
@Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
int deleteState(long streamId);
@Insert(onConflict = OnConflictStrategy.IGNORE)
void silentInsertInternal(StreamStateEntity streamState);
@Transaction
default long upsert(final StreamStateEntity stream) {
silentInsertInternal(stream);
return update(stream);
}
}

View File

@ -0,0 +1,36 @@
package org.schabi.newpipe.database.stream.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.stream.model.StreamStateEntity
@Dao
open interface StreamStateDAO : BasicDAO<StreamStateEntity?> {
@Query("SELECT * FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE)
public override fun getAll(): Flowable<List<StreamStateEntity?>?>?
@Query("DELETE FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE)
public override fun deleteAll(): Int
public override fun listByService(serviceId: Int): Flowable<List<StreamStateEntity?>?>? {
throw UnsupportedOperationException()
}
@Query("SELECT * FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.Companion.JOIN_STREAM_ID + " = :streamId")
fun getState(streamId: Long): Flowable<List<StreamStateEntity?>?>
@Query("DELETE FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.Companion.JOIN_STREAM_ID + " = :streamId")
fun deleteState(streamId: Long): Int
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun silentInsertInternal(streamState: StreamStateEntity?)
@Transaction
fun upsert(stream: StreamStateEntity?): Long {
silentInsertInternal(stream)
return update(stream).toLong()
}
}

View File

@ -1,112 +0,0 @@
package org.schabi.newpipe.database.stream.model;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import java.util.Objects;
import static androidx.room.ForeignKey.CASCADE;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@Entity(tableName = STREAM_STATE_TABLE,
primaryKeys = {JOIN_STREAM_ID},
foreignKeys = {
@ForeignKey(entity = StreamEntity.class,
parentColumns = StreamEntity.STREAM_ID,
childColumns = JOIN_STREAM_ID,
onDelete = CASCADE, onUpdate = CASCADE)
})
public class StreamStateEntity {
public static final String STREAM_STATE_TABLE = "stream_state";
public static final String JOIN_STREAM_ID = "stream_id";
// This additional field is required for the SQL query because 'stream_id' is used
// for some other joins already
public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias";
public static final String STREAM_PROGRESS_MILLIS = "progress_time";
/**
* Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s).
*/
public static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000;
/**
* Stream will be considered finished if the playback time left exceeds this threshold
* (60000ms = 60s).
* @see #isFinished(long)
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
*/
public static final long PLAYBACK_FINISHED_END_MILLISECONDS = 60000;
@ColumnInfo(name = JOIN_STREAM_ID)
private long streamUid;
@ColumnInfo(name = STREAM_PROGRESS_MILLIS)
private long progressMillis;
public StreamStateEntity(final long streamUid, final long progressMillis) {
this.streamUid = streamUid;
this.progressMillis = progressMillis;
}
public long getStreamUid() {
return streamUid;
}
public void setStreamUid(final long streamUid) {
this.streamUid = streamUid;
}
public long getProgressMillis() {
return progressMillis;
}
public void setProgressMillis(final long progressMillis) {
this.progressMillis = progressMillis;
}
/**
* The state will be considered valid, and thus be saved, if the progress is more than {@link
* #PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} or at least 1/4 of the video length.
* @param durationInSeconds the duration of the stream connected with this state, in seconds
* @return whether this stream state entity should be saved or not
*/
public boolean isValid(final long durationInSeconds) {
return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
|| progressMillis > durationInSeconds * 1000 / 4;
}
/**
* The video will be considered as finished, if the time left is less than {@link
* #PLAYBACK_FINISHED_END_MILLISECONDS} and the progress is at least 3/4 of the video length.
* The state will be saved anyway, so that it can be shown under stream info items, but the
* player will not resume if a state is considered as finished. Finished streams are also the
* ones that can be filtered out in the feed fragment.
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
* @param durationInSeconds the duration of the stream connected with this state, in seconds
* @return whether the stream is finished or not
*/
public boolean isFinished(final long durationInSeconds) {
return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS
&& progressMillis >= durationInSeconds * 1000 * 3 / 4;
}
@Override
public boolean equals(@Nullable final Object obj) {
if (obj instanceof StreamStateEntity) {
return ((StreamStateEntity) obj).streamUid == streamUid
&& ((StreamStateEntity) obj).progressMillis == progressMillis;
} else {
return false;
}
}
@Override
public int hashCode() {
return Objects.hash(streamUid, progressMillis);
}
}

View File

@ -0,0 +1,89 @@
package org.schabi.newpipe.database.stream.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
import java.util.Objects
@Entity(tableName = StreamStateEntity.STREAM_STATE_TABLE, primaryKeys = [StreamStateEntity.JOIN_STREAM_ID], foreignKeys = [ForeignKey(entity = StreamEntity::class, parentColumns = StreamEntity.STREAM_ID, childColumns = StreamStateEntity.JOIN_STREAM_ID, onDelete = CASCADE, onUpdate = CASCADE)])
class StreamStateEntity(@field:ColumnInfo(name = JOIN_STREAM_ID) private var streamUid: Long, @field:ColumnInfo(name = STREAM_PROGRESS_MILLIS) private var progressMillis: Long) {
fun getStreamUid(): Long {
return streamUid
}
fun setStreamUid(streamUid: Long) {
this.streamUid = streamUid
}
fun getProgressMillis(): Long {
return progressMillis
}
fun setProgressMillis(progressMillis: Long) {
this.progressMillis = progressMillis
}
/**
* The state will be considered valid, and thus be saved, if the progress is more than [ ][.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS] or at least 1/4 of the video length.
* @param durationInSeconds the duration of the stream connected with this state, in seconds
* @return whether this stream state entity should be saved or not
*/
fun isValid(durationInSeconds: Long): Boolean {
return (progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
|| progressMillis > durationInSeconds * 1000 / 4)
}
/**
* The video will be considered as finished, if the time left is less than [ ][.PLAYBACK_FINISHED_END_MILLISECONDS] and the progress is at least 3/4 of the video length.
* The state will be saved anyway, so that it can be shown under stream info items, but the
* player will not resume if a state is considered as finished. Finished streams are also the
* ones that can be filtered out in the feed fragment.
* @see org.schabi.newpipe.database.feed.dao.FeedDAO.getLiveOrNotPlayedStreams
* @see org.schabi.newpipe.database.feed.dao.FeedDAO.getLiveOrNotPlayedStreamsForGroup
* @param durationInSeconds the duration of the stream connected with this state, in seconds
* @return whether the stream is finished or not
*/
fun isFinished(durationInSeconds: Long): Boolean {
return (progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS
&& progressMillis >= durationInSeconds * 1000 * 3 / 4)
}
public override fun equals(obj: Any?): Boolean {
if (obj is StreamStateEntity) {
return (obj.streamUid == streamUid
&& obj.progressMillis == progressMillis)
} else {
return false
}
}
public override fun hashCode(): Int {
return Objects.hash(streamUid, progressMillis)
}
companion object {
val STREAM_STATE_TABLE: String = "stream_state"
val JOIN_STREAM_ID: String = "stream_id"
// This additional field is required for the SQL query because 'stream_id' is used
// for some other joins already
val JOIN_STREAM_ID_ALIAS: String = "stream_id_alias"
val STREAM_PROGRESS_MILLIS: String = "progress_time"
/**
* Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s).
*/
val PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS: Long = 5000
/**
* Stream will be considered finished if the playback time left exceeds this threshold
* (60000ms = 60s).
* @see .isFinished
* @see org.schabi.newpipe.database.feed.dao.FeedDAO.getLiveOrNotPlayedStreams
* @see org.schabi.newpipe.database.feed.dao.FeedDAO.getLiveOrNotPlayedStreamsForGroup
*/
val PLAYBACK_FINISHED_END_MILLISECONDS: Long = 60000
}
}

View File

@ -1,14 +0,0 @@
package org.schabi.newpipe.database.subscription;
import androidx.annotation.IntDef;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
@Retention(RetentionPolicy.SOURCE)
public @interface NotificationMode {
int DISABLED = 0;
int ENABLED = 1;
//other values reserved for the future
}

View File

@ -0,0 +1,13 @@
package org.schabi.newpipe.database.subscription
import androidx.annotation.IntDef
import org.schabi.newpipe.database.subscription.NotificationMode
@IntDef([NotificationMode.DISABLED, NotificationMode.ENABLED])
@Retention(AnnotationRetention.SOURCE)
annotation class NotificationMode() {
companion object {
val DISABLED: Int = 0
val ENABLED: Int = 1 //other values reserved for the future
}
}

View File

@ -1,198 +0,0 @@
package org.schabi.newpipe.database.subscription;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.image.ImageStrategy;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
@Entity(tableName = SUBSCRIPTION_TABLE,
indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
public class SubscriptionEntity {
public static final String SUBSCRIPTION_UID = "uid";
public static final String SUBSCRIPTION_TABLE = "subscriptions";
public static final String SUBSCRIPTION_SERVICE_ID = "service_id";
public static final String SUBSCRIPTION_URL = "url";
public static final String SUBSCRIPTION_NAME = "name";
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
public static final String SUBSCRIPTION_DESCRIPTION = "description";
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
@PrimaryKey(autoGenerate = true)
private long uid = 0;
@ColumnInfo(name = SUBSCRIPTION_SERVICE_ID)
private int serviceId = Constants.NO_SERVICE_ID;
@ColumnInfo(name = SUBSCRIPTION_URL)
private String url;
@ColumnInfo(name = SUBSCRIPTION_NAME)
private String name;
@ColumnInfo(name = SUBSCRIPTION_AVATAR_URL)
private String avatarUrl;
@ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT)
private Long subscriberCount;
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
private String description;
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
private int notificationMode;
@Ignore
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
final SubscriptionEntity result = new SubscriptionEntity();
result.setServiceId(info.getServiceId());
result.setUrl(info.getUrl());
result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()),
info.getDescription(), info.getSubscriberCount());
return result;
}
public long getUid() {
return uid;
}
public void setUid(final long uid) {
this.uid = uid;
}
public int getServiceId() {
return serviceId;
}
public void setServiceId(final int serviceId) {
this.serviceId = serviceId;
}
public String getUrl() {
return url;
}
public void setUrl(final String url) {
this.url = url;
}
public String getName() {
return name;
}
public void setName(final String name) {
this.name = name;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(final String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public Long getSubscriberCount() {
return subscriberCount;
}
public void setSubscriberCount(final Long subscriberCount) {
this.subscriberCount = subscriberCount;
}
public String getDescription() {
return description;
}
public void setDescription(final String description) {
this.description = description;
}
@NotificationMode
public int getNotificationMode() {
return notificationMode;
}
public void setNotificationMode(@NotificationMode final int notificationMode) {
this.notificationMode = notificationMode;
}
@Ignore
public void setData(final String n, final String au, final String d, final Long sc) {
this.setName(n);
this.setAvatarUrl(au);
this.setDescription(d);
this.setSubscriberCount(sc);
}
@Ignore
public ChannelInfoItem toChannelInfoItem() {
final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName());
item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl()));
item.setSubscriberCount(getSubscriberCount());
item.setDescription(getDescription());
return item;
}
// TODO: Remove these generated methods by migrating this class to a data class from Kotlin.
@Override
@SuppressWarnings("EqualsReplaceableByObjectsCall")
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final SubscriptionEntity that = (SubscriptionEntity) o;
if (uid != that.uid) {
return false;
}
if (serviceId != that.serviceId) {
return false;
}
if (!url.equals(that.url)) {
return false;
}
if (name != null ? !name.equals(that.name) : that.name != null) {
return false;
}
if (avatarUrl != null ? !avatarUrl.equals(that.avatarUrl) : that.avatarUrl != null) {
return false;
}
if (subscriberCount != null
? !subscriberCount.equals(that.subscriberCount)
: that.subscriberCount != null) {
return false;
}
return description != null
? description.equals(that.description)
: that.description == null;
}
@Override
public int hashCode() {
int result = (int) (uid ^ (uid >>> 32));
result = 31 * result + serviceId;
result = 31 * result + url.hashCode();
result = 31 * result + (name != null ? name.hashCode() : 0);
result = 31 * result + (avatarUrl != null ? avatarUrl.hashCode() : 0);
result = 31 * result + (subscriberCount != null ? subscriberCount.hashCode() : 0);
result = 31 * result + (description != null ? description.hashCode() : 0);
return result;
}
}

View File

@ -0,0 +1,182 @@
package org.schabi.newpipe.database.subscription
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
@Entity(tableName = SubscriptionEntity.SUBSCRIPTION_TABLE, indices = [Index(value = [SubscriptionEntity.SUBSCRIPTION_SERVICE_ID, SubscriptionEntity.SUBSCRIPTION_URL], unique = true)])
class SubscriptionEntity() {
@PrimaryKey(autoGenerate = true)
private var uid: Long = 0
@ColumnInfo(name = SUBSCRIPTION_SERVICE_ID)
private var serviceId: Int = NO_SERVICE_ID
@ColumnInfo(name = SUBSCRIPTION_URL)
private var url: String? = null
@ColumnInfo(name = SUBSCRIPTION_NAME)
private var name: String? = null
@ColumnInfo(name = SUBSCRIPTION_AVATAR_URL)
private var avatarUrl: String? = null
@ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT)
private var subscriberCount: Long? = null
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
private var description: String? = null
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
private var notificationMode: Int = 0
fun getUid(): Long {
return uid
}
fun setUid(uid: Long) {
this.uid = uid
}
fun getServiceId(): Int {
return serviceId
}
fun setServiceId(serviceId: Int) {
this.serviceId = serviceId
}
fun getUrl(): String? {
return url
}
fun setUrl(url: String?) {
this.url = url
}
fun getName(): String? {
return name
}
fun setName(name: String?) {
this.name = name
}
fun getAvatarUrl(): String? {
return avatarUrl
}
fun setAvatarUrl(avatarUrl: String?) {
this.avatarUrl = avatarUrl
}
fun getSubscriberCount(): Long? {
return subscriberCount
}
fun setSubscriberCount(subscriberCount: Long?) {
this.subscriberCount = subscriberCount
}
fun getDescription(): String? {
return description
}
fun setDescription(description: String?) {
this.description = description
}
@NotificationMode
fun getNotificationMode(): Int {
return notificationMode
}
fun setNotificationMode(@NotificationMode notificationMode: Int) {
this.notificationMode = notificationMode
}
@Ignore
fun setData(n: String?, au: String?, d: String?, sc: Long?) {
setName(n)
setAvatarUrl(au)
setDescription(d)
setSubscriberCount(sc)
}
@Ignore
fun toChannelInfoItem(): ChannelInfoItem {
val item: ChannelInfoItem = ChannelInfoItem(getServiceId(), getUrl(), getName())
item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl()))
item.setSubscriberCount((getSubscriberCount())!!)
item.setDescription(getDescription())
return item
}
// TODO: Remove these generated methods by migrating this class to a data class from Kotlin.
public override fun equals(o: Any?): Boolean {
if (this === o) {
return true
}
if (o == null || javaClass != o.javaClass) {
return false
}
val that: SubscriptionEntity = o as SubscriptionEntity
if (uid != that.uid) {
return false
}
if (serviceId != that.serviceId) {
return false
}
if (!(url == that.url)) {
return false
}
if (if (name != null) !(name == that.name) else that.name != null) {
return false
}
if (if (avatarUrl != null) !(avatarUrl == that.avatarUrl) else that.avatarUrl != null) {
return false
}
if (if (subscriberCount != null) !(subscriberCount == that.subscriberCount) else that.subscriberCount != null) {
return false
}
return if (description != null) (description == that.description) else that.description == null
}
public override fun hashCode(): Int {
var result: Int = (uid xor (uid ushr 32)).toInt()
result = 31 * result + serviceId
result = 31 * result + url.hashCode()
result = 31 * result + (if (name != null) name.hashCode() else 0)
result = 31 * result + (if (avatarUrl != null) avatarUrl.hashCode() else 0)
result = 31 * result + (if (subscriberCount != null) subscriberCount.hashCode() else 0)
result = 31 * result + (if (description != null) description.hashCode() else 0)
return result
}
companion object {
val SUBSCRIPTION_UID: String = "uid"
val SUBSCRIPTION_TABLE: String = "subscriptions"
val SUBSCRIPTION_SERVICE_ID: String = "service_id"
val SUBSCRIPTION_URL: String = "url"
val SUBSCRIPTION_NAME: String = "name"
val SUBSCRIPTION_AVATAR_URL: String = "avatar_url"
val SUBSCRIPTION_SUBSCRIBER_COUNT: String = "subscriber_count"
val SUBSCRIPTION_DESCRIPTION: String = "description"
val SUBSCRIPTION_NOTIFICATION_MODE: String = "notification_mode"
@JvmStatic
@Ignore
fun from(info: ChannelInfo): SubscriptionEntity {
val result: SubscriptionEntity = SubscriptionEntity()
result.setServiceId(info.getServiceId())
result.setUrl(info.getUrl())
result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()),
info.getDescription(), info.getSubscriberCount())
return result
}
}
}

View File

@ -1,97 +0,0 @@
package org.schabi.newpipe.download;
import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.ViewTreeObserver;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.FragmentTransaction;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ActivityDownloaderBinding;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.FocusOverlayView;
import us.shandian.giga.service.DownloadManagerService;
import us.shandian.giga.ui.fragment.MissionsFragment;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
public class DownloadActivity extends AppCompatActivity {
private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag";
@Override
protected void onCreate(final Bundle savedInstanceState) {
// Service
final Intent i = new Intent();
i.setClass(this, DownloadManagerService.class);
startService(i);
assureCorrectAppLanguage(this);
ThemeHelper.setTheme(this);
super.onCreate(savedInstanceState);
final ActivityDownloaderBinding downloaderBinding =
ActivityDownloaderBinding.inflate(getLayoutInflater());
setContentView(downloaderBinding.getRoot());
setSupportActionBar(downloaderBinding.toolbarLayout.toolbar);
final ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
actionBar.setTitle(R.string.downloads_title);
actionBar.setDisplayShowTitleEnabled(true);
}
getWindow().getDecorView().getViewTreeObserver()
.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
updateFragments();
getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
if (DeviceUtils.isTv(this)) {
FocusOverlayView.setupFocusObserver(this);
}
}
private void updateFragments() {
final MissionsFragment fragment = new MissionsFragment();
getSupportFragmentManager().beginTransaction()
.replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG)
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.commit();
}
@Override
public boolean onCreateOptionsMenu(final Menu menu) {
super.onCreateOptionsMenu(menu);
final MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.download_menu, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
onBackPressed();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
}

View File

@ -0,0 +1,80 @@
package org.schabi.newpipe.download
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.ViewTreeObserver.OnGlobalLayoutListener
import androidx.appcompat.app.ActionBar
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentTransaction
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ActivityDownloaderBinding
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.views.FocusOverlayView
import us.shandian.giga.service.DownloadManagerService
import us.shandian.giga.ui.fragment.MissionsFragment
class DownloadActivity() : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Service
val i: Intent = Intent()
i.setClass(this, DownloadManagerService::class.java)
startService(i)
Localization.assureCorrectAppLanguage(this)
ThemeHelper.setTheme(this)
super.onCreate(savedInstanceState)
val downloaderBinding: ActivityDownloaderBinding = ActivityDownloaderBinding.inflate(getLayoutInflater())
setContentView(downloaderBinding.getRoot())
setSupportActionBar(downloaderBinding.toolbarLayout.toolbar)
val actionBar: ActionBar? = getSupportActionBar()
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setTitle(R.string.downloads_title)
actionBar.setDisplayShowTitleEnabled(true)
}
getWindow().getDecorView().getViewTreeObserver()
.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
public override fun onGlobalLayout() {
updateFragments()
getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(this)
}
})
if (DeviceUtils.isTv(this)) {
FocusOverlayView.Companion.setupFocusObserver(this)
}
}
private fun updateFragments() {
val fragment: MissionsFragment = MissionsFragment()
getSupportFragmentManager().beginTransaction()
.replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG)
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.commit()
}
public override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
val inflater: MenuInflater = getMenuInflater()
inflater.inflate(R.menu.download_menu, menu)
return true
}
public override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.getItemId()) {
android.R.id.home -> {
onBackPressed()
return true
}
else -> return super.onOptionsItemSelected(item)
}
}
companion object {
private val MISSIONS_FRAGMENT_TAG: String = "fragment_tag"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,87 +0,0 @@
package org.schabi.newpipe.download;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.DialogFragment;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.DownloadLoadingDialogBinding;
/**
* This class contains a dialog which shows a loading indicator and has a customizable title.
*/
public class LoadingDialog extends DialogFragment {
private static final String TAG = "LoadingDialog";
private static final boolean DEBUG = MainActivity.DEBUG;
private DownloadLoadingDialogBinding dialogLoadingBinding;
private final @StringRes int title;
/**
* Create a new LoadingDialog.
*
* <p>
* The dialog contains a loading indicator and has a customizable title.
* <br/>
* Use {@code show()} to display the dialog to the user.
* </p>
*
* @param title an informative title shown in the dialog's toolbar
*/
public LoadingDialog(final @StringRes int title) {
this.title = title;
}
@Override
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (DEBUG) {
Log.d(TAG, "onCreate() called with: "
+ "savedInstanceState = [" + savedInstanceState + "]");
}
this.setCancelable(false);
}
@Override
public View onCreateView(
@NonNull final LayoutInflater inflater,
final ViewGroup container,
final Bundle savedInstanceState) {
if (DEBUG) {
Log.d(TAG, "onCreateView() called with: "
+ "inflater = [" + inflater + "], container = [" + container + "], "
+ "savedInstanceState = [" + savedInstanceState + "]");
}
return inflater.inflate(R.layout.download_loading_dialog, container);
}
@Override
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view);
initToolbar(dialogLoadingBinding.toolbarLayout.toolbar);
}
private void initToolbar(final Toolbar toolbar) {
if (DEBUG) {
Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]");
}
toolbar.setTitle(requireContext().getString(title));
toolbar.setNavigationOnClickListener(v -> dismiss());
}
@Override
public void onDestroyView() {
dialogLoadingBinding = null;
super.onDestroyView();
}
}

View File

@ -0,0 +1,76 @@
package org.schabi.newpipe.download
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.DialogFragment
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.DownloadLoadingDialogBinding
/**
* This class contains a dialog which shows a loading indicator and has a customizable title.
*/
class LoadingDialog
/**
* Create a new LoadingDialog.
*
*
*
* The dialog contains a loading indicator and has a customizable title.
* <br></br>
* Use `show()` to display the dialog to the user.
*
*
* @param title an informative title shown in the dialog's toolbar
*/(@field:StringRes @param:StringRes private val title: Int) : DialogFragment() {
private var dialogLoadingBinding: DownloadLoadingDialogBinding? = null
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (DEBUG) {
Log.d(TAG, ("onCreate() called with: "
+ "savedInstanceState = [" + savedInstanceState + "]"))
}
setCancelable(false)
}
public override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?): View? {
if (DEBUG) {
Log.d(TAG, ("onCreateView() called with: "
+ "inflater = [" + inflater + "], container = [" + container + "], "
+ "savedInstanceState = [" + savedInstanceState + "]"))
}
return inflater.inflate(R.layout.download_loading_dialog, container)
}
public override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view)
initToolbar(dialogLoadingBinding!!.toolbarLayout.toolbar)
}
private fun initToolbar(toolbar: Toolbar) {
if (DEBUG) {
Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]")
}
toolbar.setTitle(requireContext().getString(title))
toolbar.setNavigationOnClickListener(View.OnClickListener({ v: View? -> dismiss() }))
}
public override fun onDestroyView() {
dialogLoadingBinding = null
super.onDestroyView()
}
companion object {
private val TAG: String = "LoadingDialog"
private val DEBUG: Boolean = MainActivity.Companion.DEBUG
}
}

View File

@ -1,13 +1,11 @@
package org.schabi.newpipe.error;
package org.schabi.newpipe.error
import android.content.Context;
import androidx.annotation.NonNull;
import org.acra.ReportField;
import org.acra.data.CrashReportData;
import org.acra.sender.ReportSender;
import org.schabi.newpipe.R;
import android.content.Context
import org.acra.ReportField
import org.acra.data.CrashReportData
import org.acra.sender.ReportSender
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorUtil.Companion.openActivity
/*
* Created by Christian Schabesberger on 13.09.16.
@ -28,16 +26,12 @@ import org.schabi.newpipe.R;
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class AcraReportSender implements ReportSender {
@Override
public void send(@NonNull final Context context, @NonNull final CrashReportData report) {
ErrorUtil.openActivity(context, new ErrorInfo(
new String[]{report.getString(ReportField.STACK_TRACE)},
class AcraReportSender() : ReportSender {
public override fun send(context: Context, report: CrashReportData) {
openActivity(context, ErrorInfo(arrayOf<String?>(report.getString(ReportField.STACK_TRACE)),
UserAction.UI_ERROR,
ErrorInfo.SERVICE_NONE,
"ACRA report",
R.string.app_ui_crash));
R.string.app_ui_crash))
}
}

View File

@ -1,15 +1,11 @@
package org.schabi.newpipe.error;
package org.schabi.newpipe.error
import android.content.Context;
import androidx.annotation.NonNull;
import com.google.auto.service.AutoService;
import org.acra.config.CoreConfiguration;
import org.acra.sender.ReportSender;
import org.acra.sender.ReportSenderFactory;
import org.schabi.newpipe.App;
import android.content.Context
import com.google.auto.service.AutoService
import org.acra.config.CoreConfiguration
import org.acra.sender.ReportSender
import org.acra.sender.ReportSenderFactory
import org.schabi.newpipe.App
/*
* Created by Christian Schabesberger on 13.09.16.
@ -30,15 +26,13 @@ import org.schabi.newpipe.App;
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* Used by ACRA in {@link App}.initAcra() as the factory for report senders.
* Used by ACRA in [App].initAcra() as the factory for report senders.
*/
@AutoService(ReportSenderFactory.class)
public class AcraReportSenderFactory implements ReportSenderFactory {
@NonNull
public ReportSender create(@NonNull final Context context,
@NonNull final CoreConfiguration config) {
return new AcraReportSender();
@AutoService(ReportSenderFactory::class)
class AcraReportSenderFactory() : ReportSenderFactory {
public override fun create(context: Context,
config: CoreConfiguration): ReportSender {
return AcraReportSender()
}
}

View File

@ -1,348 +0,0 @@
package org.schabi.newpipe.error;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.IntentCompat;
import com.grack.nanojson.JsonWriter;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ActivityErrorBinding;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.stream.Collectors;
/*
* Created by Christian Schabesberger on 24.10.15.
*
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
* ErrorActivity.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* This activity is used to show error details and allow reporting them in various ways. Use {@link
* ErrorUtil#openActivity(Context, ErrorInfo)} to correctly open this activity.
*/
public class ErrorActivity extends AppCompatActivity {
// LOG TAGS
public static final String TAG = ErrorActivity.class.toString();
// BUNDLE TAGS
public static final String ERROR_INFO = "error_info";
public static final String ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org";
public static final String ERROR_EMAIL_SUBJECT = "Exception in ";
public static final String ERROR_GITHUB_ISSUE_URL =
"https://github.com/TeamNewPipe/NewPipe/issues";
public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private ErrorInfo errorInfo;
private String currentTimeStamp;
private ActivityErrorBinding activityErrorBinding;
////////////////////////////////////////////////////////////////////////
// Activity lifecycle
////////////////////////////////////////////////////////////////////////
@Override
protected void onCreate(final Bundle savedInstanceState) {
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceState);
ThemeHelper.setDayNightMode(this);
ThemeHelper.setTheme(this);
activityErrorBinding = ActivityErrorBinding.inflate(getLayoutInflater());
setContentView(activityErrorBinding.getRoot());
final Intent intent = getIntent();
setSupportActionBar(activityErrorBinding.toolbarLayout.toolbar);
final ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
actionBar.setTitle(R.string.error_report_title);
actionBar.setDisplayShowTitleEnabled(true);
}
errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo.class);
// important add guru meditation
addGuruMeditation();
currentTimeStamp = CURRENT_TIMESTAMP_FORMATTER.format(LocalDateTime.now());
activityErrorBinding.errorReportEmailButton.setOnClickListener(v ->
openPrivacyPolicyDialog(this, "EMAIL"));
activityErrorBinding.errorReportCopyButton.setOnClickListener(v ->
ShareUtils.copyToClipboard(this, buildMarkdown()));
activityErrorBinding.errorReportGitHubButton.setOnClickListener(v ->
openPrivacyPolicyDialog(this, "GITHUB"));
// normal bugreport
buildInfo(errorInfo);
activityErrorBinding.errorMessageView.setText(errorInfo.getMessageStringId());
activityErrorBinding.errorView.setText(formErrorText(errorInfo.getStackTraces()));
// print stack trace once again for debugging:
for (final String e : errorInfo.getStackTraces()) {
Log.e(TAG, e);
}
}
@Override
public boolean onCreateOptionsMenu(final Menu menu) {
final MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.error_menu, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
onBackPressed();
return true;
case R.id.menu_item_share_error:
ShareUtils.shareText(getApplicationContext(),
getString(R.string.error_report_title), buildJson());
return true;
default:
return false;
}
}
private void openPrivacyPolicyDialog(final Context context, final String action) {
new AlertDialog.Builder(context)
.setIcon(android.R.drawable.ic_dialog_alert)
.setTitle(R.string.privacy_policy_title)
.setMessage(R.string.start_accept_privacy_policy)
.setCancelable(false)
.setNeutralButton(R.string.read_privacy_policy, (dialog, which) ->
ShareUtils.openUrlInApp(context,
context.getString(R.string.privacy_policy_url)))
.setPositiveButton(R.string.accept, (dialog, which) -> {
if (action.equals("EMAIL")) { // send on email
final Intent i = new Intent(Intent.ACTION_SENDTO)
.setData(Uri.parse("mailto:")) // only email apps should handle this
.putExtra(Intent.EXTRA_EMAIL, new String[]{ERROR_EMAIL_ADDRESS})
.putExtra(Intent.EXTRA_SUBJECT, ERROR_EMAIL_SUBJECT
+ getString(R.string.app_name) + " "
+ BuildConfig.VERSION_NAME)
.putExtra(Intent.EXTRA_TEXT, buildJson());
ShareUtils.openIntentInApp(context, i);
} else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub
ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL);
}
})
.setNegativeButton(R.string.decline, null)
.show();
}
private String formErrorText(final String[] el) {
final String separator = "-------------------------------------";
return Arrays.stream(el)
.collect(Collectors.joining(separator + "\n", separator + "\n", separator));
}
/**
* Get the checked activity.
*
* @param returnActivity the activity to return to
* @return the casted return activity or null
*/
@Nullable
static Class<? extends Activity> getReturnActivity(final Class<?> returnActivity) {
Class<? extends Activity> checkedReturnActivity = null;
if (returnActivity != null) {
if (Activity.class.isAssignableFrom(returnActivity)) {
checkedReturnActivity = returnActivity.asSubclass(Activity.class);
} else {
checkedReturnActivity = MainActivity.class;
}
}
return checkedReturnActivity;
}
private void buildInfo(final ErrorInfo info) {
String text = "";
activityErrorBinding.errorInfoLabelsView.setText(getString(R.string.info_labels)
.replace("\\n", "\n"));
text += getUserActionString(info.getUserAction()) + "\n"
+ info.getRequest() + "\n"
+ getContentLanguageString() + "\n"
+ getContentCountryString() + "\n"
+ getAppLanguage() + "\n"
+ info.getServiceName() + "\n"
+ currentTimeStamp + "\n"
+ getPackageName() + "\n"
+ BuildConfig.VERSION_NAME + "\n"
+ getOsString();
activityErrorBinding.errorInfosView.setText(text);
}
private String buildJson() {
try {
return JsonWriter.string()
.object()
.value("user_action", getUserActionString(errorInfo.getUserAction()))
.value("request", errorInfo.getRequest())
.value("content_language", getContentLanguageString())
.value("content_country", getContentCountryString())
.value("app_language", getAppLanguage())
.value("service", errorInfo.getServiceName())
.value("package", getPackageName())
.value("version", BuildConfig.VERSION_NAME)
.value("os", getOsString())
.value("time", currentTimeStamp)
.array("exceptions", Arrays.asList(errorInfo.getStackTraces()))
.value("user_comment", activityErrorBinding.errorCommentBox.getText()
.toString())
.end()
.done();
} catch (final Throwable e) {
Log.e(TAG, "Error while erroring: Could not build json");
e.printStackTrace();
}
return "";
}
private String buildMarkdown() {
try {
final StringBuilder htmlErrorReport = new StringBuilder();
final String userComment = activityErrorBinding.errorCommentBox.getText().toString();
if (!userComment.isEmpty()) {
htmlErrorReport.append(userComment).append("\n");
}
// basic error info
htmlErrorReport
.append("## Exception")
.append("\n* __User Action:__ ")
.append(getUserActionString(errorInfo.getUserAction()))
.append("\n* __Request:__ ").append(errorInfo.getRequest())
.append("\n* __Content Country:__ ").append(getContentCountryString())
.append("\n* __Content Language:__ ").append(getContentLanguageString())
.append("\n* __App Language:__ ").append(getAppLanguage())
.append("\n* __Service:__ ").append(errorInfo.getServiceName())
.append("\n* __Version:__ ").append(BuildConfig.VERSION_NAME)
.append("\n* __OS:__ ").append(getOsString()).append("\n");
// Collapse all logs to a single paragraph when there are more than one
// to keep the GitHub issue clean.
if (errorInfo.getStackTraces().length > 1) {
htmlErrorReport
.append("<details><summary><b>Exceptions (")
.append(errorInfo.getStackTraces().length)
.append(")</b></summary><p>\n");
}
// add the logs
for (int i = 0; i < errorInfo.getStackTraces().length; i++) {
htmlErrorReport.append("<details><summary><b>Crash log ");
if (errorInfo.getStackTraces().length > 1) {
htmlErrorReport.append(i + 1);
}
htmlErrorReport.append("</b>")
.append("</summary><p>\n")
.append("\n```\n").append(errorInfo.getStackTraces()[i]).append("\n```\n")
.append("</details>\n");
}
// make sure to close everything
if (errorInfo.getStackTraces().length > 1) {
htmlErrorReport.append("</p></details>\n");
}
htmlErrorReport.append("<hr>\n");
return htmlErrorReport.toString();
} catch (final Throwable e) {
Log.e(TAG, "Error while erroring: Could not build markdown");
e.printStackTrace();
return "";
}
}
private String getUserActionString(final UserAction userAction) {
if (userAction == null) {
return "Your description is in another castle.";
} else {
return userAction.getMessage();
}
}
private String getContentCountryString() {
return Localization.getPreferredContentCountry(this).getCountryCode();
}
private String getContentLanguageString() {
return Localization.getPreferredLocalization(this).getLocalizationCode();
}
private String getAppLanguage() {
return Localization.getAppLocale(getApplicationContext()).toString();
}
private String getOsString() {
final String osBase = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
? Build.VERSION.BASE_OS : "Android";
return System.getProperty("os.name")
+ " " + (osBase.isEmpty() ? "Android" : osBase)
+ " " + Build.VERSION.RELEASE
+ " - " + Build.VERSION.SDK_INT;
}
private void addGuruMeditation() {
//just an easter egg
String text = activityErrorBinding.errorSorryView.getText().toString();
text += "\n" + getString(R.string.guru_meditation);
activityErrorBinding.errorSorryView.setText(text);
}
}

View File

@ -0,0 +1,309 @@
package org.schabi.newpipe.error
import android.R
import android.content.Context
import android.net.Uri
import android.os.Build
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.ActionBar
import androidx.appcompat.app.AlertDialog
import com.grack.nanojson.JsonWriter
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.databinding.ActivityErrorBinding
import org.schabi.newpipe.util.Localization
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Arrays
import java.util.stream.Collectors
/*
* Created by Christian Schabesberger on 24.10.15.
*
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
* ErrorActivity.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* This activity is used to show error details and allow reporting them in various ways. Use [ ][ErrorUtil.openActivity] to correctly open this activity.
*/
class ErrorActivity() : AppCompatActivity() {
private var errorInfo: ErrorInfo? = null
private var currentTimeStamp: String? = null
private var activityErrorBinding: ActivityErrorBinding? = null
////////////////////////////////////////////////////////////////////////
// Activity lifecycle
////////////////////////////////////////////////////////////////////////
protected override fun onCreate(savedInstanceState: Bundle?) {
Localization.assureCorrectAppLanguage(this)
super.onCreate(savedInstanceState)
ThemeHelper.setDayNightMode(this)
ThemeHelper.setTheme(this)
activityErrorBinding = ActivityErrorBinding.inflate(getLayoutInflater())
setContentView(activityErrorBinding!!.getRoot())
val intent: Intent = getIntent()
setSupportActionBar(activityErrorBinding!!.toolbarLayout.toolbar)
val actionBar: ActionBar? = getSupportActionBar()
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setTitle(R.string.error_report_title)
actionBar.setDisplayShowTitleEnabled(true)
}
errorInfo = IntentCompat.getParcelableExtra<ErrorInfo>(intent, ERROR_INFO, ErrorInfo::class.java)
// important add guru meditation
addGuruMeditation()
currentTimeStamp = CURRENT_TIMESTAMP_FORMATTER.format(LocalDateTime.now())
activityErrorBinding!!.errorReportEmailButton.setOnClickListener(View.OnClickListener({ v: View? -> openPrivacyPolicyDialog(this, "EMAIL") }))
activityErrorBinding!!.errorReportCopyButton.setOnClickListener(View.OnClickListener({ v: View? -> ShareUtils.copyToClipboard(this, buildMarkdown()) }))
activityErrorBinding!!.errorReportGitHubButton.setOnClickListener(View.OnClickListener({ v: View? -> openPrivacyPolicyDialog(this, "GITHUB") }))
// normal bugreport
buildInfo(errorInfo)
activityErrorBinding!!.errorMessageView.setText(errorInfo!!.messageStringId)
activityErrorBinding!!.errorView.setText(formErrorText(errorInfo!!.stackTraces))
// print stack trace once again for debugging:
for (e: String? in errorInfo!!.stackTraces) {
Log.e(TAG, (e)!!)
}
}
public override fun onCreateOptionsMenu(menu: Menu): Boolean {
val inflater: MenuInflater = getMenuInflater()
inflater.inflate(R.menu.error_menu, menu)
return true
}
public override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.getItemId()) {
R.id.home -> {
onBackPressed()
return true
}
R.id.menu_item_share_error -> {
shareText(getApplicationContext(),
getString(R.string.error_report_title), buildJson())
return true
}
else -> return false
}
}
private fun openPrivacyPolicyDialog(context: Context, action: String) {
AlertDialog.Builder(context)
.setIcon(R.drawable.ic_dialog_alert)
.setTitle(R.string.privacy_policy_title)
.setMessage(R.string.start_accept_privacy_policy)
.setCancelable(false)
.setNeutralButton(R.string.read_privacy_policy, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int ->
ShareUtils.openUrlInApp(context,
context.getString(R.string.privacy_policy_url))
}))
.setPositiveButton(R.string.accept, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int ->
if ((action == "EMAIL")) { // send on email
val i: Intent = Intent(Intent.ACTION_SENDTO)
.setData(Uri.parse("mailto:")) // only email apps should handle this
.putExtra(Intent.EXTRA_EMAIL, arrayOf<String>(ERROR_EMAIL_ADDRESS))
.putExtra(Intent.EXTRA_SUBJECT, (ERROR_EMAIL_SUBJECT
+ getString(R.string.app_name) + " "
+ BuildConfig.VERSION_NAME))
.putExtra(Intent.EXTRA_TEXT, buildJson())
ShareUtils.openIntentInApp(context, i)
} else if ((action == "GITHUB")) { // open the NewPipe issue page on GitHub
ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL)
}
}))
.setNegativeButton(R.string.decline, null)
.show()
}
private fun formErrorText(el: Array<String>): String {
val separator: String = "-------------------------------------"
return Arrays.stream(el)
.collect(Collectors.joining(separator + "\n", separator + "\n", separator))
}
private fun buildInfo(info: ErrorInfo?) {
var text: String? = ""
activityErrorBinding!!.errorInfoLabelsView.setText(getString(R.string.info_labels)
.replace("\\n", "\n"))
text += ((getUserActionString(info!!.userAction) + "\n"
+ info.request + "\n"
+ contentLanguageString + "\n"
+ contentCountryString + "\n"
+ appLanguage + "\n"
+ info.serviceName + "\n"
+ currentTimeStamp + "\n"
+ getPackageName() + "\n"
+ BuildConfig.VERSION_NAME).toString() + "\n"
+ osString)
activityErrorBinding!!.errorInfosView.setText(text)
}
private fun buildJson(): String {
try {
return JsonWriter.string()
.`object`()
.value("user_action", getUserActionString(errorInfo!!.userAction))
.value("request", errorInfo!!.request)
.value("content_language", contentLanguageString)
.value("content_country", contentCountryString)
.value("app_language", appLanguage)
.value("service", errorInfo!!.serviceName)
.value("package", getPackageName())
.value("version", BuildConfig.VERSION_NAME)
.value("os", osString)
.value("time", currentTimeStamp)
.array("exceptions", Arrays.asList<String>(*errorInfo!!.stackTraces))
.value("user_comment", activityErrorBinding!!.errorCommentBox.getText()
.toString())
.end()
.done()
} catch (e: Throwable) {
Log.e(TAG, "Error while erroring: Could not build json")
e.printStackTrace()
}
return ""
}
private fun buildMarkdown(): String {
try {
val htmlErrorReport: StringBuilder = StringBuilder()
val userComment: String = activityErrorBinding!!.errorCommentBox.getText().toString()
if (!userComment.isEmpty()) {
htmlErrorReport.append(userComment).append("\n")
}
// basic error info
htmlErrorReport
.append("## Exception")
.append("\n* __User Action:__ ")
.append(getUserActionString(errorInfo!!.userAction))
.append("\n* __Request:__ ").append(errorInfo!!.request)
.append("\n* __Content Country:__ ").append(contentCountryString)
.append("\n* __Content Language:__ ").append(contentLanguageString)
.append("\n* __App Language:__ ").append(appLanguage)
.append("\n* __Service:__ ").append(errorInfo!!.serviceName)
.append("\n* __Version:__ ").append(BuildConfig.VERSION_NAME)
.append("\n* __OS:__ ").append(osString).append("\n")
// Collapse all logs to a single paragraph when there are more than one
// to keep the GitHub issue clean.
if (errorInfo!!.stackTraces.size > 1) {
htmlErrorReport
.append("<details><summary><b>Exceptions (")
.append(errorInfo!!.stackTraces.size)
.append(")</b></summary><p>\n")
}
// add the logs
for (i in errorInfo!!.stackTraces.indices) {
htmlErrorReport.append("<details><summary><b>Crash log ")
if (errorInfo!!.stackTraces.size > 1) {
htmlErrorReport.append(i + 1)
}
htmlErrorReport.append("</b>")
.append("</summary><p>\n")
.append("\n```\n").append(errorInfo!!.stackTraces.get(i)).append("\n```\n")
.append("</details>\n")
}
// make sure to close everything
if (errorInfo!!.stackTraces.size > 1) {
htmlErrorReport.append("</p></details>\n")
}
htmlErrorReport.append("<hr>\n")
return htmlErrorReport.toString()
} catch (e: Throwable) {
Log.e(TAG, "Error while erroring: Could not build markdown")
e.printStackTrace()
return ""
}
}
private fun getUserActionString(userAction: UserAction?): String? {
if (userAction == null) {
return "Your description is in another castle."
} else {
return userAction.getMessage()
}
}
private val contentCountryString: String
private get() {
return Localization.getPreferredContentCountry(this).getCountryCode()
}
private val contentLanguageString: String
private get() {
return Localization.getPreferredLocalization(this).getLocalizationCode()
}
private val appLanguage: String
private get() {
return Localization.getAppLocale(getApplicationContext()).toString()
}
private val osString: String
private get() {
val osBase: String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) Build.VERSION.BASE_OS else "Android"
return (System.getProperty("os.name")
+ " " + (if (osBase.isEmpty()) "Android" else osBase)
+ " " + Build.VERSION.RELEASE
+ " - " + Build.VERSION.SDK_INT)
}
private fun addGuruMeditation() {
//just an easter egg
var text: String? = activityErrorBinding!!.errorSorryView.getText().toString()
text += "\n" + getString(R.string.guru_meditation)
activityErrorBinding!!.errorSorryView.setText(text)
}
companion object {
// LOG TAGS
val TAG: String = ErrorActivity::class.java.toString()
// BUNDLE TAGS
val ERROR_INFO: String = "error_info"
val ERROR_EMAIL_ADDRESS: String = "crashreport@newpipe.schabi.org"
val ERROR_EMAIL_SUBJECT: String = "Exception in "
val ERROR_GITHUB_ISSUE_URL: String = "https://github.com/TeamNewPipe/NewPipe/issues"
val CURRENT_TIMESTAMP_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
/**
* Get the checked activity.
*
* @param returnActivity the activity to return to
* @return the casted return activity or null
*/
@JvmStatic
fun getReturnActivity(returnActivity: Class<*>?): Class<out Activity?>? {
var checkedReturnActivity: Class<out Activity?>? = null
if (returnActivity != null) {
if (Activity::class.java.isAssignableFrom(returnActivity)) {
checkedReturnActivity = returnActivity.asSubclass<Activity?>(Activity::class.java)
} else {
checkedReturnActivity = MainActivity::class.java
}
}
return checkedReturnActivity
}
}
}

View File

@ -1,238 +0,0 @@
package org.schabi.newpipe.error;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.webkit.CookieManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.NavUtils;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.DownloaderImpl;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ActivityRecaptchaBinding;
import org.schabi.newpipe.extractor.utils.Utils;
import org.schabi.newpipe.util.ThemeHelper;
import java.io.UnsupportedEncodingException;
/*
* Created by beneth <bmauduit@beneth.fr> on 06.12.16.
*
* Copyright (C) Christian Schabesberger 2015 <chris.schabesberger@mailbox.org>
* ReCaptchaActivity.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class ReCaptchaActivity extends AppCompatActivity {
public static final int RECAPTCHA_REQUEST = 10;
public static final String RECAPTCHA_URL_EXTRA = "recaptcha_url_extra";
public static final String TAG = ReCaptchaActivity.class.toString();
public static final String YT_URL = "https://www.youtube.com";
public static final String RECAPTCHA_COOKIES_KEY = "recaptcha_cookies";
public static String sanitizeRecaptchaUrl(@Nullable final String url) {
if (url == null || url.trim().isEmpty()) {
return YT_URL; // YouTube is the most likely service to have thrown a recaptcha
} else {
// remove "pbj=1" parameter from YouYube urls, as it makes the page JSON and not HTML
return url.replace("&pbj=1", "").replace("pbj=1&", "").replace("?pbj=1", "");
}
}
private ActivityRecaptchaBinding recaptchaBinding;
private String foundCookies = "";
@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(final Bundle savedInstanceState) {
ThemeHelper.setTheme(this);
super.onCreate(savedInstanceState);
recaptchaBinding = ActivityRecaptchaBinding.inflate(getLayoutInflater());
setContentView(recaptchaBinding.getRoot());
setSupportActionBar(recaptchaBinding.toolbar);
final String url = sanitizeRecaptchaUrl(getIntent().getStringExtra(RECAPTCHA_URL_EXTRA));
// set return to Cancel by default
setResult(RESULT_CANCELED);
// enable Javascript
final WebSettings webSettings = recaptchaBinding.reCaptchaWebView.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setUserAgentString(DownloaderImpl.USER_AGENT);
recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(final WebView view,
final WebResourceRequest request) {
if (MainActivity.DEBUG) {
Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString());
}
handleCookiesFromUrl(request.getUrl().toString());
return false;
}
@Override
public void onPageFinished(final WebView view, final String url) {
super.onPageFinished(view, url);
handleCookiesFromUrl(url);
}
});
// cleaning cache, history and cookies from webView
recaptchaBinding.reCaptchaWebView.clearCache(true);
recaptchaBinding.reCaptchaWebView.clearHistory();
CookieManager.getInstance().removeAllCookies(null);
recaptchaBinding.reCaptchaWebView.loadUrl(url);
}
@Override
public boolean onCreateOptionsMenu(final Menu menu) {
getMenuInflater().inflate(R.menu.menu_recaptcha, menu);
final ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(false);
actionBar.setTitle(R.string.title_activity_recaptcha);
actionBar.setSubtitle(R.string.subtitle_activity_recaptcha);
}
return true;
}
@Override
public void onBackPressed() {
saveCookiesAndFinish();
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == R.id.menu_item_done) {
saveCookiesAndFinish();
return true;
}
return false;
}
private void saveCookiesAndFinish() {
// try to get cookies of unclosed page
handleCookiesFromUrl(recaptchaBinding.reCaptchaWebView.getUrl());
if (MainActivity.DEBUG) {
Log.d(TAG, "saveCookiesAndFinish: foundCookies=" + foundCookies);
}
if (!foundCookies.isEmpty()) {
// save cookies to preferences
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
getApplicationContext());
final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key);
prefs.edit().putString(key, foundCookies).apply();
// give cookies to Downloader class
DownloaderImpl.getInstance().setCookie(RECAPTCHA_COOKIES_KEY, foundCookies);
setResult(RESULT_OK);
}
// Navigate to blank page (unloads youtube to prevent background playback)
recaptchaBinding.reCaptchaWebView.loadUrl("about:blank");
final Intent intent = new Intent(this, org.schabi.newpipe.MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
NavUtils.navigateUpTo(this, intent);
}
private void handleCookiesFromUrl(@Nullable final String url) {
if (MainActivity.DEBUG) {
Log.d(TAG, "handleCookiesFromUrl: url=" + (url == null ? "null" : url));
}
if (url == null) {
return;
}
final String cookies = CookieManager.getInstance().getCookie(url);
handleCookies(cookies);
// sometimes cookies are inside the url
final int abuseStart = url.indexOf("google_abuse=");
if (abuseStart != -1) {
final int abuseEnd = url.indexOf("+path");
try {
String abuseCookie = url.substring(abuseStart + 13, abuseEnd);
abuseCookie = Utils.decodeUrlUtf8(abuseCookie);
handleCookies(abuseCookie);
} catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) {
if (MainActivity.DEBUG) {
e.printStackTrace();
Log.d(TAG, "handleCookiesFromUrl: invalid google abuse starting at "
+ abuseStart + " and ending at " + abuseEnd + " for url " + url);
}
}
}
}
private void handleCookies(@Nullable final String cookies) {
if (MainActivity.DEBUG) {
Log.d(TAG, "handleCookies: cookies=" + (cookies == null ? "null" : cookies));
}
if (cookies == null) {
return;
}
addYoutubeCookies(cookies);
// add here methods to extract cookies for other services
}
private void addYoutubeCookies(@NonNull final String cookies) {
if (cookies.contains("s_gl=") || cookies.contains("goojf=")
|| cookies.contains("VISITOR_INFO1_LIVE=")
|| cookies.contains("GOOGLE_ABUSE_EXEMPTION=")) {
// youtube seems to also need the other cookies:
addCookie(cookies);
}
}
private void addCookie(final String cookie) {
if (foundCookies.contains(cookie)) {
return;
}
if (foundCookies.isEmpty() || foundCookies.endsWith("; ")) {
foundCookies += cookie;
} else if (foundCookies.endsWith(";")) {
foundCookies += " " + cookie;
} else {
foundCookies += "; " + cookie;
}
}
}

View File

@ -0,0 +1,217 @@
package org.schabi.newpipe.error
import android.annotation.SuppressLint
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.ActionBar
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.preference.PreferenceManager
import org.schabi.newpipe.DownloaderImpl
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ActivityRecaptchaBinding
import org.schabi.newpipe.extractor.utils.Utils
import org.schabi.newpipe.util.ThemeHelper
import java.io.UnsupportedEncodingException
/*
* Created by beneth <bmauduit@beneth.fr> on 06.12.16.
*
* Copyright (C) Christian Schabesberger 2015 <chris.schabesberger@mailbox.org>
* ReCaptchaActivity.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
class ReCaptchaActivity() : AppCompatActivity() {
private var recaptchaBinding: ActivityRecaptchaBinding? = null
private var foundCookies: String = ""
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.setTheme(this)
super.onCreate(savedInstanceState)
recaptchaBinding = ActivityRecaptchaBinding.inflate(getLayoutInflater())
setContentView(recaptchaBinding!!.getRoot())
setSupportActionBar(recaptchaBinding!!.toolbar)
val url: String = sanitizeRecaptchaUrl(getIntent().getStringExtra(RECAPTCHA_URL_EXTRA))
// set return to Cancel by default
setResult(RESULT_CANCELED)
// enable Javascript
val webSettings: WebSettings = recaptchaBinding!!.reCaptchaWebView.getSettings()
webSettings.setJavaScriptEnabled(true)
webSettings.setUserAgentString(DownloaderImpl.Companion.USER_AGENT)
recaptchaBinding!!.reCaptchaWebView.setWebViewClient(object : WebViewClient() {
public override fun shouldOverrideUrlLoading(view: WebView,
request: WebResourceRequest): Boolean {
if (MainActivity.Companion.DEBUG) {
Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString())
}
handleCookiesFromUrl(request.getUrl().toString())
return false
}
public override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
handleCookiesFromUrl(url)
}
})
// cleaning cache, history and cookies from webView
recaptchaBinding!!.reCaptchaWebView.clearCache(true)
recaptchaBinding!!.reCaptchaWebView.clearHistory()
CookieManager.getInstance().removeAllCookies(null)
recaptchaBinding!!.reCaptchaWebView.loadUrl(url)
}
public override fun onCreateOptionsMenu(menu: Menu): Boolean {
getMenuInflater().inflate(R.menu.menu_recaptcha, menu)
val actionBar: ActionBar? = getSupportActionBar()
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(false)
actionBar.setTitle(R.string.title_activity_recaptcha)
actionBar.setSubtitle(R.string.subtitle_activity_recaptcha)
}
return true
}
public override fun onBackPressed() {
saveCookiesAndFinish()
}
public override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.getItemId() == R.id.menu_item_done) {
saveCookiesAndFinish()
return true
}
return false
}
private fun saveCookiesAndFinish() {
// try to get cookies of unclosed page
handleCookiesFromUrl(recaptchaBinding!!.reCaptchaWebView.getUrl())
if (MainActivity.Companion.DEBUG) {
Log.d(TAG, "saveCookiesAndFinish: foundCookies=" + foundCookies)
}
if (!foundCookies.isEmpty()) {
// save cookies to preferences
val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(
getApplicationContext())
val key: String = getApplicationContext().getString(R.string.recaptcha_cookies_key)
prefs.edit().putString(key, foundCookies).apply()
// give cookies to Downloader class
DownloaderImpl.Companion.getInstance()!!.setCookie(RECAPTCHA_COOKIES_KEY, foundCookies)
setResult(RESULT_OK)
}
// Navigate to blank page (unloads youtube to prevent background playback)
recaptchaBinding!!.reCaptchaWebView.loadUrl("about:blank")
val intent: Intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
NavUtils.navigateUpTo(this, intent)
}
private fun handleCookiesFromUrl(url: String?) {
if (MainActivity.Companion.DEBUG) {
Log.d(TAG, "handleCookiesFromUrl: url=" + (if (url == null) "null" else url))
}
if (url == null) {
return
}
val cookies: String = CookieManager.getInstance().getCookie(url)
handleCookies(cookies)
// sometimes cookies are inside the url
val abuseStart: Int = url.indexOf("google_abuse=")
if (abuseStart != -1) {
val abuseEnd: Int = url.indexOf("+path")
try {
var abuseCookie: String? = url.substring(abuseStart + 13, abuseEnd)
abuseCookie = Utils.decodeUrlUtf8(abuseCookie)
handleCookies(abuseCookie)
} catch (e: UnsupportedEncodingException) {
if (MainActivity.Companion.DEBUG) {
e.printStackTrace()
Log.d(TAG, ("handleCookiesFromUrl: invalid google abuse starting at "
+ abuseStart + " and ending at " + abuseEnd + " for url " + url))
}
} catch (e: StringIndexOutOfBoundsException) {
if (MainActivity.Companion.DEBUG) {
e.printStackTrace()
Log.d(TAG, ("handleCookiesFromUrl: invalid google abuse starting at "
+ abuseStart + " and ending at " + abuseEnd + " for url " + url))
}
}
}
}
private fun handleCookies(cookies: String?) {
if (MainActivity.Companion.DEBUG) {
Log.d(TAG, "handleCookies: cookies=" + (if (cookies == null) "null" else cookies))
}
if (cookies == null) {
return
}
addYoutubeCookies(cookies)
// add here methods to extract cookies for other services
}
private fun addYoutubeCookies(cookies: String) {
if ((cookies.contains("s_gl=") || cookies.contains("goojf=")
|| cookies.contains("VISITOR_INFO1_LIVE=")
|| cookies.contains("GOOGLE_ABUSE_EXEMPTION="))) {
// youtube seems to also need the other cookies:
addCookie(cookies)
}
}
private fun addCookie(cookie: String) {
if (foundCookies.contains(cookie)) {
return
}
if (foundCookies.isEmpty() || foundCookies.endsWith("; ")) {
foundCookies += cookie
} else if (foundCookies.endsWith(";")) {
foundCookies += " " + cookie
} else {
foundCookies += "; " + cookie
}
}
companion object {
val RECAPTCHA_REQUEST: Int = 10
val RECAPTCHA_URL_EXTRA: String = "recaptcha_url_extra"
val TAG: String = ReCaptchaActivity::class.java.toString()
val YT_URL: String = "https://www.youtube.com"
val RECAPTCHA_COOKIES_KEY: String = "recaptcha_cookies"
fun sanitizeRecaptchaUrl(url: String?): String {
if (url == null || url.trim({ it <= ' ' }).isEmpty()) {
return YT_URL // YouTube is the most likely service to have thrown a recaptcha
} else {
// remove "pbj=1" parameter from YouYube urls, as it makes the page JSON and not HTML
return url.replace("&pbj=1", "").replace("pbj=1&", "").replace("?pbj=1", "")
}
}
}
}

View File

@ -1,9 +1,9 @@
package org.schabi.newpipe.error;
package org.schabi.newpipe.error
/**
* The user actions that can cause an error.
*/
public enum UserAction {
enum class UserAction(val message: String) {
USER_REPORT("user report"),
UI_ERROR("ui error"),
SUBSCRIPTION_CHANGE("subscription change"),
@ -31,15 +31,6 @@ public enum UserAction {
PREFERENCES_MIGRATION("migration of preferences"),
SHARE_TO_NEWPIPE("share to newpipe"),
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
OPEN_INFO_ITEM_DIALOG("open info item dialog");
OPEN_INFO_ITEM_DIALOG("open info item dialog")
private final String message;
UserAction(final String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}

View File

@ -1,13 +1,13 @@
package org.schabi.newpipe.fragments;
package org.schabi.newpipe.fragments
/**
* Indicates that the current fragment can handle back presses.
*/
public interface BackPressable {
open interface BackPressable {
/**
* A back press was delegated to this fragment.
*
* @return if the back press was handled
*/
boolean onBackPressed();
fun onBackPressed(): Boolean
}

View File

@ -1,226 +0,0 @@
package org.schabi.newpipe.fragments;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.fragment.app.Fragment;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorPanelHelper;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.util.InfoCache;
import java.util.concurrent.atomic.AtomicBoolean;
import icepick.State;
public abstract class BaseStateFragment<I> extends BaseFragment implements ViewContract<I> {
@State
protected AtomicBoolean wasLoading = new AtomicBoolean();
protected AtomicBoolean isLoading = new AtomicBoolean();
@Nullable
protected View emptyStateView;
@Nullable
protected TextView emptyStateMessageView;
@Nullable
private ProgressBar loadingProgressBar;
private ErrorPanelHelper errorPanelHelper;
@Nullable
@State
protected ErrorInfo lastPanelError = null;
@Override
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState);
doInitialLoadLogic();
}
@Override
public void onPause() {
super.onPause();
wasLoading.set(isLoading.get());
}
@Override
public void onResume() {
super.onResume();
if (lastPanelError != null) {
showError(lastPanelError);
}
}
/*//////////////////////////////////////////////////////////////////////////
// Init
//////////////////////////////////////////////////////////////////////////*/
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
emptyStateView = rootView.findViewById(R.id.empty_state_view);
emptyStateMessageView = rootView.findViewById(R.id.empty_state_message);
loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar);
errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked);
}
@Override
public void onDestroyView() {
super.onDestroyView();
if (errorPanelHelper != null) {
errorPanelHelper.dispose();
}
emptyStateView = null;
emptyStateMessageView = null;
}
protected void onRetryButtonClicked() {
reloadContent();
}
public void reloadContent() {
startLoading(true);
}
/*//////////////////////////////////////////////////////////////////////////
// Load
//////////////////////////////////////////////////////////////////////////*/
protected void doInitialLoadLogic() {
startLoading(true);
}
protected void startLoading(final boolean forceLoad) {
if (DEBUG) {
Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]");
}
showLoading();
isLoading.set(true);
}
/*//////////////////////////////////////////////////////////////////////////
// Contract
//////////////////////////////////////////////////////////////////////////*/
@Override
public void showLoading() {
if (emptyStateView != null) {
animate(emptyStateView, false, 150);
}
if (loadingProgressBar != null) {
animate(loadingProgressBar, true, 400);
}
hideErrorPanel();
}
@Override
public void hideLoading() {
if (emptyStateView != null) {
animate(emptyStateView, false, 150);
}
if (loadingProgressBar != null) {
animate(loadingProgressBar, false, 0);
}
hideErrorPanel();
}
public void showEmptyState() {
isLoading.set(false);
if (emptyStateView != null) {
animate(emptyStateView, true, 200);
}
if (loadingProgressBar != null) {
animate(loadingProgressBar, false, 0);
}
hideErrorPanel();
}
@Override
public void handleResult(final I result) {
if (DEBUG) {
Log.d(TAG, "handleResult() called with: result = [" + result + "]");
}
hideLoading();
}
@Override
public void handleError() {
isLoading.set(false);
InfoCache.getInstance().clearCache();
if (emptyStateView != null) {
animate(emptyStateView, false, 150);
}
if (loadingProgressBar != null) {
animate(loadingProgressBar, false, 0);
}
}
/*//////////////////////////////////////////////////////////////////////////
// Error handling
//////////////////////////////////////////////////////////////////////////*/
public final void showError(final ErrorInfo errorInfo) {
handleError();
if (isDetached() || isRemoving()) {
if (DEBUG) {
Log.w(TAG, "showError() is detached or removing = [" + errorInfo + "]");
}
return;
}
errorPanelHelper.showError(errorInfo);
lastPanelError = errorInfo;
}
public final void showTextError(@NonNull final String errorString) {
handleError();
if (isDetached() || isRemoving()) {
if (DEBUG) {
Log.w(TAG, "showTextError() is detached or removing = [" + errorString + "]");
}
return;
}
errorPanelHelper.showTextError(errorString);
}
protected void setEmptyStateMessage(@StringRes final int text) {
if (emptyStateMessageView != null) {
emptyStateMessageView.setText(text);
}
}
public final void hideErrorPanel() {
errorPanelHelper.hide();
lastPanelError = null;
}
public final boolean isErrorPanelVisible() {
return errorPanelHelper.isVisible();
}
/**
* Directly calls {@link ErrorUtil#showSnackbar(Fragment, ErrorInfo)}, that shows a snackbar if
* a valid view can be found, otherwise creates an error report notification.
*
* @param errorInfo The error information
*/
public void showSnackBarError(final ErrorInfo errorInfo) {
if (DEBUG) {
Log.d(TAG, "showSnackBarError() called with: errorInfo = [" + errorInfo + "]");
}
ErrorUtil.showSnackbar(this, errorInfo);
}
}

View File

@ -0,0 +1,197 @@
package org.schabi.newpipe.fragments
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.StringRes
import icepick.State
import org.schabi.newpipe.BaseFragment
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorPanelHelper
import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.util.InfoCache
import java.util.concurrent.atomic.AtomicBoolean
abstract class BaseStateFragment<I>() : BaseFragment(), ViewContract<I> {
@State
protected var wasLoading: AtomicBoolean = AtomicBoolean()
protected var isLoading: AtomicBoolean = AtomicBoolean()
protected var emptyStateView: View? = null
protected var emptyStateMessageView: TextView? = null
private var loadingProgressBar: ProgressBar? = null
private var errorPanelHelper: ErrorPanelHelper? = null
@State
protected var lastPanelError: ErrorInfo? = null
public override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
super.onViewCreated(rootView, savedInstanceState)
doInitialLoadLogic()
}
public override fun onPause() {
super.onPause()
wasLoading.set(isLoading.get())
}
public override fun onResume() {
super.onResume()
if (lastPanelError != null) {
showError(lastPanelError!!)
}
}
/*//////////////////////////////////////////////////////////////////////////
// Init
////////////////////////////////////////////////////////////////////////// */
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
super.initViews(rootView, savedInstanceState)
emptyStateView = rootView.findViewById(R.id.empty_state_view)
emptyStateMessageView = rootView.findViewById(R.id.empty_state_message)
loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar)
errorPanelHelper = ErrorPanelHelper(this, rootView, Runnable({ onRetryButtonClicked() }))
}
public override fun onDestroyView() {
super.onDestroyView()
if (errorPanelHelper != null) {
errorPanelHelper!!.dispose()
}
emptyStateView = null
emptyStateMessageView = null
}
protected fun onRetryButtonClicked() {
reloadContent()
}
open fun reloadContent() {
startLoading(true)
}
/*//////////////////////////////////////////////////////////////////////////
// Load
////////////////////////////////////////////////////////////////////////// */
protected open fun doInitialLoadLogic() {
startLoading(true)
}
protected open fun startLoading(forceLoad: Boolean) {
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]")
}
showLoading()
isLoading.set(true)
}
/*//////////////////////////////////////////////////////////////////////////
// Contract
////////////////////////////////////////////////////////////////////////// */
public override fun showLoading() {
if (emptyStateView != null) {
emptyStateView!!.animate(false, 150)
}
if (loadingProgressBar != null) {
loadingProgressBar!!.animate(true, 400)
}
hideErrorPanel()
}
public override fun hideLoading() {
if (emptyStateView != null) {
emptyStateView!!.animate(false, 150)
}
if (loadingProgressBar != null) {
loadingProgressBar!!.animate(false, 0)
}
hideErrorPanel()
}
public override fun showEmptyState() {
isLoading.set(false)
if (emptyStateView != null) {
emptyStateView!!.animate(true, 200)
}
if (loadingProgressBar != null) {
loadingProgressBar!!.animate(false, 0)
}
hideErrorPanel()
}
public override fun handleResult(result: I) {
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, "handleResult() called with: result = [" + result + "]")
}
hideLoading()
}
public override fun handleError() {
isLoading.set(false)
InfoCache.Companion.getInstance().clearCache()
if (emptyStateView != null) {
emptyStateView!!.animate(false, 150)
}
if (loadingProgressBar != null) {
loadingProgressBar!!.animate(false, 0)
}
}
/*//////////////////////////////////////////////////////////////////////////
// Error handling
////////////////////////////////////////////////////////////////////////// */
fun showError(errorInfo: ErrorInfo) {
handleError()
if (isDetached() || isRemoving()) {
if (BaseFragment.Companion.DEBUG) {
Log.w(TAG, "showError() is detached or removing = [" + errorInfo + "]")
}
return
}
errorPanelHelper!!.showError(errorInfo)
lastPanelError = errorInfo
}
fun showTextError(errorString: String) {
handleError()
if (isDetached() || isRemoving()) {
if (BaseFragment.Companion.DEBUG) {
Log.w(TAG, "showTextError() is detached or removing = [" + errorString + "]")
}
return
}
errorPanelHelper!!.showTextError(errorString)
}
protected fun setEmptyStateMessage(@StringRes text: Int) {
if (emptyStateMessageView != null) {
emptyStateMessageView!!.setText(text)
}
}
fun hideErrorPanel() {
errorPanelHelper!!.hide()
lastPanelError = null
}
val isErrorPanelVisible: Boolean
get() {
return errorPanelHelper!!.isVisible()
}
/**
* Directly calls [ErrorUtil.showSnackbar], that shows a snackbar if
* a valid view can be found, otherwise creates an error report notification.
*
* @param errorInfo The error information
*/
fun showSnackBarError(errorInfo: ErrorInfo) {
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, "showSnackBarError() called with: errorInfo = [" + errorInfo + "]")
}
showSnackbar(this, errorInfo)
}
}

View File

@ -1,30 +0,0 @@
package org.schabi.newpipe.fragments;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.Nullable;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
public class BlankFragment extends BaseFragment {
@Nullable
@Override
public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
final Bundle savedInstanceState) {
setTitle("NewPipe");
return inflater.inflate(R.layout.fragment_blank, container, false);
}
@Override
public void onResume() {
super.onResume();
setTitle("NewPipe");
// leave this inline. Will make it harder for copy cats.
// If you are a Copy cat FUCK YOU.
// I WILL FIND YOU, AND I WILL ...
}
}

View File

@ -0,0 +1,24 @@
package org.schabi.newpipe.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.schabi.newpipe.BaseFragment
import org.schabi.newpipe.R
class BlankFragment() : BaseFragment() {
public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
setTitle("NewPipe")
return inflater.inflate(R.layout.fragment_blank, container, false)
}
public override fun onResume() {
super.onResume()
setTitle("NewPipe")
// leave this inline. Will make it harder for copy cats.
// If you are a Copy cat FUCK YOU.
// I WILL FIND YOU, AND I WILL ...
}
}

View File

@ -1,33 +0,0 @@
package org.schabi.newpipe.fragments;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.Nullable;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
public class EmptyFragment extends BaseFragment {
private static final String SHOW_MESSAGE = "SHOW_MESSAGE";
public static final EmptyFragment newInstance(final boolean showMessage) {
final EmptyFragment emptyFragment = new EmptyFragment();
final Bundle bundle = new Bundle(1);
bundle.putBoolean(SHOW_MESSAGE, showMessage);
emptyFragment.setArguments(bundle);
return emptyFragment;
}
@Override
public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
final Bundle savedInstanceState) {
final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE);
final View view = inflater.inflate(R.layout.fragment_empty, container, false);
view.findViewById(R.id.empty_state_view).setVisibility(
showMessage ? View.VISIBLE : View.GONE);
return view;
}
}

View File

@ -0,0 +1,30 @@
package org.schabi.newpipe.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.schabi.newpipe.BaseFragment
import org.schabi.newpipe.R
class EmptyFragment() : BaseFragment() {
public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
val showMessage: Boolean = getArguments()!!.getBoolean(SHOW_MESSAGE)
val view: View = inflater.inflate(R.layout.fragment_empty, container, false)
view.findViewById<View>(R.id.empty_state_view).setVisibility(
if (showMessage) View.VISIBLE else View.GONE)
return view
}
companion object {
private val SHOW_MESSAGE: String = "SHOW_MESSAGE"
fun newInstance(showMessage: Boolean): EmptyFragment {
val emptyFragment: EmptyFragment = EmptyFragment()
val bundle: Bundle = Bundle(1)
bundle.putBoolean(SHOW_MESSAGE, showMessage)
emptyFragment.setArguments(bundle)
return emptyFragment
}
}
}

View File

@ -1,342 +0,0 @@
package org.schabi.newpipe.fragments;
import static android.widget.RelativeLayout.ABOVE;
import static android.widget.RelativeLayout.ALIGN_PARENT_BOTTOM;
import static android.widget.RelativeLayout.ALIGN_PARENT_TOP;
import static android.widget.RelativeLayout.BELOW;
import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_BOTTOM;
import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_TOP;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.RelativeLayout;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround;
import androidx.preference.PreferenceManager;
import androidx.viewpager.widget.ViewPager;
import com.google.android.material.tabs.TabLayout;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentMainBinding;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.settings.tabs.Tab;
import org.schabi.newpipe.settings.tabs.TabsManager;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.ScrollableTabLayout;
import java.util.ArrayList;
import java.util.List;
public class MainFragment extends BaseFragment implements TabLayout.OnTabSelectedListener {
private FragmentMainBinding binding;
private SelectedTabsPagerAdapter pagerAdapter;
private final List<Tab> tabsList = new ArrayList<>();
private TabsManager tabsManager;
private boolean hasTabsChanged = false;
private SharedPreferences prefs;
private boolean youtubeRestrictedModeEnabled;
private String youtubeRestrictedModeEnabledKey;
private boolean mainTabsPositionBottom;
private String mainTabsPositionKey;
/*//////////////////////////////////////////////////////////////////////////
// Fragment's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
tabsManager = TabsManager.getManager(activity);
tabsManager.setSavedTabsListener(() -> {
if (DEBUG) {
Log.d(TAG, "TabsManager.SavedTabsChangeListener: "
+ "onTabsChanged called, isResumed = " + isResumed());
}
if (isResumed()) {
setupTabs();
} else {
hasTabsChanged = true;
}
});
prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
youtubeRestrictedModeEnabled = prefs.getBoolean(youtubeRestrictedModeEnabledKey, false);
mainTabsPositionKey = getString(R.string.main_tabs_position_key);
mainTabsPositionBottom = prefs.getBoolean(mainTabsPositionKey, false);
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_main, container, false);
}
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
binding = FragmentMainBinding.bind(rootView);
binding.mainTabLayout.setupWithViewPager(binding.pager);
binding.mainTabLayout.addOnTabSelectedListener(this);
setupTabs();
updateTabLayoutPosition();
}
@Override
public void onResume() {
super.onResume();
final boolean newYoutubeRestrictedModeEnabled =
prefs.getBoolean(youtubeRestrictedModeEnabledKey, false);
if (youtubeRestrictedModeEnabled != newYoutubeRestrictedModeEnabled || hasTabsChanged) {
youtubeRestrictedModeEnabled = newYoutubeRestrictedModeEnabled;
setupTabs();
}
final boolean newMainTabsPosition = prefs.getBoolean(mainTabsPositionKey, false);
if (mainTabsPositionBottom != newMainTabsPosition) {
mainTabsPositionBottom = newMainTabsPosition;
updateTabLayoutPosition();
}
}
@Override
public void onDestroy() {
super.onDestroy();
tabsManager.unsetSavedTabsListener();
if (binding != null) {
binding.pager.setAdapter(null);
binding = null;
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
/*//////////////////////////////////////////////////////////////////////////
// Menu
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
inflater.inflate(R.menu.menu_main_fragment, menu);
final ActionBar supportActionBar = activity.getSupportActionBar();
if (supportActionBar != null) {
supportActionBar.setDisplayHomeAsUpEnabled(false);
}
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == R.id.action_search) {
try {
NavigationHelper.openSearchFragment(getFM(),
ServiceHelper.getSelectedServiceId(activity), "");
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Opening search fragment", e);
}
return true;
}
return super.onOptionsItemSelected(item);
}
/*//////////////////////////////////////////////////////////////////////////
// Tabs
//////////////////////////////////////////////////////////////////////////*/
private void setupTabs() {
tabsList.clear();
tabsList.addAll(tabsManager.getTabs());
if (pagerAdapter == null || !pagerAdapter.sameTabs(tabsList)) {
pagerAdapter = new SelectedTabsPagerAdapter(requireContext(),
getChildFragmentManager(), tabsList);
}
binding.pager.setAdapter(null);
binding.pager.setAdapter(pagerAdapter);
updateTabsIconAndDescription();
updateTitleForTab(binding.pager.getCurrentItem());
hasTabsChanged = false;
}
private void updateTabsIconAndDescription() {
for (int i = 0; i < tabsList.size(); i++) {
final TabLayout.Tab tabToSet = binding.mainTabLayout.getTabAt(i);
if (tabToSet != null) {
final Tab tab = tabsList.get(i);
tabToSet.setIcon(tab.getTabIconRes(requireContext()));
tabToSet.setContentDescription(tab.getTabName(requireContext()));
}
}
}
private void updateTitleForTab(final int tabPosition) {
setTitle(tabsList.get(tabPosition).getTabName(requireContext()));
}
public void commitPlaylistTabs() {
pagerAdapter.getLocalPlaylistFragments()
.stream()
.forEach(LocalPlaylistFragment::saveImmediate);
}
private void updateTabLayoutPosition() {
final ScrollableTabLayout tabLayout = binding.mainTabLayout;
final ViewPager viewPager = binding.pager;
final boolean bottom = mainTabsPositionBottom;
// change layout params to make the tab layout appear either at the top or at the bottom
final var tabParams = (RelativeLayout.LayoutParams) tabLayout.getLayoutParams();
final var pagerParams = (RelativeLayout.LayoutParams) viewPager.getLayoutParams();
tabParams.removeRule(bottom ? ALIGN_PARENT_TOP : ALIGN_PARENT_BOTTOM);
tabParams.addRule(bottom ? ALIGN_PARENT_BOTTOM : ALIGN_PARENT_TOP);
pagerParams.removeRule(bottom ? BELOW : ABOVE);
pagerParams.addRule(bottom ? ABOVE : BELOW, R.id.main_tab_layout);
tabLayout.setSelectedTabIndicatorGravity(
bottom ? INDICATOR_GRAVITY_TOP : INDICATOR_GRAVITY_BOTTOM);
tabLayout.setLayoutParams(tabParams);
viewPager.setLayoutParams(pagerParams);
// change the background and icon color of the tab layout:
// service-colored at the top, app-background-colored at the bottom
tabLayout.setBackgroundColor(ThemeHelper.resolveColorFromAttr(requireContext(),
bottom ? R.attr.colorSecondary : R.attr.colorPrimary));
@ColorInt final int iconColor = bottom
? ThemeHelper.resolveColorFromAttr(requireContext(), R.attr.colorAccent)
: Color.WHITE;
tabLayout.setTabRippleColor(ColorStateList.valueOf(iconColor).withAlpha(32));
tabLayout.setTabIconTint(ColorStateList.valueOf(iconColor));
tabLayout.setSelectedTabIndicatorColor(iconColor);
}
@Override
public void onTabSelected(final TabLayout.Tab selectedTab) {
if (DEBUG) {
Log.d(TAG, "onTabSelected() called with: selectedTab = [" + selectedTab + "]");
}
updateTitleForTab(selectedTab.getPosition());
}
@Override
public void onTabUnselected(final TabLayout.Tab tab) { }
@Override
public void onTabReselected(final TabLayout.Tab tab) {
if (DEBUG) {
Log.d(TAG, "onTabReselected() called with: tab = [" + tab + "]");
}
updateTitleForTab(tab.getPosition());
}
public static final class SelectedTabsPagerAdapter
extends FragmentStatePagerAdapterMenuWorkaround {
private final Context context;
private final List<Tab> internalTabsList;
/**
* Keep reference to LocalPlaylistFragments, because their data can be modified by the user
* during runtime and changes are not committed immediately. However, in some cases,
* the changes need to be committed immediately by calling
* {@link LocalPlaylistFragment#saveImmediate()}.
* The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called.
*/
private final List<LocalPlaylistFragment> localPlaylistFragments = new ArrayList<>();
private SelectedTabsPagerAdapter(final Context context,
final FragmentManager fragmentManager,
final List<Tab> tabsList) {
super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
this.context = context;
this.internalTabsList = new ArrayList<>(tabsList);
}
@NonNull
@Override
public Fragment getItem(final int position) {
final Tab tab = internalTabsList.get(position);
final Fragment fragment;
try {
fragment = tab.getFragment(context);
} catch (final ExtractionException e) {
ErrorUtil.showUiErrorSnackbar(context, "Getting fragment item", e);
return new BlankFragment();
}
if (fragment instanceof BaseFragment) {
((BaseFragment) fragment).useAsFrontPage(true);
}
if (fragment instanceof LocalPlaylistFragment) {
localPlaylistFragments.add((LocalPlaylistFragment) fragment);
}
return fragment;
}
public List<LocalPlaylistFragment> getLocalPlaylistFragments() {
return localPlaylistFragments;
}
@Override
public int getItemPosition(@NonNull final Object object) {
// Causes adapter to reload all Fragments when
// notifyDataSetChanged is called
return POSITION_NONE;
}
@Override
public int getCount() {
return internalTabsList.size();
}
public boolean sameTabs(final List<Tab> tabsToCompare) {
return internalTabsList.equals(tabsToCompare);
}
}
}

View File

@ -0,0 +1,284 @@
package org.schabi.newpipe.fragments
import android.content.Context
import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.RelativeLayout
import androidx.annotation.ColorInt
import androidx.appcompat.app.ActionBar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround
import androidx.preference.PreferenceManager
import androidx.viewpager.widget.ViewPager
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import org.schabi.newpipe.BaseFragment
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.FragmentMainBinding
import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar
import org.schabi.newpipe.extractor.exceptions.ExtractionException
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment
import org.schabi.newpipe.settings.tabs.Tab
import org.schabi.newpipe.settings.tabs.TabsManager
import org.schabi.newpipe.settings.tabs.TabsManager.SavedTabsChangeListener
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.views.ScrollableTabLayout
import java.util.function.Consumer
class MainFragment() : BaseFragment(), OnTabSelectedListener {
private var binding: FragmentMainBinding? = null
private var pagerAdapter: SelectedTabsPagerAdapter? = null
private val tabsList: MutableList<Tab?> = ArrayList()
private var tabsManager: TabsManager? = null
private var hasTabsChanged: Boolean = false
private var prefs: SharedPreferences? = null
private var youtubeRestrictedModeEnabled: Boolean = false
private var youtubeRestrictedModeEnabledKey: String? = null
private var mainTabsPositionBottom: Boolean = false
private var mainTabsPositionKey: String? = null
/*//////////////////////////////////////////////////////////////////////////
// Fragment's LifeCycle
////////////////////////////////////////////////////////////////////////// */
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
tabsManager = TabsManager.Companion.getManager((activity)!!)
tabsManager!!.setSavedTabsListener(SavedTabsChangeListener({
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, ("TabsManager.SavedTabsChangeListener: "
+ "onTabsChanged called, isResumed = " + isResumed()))
}
if (isResumed()) {
setupTabs()
} else {
hasTabsChanged = true
}
}))
prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled)
youtubeRestrictedModeEnabled = prefs.getBoolean(youtubeRestrictedModeEnabledKey, false)
mainTabsPositionKey = getString(R.string.main_tabs_position_key)
mainTabsPositionBottom = prefs.getBoolean(mainTabsPositionKey, false)
}
public override fun onCreateView(inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_main, container, false)
}
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
super.initViews(rootView, savedInstanceState)
binding = FragmentMainBinding.bind(rootView)
binding!!.mainTabLayout.setupWithViewPager(binding!!.pager)
binding!!.mainTabLayout.addOnTabSelectedListener(this)
setupTabs()
updateTabLayoutPosition()
}
public override fun onResume() {
super.onResume()
val newYoutubeRestrictedModeEnabled: Boolean = prefs!!.getBoolean(youtubeRestrictedModeEnabledKey, false)
if (youtubeRestrictedModeEnabled != newYoutubeRestrictedModeEnabled || hasTabsChanged) {
youtubeRestrictedModeEnabled = newYoutubeRestrictedModeEnabled
setupTabs()
}
val newMainTabsPosition: Boolean = prefs!!.getBoolean(mainTabsPositionKey, false)
if (mainTabsPositionBottom != newMainTabsPosition) {
mainTabsPositionBottom = newMainTabsPosition
updateTabLayoutPosition()
}
}
public override fun onDestroy() {
super.onDestroy()
tabsManager!!.unsetSavedTabsListener()
if (binding != null) {
binding!!.pager.setAdapter(null)
binding = null
}
}
public override fun onDestroyView() {
super.onDestroyView()
binding = null
}
/*//////////////////////////////////////////////////////////////////////////
// Menu
////////////////////////////////////////////////////////////////////////// */
public override fun onCreateOptionsMenu(menu: Menu,
inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, ("onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]"))
}
inflater.inflate(R.menu.menu_main_fragment, menu)
val supportActionBar: ActionBar? = activity!!.getSupportActionBar()
if (supportActionBar != null) {
supportActionBar.setDisplayHomeAsUpEnabled(false)
}
}
public override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.getItemId() == R.id.action_search) {
try {
NavigationHelper.openSearchFragment(getFM(),
ServiceHelper.getSelectedServiceId((activity)!!), "")
} catch (e: Exception) {
showUiErrorSnackbar(this, "Opening search fragment", e)
}
return true
}
return super.onOptionsItemSelected(item)
}
/*//////////////////////////////////////////////////////////////////////////
// Tabs
////////////////////////////////////////////////////////////////////////// */
private fun setupTabs() {
tabsList.clear()
tabsList.addAll((tabsManager!!.getTabs())!!)
if (pagerAdapter == null || !pagerAdapter!!.sameTabs(tabsList)) {
pagerAdapter = SelectedTabsPagerAdapter(requireContext(),
getChildFragmentManager(), tabsList)
}
binding!!.pager.setAdapter(null)
binding!!.pager.setAdapter(pagerAdapter)
updateTabsIconAndDescription()
updateTitleForTab(binding!!.pager.getCurrentItem())
hasTabsChanged = false
}
private fun updateTabsIconAndDescription() {
for (i in tabsList.indices) {
val tabToSet: TabLayout.Tab? = binding!!.mainTabLayout.getTabAt(i)
if (tabToSet != null) {
val tab: Tab? = tabsList.get(i)
tabToSet.setIcon(tab!!.getTabIconRes(requireContext()))
tabToSet.setContentDescription(tab.getTabName(requireContext()))
}
}
}
private fun updateTitleForTab(tabPosition: Int) {
setTitle(tabsList.get(tabPosition)!!.getTabName(requireContext()))
}
fun commitPlaylistTabs() {
pagerAdapter!!.getLocalPlaylistFragments()
.stream()
.forEach(Consumer({ obj: LocalPlaylistFragment? -> obj!!.saveImmediate() }))
}
private fun updateTabLayoutPosition() {
val tabLayout: ScrollableTabLayout = binding!!.mainTabLayout
val viewPager: ViewPager = binding!!.pager
val bottom: Boolean = mainTabsPositionBottom
// change layout params to make the tab layout appear either at the top or at the bottom
val tabParams: RelativeLayout.LayoutParams = tabLayout.getLayoutParams() as RelativeLayout.LayoutParams
val pagerParams: RelativeLayout.LayoutParams = viewPager.getLayoutParams() as RelativeLayout.LayoutParams
tabParams.removeRule(if (bottom) RelativeLayout.ALIGN_PARENT_TOP else RelativeLayout.ALIGN_PARENT_BOTTOM)
tabParams.addRule(if (bottom) RelativeLayout.ALIGN_PARENT_BOTTOM else RelativeLayout.ALIGN_PARENT_TOP)
pagerParams.removeRule(if (bottom) RelativeLayout.BELOW else RelativeLayout.ABOVE)
pagerParams.addRule(if (bottom) RelativeLayout.ABOVE else RelativeLayout.BELOW, R.id.main_tab_layout)
tabLayout.setSelectedTabIndicatorGravity(
if (bottom) TabLayout.INDICATOR_GRAVITY_TOP else TabLayout.INDICATOR_GRAVITY_BOTTOM)
tabLayout.setLayoutParams(tabParams)
viewPager.setLayoutParams(pagerParams)
// change the background and icon color of the tab layout:
// service-colored at the top, app-background-colored at the bottom
tabLayout.setBackgroundColor(ThemeHelper.resolveColorFromAttr(requireContext(),
if (bottom) R.attr.colorSecondary else R.attr.colorPrimary))
@ColorInt val iconColor: Int = if (bottom) ThemeHelper.resolveColorFromAttr(requireContext(), R.attr.colorAccent) else Color.WHITE
tabLayout.setTabRippleColor(ColorStateList.valueOf(iconColor).withAlpha(32))
tabLayout.setTabIconTint(ColorStateList.valueOf(iconColor))
tabLayout.setSelectedTabIndicatorColor(iconColor)
}
public override fun onTabSelected(selectedTab: TabLayout.Tab) {
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, "onTabSelected() called with: selectedTab = [" + selectedTab + "]")
}
updateTitleForTab(selectedTab.getPosition())
}
public override fun onTabUnselected(tab: TabLayout.Tab) {}
public override fun onTabReselected(tab: TabLayout.Tab) {
if (BaseFragment.Companion.DEBUG) {
Log.d(TAG, "onTabReselected() called with: tab = [" + tab + "]")
}
updateTitleForTab(tab.getPosition())
}
class SelectedTabsPagerAdapter(private val context: Context,
fragmentManager: FragmentManager,
tabsList: List<Tab?>) : FragmentStatePagerAdapterMenuWorkaround(fragmentManager, FragmentStatePagerAdapterMenuWorkaround.Companion.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
private val internalTabsList: List<Tab?>
/**
* Keep reference to LocalPlaylistFragments, because their data can be modified by the user
* during runtime and changes are not committed immediately. However, in some cases,
* the changes need to be committed immediately by calling
* [LocalPlaylistFragment.saveImmediate].
* The fragments are removed when [LocalPlaylistFragment.onDestroy] is called.
*/
private val localPlaylistFragments: MutableList<LocalPlaylistFragment?> = ArrayList()
init {
internalTabsList = ArrayList(tabsList)
}
public override fun getItem(position: Int): Fragment {
val tab: Tab? = internalTabsList.get(position)
val fragment: Fragment
try {
fragment = tab!!.getFragment(context)
} catch (e: ExtractionException) {
showUiErrorSnackbar(context, "Getting fragment item", e)
return BlankFragment()
}
if (fragment is BaseFragment) {
fragment.useAsFrontPage(true)
}
if (fragment is LocalPlaylistFragment) {
localPlaylistFragments.add(fragment as LocalPlaylistFragment?)
}
return fragment
}
fun getLocalPlaylistFragments(): MutableList<LocalPlaylistFragment?> {
return localPlaylistFragments
}
public override fun getItemPosition(`object`: Any): Int {
// Causes adapter to reload all Fragments when
// notifyDataSetChanged is called
return POSITION_NONE
}
public override fun getCount(): Int {
return internalTabsList.size
}
fun sameTabs(tabsToCompare: List<Tab?>): Boolean {
return (internalTabsList == tabsToCompare)
}
}
}

View File

@ -1,47 +0,0 @@
package org.schabi.newpipe.fragments;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
/**
* Recycler view scroll listener which calls the method {@link #onScrolledDown(RecyclerView)}
* if the view is scrolled below the last item.
*/
public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener {
@Override
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy);
if (dy > 0) {
int pastVisibleItems = 0;
final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
final int visibleItemCount = layoutManager.getChildCount();
final int totalItemCount = layoutManager.getItemCount();
// Already covers the GridLayoutManager case
if (layoutManager instanceof LinearLayoutManager) {
pastVisibleItems = ((LinearLayoutManager) layoutManager)
.findFirstVisibleItemPosition();
} else if (layoutManager instanceof StaggeredGridLayoutManager) {
final int[] positions = ((StaggeredGridLayoutManager) layoutManager)
.findFirstVisibleItemPositions(null);
if (positions != null && positions.length > 0) {
pastVisibleItems = positions[0];
}
}
if ((visibleItemCount + pastVisibleItems) >= totalItemCount) {
onScrolledDown(recyclerView);
}
}
}
/**
* Called when the recycler view is scrolled below the last item.
*
* @param recyclerView the recycler view
*/
public abstract void onScrolledDown(RecyclerView recyclerView);
}

View File

@ -0,0 +1,43 @@
package org.schabi.newpipe.fragments
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
/**
* Recycler view scroll listener which calls the method [.onScrolledDown]
* if the view is scrolled below the last item.
*/
abstract class OnScrollBelowItemsListener() : RecyclerView.OnScrollListener() {
public override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy > 0) {
var pastVisibleItems: Int = 0
val layoutManager: RecyclerView.LayoutManager? = recyclerView.getLayoutManager()
val visibleItemCount: Int = layoutManager!!.getChildCount()
val totalItemCount: Int = layoutManager.getItemCount()
// Already covers the GridLayoutManager case
if (layoutManager is LinearLayoutManager) {
pastVisibleItems = layoutManager
.findFirstVisibleItemPosition()
} else if (layoutManager is StaggeredGridLayoutManager) {
val positions: IntArray? = layoutManager
.findFirstVisibleItemPositions(null)
if (positions != null && positions.size > 0) {
pastVisibleItems = positions.get(0)
}
}
if ((visibleItemCount + pastVisibleItems) >= totalItemCount) {
onScrolledDown(recyclerView)
}
}
}
/**
* Called when the recycler view is scrolled below the last item.
*
* @param recyclerView the recycler view
*/
abstract fun onScrolledDown(recyclerView: RecyclerView?)
}

View File

@ -1,13 +0,0 @@
package org.schabi.newpipe.fragments;
public interface ViewContract<I> {
void showLoading();
void hideLoading();
void showEmptyState();
void handleResult(I result);
void handleError();
}

View File

@ -0,0 +1,9 @@
package org.schabi.newpipe.fragments
open interface ViewContract<I> {
fun showLoading()
fun hideLoading()
fun showEmptyState()
fun handleResult(result: I)
fun handleError()
}

View File

@ -1,281 +0,0 @@
package org.schabi.newpipe.fragments.detail;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.graphics.Typeface;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.StyleSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.TooltipCompat;
import androidx.core.text.HtmlCompat;
import com.google.android.material.chip.Chip;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentDescriptionBinding;
import org.schabi.newpipe.databinding.ItemMetadataBinding;
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.List;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public abstract class BaseDescriptionFragment extends BaseFragment {
private final CompositeDisposable descriptionDisposables = new CompositeDisposable();
protected FragmentDescriptionBinding binding;
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
binding = FragmentDescriptionBinding.inflate(inflater, container, false);
setupDescription();
setupMetadata(inflater, binding.detailMetadataLayout);
addTagsMetadataItem(inflater, binding.detailMetadataLayout);
return binding.getRoot();
}
@Override
public void onDestroy() {
descriptionDisposables.clear();
super.onDestroy();
}
/**
* Get the description to display.
* @return description object, if available
*/
@Nullable
protected abstract Description getDescription();
/**
* Get the streaming service. Used for generating description links.
* @return streaming service
*/
@NonNull
protected abstract StreamingService getService();
/**
* Get the streaming service ID. Used for tag links.
* @return service ID
*/
protected abstract int getServiceId();
/**
* Get the URL of the described video or audio, used to generate description links.
* @return stream URL
*/
@Nullable
protected abstract String getStreamUrl();
/**
* Get the list of tags to display below the description.
* @return tag list
*/
@NonNull
public abstract List<String> getTags();
/**
* Add additional metadata to display.
* @param inflater LayoutInflater
* @param layout detailMetadataLayout
*/
protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout);
private void setupDescription() {
final Description description = getDescription();
if (description == null || isEmpty(description.getContent())
|| description == Description.EMPTY_DESCRIPTION) {
binding.detailDescriptionView.setVisibility(View.GONE);
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
return;
}
// start with disabled state. This also loads description content (!)
disableDescriptionSelection();
binding.detailSelectDescriptionButton.setOnClickListener(v -> {
if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
disableDescriptionSelection();
} else {
// enable selection only when button is clicked to prevent flickering
enableDescriptionSelection();
}
});
}
private void enableDescriptionSelection() {
binding.detailDescriptionNoteView.setVisibility(View.VISIBLE);
binding.detailDescriptionView.setTextIsSelectable(true);
final String buttonLabel = getString(R.string.description_select_disable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close);
}
private void disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable
final Description description = getDescription();
if (description != null) {
TextLinkifier.fromDescription(binding.detailDescriptionView,
description, HtmlCompat.FROM_HTML_MODE_LEGACY,
getService(), getStreamUrl(),
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
}
binding.detailDescriptionNoteView.setVisibility(View.GONE);
binding.detailDescriptionView.setTextIsSelectable(false);
final String buttonLabel = getString(R.string.description_select_enable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
}
protected void addMetadataItem(final LayoutInflater inflater,
final LinearLayout layout,
final boolean linkifyContent,
@StringRes final int type,
@NonNull final String content) {
if (isBlank(content)) {
return;
}
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
ShareUtils.copyToClipboard(requireContext(), content);
return true;
});
if (linkifyContent) {
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
} else {
itemBinding.metadataContentView.setText(content);
}
itemBinding.metadataContentView.setClickable(true);
layout.addView(itemBinding.getRoot());
}
private String imageSizeToText(final int heightOrWidth) {
if (heightOrWidth < 0) {
return getString(R.string.question_mark);
} else {
return String.valueOf(heightOrWidth);
}
}
protected void addImagesMetadataItem(final LayoutInflater inflater,
final LinearLayout layout,
@StringRes final int type,
final List<Image> images) {
final String preferredImageUrl = ImageStrategy.choosePreferredImage(images);
if (preferredImageUrl == null) {
return; // null will be returned in case there is no image
}
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
final SpannableStringBuilder urls = new SpannableStringBuilder();
for (final Image image : images) {
if (urls.length() != 0) {
urls.append(", ");
}
final int entryBegin = urls.length();
if (image.getHeight() != Image.HEIGHT_UNKNOWN
|| image.getWidth() != Image.WIDTH_UNKNOWN
// if even the resolution level is unknown, ?x? will be shown
|| image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
urls.append(imageSizeToText(image.getHeight()));
urls.append('x');
urls.append(imageSizeToText(image.getWidth()));
} else {
switch (image.getEstimatedResolutionLevel()) {
case LOW -> urls.append(getString(R.string.image_quality_low));
case MEDIUM -> urls.append(getString(R.string.image_quality_medium));
case HIGH -> urls.append(getString(R.string.image_quality_high));
default -> {
// unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out
}
}
}
urls.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull final View widget) {
ShareUtils.openUrlInBrowser(requireContext(), image.getUrl());
}
}, entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (preferredImageUrl.equals(image.getUrl())) {
urls.setSpan(new StyleSpan(Typeface.BOLD), entryBegin, urls.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
itemBinding.metadataContentView.setText(urls);
itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance());
layout.addView(itemBinding.getRoot());
}
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
final List<String> tags = getTags();
if (!tags.isEmpty()) {
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
final Chip chip = (Chip) inflater.inflate(R.layout.chip,
itemBinding.metadataTagsChips, false);
chip.setText(tag);
chip.setOnClickListener(this::onTagClick);
chip.setOnLongClickListener(this::onTagLongClick);
itemBinding.metadataTagsChips.addView(chip);
});
layout.addView(itemBinding.getRoot());
}
}
private void onTagClick(final View chip) {
if (getParentFragment() != null) {
NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
getServiceId(), ((Chip) chip).getText().toString());
}
}
private boolean onTagLongClick(final View chip) {
ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString());
return true;
}
}

View File

@ -0,0 +1,242 @@
package org.schabi.newpipe.fragments.detail
import android.graphics.Typeface
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextUtils
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.View
import android.view.View.OnLongClickListener
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.StringRes
import androidx.appcompat.widget.TooltipCompat
import androidx.core.text.HtmlCompat
import com.google.android.material.chip.Chip
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.schabi.newpipe.BaseFragment
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.FragmentDescriptionBinding
import org.schabi.newpipe.databinding.ItemMetadataBinding
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.extractor.Image.ResolutionLevel
import org.schabi.newpipe.extractor.StreamingService
import org.schabi.newpipe.extractor.stream.Description
import org.schabi.newpipe.extractor.utils.Utils
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
import org.schabi.newpipe.util.image.ImageStrategy
import org.schabi.newpipe.util.text.TextLinkifier
import java.util.function.Consumer
abstract class BaseDescriptionFragment() : BaseFragment() {
private val descriptionDisposables: CompositeDisposable = CompositeDisposable()
protected var binding: FragmentDescriptionBinding? = null
public override fun onCreateView(inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?): View? {
binding = FragmentDescriptionBinding.inflate(inflater, container, false)
setupDescription()
setupMetadata(inflater, binding!!.detailMetadataLayout)
addTagsMetadataItem(inflater, binding!!.detailMetadataLayout)
return binding!!.getRoot()
}
public override fun onDestroy() {
descriptionDisposables.clear()
super.onDestroy()
}
/**
* Get the description to display.
* @return description object, if available
*/
protected abstract fun getDescription(): Description?
/**
* Get the streaming service. Used for generating description links.
* @return streaming service
*/
protected abstract fun getService(): StreamingService
/**
* Get the streaming service ID. Used for tag links.
* @return service ID
*/
protected abstract fun getServiceId(): Int
/**
* Get the URL of the described video or audio, used to generate description links.
* @return stream URL
*/
protected abstract fun getStreamUrl(): String?
/**
* Get the list of tags to display below the description.
* @return tag list
*/
abstract fun getTags(): List<String?>
/**
* Add additional metadata to display.
* @param inflater LayoutInflater
* @param layout detailMetadataLayout
*/
protected abstract fun setupMetadata(inflater: LayoutInflater?, layout: LinearLayout?)
private fun setupDescription() {
val description: Description? = getDescription()
if (((description == null) || TextUtils.isEmpty(description.getContent())
|| (description === Description.EMPTY_DESCRIPTION))) {
binding!!.detailDescriptionView.setVisibility(View.GONE)
binding!!.detailSelectDescriptionButton.setVisibility(View.GONE)
return
}
// start with disabled state. This also loads description content (!)
disableDescriptionSelection()
binding!!.detailSelectDescriptionButton.setOnClickListener(View.OnClickListener({ v: View? ->
if (binding!!.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
disableDescriptionSelection()
} else {
// enable selection only when button is clicked to prevent flickering
enableDescriptionSelection()
}
}))
}
private fun enableDescriptionSelection() {
binding!!.detailDescriptionNoteView.setVisibility(View.VISIBLE)
binding!!.detailDescriptionView.setTextIsSelectable(true)
val buttonLabel: String = getString(R.string.description_select_disable)
binding!!.detailSelectDescriptionButton.setContentDescription(buttonLabel)
TooltipCompat.setTooltipText(binding!!.detailSelectDescriptionButton, buttonLabel)
binding!!.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close)
}
private fun disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable
val description: Description? = getDescription()
if (description != null) {
TextLinkifier.fromDescription(binding!!.detailDescriptionView,
description, HtmlCompat.FROM_HTML_MODE_LEGACY,
getService(), getStreamUrl(),
descriptionDisposables, TextLinkifier.SET_LINK_MOVEMENT_METHOD)
}
binding!!.detailDescriptionNoteView.setVisibility(View.GONE)
binding!!.detailDescriptionView.setTextIsSelectable(false)
val buttonLabel: String = getString(R.string.description_select_enable)
binding!!.detailSelectDescriptionButton.setContentDescription(buttonLabel)
TooltipCompat.setTooltipText(binding!!.detailSelectDescriptionButton, buttonLabel)
binding!!.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all)
}
protected fun addMetadataItem(inflater: LayoutInflater?,
layout: LinearLayout,
linkifyContent: Boolean,
@StringRes type: Int,
content: String) {
if (Utils.isBlank(content)) {
return
}
val itemBinding: ItemMetadataBinding = ItemMetadataBinding.inflate((inflater)!!, layout, false)
itemBinding.metadataTypeView.setText(type)
itemBinding.metadataTypeView.setOnLongClickListener(OnLongClickListener({ v: View? ->
ShareUtils.copyToClipboard(requireContext(), content)
true
}))
if (linkifyContent) {
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
descriptionDisposables, TextLinkifier.SET_LINK_MOVEMENT_METHOD)
} else {
itemBinding.metadataContentView.setText(content)
}
itemBinding.metadataContentView.setClickable(true)
layout.addView(itemBinding.getRoot())
}
private fun imageSizeToText(heightOrWidth: Int): String {
if (heightOrWidth < 0) {
return getString(R.string.question_mark)
} else {
return heightOrWidth.toString()
}
}
protected fun addImagesMetadataItem(inflater: LayoutInflater?,
layout: LinearLayout,
@StringRes type: Int,
images: List<Image?>) {
val preferredImageUrl: String? = ImageStrategy.choosePreferredImage(images)
if (preferredImageUrl == null) {
return // null will be returned in case there is no image
}
val itemBinding: ItemMetadataBinding = ItemMetadataBinding.inflate((inflater)!!, layout, false)
itemBinding.metadataTypeView.setText(type)
val urls: SpannableStringBuilder = SpannableStringBuilder()
for (image: Image? in images) {
if (urls.length != 0) {
urls.append(", ")
}
val entryBegin: Int = urls.length
if ((image!!.getHeight() != Image.HEIGHT_UNKNOWN
) || (image.getWidth() != Image.WIDTH_UNKNOWN // if even the resolution level is unknown, ?x? will be shown
) || (image.getEstimatedResolutionLevel() == ResolutionLevel.UNKNOWN)) {
urls.append(imageSizeToText(image.getHeight()))
urls.append('x')
urls.append(imageSizeToText(image.getWidth()))
} else {
when (image.getEstimatedResolutionLevel()) {
ResolutionLevel.LOW -> urls.append(getString(R.string.image_quality_low))
ResolutionLevel.MEDIUM -> urls.append(getString(R.string.image_quality_medium))
ResolutionLevel.HIGH -> urls.append(getString(R.string.image_quality_high))
else -> {}
}
}
urls.setSpan(object : ClickableSpan() {
public override fun onClick(widget: View) {
ShareUtils.openUrlInBrowser(requireContext(), image.getUrl())
}
}, entryBegin, urls.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if ((preferredImageUrl == image.getUrl())) {
urls.setSpan(StyleSpan(Typeface.BOLD), entryBegin, urls.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
itemBinding.metadataContentView.setText(urls)
itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance())
layout.addView(itemBinding.getRoot())
}
private fun addTagsMetadataItem(inflater: LayoutInflater, layout: LinearLayout) {
val tags: List<String?> = getTags()
if (!tags.isEmpty()) {
val itemBinding: ItemMetadataTagsBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false)
tags.stream().sorted(java.lang.String.CASE_INSENSITIVE_ORDER).forEach(Consumer({ tag: String? ->
val chip: Chip = inflater.inflate(R.layout.chip,
itemBinding.metadataTagsChips, false) as Chip
chip.setText(tag)
chip.setOnClickListener(View.OnClickListener({ chip: View -> onTagClick(chip) }))
chip.setOnLongClickListener(OnLongClickListener({ chip: View -> onTagLongClick(chip) }))
itemBinding.metadataTagsChips.addView(chip)
}))
layout.addView(itemBinding.getRoot())
}
}
private fun onTagClick(chip: View) {
if (getParentFragment() != null) {
NavigationHelper.openSearchFragment(getParentFragment()!!.getParentFragmentManager(),
getServiceId(), (chip as Chip).getText().toString())
}
}
private fun onTagLongClick(chip: View): Boolean {
ShareUtils.copyToClipboard(requireContext(), (chip as Chip).getText().toString())
return true
}
}

View File

@ -1,140 +0,0 @@
package org.schabi.newpipe.fragments.detail;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
import static org.schabi.newpipe.util.Localization.getAppLocale;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.Localization;
import java.util.List;
import icepick.State;
public class DescriptionFragment extends BaseDescriptionFragment {
@State
StreamInfo streamInfo;
public DescriptionFragment(final StreamInfo streamInfo) {
this.streamInfo = streamInfo;
}
public DescriptionFragment() {
// keep empty constructor for IcePick when resuming fragment from memory
}
@Nullable
@Override
protected Description getDescription() {
return streamInfo.getDescription();
}
@NonNull
@Override
protected StreamingService getService() {
return streamInfo.getService();
}
@Override
protected int getServiceId() {
return streamInfo.getServiceId();
}
@NonNull
@Override
protected String getStreamUrl() {
return streamInfo.getUrl();
}
@NonNull
@Override
public List<String> getTags() {
return streamInfo.getTags();
}
@Override
protected void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
if (streamInfo != null && streamInfo.getUploadDate() != null) {
binding.detailUploadDateView.setText(Localization
.localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime()));
} else {
binding.detailUploadDateView.setVisibility(View.GONE);
}
if (streamInfo == null) {
return;
}
addMetadataItem(inflater, layout, false, R.string.metadata_category,
streamInfo.getCategory());
addMetadataItem(inflater, layout, false, R.string.metadata_licence,
streamInfo.getLicence());
addPrivacyMetadataItem(inflater, layout);
if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) {
addMetadataItem(inflater, layout, false, R.string.metadata_age_limit,
String.valueOf(streamInfo.getAgeLimit()));
}
if (streamInfo.getLanguageInfo() != null) {
addMetadataItem(inflater, layout, false, R.string.metadata_language,
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
}
addMetadataItem(inflater, layout, true, R.string.metadata_support,
streamInfo.getSupportInfo());
addMetadataItem(inflater, layout, true, R.string.metadata_host,
streamInfo.getHost());
addImagesMetadataItem(inflater, layout, R.string.metadata_thumbnails,
streamInfo.getThumbnails());
addImagesMetadataItem(inflater, layout, R.string.metadata_uploader_avatars,
streamInfo.getUploaderAvatars());
addImagesMetadataItem(inflater, layout, R.string.metadata_subchannel_avatars,
streamInfo.getSubChannelAvatars());
}
private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
if (streamInfo.getPrivacy() != null) {
@StringRes final int contentRes;
switch (streamInfo.getPrivacy()) {
case PUBLIC:
contentRes = R.string.metadata_privacy_public;
break;
case UNLISTED:
contentRes = R.string.metadata_privacy_unlisted;
break;
case PRIVATE:
contentRes = R.string.metadata_privacy_private;
break;
case INTERNAL:
contentRes = R.string.metadata_privacy_internal;
break;
case OTHER:
default:
contentRes = 0;
break;
}
if (contentRes != 0) {
addMetadataItem(inflater, layout, false, R.string.metadata_privacy,
getString(contentRes));
}
}
}
}

View File

@ -0,0 +1,97 @@
package org.schabi.newpipe.fragments.detail
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import androidx.annotation.StringRes
import icepick.State
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.StreamingService
import org.schabi.newpipe.extractor.stream.Description
import org.schabi.newpipe.extractor.stream.StreamExtractor
import org.schabi.newpipe.extractor.stream.StreamExtractor.Privacy
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.util.Localization
class DescriptionFragment : BaseDescriptionFragment {
@State
var streamInfo: StreamInfo? = null
constructor(streamInfo: StreamInfo?) {
this.streamInfo = streamInfo
}
constructor()
override fun getDescription(): Description? {
return streamInfo!!.getDescription()
}
override fun getService(): StreamingService {
return streamInfo!!.getService()
}
override fun getServiceId(): Int {
return streamInfo!!.getServiceId()
}
override fun getStreamUrl(): String {
return streamInfo!!.getUrl()
}
public override fun getTags(): List<String?> {
return streamInfo!!.getTags()
}
override fun setupMetadata(inflater: LayoutInflater?,
layout: LinearLayout?) {
if (streamInfo != null && streamInfo!!.getUploadDate() != null) {
binding!!.detailUploadDateView.setText(Localization.localizeUploadDate((activity)!!, streamInfo!!.getUploadDate().offsetDateTime()))
} else {
binding!!.detailUploadDateView.setVisibility(View.GONE)
}
if (streamInfo == null) {
return
}
addMetadataItem(inflater, (layout)!!, false, R.string.metadata_category,
streamInfo!!.getCategory())
addMetadataItem(inflater, (layout), false, R.string.metadata_licence,
streamInfo!!.getLicence())
addPrivacyMetadataItem(inflater, layout)
if (streamInfo!!.getAgeLimit() != StreamExtractor.NO_AGE_LIMIT) {
addMetadataItem(inflater, (layout), false, R.string.metadata_age_limit, streamInfo!!.getAgeLimit().toString())
}
if (streamInfo!!.getLanguageInfo() != null) {
addMetadataItem(inflater, (layout), false, R.string.metadata_language,
streamInfo!!.getLanguageInfo().getDisplayLanguage(Localization.getAppLocale((getContext())!!)))
}
addMetadataItem(inflater, (layout), true, R.string.metadata_support,
streamInfo!!.getSupportInfo())
addMetadataItem(inflater, (layout), true, R.string.metadata_host,
streamInfo!!.getHost())
addImagesMetadataItem(inflater, (layout), R.string.metadata_thumbnails,
streamInfo!!.getThumbnails())
addImagesMetadataItem(inflater, (layout), R.string.metadata_uploader_avatars,
streamInfo!!.getUploaderAvatars())
addImagesMetadataItem(inflater, (layout), R.string.metadata_subchannel_avatars,
streamInfo!!.getSubChannelAvatars())
}
private fun addPrivacyMetadataItem(inflater: LayoutInflater?, layout: LinearLayout?) {
if (streamInfo!!.getPrivacy() != null) {
@StringRes val contentRes: Int
when (streamInfo!!.getPrivacy()) {
Privacy.PUBLIC -> contentRes = R.string.metadata_privacy_public
Privacy.UNLISTED -> contentRes = R.string.metadata_privacy_unlisted
Privacy.PRIVATE -> contentRes = R.string.metadata_privacy_private
Privacy.INTERNAL -> contentRes = R.string.metadata_privacy_internal
Privacy.OTHER -> contentRes = 0
else -> contentRes = 0
}
if (contentRes != 0) {
addMetadataItem(inflater, (layout)!!, false, R.string.metadata_privacy,
getString(contentRes))
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More