Implement new feed and subscriptions groups
- Introduce Groupie for easier lists implementations - Use some of the new components of the Android Architecture libraries - Add a bunch of icons for groups, using vectors, which still is compatible with older APIs through the compatibility layer
This commit is contained in:
parent
e8ab5aacc7
commit
20a4bb0936
|
@ -79,6 +79,11 @@ android {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Required and used only by groupie
|
||||||
|
androidExtensions {
|
||||||
|
experimental = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
|
@ -111,6 +116,13 @@ dependencies {
|
||||||
implementation "androidx.cardview:cardview:${androidxLibVersion}"
|
implementation "androidx.cardview:cardview:${androidxLibVersion}"
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||||
|
|
||||||
|
implementation 'com.xwray:groupie:2.3.0'
|
||||||
|
implementation 'com.xwray:groupie-kotlin-android-extensions:2.3.0'
|
||||||
|
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-livedata:2.0.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel:2.0.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
|
||||||
|
|
||||||
// Originally in NewPipeExtractor
|
// Originally in NewPipeExtractor
|
||||||
implementation 'com.grack:nanojson:1.1'
|
implementation 'com.grack:nanojson:1.1'
|
||||||
implementation 'org.jsoup:jsoup:1.9.2'
|
implementation 'org.jsoup:jsoup:1.9.2'
|
||||||
|
|
|
@ -564,7 +564,7 @@
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "iconId",
|
"fieldPath": "icon",
|
||||||
"columnName": "icon_id",
|
"columnName": "icon_id",
|
||||||
"affinity": "INTEGER",
|
"affinity": "INTEGER",
|
||||||
"notNull": true
|
"notNull": true
|
||||||
|
|
|
@ -74,6 +74,7 @@
|
||||||
|
|
||||||
<service android:name=".local.subscription.services.SubscriptionsImportService"/>
|
<service android:name=".local.subscription.services.SubscriptionsImportService"/>
|
||||||
<service android:name=".local.subscription.services.SubscriptionsExportService"/>
|
<service android:name=".local.subscription.services.SubscriptionsExportService"/>
|
||||||
|
<service android:name=".local.feed.service.FeedLoadService"/>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".PanicResponderActivity"
|
android:name=".PanicResponderActivity"
|
||||||
|
|
|
@ -240,7 +240,7 @@ public class MainActivity extends AppCompatActivity {
|
||||||
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
|
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
|
||||||
break;
|
break;
|
||||||
case ITEM_ID_FEED:
|
case ITEM_ID_FEED:
|
||||||
NavigationHelper.openWhatsNewFragment(getSupportFragmentManager());
|
NavigationHelper.openFeedFragment(getSupportFragmentManager());
|
||||||
break;
|
break;
|
||||||
case ITEM_ID_BOOKMARKS:
|
case ITEM_ID_BOOKMARKS:
|
||||||
NavigationHelper.openBookmarksFragment(getSupportFragmentManager());
|
NavigationHelper.openBookmarksFragment(getSupportFragmentManager());
|
||||||
|
|
|
@ -3,6 +3,7 @@ package org.schabi.newpipe.database;
|
||||||
import androidx.room.TypeConverter;
|
import androidx.room.TypeConverter;
|
||||||
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon;
|
||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
|
@ -37,4 +38,18 @@ public class Converters {
|
||||||
public static String stringOf(StreamType streamType) {
|
public static String stringOf(StreamType streamType) {
|
||||||
return streamType.name();
|
return streamType.name();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
public static Integer integerOf(FeedGroupIcon feedGroupIcon) {
|
||||||
|
return feedGroupIcon.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
public static FeedGroupIcon feedGroupIconOf(Integer id) {
|
||||||
|
for (FeedGroupIcon icon : FeedGroupIcon.values()) {
|
||||||
|
if (icon.getId() == id) return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalArgumentException("There's no feed group icon with the id \"" + id + "\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import androidx.room.Query
|
||||||
import io.reactivex.Flowable
|
import io.reactivex.Flowable
|
||||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class FeedDAO {
|
abstract class FeedDAO {
|
||||||
|
@ -19,7 +20,9 @@ abstract class FeedDAO {
|
||||||
INNER JOIN feed f
|
INNER JOIN feed f
|
||||||
ON s.uid = f.stream_id
|
ON s.uid = f.stream_id
|
||||||
|
|
||||||
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC
|
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||||
|
|
||||||
|
LIMIT 500
|
||||||
""")
|
""")
|
||||||
abstract fun getAllStreams(): Flowable<List<StreamEntity>>
|
abstract fun getAllStreams(): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
|
@ -36,12 +39,45 @@ abstract class FeedDAO {
|
||||||
ON fg.uid = fgs.group_id
|
ON fg.uid = fgs.group_id
|
||||||
|
|
||||||
WHERE fgs.group_id = :groupId
|
WHERE fgs.group_id = :groupId
|
||||||
|
|
||||||
|
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||||
|
LIMIT 500
|
||||||
""")
|
""")
|
||||||
abstract fun getAllStreamsFromGroup(groupId: Long): Flowable<List<StreamEntity>>
|
abstract fun getAllStreamsFromGroup(groupId: Long): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.FAIL)
|
@Query("""
|
||||||
|
DELETE FROM feed WHERE
|
||||||
|
|
||||||
|
feed.stream_id IN (
|
||||||
|
SELECT s.uid FROM streams s
|
||||||
|
|
||||||
|
INNER JOIN feed f
|
||||||
|
ON s.uid = f.stream_id
|
||||||
|
|
||||||
|
WHERE s.upload_date < :date
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
abstract fun unlinkStreamsOlderThan(date: Date)
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
DELETE FROM feed
|
||||||
|
|
||||||
|
WHERE feed.subscription_id = :subscriptionId
|
||||||
|
|
||||||
|
AND feed.stream_id IN (
|
||||||
|
SELECT s.uid FROM streams s
|
||||||
|
|
||||||
|
INNER JOIN feed f
|
||||||
|
ON s.uid = f.stream_id
|
||||||
|
|
||||||
|
WHERE s.stream_type = "LIVE_STREAM" OR s.stream_type = "AUDIO_LIVE_STREAM"
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
abstract fun unlinkOldLivestreams(subscriptionId: Long)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
abstract fun insert(feedEntity: FeedEntity)
|
abstract fun insert(feedEntity: FeedEntity)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.FAIL)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
abstract fun insertAll(entities: List<FeedEntity>): List<Long>
|
abstract fun insertAll(entities: List<FeedEntity>): List<Long>
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,16 +2,43 @@ package org.schabi.newpipe.database.feed.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import io.reactivex.Flowable
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Maybe
|
||||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class FeedGroupDAO {
|
abstract class FeedGroupDAO {
|
||||||
@Query("DELETE FROM feed_group")
|
|
||||||
abstract fun deleteAll(): Int
|
|
||||||
|
|
||||||
@Query("SELECT * FROM feed_group")
|
@Query("SELECT * FROM feed_group")
|
||||||
abstract fun getAll(): Flowable<List<FeedGroupEntity>>
|
abstract fun getAll(): Flowable<List<FeedGroupEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM feed_group WHERE uid = :groupId")
|
||||||
|
abstract fun getGroup(groupId: Long): Maybe<FeedGroupEntity>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
abstract fun insert(feedEntity: FeedGroupEntity)
|
abstract fun insert(feedEntity: FeedGroupEntity): Long
|
||||||
|
|
||||||
|
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun update(feedGroupEntity: FeedGroupEntity): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM feed_group")
|
||||||
|
abstract fun deleteAll(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM feed_group WHERE uid = :groupId")
|
||||||
|
abstract fun delete(groupId: Long): Int
|
||||||
|
|
||||||
|
@Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId")
|
||||||
|
abstract fun getSubscriptionIdsFor(groupId: Long): Flowable<List<Long>>
|
||||||
|
|
||||||
|
@Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId")
|
||||||
|
abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun insertSubscriptionsToGroup(entities: List<FeedGroupSubscriptionEntity>): List<Long>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>) {
|
||||||
|
deleteSubscriptionsFromGroup(groupId)
|
||||||
|
insertSubscriptionsToGroup(subscriptionIds.map { FeedGroupSubscriptionEntity(groupId, it) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
|
||||||
@Entity(tableName = FEED_GROUP_TABLE)
|
@Entity(tableName = FEED_GROUP_TABLE)
|
||||||
data class FeedGroupEntity(
|
data class FeedGroupEntity(
|
||||||
|
@ -15,7 +16,7 @@ data class FeedGroupEntity(
|
||||||
var name: String,
|
var name: String,
|
||||||
|
|
||||||
@ColumnInfo(name = ICON)
|
@ColumnInfo(name = ICON)
|
||||||
var iconId: Int
|
var icon: FeedGroupIcon
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val FEED_GROUP_TABLE = "feed_group"
|
const val FEED_GROUP_TABLE = "feed_group"
|
||||||
|
|
|
@ -1,72 +0,0 @@
|
||||||
package org.schabi.newpipe.database.subscription;
|
|
||||||
|
|
||||||
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 java.util.List;
|
|
||||||
|
|
||||||
import io.reactivex.Flowable;
|
|
||||||
|
|
||||||
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_UID;
|
|
||||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
public abstract class SubscriptionDAO implements BasicDAO<SubscriptionEntity> {
|
|
||||||
@Override
|
|
||||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE)
|
|
||||||
public abstract Flowable<List<SubscriptionEntity>> getAll();
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM subscriptions")
|
|
||||||
public abstract Flowable<Long> rowCount();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Query("DELETE FROM " + SUBSCRIPTION_TABLE)
|
|
||||||
public abstract int deleteAll();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
|
||||||
public abstract Flowable<List<SubscriptionEntity>> listByService(int serviceId);
|
|
||||||
|
|
||||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " +
|
|
||||||
SUBSCRIPTION_URL + " LIKE :url AND " +
|
|
||||||
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
|
||||||
public abstract Flowable<List<SubscriptionEntity>> getSubscription(int serviceId, String url);
|
|
||||||
|
|
||||||
@Query("SELECT " + SUBSCRIPTION_UID + " FROM " + SUBSCRIPTION_TABLE + " WHERE " +
|
|
||||||
SUBSCRIPTION_URL + " LIKE :url AND " +
|
|
||||||
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
|
||||||
abstract Long getSubscriptionIdInternal(int serviceId, String url);
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
|
||||||
abstract Long insertInternal(final SubscriptionEntity entities);
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
public List<SubscriptionEntity> upsertAll(List<SubscriptionEntity> entities) {
|
|
||||||
for (SubscriptionEntity entity : entities) {
|
|
||||||
Long uid = insertInternal(entity);
|
|
||||||
|
|
||||||
if (uid != -1) {
|
|
||||||
entity.setUid(uid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
uid = getSubscriptionIdInternal(entity.getServiceId(), entity.getUrl());
|
|
||||||
entity.setUid(uid);
|
|
||||||
|
|
||||||
if (uid == -1) {
|
|
||||||
throw new IllegalStateException("Invalid subscription id (-1)");
|
|
||||||
}
|
|
||||||
|
|
||||||
update(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
return entities;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
package org.schabi.newpipe.database.subscription
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Maybe
|
||||||
|
import org.schabi.newpipe.database.BasicDAO
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||||
|
@Query("SELECT COUNT(*) FROM subscriptions")
|
||||||
|
abstract fun rowCount(): Flowable<Long>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions WHERE service_id = :serviceId")
|
||||||
|
abstract override fun listByService(serviceId: Int): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC")
|
||||||
|
abstract override fun getAll(): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||||
|
abstract fun getSubscriptionFlowable(serviceId: Int, url: String): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||||
|
abstract fun getSubscription(serviceId: Int, url: String): Maybe<SubscriptionEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions WHERE uid = :subscriptionId")
|
||||||
|
abstract fun getSubscription(subscriptionId: Long): SubscriptionEntity
|
||||||
|
|
||||||
|
@Query("DELETE FROM subscriptions")
|
||||||
|
abstract override fun deleteAll(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||||
|
abstract fun deleteSubscription(serviceId: Int, url: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT uid FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||||
|
internal abstract fun getSubscriptionIdInternal(serviceId: Int, url: String): Long?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> {
|
||||||
|
val insertUidList = silentInsertAllInternal(entities)
|
||||||
|
|
||||||
|
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
|
||||||
|
val entity = entities[index]
|
||||||
|
|
||||||
|
if (uidFromInsert != -1L) {
|
||||||
|
entity.uid = uidFromInsert
|
||||||
|
} else {
|
||||||
|
val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url)
|
||||||
|
?: throw IllegalStateException("Subscription cannot be null just after insertion.")
|
||||||
|
entity.uid = subscriptionIdFromDb
|
||||||
|
|
||||||
|
update(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,14 +19,14 @@ import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCR
|
||||||
indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
|
indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
|
||||||
public class SubscriptionEntity {
|
public class SubscriptionEntity {
|
||||||
|
|
||||||
public final static String SUBSCRIPTION_UID = "uid";
|
public static final String SUBSCRIPTION_UID = "uid";
|
||||||
final static String SUBSCRIPTION_TABLE = "subscriptions";
|
public static final String SUBSCRIPTION_TABLE = "subscriptions";
|
||||||
final static String SUBSCRIPTION_SERVICE_ID = "service_id";
|
public static final String SUBSCRIPTION_SERVICE_ID = "service_id";
|
||||||
final static String SUBSCRIPTION_URL = "url";
|
public static final String SUBSCRIPTION_URL = "url";
|
||||||
final static String SUBSCRIPTION_NAME = "name";
|
public static final String SUBSCRIPTION_NAME = "name";
|
||||||
final static String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||||
final static String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||||
final static String SUBSCRIPTION_DESCRIPTION = "description";
|
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
||||||
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
private long uid = 0;
|
private long uid = 0;
|
||||||
|
|
|
@ -59,7 +59,10 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
||||||
@Override
|
@Override
|
||||||
public void onAttach(Context context) {
|
public void onAttach(Context context) {
|
||||||
super.onAttach(context);
|
super.onAttach(context);
|
||||||
infoListAdapter = new InfoListAdapter(activity);
|
|
||||||
|
if (infoListAdapter == null) {
|
||||||
|
infoListAdapter = new InfoListAdapter(activity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -78,7 +81,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
||||||
@Override
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
StateSaver.onDestroy(savedState);
|
if (useDefaultStateSaving) StateSaver.onDestroy(savedState);
|
||||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||||
.unregisterOnSharedPreferenceChangeListener(this);
|
.unregisterOnSharedPreferenceChangeListener(this);
|
||||||
}
|
}
|
||||||
|
@ -103,6 +106,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
protected StateSaver.SavedState savedState;
|
protected StateSaver.SavedState savedState;
|
||||||
|
protected boolean useDefaultStateSaving = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the default implementation of {@link StateSaver.WriteRead} should be used.
|
||||||
|
*
|
||||||
|
* @see StateSaver
|
||||||
|
*/
|
||||||
|
public void useDefaultStateSaving(boolean useDefault) {
|
||||||
|
this.useDefaultStateSaving = useDefault;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String generateSuffix() {
|
public String generateSuffix() {
|
||||||
|
@ -112,26 +125,28 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void writeTo(Queue<Object> objectsToSave) {
|
public void writeTo(Queue<Object> objectsToSave) {
|
||||||
objectsToSave.add(infoListAdapter.getItemsList());
|
if (useDefaultStateSaving) objectsToSave.add(infoListAdapter.getItemsList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
|
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
|
||||||
infoListAdapter.getItemsList().clear();
|
if (useDefaultStateSaving) {
|
||||||
infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll());
|
infoListAdapter.getItemsList().clear();
|
||||||
|
infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSaveInstanceState(Bundle bundle) {
|
public void onSaveInstanceState(Bundle bundle) {
|
||||||
super.onSaveInstanceState(bundle);
|
super.onSaveInstanceState(bundle);
|
||||||
savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this);
|
if (useDefaultStateSaving) savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onRestoreInstanceState(@NonNull Bundle bundle) {
|
protected void onRestoreInstanceState(@NonNull Bundle bundle) {
|
||||||
super.onRestoreInstanceState(bundle);
|
super.onRestoreInstanceState(bundle);
|
||||||
savedState = StateSaver.tryToRestore(bundle, this);
|
if (useDefaultStateSaving) savedState = StateSaver.tryToRestore(bundle, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
|
|
@ -33,7 +33,7 @@ import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||||
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
import org.schabi.newpipe.report.UserAction;
|
import org.schabi.newpipe.report.UserAction;
|
||||||
|
@ -66,7 +66,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
|
|
||||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
private Disposable subscribeButtonMonitor;
|
private Disposable subscribeButtonMonitor;
|
||||||
private SubscriptionService subscriptionService;
|
private SubscriptionManager subscriptionManager;
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Views
|
// Views
|
||||||
|
@ -109,7 +109,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
@Override
|
@Override
|
||||||
public void onAttach(Context context) {
|
public void onAttach(Context context) {
|
||||||
super.onAttach(context);
|
super.onAttach(context);
|
||||||
subscriptionService = SubscriptionService.getInstance(activity);
|
subscriptionManager = new SubscriptionManager(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -212,8 +212,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
0);
|
0);
|
||||||
};
|
};
|
||||||
|
|
||||||
final Observable<List<SubscriptionEntity>> observable = subscriptionService.subscriptionTable()
|
final Observable<List<SubscriptionEntity>> observable = subscriptionManager.subscriptionTable()
|
||||||
.getSubscription(info.getServiceId(), info.getUrl())
|
.getSubscriptionFlowable(info.getServiceId(), info.getUrl())
|
||||||
.toObservable();
|
.toObservable();
|
||||||
|
|
||||||
disposables.add(observable
|
disposables.add(observable
|
||||||
|
@ -231,16 +231,16 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription) {
|
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription, ChannelInfo info) {
|
||||||
return (@NonNull Object o) -> {
|
return (@NonNull Object o) -> {
|
||||||
subscriptionService.subscriptionTable().insert(subscription);
|
subscriptionManager.insertSubscription(subscription, info);
|
||||||
return o;
|
return o;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) {
|
private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) {
|
||||||
return (@NonNull Object o) -> {
|
return (@NonNull Object o) -> {
|
||||||
subscriptionService.subscriptionTable().delete(subscription);
|
subscriptionManager.deleteSubscription(subscription);
|
||||||
return o;
|
return o;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -258,7 +258,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
"Updating Subscription for " + info.getUrl(),
|
"Updating Subscription for " + info.getUrl(),
|
||||||
R.string.subscription_update_failed);
|
R.string.subscription_update_failed);
|
||||||
|
|
||||||
disposables.add(subscriptionService.updateChannelInfo(info)
|
disposables.add(subscriptionManager.updateChannelInfo(info)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(onComplete, onError));
|
.subscribe(onComplete, onError));
|
||||||
|
@ -288,7 +288,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) {
|
private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) {
|
||||||
return (List<SubscriptionEntity> subscriptionEntities) -> {
|
return (List<SubscriptionEntity> subscriptionEntities) -> {
|
||||||
if (DEBUG)
|
if (DEBUG)
|
||||||
Log.d(TAG, "subscriptionService.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]");
|
Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]");
|
||||||
if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose();
|
if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose();
|
||||||
|
|
||||||
if (subscriptionEntities.isEmpty()) {
|
if (subscriptionEntities.isEmpty()) {
|
||||||
|
@ -300,7 +300,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
info.getAvatarUrl(),
|
info.getAvatarUrl(),
|
||||||
info.getDescription(),
|
info.getDescription(),
|
||||||
info.getSubscriberCount());
|
info.getSubscriberCount());
|
||||||
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel));
|
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel, info));
|
||||||
} else {
|
} else {
|
||||||
if (DEBUG) Log.d(TAG, "Found subscription to this channel!");
|
if (DEBUG) Log.d(TAG, "Found subscription to this channel!");
|
||||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||||
|
|
|
@ -122,7 +122,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||||
this.useGridVariant = useGridVariant;
|
this.useGridVariant = useGridVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addInfoItemList(@Nullable final List<InfoItem> data) {
|
public void addInfoItemList(@Nullable final List<? extends InfoItem> data) {
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -147,6 +147,12 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setInfoItemList(List<? extends InfoItem> data) {
|
||||||
|
infoItemList.clear();
|
||||||
|
infoItemList.addAll(data);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
public void addInfoItem(@Nullable InfoItem data) {
|
public void addInfoItem(@Nullable InfoItem data) {
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -0,0 +1,150 @@
|
||||||
|
package org.schabi.newpipe.local.feed
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.preference.PreferenceManager
|
||||||
|
import android.util.Log
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Maybe
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.MainActivity.DEBUG
|
||||||
|
import org.schabi.newpipe.NewPipeDatabase
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
class FeedDatabaseManager(context: Context) {
|
||||||
|
private val database = NewPipeDatabase.getInstance(context)
|
||||||
|
private val feedTable = database.feedDAO()
|
||||||
|
private val feedGroupTable = database.feedGroupDAO()
|
||||||
|
private val streamTable = database.streamDAO()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Only items that are newer than this will be saved.
|
||||||
|
*/
|
||||||
|
val FEED_OLDEST_ALLOWED_DATE: Calendar = Calendar.getInstance().apply {
|
||||||
|
add(Calendar.WEEK_OF_YEAR, -13)
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groups() = feedGroupTable.getAll()
|
||||||
|
|
||||||
|
fun database() = database
|
||||||
|
|
||||||
|
fun asStreamItems(groupId: Long = -1): Flowable<List<StreamInfoItem>> {
|
||||||
|
val streams =
|
||||||
|
if (groupId >= 0) feedTable.getAllStreamsFromGroup(groupId)
|
||||||
|
else feedTable.getAllStreams()
|
||||||
|
|
||||||
|
return streams.map<List<StreamInfoItem>> {
|
||||||
|
val items = ArrayList<StreamInfoItem>(it.size)
|
||||||
|
for (streamEntity in it) items.add(streamEntity.toStreamInfoItem())
|
||||||
|
return@map items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun upsertAll(subscriptionId: Long, items: List<StreamInfoItem>,
|
||||||
|
oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) {
|
||||||
|
val itemsToInsert = ArrayList<StreamInfoItem>()
|
||||||
|
loop@ for (streamItem in items) {
|
||||||
|
val uploadDate = streamItem.uploadDate
|
||||||
|
|
||||||
|
itemsToInsert += when {
|
||||||
|
uploadDate == null && streamItem.streamType == StreamType.LIVE_STREAM -> streamItem
|
||||||
|
uploadDate != null && uploadDate.date().time >= oldestAllowedDate -> streamItem
|
||||||
|
else -> continue@loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feedTable.unlinkOldLivestreams(subscriptionId)
|
||||||
|
|
||||||
|
if (itemsToInsert.isNotEmpty()) {
|
||||||
|
val streamEntities = itemsToInsert.map { StreamEntity(it) }
|
||||||
|
val streamIds = streamTable.upsertAll(streamEntities)
|
||||||
|
val feedEntities = streamIds.map { FeedEntity(it, subscriptionId) }
|
||||||
|
|
||||||
|
feedTable.insertAll(feedEntities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLastUpdated(context: Context): Calendar? {
|
||||||
|
val lastUpdatedMillis = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getLong(context.getString(R.string.feed_last_updated_key), -1)
|
||||||
|
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
if (lastUpdatedMillis > 0) {
|
||||||
|
calendar.timeInMillis = lastUpdatedMillis
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLastUpdated(context: Context, lastUpdated: Calendar?) {
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
.putLong(context.getString(R.string.feed_last_updated_key), lastUpdated?.timeInMillis ?: -1).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeOrphansOrOlderStreams(oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) {
|
||||||
|
feedTable.unlinkStreamsOlderThan(oldestAllowedDate)
|
||||||
|
streamTable.deleteOrphans()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
feedTable.deleteAll()
|
||||||
|
val deletedOrphans = streamTable.deleteOrphans()
|
||||||
|
if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans")
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Feed Groups
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
fun subscriptionIdsForGroup(groupId: Long): Flowable<List<Long>> {
|
||||||
|
return feedGroupTable.getSubscriptionIdsFor(groupId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>): Completable {
|
||||||
|
return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createGroup(name: String, icon: FeedGroupIcon): Maybe<Long> {
|
||||||
|
return Maybe.fromCallable { feedGroupTable.insert(FeedGroupEntity(0, name, icon)) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGroup(groupId: Long): Maybe<FeedGroupEntity> {
|
||||||
|
return feedGroupTable.getGroup(groupId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGroup(feedGroupEntity: FeedGroupEntity): Completable {
|
||||||
|
return Completable.fromCallable { feedGroupTable.update(feedGroupEntity) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGroup(groupId: Long): Completable {
|
||||||
|
return Completable.fromCallable { feedGroupTable.delete(groupId) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,444 +0,0 @@
|
||||||
package org.schabi.newpipe.local.feed;
|
|
||||||
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Handler;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.appcompat.app.ActionBar;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
|
|
||||||
import org.reactivestreams.Subscriber;
|
|
||||||
import org.reactivestreams.Subscription;
|
|
||||||
import org.schabi.newpipe.R;
|
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
|
||||||
import org.schabi.newpipe.fragments.list.BaseListFragment;
|
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
|
||||||
import org.schabi.newpipe.report.UserAction;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Queue;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
import io.reactivex.Flowable;
|
|
||||||
import io.reactivex.MaybeObserver;
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.disposables.CompositeDisposable;
|
|
||||||
import io.reactivex.disposables.Disposable;
|
|
||||||
|
|
||||||
public class FeedFragment extends BaseListFragment<List<SubscriptionEntity>, Void> {
|
|
||||||
|
|
||||||
private static final int OFF_SCREEN_ITEMS_COUNT = 3;
|
|
||||||
private static final int MIN_ITEMS_INITIAL_LOAD = 8;
|
|
||||||
private int FEED_LOAD_COUNT = MIN_ITEMS_INITIAL_LOAD;
|
|
||||||
|
|
||||||
private int subscriptionPoolSize;
|
|
||||||
|
|
||||||
private SubscriptionService subscriptionService;
|
|
||||||
|
|
||||||
private AtomicBoolean allItemsLoaded = new AtomicBoolean(false);
|
|
||||||
private HashSet<String> itemsLoaded = new HashSet<>();
|
|
||||||
private final AtomicInteger requestLoadedAtomic = new AtomicInteger();
|
|
||||||
|
|
||||||
private CompositeDisposable compositeDisposable = new CompositeDisposable();
|
|
||||||
private Disposable subscriptionObserver;
|
|
||||||
private Subscription feedSubscriber;
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment LifeCycle
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
subscriptionService = SubscriptionService.getInstance(activity);
|
|
||||||
|
|
||||||
FEED_LOAD_COUNT = howManyItemsToLoad();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
|
||||||
|
|
||||||
if(!useAsFrontPage) {
|
|
||||||
setTitle(activity.getString(R.string.fragment_whats_new));
|
|
||||||
}
|
|
||||||
return inflater.inflate(R.layout.fragment_feed, container, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPause() {
|
|
||||||
super.onPause();
|
|
||||||
disposeEverything();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
if (wasLoading.get()) doInitialLoadLogic();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy() {
|
|
||||||
super.onDestroy();
|
|
||||||
|
|
||||||
disposeEverything();
|
|
||||||
subscriptionService = null;
|
|
||||||
compositeDisposable = null;
|
|
||||||
subscriptionObserver = null;
|
|
||||||
feedSubscriber = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroyView() {
|
|
||||||
// Do not monitor for updates when user is not viewing the feed fragment.
|
|
||||||
// This is a waste of bandwidth.
|
|
||||||
disposeEverything();
|
|
||||||
super.onDestroyView();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setUserVisibleHint(boolean isVisibleToUser) {
|
|
||||||
super.setUserVisibleHint(isVisibleToUser);
|
|
||||||
if (activity != null && isVisibleToUser) {
|
|
||||||
setTitle(activity.getString(R.string.fragment_whats_new));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
|
||||||
super.onCreateOptionsMenu(menu, inflater);
|
|
||||||
|
|
||||||
ActionBar supportActionBar = activity.getSupportActionBar();
|
|
||||||
|
|
||||||
if(useAsFrontPage) {
|
|
||||||
supportActionBar.setDisplayShowTitleEnabled(true);
|
|
||||||
//supportActionBar.setDisplayShowTitleEnabled(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void reloadContent() {
|
|
||||||
resetFragment();
|
|
||||||
super.reloadContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// StateSaving
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void writeTo(Queue<Object> objectsToSave) {
|
|
||||||
super.writeTo(objectsToSave);
|
|
||||||
objectsToSave.add(allItemsLoaded);
|
|
||||||
objectsToSave.add(itemsLoaded);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
|
|
||||||
super.readFrom(savedObjects);
|
|
||||||
allItemsLoaded = (AtomicBoolean) savedObjects.poll();
|
|
||||||
itemsLoaded = (HashSet<String>) savedObjects.poll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Feed Loader
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void startLoading(boolean forceLoad) {
|
|
||||||
if (DEBUG) Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]");
|
|
||||||
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
|
||||||
|
|
||||||
if (allItemsLoaded.get()) {
|
|
||||||
if (infoListAdapter.getItemsList().size() == 0) {
|
|
||||||
showEmptyState();
|
|
||||||
} else {
|
|
||||||
showListFooter(false);
|
|
||||||
hideLoading();
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.set(true);
|
|
||||||
showLoading();
|
|
||||||
showListFooter(true);
|
|
||||||
subscriptionObserver = subscriptionService.getSubscription()
|
|
||||||
.onErrorReturnItem(Collections.emptyList())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(this::handleResult, this::onError);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleResult(@androidx.annotation.NonNull List<SubscriptionEntity> result) {
|
|
||||||
super.handleResult(result);
|
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
|
||||||
infoListAdapter.clearStreamItemList();
|
|
||||||
showEmptyState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptionPoolSize = result.size();
|
|
||||||
Flowable.fromIterable(result)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(getSubscriptionObserver());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Responsible for reacting to user pulling request and starting a request for new feed stream.
|
|
||||||
* <p>
|
|
||||||
* On initialization, it automatically requests the amount of feed needed to display
|
|
||||||
* a minimum amount required (FEED_LOAD_SIZE).
|
|
||||||
* <p>
|
|
||||||
* Upon receiving a user pull, it creates a Single Observer to fetch the ChannelInfo
|
|
||||||
* containing the feed streams.
|
|
||||||
**/
|
|
||||||
private Subscriber<SubscriptionEntity> getSubscriptionObserver() {
|
|
||||||
return new Subscriber<SubscriptionEntity>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Subscription s) {
|
|
||||||
if (feedSubscriber != null) feedSubscriber.cancel();
|
|
||||||
feedSubscriber = s;
|
|
||||||
|
|
||||||
int requestSize = FEED_LOAD_COUNT - infoListAdapter.getItemsList().size();
|
|
||||||
if (wasLoading.getAndSet(false)) requestSize = FEED_LOAD_COUNT;
|
|
||||||
|
|
||||||
boolean hasToLoad = requestSize > 0;
|
|
||||||
if (hasToLoad) {
|
|
||||||
requestLoadedAtomic.set(infoListAdapter.getItemsList().size());
|
|
||||||
requestFeed(requestSize);
|
|
||||||
}
|
|
||||||
isLoading.set(hasToLoad);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(SubscriptionEntity subscriptionEntity) {
|
|
||||||
if (!itemsLoaded.contains(subscriptionEntity.getServiceId() + subscriptionEntity.getUrl())) {
|
|
||||||
subscriptionService.getChannelInfo(subscriptionEntity)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.onErrorComplete(
|
|
||||||
(@io.reactivex.annotations.NonNull Throwable throwable) ->
|
|
||||||
FeedFragment.super.onError(throwable))
|
|
||||||
.subscribe(
|
|
||||||
getChannelInfoObserver(subscriptionEntity.getServiceId(),
|
|
||||||
subscriptionEntity.getUrl()));
|
|
||||||
} else {
|
|
||||||
requestFeed(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable exception) {
|
|
||||||
FeedFragment.this.onError(exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
if (DEBUG) Log.d(TAG, "getSubscriptionObserver > onComplete() called");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On each request, a subscription item from the updated table is transformed
|
|
||||||
* into a ChannelInfo, containing the latest streams from the channel.
|
|
||||||
* <p>
|
|
||||||
* Currently, the feed uses the first into from the list of streams.
|
|
||||||
* <p>
|
|
||||||
* If chosen feed already displayed, then we request another feed from another
|
|
||||||
* subscription, until the subscription table runs out of new items.
|
|
||||||
* <p>
|
|
||||||
* This Observer is self-contained and will close itself when complete. However, this
|
|
||||||
* does not obey the fragment lifecycle and may continue running in the background
|
|
||||||
* until it is complete. This is done due to RxJava2 no longer propagate errors once
|
|
||||||
* an observer is unsubscribed while the thread process is still running.
|
|
||||||
* <p>
|
|
||||||
* To solve the above issue, we can either set a global RxJava Error Handler, or
|
|
||||||
* manage exceptions case by case. This should be done if the current implementation is
|
|
||||||
* too costly when dealing with larger subscription sets.
|
|
||||||
*
|
|
||||||
* @param url + serviceId to put in {@link #allItemsLoaded} to signal that this specific entity has been loaded.
|
|
||||||
*/
|
|
||||||
private MaybeObserver<ChannelInfo> getChannelInfoObserver(final int serviceId, final String url) {
|
|
||||||
return new MaybeObserver<ChannelInfo>() {
|
|
||||||
private Disposable observer;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
observer = d;
|
|
||||||
compositeDisposable.add(d);
|
|
||||||
isLoading.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called only when response is non-empty
|
|
||||||
@Override
|
|
||||||
public void onSuccess(final ChannelInfo channelInfo) {
|
|
||||||
if (infoListAdapter == null || channelInfo.getRelatedItems().isEmpty()) {
|
|
||||||
onDone();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final InfoItem item = channelInfo.getRelatedItems().get(0);
|
|
||||||
// Keep requesting new items if the current one already exists
|
|
||||||
boolean itemExists = doesItemExist(infoListAdapter.getItemsList(), item);
|
|
||||||
if (!itemExists) {
|
|
||||||
infoListAdapter.addInfoItem(item);
|
|
||||||
//updateSubscription(channelInfo);
|
|
||||||
} else {
|
|
||||||
requestFeed(1);
|
|
||||||
}
|
|
||||||
onDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable exception) {
|
|
||||||
showSnackBarError(exception,
|
|
||||||
UserAction.SUBSCRIPTION,
|
|
||||||
NewPipe.getNameOfService(serviceId),
|
|
||||||
url, 0);
|
|
||||||
requestFeed(1);
|
|
||||||
onDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called only when response is empty
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
onDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onDone() {
|
|
||||||
if (observer.isDisposed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsLoaded.add(serviceId + url);
|
|
||||||
compositeDisposable.remove(observer);
|
|
||||||
|
|
||||||
int loaded = requestLoadedAtomic.incrementAndGet();
|
|
||||||
if (loaded >= Math.min(FEED_LOAD_COUNT, subscriptionPoolSize)) {
|
|
||||||
requestLoadedAtomic.set(0);
|
|
||||||
isLoading.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemsLoaded.size() == subscriptionPoolSize) {
|
|
||||||
if (DEBUG) Log.d(TAG, "getChannelInfoObserver > All Items Loaded");
|
|
||||||
allItemsLoaded.set(true);
|
|
||||||
showListFooter(false);
|
|
||||||
isLoading.set(false);
|
|
||||||
hideLoading();
|
|
||||||
if (infoListAdapter.getItemsList().size() == 0) {
|
|
||||||
showEmptyState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void loadMoreItems() {
|
|
||||||
isLoading.set(true);
|
|
||||||
delayHandler.removeCallbacksAndMessages(null);
|
|
||||||
// Add a little of a delay when requesting more items because the cache is so fast,
|
|
||||||
// that the view seems stuck to the user when he scroll to the bottom
|
|
||||||
delayHandler.postDelayed(() -> requestFeed(FEED_LOAD_COUNT), 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean hasMoreItems() {
|
|
||||||
return !allItemsLoaded.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
private final Handler delayHandler = new Handler();
|
|
||||||
|
|
||||||
private void requestFeed(final int count) {
|
|
||||||
if (DEBUG) Log.d(TAG, "requestFeed() called with: count = [" + count + "], feedSubscriber = [" + feedSubscriber + "]");
|
|
||||||
if (feedSubscriber == null) return;
|
|
||||||
|
|
||||||
isLoading.set(true);
|
|
||||||
delayHandler.removeCallbacksAndMessages(null);
|
|
||||||
feedSubscriber.request(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Utils
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
private void resetFragment() {
|
|
||||||
if (DEBUG) Log.d(TAG, "resetFragment() called");
|
|
||||||
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
|
||||||
if (compositeDisposable != null) compositeDisposable.clear();
|
|
||||||
if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
|
|
||||||
|
|
||||||
delayHandler.removeCallbacksAndMessages(null);
|
|
||||||
requestLoadedAtomic.set(0);
|
|
||||||
allItemsLoaded.set(false);
|
|
||||||
showListFooter(false);
|
|
||||||
itemsLoaded.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void disposeEverything() {
|
|
||||||
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
|
||||||
if (compositeDisposable != null) compositeDisposable.clear();
|
|
||||||
if (feedSubscriber != null) feedSubscriber.cancel();
|
|
||||||
delayHandler.removeCallbacksAndMessages(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean doesItemExist(final List<InfoItem> items, final InfoItem item) {
|
|
||||||
for (final InfoItem existingItem : items) {
|
|
||||||
if (existingItem.getInfoType() == item.getInfoType() &&
|
|
||||||
existingItem.getServiceId() == item.getServiceId() &&
|
|
||||||
existingItem.getName().equals(item.getName()) &&
|
|
||||||
existingItem.getUrl().equals(item.getUrl())) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int howManyItemsToLoad() {
|
|
||||||
int heightPixels = getResources().getDisplayMetrics().heightPixels;
|
|
||||||
int itemHeightPixels = activity.getResources().getDimensionPixelSize(R.dimen.video_item_search_height);
|
|
||||||
|
|
||||||
int items = itemHeightPixels > 0
|
|
||||||
? heightPixels / itemHeightPixels + OFF_SCREEN_ITEMS_COUNT
|
|
||||||
: MIN_ITEMS_INITIAL_LOAD;
|
|
||||||
return Math.max(MIN_ITEMS_INITIAL_LOAD, items);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment Error Handling
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void showError(String message, boolean showRetryButton) {
|
|
||||||
resetFragment();
|
|
||||||
super.showError(message, showRetryButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean onError(Throwable exception) {
|
|
||||||
if (super.onError(exception)) return true;
|
|
||||||
|
|
||||||
int errorId = exception instanceof ExtractionException
|
|
||||||
? R.string.parsing_error
|
|
||||||
: R.string.general_error;
|
|
||||||
onUnrecoverableError(exception,
|
|
||||||
UserAction.SOMETHING_ELSE,
|
|
||||||
"none",
|
|
||||||
"Requesting feed",
|
|
||||||
errorId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,288 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Mauricio Colli <mauriciocolli@outlook.com>
|
||||||
|
* FeedFragment.kt is part of NewPipe
|
||||||
|
*
|
||||||
|
* License: GPL-3.0+
|
||||||
|
* This program 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.
|
||||||
|
*
|
||||||
|
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.schabi.newpipe.local.feed
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.view.*
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import icepick.State
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import kotlinx.android.synthetic.main.error_retry.*
|
||||||
|
import kotlinx.android.synthetic.main.fragment_feed.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.fragments.list.BaseListFragment
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||||
|
import org.schabi.newpipe.report.UserAction
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||||
|
import org.schabi.newpipe.util.Localization
|
||||||
|
|
||||||
|
class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||||
|
private lateinit var viewModel: FeedViewModel
|
||||||
|
private lateinit var feedDatabaseManager: FeedDatabaseManager
|
||||||
|
@State @JvmField var listState: Parcelable? = null
|
||||||
|
|
||||||
|
private var groupId = -1L
|
||||||
|
private var groupName = ""
|
||||||
|
|
||||||
|
init {
|
||||||
|
setHasOptionsMenu(true)
|
||||||
|
useDefaultStateSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
groupId = arguments?.getLong(KEY_GROUP_ID, -1) ?: -1
|
||||||
|
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
|
||||||
|
|
||||||
|
feedDatabaseManager = FeedDatabaseManager(requireContext())
|
||||||
|
if (feedDatabaseManager.getLastUpdated(requireContext()) == null) {
|
||||||
|
triggerUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment_feed, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(rootView, savedInstanceState)
|
||||||
|
|
||||||
|
viewModel = ViewModelProviders.of(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java)
|
||||||
|
viewModel.stateLiveData.observe(viewLifecycleOwner, Observer { it?.let(::handleResult) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
listState = items_list?.layoutManager?.onSaveInstanceState()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
updateRelativeTimeViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
|
||||||
|
super.setUserVisibleHint(isVisibleToUser)
|
||||||
|
|
||||||
|
if (!isVisibleToUser && view != null) {
|
||||||
|
updateRelativeTimeViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initListeners() {
|
||||||
|
super.initListeners()
|
||||||
|
refresh_root_view.setOnClickListener {
|
||||||
|
triggerUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Menu
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
|
activity.supportActionBar?.setTitle(R.string.fragment_whats_new)
|
||||||
|
activity.supportActionBar?.subtitle = groupName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyOptionsMenu() {
|
||||||
|
super.onDestroyOptionsMenu()
|
||||||
|
activity.supportActionBar?.subtitle = null
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun showLoading() {
|
||||||
|
animateView(refresh_root_view, false, 0)
|
||||||
|
animateView(items_list, false, 0)
|
||||||
|
|
||||||
|
animateView(loading_progress_bar, true, 200)
|
||||||
|
animateView(loading_progress_text, true, 200)
|
||||||
|
|
||||||
|
empty_state_view?.let { animateView(it, false, 0) }
|
||||||
|
animateView(error_panel, false, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hideLoading() {
|
||||||
|
animateView(refresh_root_view, true, 200)
|
||||||
|
animateView(items_list, true, 300)
|
||||||
|
|
||||||
|
animateView(loading_progress_bar, false, 0)
|
||||||
|
animateView(loading_progress_text, false, 0)
|
||||||
|
|
||||||
|
empty_state_view?.let { animateView(it, false, 0) }
|
||||||
|
animateView(error_panel, false, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showEmptyState() {
|
||||||
|
animateView(refresh_root_view, true, 200)
|
||||||
|
animateView(items_list, false, 0)
|
||||||
|
|
||||||
|
animateView(loading_progress_bar, false, 0)
|
||||||
|
animateView(loading_progress_text, false, 0)
|
||||||
|
|
||||||
|
empty_state_view?.let { animateView(it, true, 800) }
|
||||||
|
animateView(error_panel, false, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showError(message: String, showRetryButton: Boolean) {
|
||||||
|
infoListAdapter.clearStreamItemList()
|
||||||
|
animateView(refresh_root_view, false, 120)
|
||||||
|
animateView(items_list, false, 120)
|
||||||
|
|
||||||
|
animateView(loading_progress_bar, false, 120)
|
||||||
|
animateView(loading_progress_text, false, 120)
|
||||||
|
|
||||||
|
error_message_view.text = message
|
||||||
|
animateView(error_button_retry, showRetryButton, if (showRetryButton) 600 else 0)
|
||||||
|
animateView(error_panel, true, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleResult(result: FeedState) {
|
||||||
|
when (result) {
|
||||||
|
is FeedState.ProgressState -> handleProgressState(result)
|
||||||
|
is FeedState.LoadedState -> handleLoadedState(result)
|
||||||
|
is FeedState.ErrorState -> if (handleErrorState(result)) return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRefreshViewState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleProgressState(progressState: FeedState.ProgressState) {
|
||||||
|
showLoading()
|
||||||
|
|
||||||
|
val isIndeterminate = progressState.currentProgress == -1 &&
|
||||||
|
progressState.maxProgress == -1
|
||||||
|
|
||||||
|
if (!isIndeterminate) {
|
||||||
|
loading_progress_text.text = "${progressState.currentProgress}/${progressState.maxProgress}"
|
||||||
|
} else if (progressState.progressMessage > 0) {
|
||||||
|
loading_progress_text?.setText(progressState.progressMessage)
|
||||||
|
} else {
|
||||||
|
loading_progress_text?.text = "∞/∞"
|
||||||
|
}
|
||||||
|
|
||||||
|
loading_progress_bar.isIndeterminate = isIndeterminate ||
|
||||||
|
(progressState.maxProgress > 0 && progressState.currentProgress == 0)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
loading_progress_bar?.setProgress(progressState.currentProgress, true)
|
||||||
|
} else {
|
||||||
|
loading_progress_bar.progress = progressState.currentProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
loading_progress_bar.max = progressState.maxProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
|
||||||
|
infoListAdapter.setInfoItemList(loadedState.items)
|
||||||
|
listState?.run {
|
||||||
|
items_list.layoutManager?.onRestoreInstanceState(listState)
|
||||||
|
listState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loadedState.itemsErrors.isEmpty()) {
|
||||||
|
showSnackBarError(loadedState.itemsErrors, UserAction.REQUESTED_FEED,
|
||||||
|
"none", "Loading feed", R.string.general_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedState.items.isEmpty()) {
|
||||||
|
showEmptyState()
|
||||||
|
} else {
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun handleErrorState(errorState: FeedState.ErrorState): Boolean {
|
||||||
|
hideLoading()
|
||||||
|
errorState.error?.let {
|
||||||
|
onError(errorState.error)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRelativeTimeViews() {
|
||||||
|
updateRefreshViewState()
|
||||||
|
infoListAdapter.notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRefreshViewState() {
|
||||||
|
val lastUpdated = feedDatabaseManager.getLastUpdated(requireContext())
|
||||||
|
val updatedAt = when {
|
||||||
|
lastUpdated != null -> Localization.relativeTime(lastUpdated)
|
||||||
|
else -> "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_text?.text = getString(R.string.feed_last_updated, updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Load Service Handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun doInitialLoadLogic() {}
|
||||||
|
override fun reloadContent() = triggerUpdate()
|
||||||
|
override fun loadMoreItems() {}
|
||||||
|
override fun hasMoreItems() = false
|
||||||
|
|
||||||
|
private fun triggerUpdate() {
|
||||||
|
getActivity()?.startService(Intent(requireContext(), FeedLoadService::class.java))
|
||||||
|
listState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(exception: Throwable): Boolean {
|
||||||
|
if (super.onError(exception)) return true
|
||||||
|
|
||||||
|
if (useAsFrontPage) {
|
||||||
|
showSnackBarError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnrecoverableError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_GROUP_ID = "ARG_GROUP_ID"
|
||||||
|
const val KEY_GROUP_NAME = "ARG_GROUP_NAME"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun newInstance(groupId: Long = -1, groupName: String? = null): FeedFragment {
|
||||||
|
val feedFragment = FeedFragment()
|
||||||
|
|
||||||
|
feedFragment.arguments = Bundle().apply {
|
||||||
|
putLong(KEY_GROUP_ID, groupId)
|
||||||
|
putString(KEY_GROUP_NAME, groupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return feedFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.schabi.newpipe.local.feed
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
sealed class FeedState {
|
||||||
|
data class ProgressState(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : FeedState()
|
||||||
|
data class LoadedState(val lastUpdated: Calendar? = null, val items: List<StreamInfoItem>, var itemsErrors: List<Throwable> = emptyList()) : FeedState()
|
||||||
|
data class ErrorState(val error: Throwable? = null) : FeedState()
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
package org.schabi.newpipe.local.feed
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.functions.Function3
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedEventManager
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||||
|
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class FeedViewModel(applicationContext: Context, val groupId: Long = -1) : ViewModel() {
|
||||||
|
class Factory(val context: Context, val groupId: Long = -1) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||||
|
return FeedViewModel(context.applicationContext, groupId) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
|
||||||
|
private var subscriptionManager: SubscriptionManager = SubscriptionManager(applicationContext)
|
||||||
|
|
||||||
|
val stateLiveData = MutableLiveData<FeedState>()
|
||||||
|
|
||||||
|
private var combineDisposable = Flowable
|
||||||
|
.combineLatest(
|
||||||
|
FeedEventManager.events(),
|
||||||
|
feedDatabaseManager.asStreamItems(groupId),
|
||||||
|
subscriptionManager.subscriptionTable().rowCount(),
|
||||||
|
|
||||||
|
Function3 { t1: FeedEventManager.Event, t2: List<StreamInfoItem>, t3: Long -> return@Function3 Triple(first = t1, second = t2, third = t3) }
|
||||||
|
)
|
||||||
|
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe {
|
||||||
|
val (event, listFromDB, subsCount) = it
|
||||||
|
|
||||||
|
var lastUpdated = feedDatabaseManager.getLastUpdated(applicationContext)
|
||||||
|
if (subsCount == 0L && lastUpdated != null) {
|
||||||
|
feedDatabaseManager.setLastUpdated(applicationContext, null)
|
||||||
|
lastUpdated = null
|
||||||
|
}
|
||||||
|
|
||||||
|
stateLiveData.postValue(when (event) {
|
||||||
|
is FeedEventManager.Event.IdleEvent -> FeedState.LoadedState(lastUpdated, listFromDB)
|
||||||
|
is FeedEventManager.Event.ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
|
||||||
|
is FeedEventManager.Event.SuccessResultEvent -> FeedState.LoadedState(lastUpdated, listFromDB, event.itemsErrors)
|
||||||
|
is FeedEventManager.Event.ErrorResultEvent -> throw event.error
|
||||||
|
})
|
||||||
|
|
||||||
|
if (event is FeedEventManager.Event.ErrorResultEvent || event is FeedEventManager.Event.SuccessResultEvent) {
|
||||||
|
FeedEventManager.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
combineDisposable.dispose()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
package org.schabi.newpipe.local.feed.service
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.processors.BehaviorProcessor
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
object FeedEventManager {
|
||||||
|
private var processor: BehaviorProcessor<Event> = BehaviorProcessor.create()
|
||||||
|
private var ignoreUpstream = AtomicBoolean()
|
||||||
|
private var eventsFlowable = processor.startWith(IdleEvent)
|
||||||
|
|
||||||
|
fun postEvent(event: Event) {
|
||||||
|
processor.onNext(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun events(): Flowable<Event> {
|
||||||
|
return eventsFlowable.filter { !ignoreUpstream.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
ignoreUpstream.set(true)
|
||||||
|
postEvent(IdleEvent)
|
||||||
|
ignoreUpstream.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Event {
|
||||||
|
object IdleEvent : Event()
|
||||||
|
data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
|
||||||
|
constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SuccessResultEvent(val itemsErrors: List<Throwable> = emptyList()) : Event()
|
||||||
|
data class ErrorResultEvent(val error: Throwable) : Event()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,399 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Mauricio Colli <mauriciocolli@outlook.com>
|
||||||
|
* FeedLoadService.kt is part of NewPipe
|
||||||
|
*
|
||||||
|
* License: GPL-3.0+
|
||||||
|
* This program 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.
|
||||||
|
*
|
||||||
|
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.schabi.newpipe.local.feed.service
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Notification
|
||||||
|
import io.reactivex.Single
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import io.reactivex.functions.Consumer
|
||||||
|
import io.reactivex.functions.Function
|
||||||
|
import io.reactivex.processors.PublishProcessor
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.reactivestreams.Subscriber
|
||||||
|
import org.reactivestreams.Subscription
|
||||||
|
import org.schabi.newpipe.MainActivity.DEBUG
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||||
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.*
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||||
|
import org.schabi.newpipe.util.ExtractorHelper
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
class FeedLoadService : Service() {
|
||||||
|
companion object {
|
||||||
|
private val TAG = FeedLoadService::class.java.simpleName
|
||||||
|
private const val NOTIFICATION_ID = 7293450
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often the notification will be updated.
|
||||||
|
*/
|
||||||
|
private const val NOTIFICATION_SAMPLING_PERIOD = 1500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many extractions will be running in parallel.
|
||||||
|
*/
|
||||||
|
private const val PARALLEL_EXTRACTIONS = 6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of items to buffer to mass-insert in the database.
|
||||||
|
*/
|
||||||
|
private const val BUFFER_COUNT_BEFORE_INSERT = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingSubscription: Subscription? = null
|
||||||
|
private lateinit var subscriptionManager: SubscriptionManager
|
||||||
|
|
||||||
|
private lateinit var feedDatabaseManager: FeedDatabaseManager
|
||||||
|
private lateinit var feedResultsHolder: ResultsHolder
|
||||||
|
|
||||||
|
private var disposables = CompositeDisposable()
|
||||||
|
private var notificationUpdater = PublishProcessor.create<String>()
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Lifecycle
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
subscriptionManager = SubscriptionManager(this)
|
||||||
|
feedDatabaseManager = FeedDatabaseManager(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "]," +
|
||||||
|
" flags = [" + flags + "], startId = [" + startId + "]")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent == null || loadingSubscription != null) {
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
setupNotification()
|
||||||
|
startLoading()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disposeAll() {
|
||||||
|
loadingSubscription?.cancel()
|
||||||
|
loadingSubscription = null
|
||||||
|
|
||||||
|
disposables.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopService() {
|
||||||
|
disposeAll()
|
||||||
|
stopForeground(true)
|
||||||
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Loading & Handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private class RequestException(message: String, cause: Throwable) : Exception(message, cause) {
|
||||||
|
companion object {
|
||||||
|
fun wrapList(info: ChannelInfo): List<Throwable> {
|
||||||
|
val toReturn = ArrayList<Throwable>(info.errors.size)
|
||||||
|
for (error in info.errors) {
|
||||||
|
toReturn.add(RequestException(info.serviceId.toString() + ":" + info.url, error))
|
||||||
|
}
|
||||||
|
return toReturn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLoading() {
|
||||||
|
feedResultsHolder = ResultsHolder()
|
||||||
|
|
||||||
|
subscriptionManager
|
||||||
|
.subscriptions()
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
.doOnNext {
|
||||||
|
currentProgress.set(0)
|
||||||
|
maxProgress.set(it.size)
|
||||||
|
}
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.doOnNext {
|
||||||
|
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||||
|
updateNotificationProgress(null)
|
||||||
|
broadcastProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.flatMap { Flowable.fromIterable(it) }
|
||||||
|
|
||||||
|
.parallel(PARALLEL_EXTRACTIONS)
|
||||||
|
.runOn(Schedulers.io())
|
||||||
|
.map { subscriptionEntity ->
|
||||||
|
try {
|
||||||
|
val channelInfo = ExtractorHelper
|
||||||
|
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
|
||||||
|
.blockingGet()
|
||||||
|
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, channelInfo))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||||
|
val wrapper = RequestException(request, e)
|
||||||
|
return@map Notification.createOnError<Pair<Long, ChannelInfo>>(wrapper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sequential()
|
||||||
|
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.doOnNext(errorHandlingConsumer)
|
||||||
|
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.doOnNext(notificationsConsumer)
|
||||||
|
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||||
|
.doOnNext(databaseConsumer)
|
||||||
|
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(resultSubscriber)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastProgress() {
|
||||||
|
postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val resultSubscriber
|
||||||
|
get() = object : Subscriber<List<Notification<Pair<Long, ChannelInfo>>>> {
|
||||||
|
|
||||||
|
override fun onSubscribe(s: Subscription) {
|
||||||
|
loadingSubscription = s
|
||||||
|
s.request(java.lang.Long.MAX_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNext(notification: List<Notification<Pair<Long, ChannelInfo>>>) {
|
||||||
|
if (DEBUG) Log.v(TAG, "onNext() → $notification")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(error: Throwable) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onComplete() {
|
||||||
|
if (maxProgress.get() == 0) {
|
||||||
|
postEvent(IdleEvent)
|
||||||
|
stopService()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentProgress.set(-1)
|
||||||
|
maxProgress.set(-1)
|
||||||
|
|
||||||
|
notificationUpdater.onNext(getString(R.string.feed_processing_message))
|
||||||
|
postEvent(ProgressEvent(R.string.feed_processing_message))
|
||||||
|
|
||||||
|
disposables.add(Single
|
||||||
|
.fromCallable {
|
||||||
|
feedResultsHolder.ready()
|
||||||
|
|
||||||
|
postEvent(ProgressEvent(R.string.feed_processing_message))
|
||||||
|
feedDatabaseManager.removeOrphansOrOlderStreams()
|
||||||
|
feedDatabaseManager.setLastUpdated(this@FeedLoadService, feedResultsHolder.lastUpdated)
|
||||||
|
|
||||||
|
postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { _, throwable ->
|
||||||
|
if (throwable != null) {
|
||||||
|
Log.e(TAG, "Error while storing result", throwable)
|
||||||
|
handleError(throwable)
|
||||||
|
return@subscribe
|
||||||
|
}
|
||||||
|
stopService()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val databaseConsumer: Consumer<List<Notification<Pair<Long, ChannelInfo>>>>
|
||||||
|
get() = Consumer {
|
||||||
|
feedDatabaseManager.database().runInTransaction {
|
||||||
|
for (notification in it) {
|
||||||
|
|
||||||
|
if (notification.isOnNext) {
|
||||||
|
val subscriptionId = notification.value!!.first
|
||||||
|
val info = notification.value!!.second
|
||||||
|
|
||||||
|
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||||
|
subscriptionManager.updateFromInfo(subscriptionId, info)
|
||||||
|
|
||||||
|
if (info.errors.isNotEmpty()) {
|
||||||
|
feedResultsHolder.addErrors(RequestException.wrapList(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (notification.isOnError) {
|
||||||
|
feedResultsHolder.addError(notification.error!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val errorHandlingConsumer: Consumer<Notification<Pair<Long, ChannelInfo>>>
|
||||||
|
get() = Consumer {
|
||||||
|
if (it.isOnError) {
|
||||||
|
var error = it.error!!
|
||||||
|
if (error is RequestException) error = error.cause!!
|
||||||
|
val cause = error.cause
|
||||||
|
|
||||||
|
when {
|
||||||
|
error is IOException -> throw error
|
||||||
|
cause is IOException -> throw cause
|
||||||
|
|
||||||
|
error is ReCaptchaException -> throw error
|
||||||
|
cause is ReCaptchaException -> throw cause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notificationsConsumer: Consumer<Notification<Pair<Long, ChannelInfo>>>
|
||||||
|
get() = Consumer { onItemCompleted(it.value?.second?.name) }
|
||||||
|
|
||||||
|
private fun onItemCompleted(updateDescription: String?) {
|
||||||
|
currentProgress.incrementAndGet()
|
||||||
|
notificationUpdater.onNext(updateDescription ?: "")
|
||||||
|
|
||||||
|
broadcastProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Notification
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
private lateinit var notificationBuilder: NotificationCompat.Builder
|
||||||
|
|
||||||
|
private var currentProgress = AtomicInteger(-1)
|
||||||
|
private var maxProgress = AtomicInteger(-1)
|
||||||
|
|
||||||
|
private fun createNotification(): NotificationCompat.Builder {
|
||||||
|
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(-1, -1, true)
|
||||||
|
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setContentTitle(getString(R.string.feed_notification_loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupNotification() {
|
||||||
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
|
notificationBuilder = createNotification()
|
||||||
|
|
||||||
|
val throttleAfterFirstEmission = Function { flow: Flowable<String> ->
|
||||||
|
flow.limit(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
|
||||||
|
}
|
||||||
|
|
||||||
|
disposables.add(notificationUpdater
|
||||||
|
.publish(throttleAfterFirstEmission)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(this::updateNotificationProgress))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotificationProgress(updateDescription: String?) {
|
||||||
|
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
|
||||||
|
|
||||||
|
if (maxProgress.get() == -1) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
|
||||||
|
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||||
|
notificationBuilder.setContentText(updateDescription)
|
||||||
|
} else {
|
||||||
|
val progressText = this.currentProgress.toString() + "/" + maxProgress
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
|
||||||
|
} else {
|
||||||
|
notificationBuilder.setContentInfo(progressText)
|
||||||
|
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Error handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun handleError(error: Throwable) {
|
||||||
|
postEvent(ErrorResultEvent(error))
|
||||||
|
stopService()
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Results Holder
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
class ResultsHolder {
|
||||||
|
/**
|
||||||
|
* The time the items have been loaded.
|
||||||
|
*/
|
||||||
|
internal lateinit var lastUpdated: Calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of errors that may have happen during loading.
|
||||||
|
*/
|
||||||
|
internal lateinit var itemsErrors: List<Throwable>
|
||||||
|
|
||||||
|
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
|
||||||
|
|
||||||
|
fun addError(error: Throwable) {
|
||||||
|
itemsErrorsHolder.add(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addErrors(errors: List<Throwable>) {
|
||||||
|
itemsErrorsHolder.addAll(errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ready() {
|
||||||
|
itemsErrors = itemsErrorsHolder.toList()
|
||||||
|
lastUpdated = Calendar.getInstance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
package org.schabi.newpipe.local.subscription
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
|
|
||||||
|
enum class FeedGroupIcon(
|
||||||
|
/**
|
||||||
|
* The id that will be used to store and retrieve icons from some persistent storage (e.g. DB).
|
||||||
|
*/
|
||||||
|
val id: Int,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attribute that points to a drawable resource. "R.attr" is used here to support multiple themes.
|
||||||
|
*/
|
||||||
|
@AttrRes val drawableResourceAttr: Int
|
||||||
|
) {
|
||||||
|
ALL(0, R.attr.ic_asterisk),
|
||||||
|
MUSIC(1, R.attr.ic_music_note),
|
||||||
|
EDUCATION(2, R.attr.ic_school),
|
||||||
|
FITNESS(3, R.attr.ic_fitness),
|
||||||
|
SPACE(4, R.attr.ic_telescope),
|
||||||
|
COMPUTER(5, R.attr.ic_computer),
|
||||||
|
GAMING(6, R.attr.ic_videogame),
|
||||||
|
SPORTS(7, R.attr.ic_sports),
|
||||||
|
NEWS(8, R.attr.ic_megaphone),
|
||||||
|
FAVORITES(9, R.attr.ic_heart),
|
||||||
|
CAR(10, R.attr.ic_car),
|
||||||
|
MOTORCYCLE(11, R.attr.ic_motorcycle),
|
||||||
|
TREND(12, R.attr.ic_trending_up),
|
||||||
|
MOVIE(13, R.attr.ic_movie),
|
||||||
|
BACKUP(14, R.attr.ic_backup),
|
||||||
|
ART(15, R.attr.palette),
|
||||||
|
PERSON(16, R.attr.ic_person),
|
||||||
|
PEOPLE(17, R.attr.ic_people),
|
||||||
|
MONEY(18, R.attr.ic_money),
|
||||||
|
KIDS(19, R.attr.ic_kids),
|
||||||
|
FOOD(20, R.attr.ic_fastfood),
|
||||||
|
SMILE(21, R.attr.ic_smile),
|
||||||
|
EXPLORE(22, R.attr.ic_explore),
|
||||||
|
RESTAURANT(23, R.attr.ic_restaurant),
|
||||||
|
MIC(24, R.attr.ic_mic),
|
||||||
|
HEADSET(25, R.attr.audio),
|
||||||
|
RADIO(26, R.attr.ic_radio),
|
||||||
|
SHOPPING_CART(27, R.attr.ic_shopping_cart),
|
||||||
|
WATCH_LATER(28, R.attr.ic_watch_later),
|
||||||
|
WORK(29, R.attr.ic_work),
|
||||||
|
HOT(30, R.attr.ic_hot),
|
||||||
|
CHANNEL(31, R.attr.ic_channel),
|
||||||
|
BOOKMARK(32, R.attr.ic_bookmark),
|
||||||
|
PETS(33, R.attr.ic_pets),
|
||||||
|
WORLD(34, R.attr.ic_world),
|
||||||
|
STAR(35, R.attr.ic_stars),
|
||||||
|
SUN(36, R.attr.ic_sunny);
|
||||||
|
|
||||||
|
@DrawableRes
|
||||||
|
fun getDrawableRes(context: Context): Int {
|
||||||
|
return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,595 +0,0 @@
|
||||||
package org.schabi.newpipe.local.subscription;
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.app.AlertDialog;
|
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.IntentFilter;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.content.res.Resources;
|
|
||||||
import android.graphics.Color;
|
|
||||||
import android.graphics.PorterDuff;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Environment;
|
|
||||||
import android.os.Parcelable;
|
|
||||||
import android.preference.PreferenceManager;
|
|
||||||
import androidx.annotation.DrawableRes;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.fragment.app.FragmentManager;
|
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
|
||||||
import androidx.appcompat.app.ActionBar;
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager;
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.ImageView;
|
|
||||||
import android.widget.TextView;
|
|
||||||
import android.widget.Toast;
|
|
||||||
|
|
||||||
import com.nononsenseapps.filepicker.Utils;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
|
||||||
import org.schabi.newpipe.extractor.StreamingService;
|
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
|
||||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
|
||||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
|
||||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService;
|
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
|
|
||||||
import org.schabi.newpipe.report.UserAction;
|
|
||||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
|
||||||
import org.schabi.newpipe.util.OnClickGesture;
|
|
||||||
import org.schabi.newpipe.util.ServiceHelper;
|
|
||||||
import org.schabi.newpipe.util.ShareUtils;
|
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
|
||||||
import org.schabi.newpipe.views.CollapsibleView;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.text.SimpleDateFormat;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import icepick.State;
|
|
||||||
import io.reactivex.Observer;
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.disposables.CompositeDisposable;
|
|
||||||
import io.reactivex.disposables.Disposable;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE;
|
|
||||||
import static org.schabi.newpipe.util.AnimationUtils.animateRotation;
|
|
||||||
import static org.schabi.newpipe.util.AnimationUtils.animateView;
|
|
||||||
|
|
||||||
public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEntity>> implements SharedPreferences.OnSharedPreferenceChangeListener {
|
|
||||||
private static final int REQUEST_EXPORT_CODE = 666;
|
|
||||||
private static final int REQUEST_IMPORT_CODE = 667;
|
|
||||||
|
|
||||||
private RecyclerView itemsList;
|
|
||||||
@State
|
|
||||||
protected Parcelable itemsListState;
|
|
||||||
private InfoListAdapter infoListAdapter;
|
|
||||||
private int updateFlags = 0;
|
|
||||||
|
|
||||||
private static final int LIST_MODE_UPDATE_FLAG = 0x32;
|
|
||||||
|
|
||||||
private View whatsNewItemListHeader;
|
|
||||||
private View importExportListHeader;
|
|
||||||
|
|
||||||
@State
|
|
||||||
protected Parcelable importExportOptionsState;
|
|
||||||
private CollapsibleView importExportOptions;
|
|
||||||
|
|
||||||
private CompositeDisposable disposables = new CompositeDisposable();
|
|
||||||
private SubscriptionService subscriptionService;
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment LifeCycle
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setHasOptionsMenu(true);
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
|
||||||
.registerOnSharedPreferenceChangeListener(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setUserVisibleHint(boolean isVisibleToUser) {
|
|
||||||
super.setUserVisibleHint(isVisibleToUser);
|
|
||||||
if (activity != null && isVisibleToUser) {
|
|
||||||
setTitle(activity.getString(R.string.tab_subscriptions));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAttach(Context context) {
|
|
||||||
super.onAttach(context);
|
|
||||||
infoListAdapter = new InfoListAdapter(activity);
|
|
||||||
subscriptionService = SubscriptionService.getInstance(activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDetach() {
|
|
||||||
super.onDetach();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
@Override
|
|
||||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
|
||||||
return inflater.inflate(R.layout.fragment_subscription, container, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
setupBroadcastReceiver();
|
|
||||||
if (updateFlags != 0) {
|
|
||||||
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
|
|
||||||
final boolean useGrid = isGridLayout();
|
|
||||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
|
||||||
infoListAdapter.setGridItemVariants(useGrid);
|
|
||||||
infoListAdapter.notifyDataSetChanged();
|
|
||||||
}
|
|
||||||
updateFlags = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPause() {
|
|
||||||
super.onPause();
|
|
||||||
itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
|
|
||||||
importExportOptionsState = importExportOptions.onSaveInstanceState();
|
|
||||||
|
|
||||||
if (subscriptionBroadcastReceiver != null && activity != null) {
|
|
||||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroyView() {
|
|
||||||
if (disposables != null) disposables.clear();
|
|
||||||
|
|
||||||
super.onDestroyView();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy() {
|
|
||||||
if (disposables != null) disposables.dispose();
|
|
||||||
disposables = null;
|
|
||||||
subscriptionService = null;
|
|
||||||
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
|
||||||
.unregisterOnSharedPreferenceChangeListener(this);
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected RecyclerView.LayoutManager getListLayoutManager() {
|
|
||||||
return new LinearLayoutManager(activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected RecyclerView.LayoutManager getGridLayoutManager() {
|
|
||||||
final Resources resources = activity.getResources();
|
|
||||||
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
|
|
||||||
width += (24 * resources.getDisplayMetrics().density);
|
|
||||||
final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels / (double)width);
|
|
||||||
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
|
|
||||||
lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount));
|
|
||||||
return lm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*/////////////////////////////////////////////////////////////////////////
|
|
||||||
// Menu
|
|
||||||
/////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
|
||||||
super.onCreateOptionsMenu(menu, inflater);
|
|
||||||
|
|
||||||
ActionBar supportActionBar = activity.getSupportActionBar();
|
|
||||||
if (supportActionBar != null) {
|
|
||||||
supportActionBar.setDisplayShowTitleEnabled(true);
|
|
||||||
setTitle(getString(R.string.tab_subscriptions));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Subscriptions import/export
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
private BroadcastReceiver subscriptionBroadcastReceiver;
|
|
||||||
|
|
||||||
private void setupBroadcastReceiver() {
|
|
||||||
if (activity == null) return;
|
|
||||||
|
|
||||||
if (subscriptionBroadcastReceiver != null) {
|
|
||||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
|
|
||||||
}
|
|
||||||
|
|
||||||
final IntentFilter filters = new IntentFilter();
|
|
||||||
filters.addAction(SubscriptionsExportService.EXPORT_COMPLETE_ACTION);
|
|
||||||
filters.addAction(SubscriptionsImportService.IMPORT_COMPLETE_ACTION);
|
|
||||||
subscriptionBroadcastReceiver = new BroadcastReceiver() {
|
|
||||||
@Override
|
|
||||||
public void onReceive(Context context, Intent intent) {
|
|
||||||
if (importExportOptions != null) importExportOptions.collapse();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver, filters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private View addItemView(final String title, @DrawableRes final int icon, ViewGroup container) {
|
|
||||||
final View itemRoot = View.inflate(getContext(), R.layout.subscription_import_export_item, null);
|
|
||||||
final TextView titleView = itemRoot.findViewById(android.R.id.text1);
|
|
||||||
final ImageView iconView = itemRoot.findViewById(android.R.id.icon1);
|
|
||||||
|
|
||||||
titleView.setText(title);
|
|
||||||
iconView.setImageResource(icon);
|
|
||||||
|
|
||||||
container.addView(itemRoot);
|
|
||||||
return itemRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupImportFromItems(final ViewGroup listHolder) {
|
|
||||||
final View previousBackupItem = addItemView(getString(R.string.previous_export),
|
|
||||||
ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_backup), listHolder);
|
|
||||||
previousBackupItem.setOnClickListener(item -> onImportPreviousSelected());
|
|
||||||
|
|
||||||
final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE;
|
|
||||||
final String[] services = getResources().getStringArray(R.array.service_list);
|
|
||||||
for (String serviceName : services) {
|
|
||||||
try {
|
|
||||||
final StreamingService service = NewPipe.getService(serviceName);
|
|
||||||
|
|
||||||
final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor();
|
|
||||||
if (subscriptionExtractor == null) continue;
|
|
||||||
|
|
||||||
final List<SubscriptionExtractor.ContentSource> supportedSources = subscriptionExtractor.getSupportedSources();
|
|
||||||
if (supportedSources.isEmpty()) continue;
|
|
||||||
|
|
||||||
final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder);
|
|
||||||
final ImageView iconView = itemView.findViewById(android.R.id.icon1);
|
|
||||||
iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN);
|
|
||||||
|
|
||||||
itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId()));
|
|
||||||
} catch (ExtractionException e) {
|
|
||||||
throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupExportToItems(final ViewGroup listHolder) {
|
|
||||||
final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder);
|
|
||||||
previousBackupItem.setOnClickListener(item -> onExportSelected());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onImportFromServiceSelected(int serviceId) {
|
|
||||||
FragmentManager fragmentManager = getFM();
|
|
||||||
NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onImportPreviousSelected() {
|
|
||||||
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onExportSelected() {
|
|
||||||
final String date = new SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(new Date());
|
|
||||||
final String exportName = "newpipe_subscriptions_" + date + ".json";
|
|
||||||
final File exportFile = new File(Environment.getExternalStorageDirectory(), exportName);
|
|
||||||
|
|
||||||
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.getAbsolutePath()), REQUEST_EXPORT_CODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
|
||||||
super.onActivityResult(requestCode, resultCode, data);
|
|
||||||
if (data != null && data.getData() != null && resultCode == Activity.RESULT_OK) {
|
|
||||||
if (requestCode == REQUEST_EXPORT_CODE) {
|
|
||||||
final File exportFile = Utils.getFileForUri(data.getData());
|
|
||||||
if (!exportFile.getParentFile().canWrite() || !exportFile.getParentFile().canRead()) {
|
|
||||||
Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show();
|
|
||||||
} else {
|
|
||||||
activity.startService(new Intent(activity, SubscriptionsExportService.class)
|
|
||||||
.putExtra(SubscriptionsExportService.KEY_FILE_PATH, exportFile.getAbsolutePath()));
|
|
||||||
}
|
|
||||||
} else if (requestCode == REQUEST_IMPORT_CODE) {
|
|
||||||
final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
|
|
||||||
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
|
|
||||||
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
|
||||||
.putExtra(KEY_VALUE, path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*/////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment Views
|
|
||||||
/////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void initViews(View rootView, Bundle savedInstanceState) {
|
|
||||||
super.initViews(rootView, savedInstanceState);
|
|
||||||
|
|
||||||
final boolean useGrid = isGridLayout();
|
|
||||||
infoListAdapter = new InfoListAdapter(getActivity());
|
|
||||||
itemsList = rootView.findViewById(R.id.items_list);
|
|
||||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
|
||||||
|
|
||||||
View headerRootLayout;
|
|
||||||
infoListAdapter.setHeader(headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, itemsList, false));
|
|
||||||
whatsNewItemListHeader = headerRootLayout.findViewById(R.id.whats_new);
|
|
||||||
importExportListHeader = headerRootLayout.findViewById(R.id.import_export);
|
|
||||||
importExportOptions = headerRootLayout.findViewById(R.id.import_export_options);
|
|
||||||
|
|
||||||
infoListAdapter.useMiniItemVariants(true);
|
|
||||||
infoListAdapter.setGridItemVariants(useGrid);
|
|
||||||
itemsList.setAdapter(infoListAdapter);
|
|
||||||
|
|
||||||
setupImportFromItems(headerRootLayout.findViewById(R.id.import_from_options));
|
|
||||||
setupExportToItems(headerRootLayout.findViewById(R.id.export_to_options));
|
|
||||||
|
|
||||||
if (importExportOptionsState != null) {
|
|
||||||
importExportOptions.onRestoreInstanceState(importExportOptionsState);
|
|
||||||
importExportOptionsState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
importExportOptions.addListener(getExpandIconSyncListener(headerRootLayout.findViewById(R.id.import_export_expand_icon)));
|
|
||||||
importExportOptions.ready();
|
|
||||||
}
|
|
||||||
|
|
||||||
private CollapsibleView.StateListener getExpandIconSyncListener(final ImageView iconView) {
|
|
||||||
return newState -> animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void initListeners() {
|
|
||||||
super.initListeners();
|
|
||||||
|
|
||||||
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<ChannelInfoItem>() {
|
|
||||||
|
|
||||||
public void selected(ChannelInfoItem selectedItem) {
|
|
||||||
final FragmentManager fragmentManager = getFM();
|
|
||||||
NavigationHelper.openChannelFragment(fragmentManager,
|
|
||||||
selectedItem.getServiceId(),
|
|
||||||
selectedItem.getUrl(),
|
|
||||||
selectedItem.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void held(ChannelInfoItem selectedItem) {
|
|
||||||
showLongTapDialog(selectedItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
whatsNewItemListHeader.setOnClickListener(v -> {
|
|
||||||
FragmentManager fragmentManager = getFM();
|
|
||||||
NavigationHelper.openWhatsNewFragment(fragmentManager);
|
|
||||||
});
|
|
||||||
importExportListHeader.setOnClickListener(v -> importExportOptions.switchState());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showLongTapDialog(ChannelInfoItem selectedItem) {
|
|
||||||
final Context context = getContext();
|
|
||||||
final Activity activity = getActivity();
|
|
||||||
if (context == null || context.getResources() == null || getActivity() == null) return;
|
|
||||||
|
|
||||||
final String[] commands = new String[]{
|
|
||||||
context.getResources().getString(R.string.unsubscribe),
|
|
||||||
context.getResources().getString(R.string.share)
|
|
||||||
};
|
|
||||||
|
|
||||||
final DialogInterface.OnClickListener actions = (dialogInterface, i) -> {
|
|
||||||
switch (i) {
|
|
||||||
case 0:
|
|
||||||
deleteChannel(selectedItem);
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
shareChannel(selectedItem);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
|
||||||
bannerView.setSelected(true);
|
|
||||||
|
|
||||||
TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
|
||||||
titleView.setText(selectedItem.getName());
|
|
||||||
|
|
||||||
TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
|
||||||
detailsView.setVisibility(View.GONE);
|
|
||||||
|
|
||||||
new AlertDialog.Builder(activity)
|
|
||||||
.setCustomTitle(bannerView)
|
|
||||||
.setItems(commands, actions)
|
|
||||||
.create()
|
|
||||||
.show();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private void shareChannel(ChannelInfoItem selectedItem) {
|
|
||||||
ShareUtils.shareUrl(getContext(), selectedItem.getName(), selectedItem.getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("CheckResult")
|
|
||||||
private void deleteChannel(ChannelInfoItem selectedItem) {
|
|
||||||
subscriptionService.subscriptionTable()
|
|
||||||
.getSubscription(selectedItem.getServiceId(), selectedItem.getUrl())
|
|
||||||
.toObservable()
|
|
||||||
.observeOn(Schedulers.io())
|
|
||||||
.subscribe(getDeleteObserver());
|
|
||||||
|
|
||||||
Toast.makeText(activity, getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private Observer<List<SubscriptionEntity>> getDeleteObserver() {
|
|
||||||
return new Observer<List<SubscriptionEntity>>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
disposables.add(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(List<SubscriptionEntity> subscriptionEntities) {
|
|
||||||
subscriptionService.subscriptionTable().delete(subscriptionEntities);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable exception) {
|
|
||||||
SubscriptionFragment.this.onError(exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() { }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void resetFragment() {
|
|
||||||
if (disposables != null) disposables.clear();
|
|
||||||
if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
// Subscriptions Loader
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void startLoading(boolean forceLoad) {
|
|
||||||
super.startLoading(forceLoad);
|
|
||||||
resetFragment();
|
|
||||||
|
|
||||||
subscriptionService.getSubscription().toObservable()
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(getSubscriptionObserver());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Observer<List<SubscriptionEntity>> getSubscriptionObserver() {
|
|
||||||
return new Observer<List<SubscriptionEntity>>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
showLoading();
|
|
||||||
disposables.add(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(List<SubscriptionEntity> subscriptions) {
|
|
||||||
handleResult(subscriptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable exception) {
|
|
||||||
SubscriptionFragment.this.onError(exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleResult(@NonNull List<SubscriptionEntity> result) {
|
|
||||||
super.handleResult(result);
|
|
||||||
|
|
||||||
infoListAdapter.clearStreamItemList();
|
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
|
||||||
whatsNewItemListHeader.setVisibility(View.GONE);
|
|
||||||
showEmptyState();
|
|
||||||
} else {
|
|
||||||
infoListAdapter.addInfoItemList(getSubscriptionItems(result));
|
|
||||||
if (itemsListState != null) {
|
|
||||||
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
|
|
||||||
itemsListState = null;
|
|
||||||
}
|
|
||||||
whatsNewItemListHeader.setVisibility(View.VISIBLE);
|
|
||||||
hideLoading();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private List<InfoItem> getSubscriptionItems(List<SubscriptionEntity> subscriptions) {
|
|
||||||
List<InfoItem> items = new ArrayList<>();
|
|
||||||
for (final SubscriptionEntity subscription : subscriptions) {
|
|
||||||
items.add(subscription.toChannelInfoItem());
|
|
||||||
}
|
|
||||||
|
|
||||||
Collections.sort(items,
|
|
||||||
(InfoItem o1, InfoItem o2) ->
|
|
||||||
o1.getName().compareToIgnoreCase(o2.getName()));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Contract
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void showLoading() {
|
|
||||||
super.showLoading();
|
|
||||||
animateView(itemsList, false, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void hideLoading() {
|
|
||||||
super.hideLoading();
|
|
||||||
animateView(itemsList, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment Error Handling
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean onError(Throwable exception) {
|
|
||||||
resetFragment();
|
|
||||||
if (super.onError(exception)) return true;
|
|
||||||
|
|
||||||
onUnrecoverableError(exception,
|
|
||||||
UserAction.SOMETHING_ELSE,
|
|
||||||
"none",
|
|
||||||
"Subscriptions",
|
|
||||||
R.string.general_error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
|
|
||||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
|
||||||
updateFlags |= LIST_MODE_UPDATE_FLAG;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected boolean isGridLayout() {
|
|
||||||
final String list_mode = PreferenceManager.getDefaultSharedPreferences(activity).getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value));
|
|
||||||
if ("auto".equals(list_mode)) {
|
|
||||||
final Configuration configuration = getResources().getConfiguration();
|
|
||||||
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
|
||||||
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
|
|
||||||
} else {
|
|
||||||
return "grid".equals(list_mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,364 @@
|
||||||
|
package org.schabi.newpipe.local.subscription
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.*
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Environment
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.view.*
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.nononsenseapps.filepicker.Utils
|
||||||
|
import com.xwray.groupie.Group
|
||||||
|
import com.xwray.groupie.GroupAdapter
|
||||||
|
import com.xwray.groupie.Item
|
||||||
|
import com.xwray.groupie.Section
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import icepick.State
|
||||||
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import kotlinx.android.synthetic.main.dialog_title.view.*
|
||||||
|
import kotlinx.android.synthetic.main.fragment_subscription.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||||
|
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionViewModel.*
|
||||||
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog
|
||||||
|
import org.schabi.newpipe.local.subscription.item.*
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.*
|
||||||
|
import org.schabi.newpipe.report.UserAction
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||||
|
import org.schabi.newpipe.util.FilePickerActivityHelper
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper
|
||||||
|
import org.schabi.newpipe.util.OnClickGesture
|
||||||
|
import org.schabi.newpipe.util.ShareUtils
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||||
|
private lateinit var viewModel: SubscriptionViewModel
|
||||||
|
private lateinit var subscriptionManager: SubscriptionManager
|
||||||
|
private val disposables: CompositeDisposable = CompositeDisposable()
|
||||||
|
|
||||||
|
private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
private val groupAdapter = GroupAdapter<ViewHolder>()
|
||||||
|
private val feedGroupsSection = Section()
|
||||||
|
private var feedGroupsCarousel: FeedGroupCarouselItem? = null
|
||||||
|
private lateinit var importExportItem: FeedImportExportItem
|
||||||
|
private val subscriptionsSection = Section()
|
||||||
|
|
||||||
|
@State @JvmField var itemsListState: Parcelable? = null
|
||||||
|
@State @JvmField var feedGroupsListState: Parcelable? = null
|
||||||
|
@State @JvmField var importExportItemExpandedState: Boolean = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
setHasOptionsMenu(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment LifeCycle
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setupInitialLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
|
||||||
|
super.setUserVisibleHint(isVisibleToUser)
|
||||||
|
if (activity != null && isVisibleToUser) {
|
||||||
|
setTitle(activity.getString(R.string.tab_subscriptions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
subscriptionManager = SubscriptionManager(requireContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment_subscription, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
setupBroadcastReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
itemsListState = items_list.layoutManager?.onSaveInstanceState()
|
||||||
|
feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
|
||||||
|
importExportItemExpandedState = importExportItem.isExpanded
|
||||||
|
|
||||||
|
if (subscriptionBroadcastReceiver != null && activity != null) {
|
||||||
|
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
disposables.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Menu
|
||||||
|
//////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
|
|
||||||
|
val supportActionBar = activity.supportActionBar
|
||||||
|
if (supportActionBar != null) {
|
||||||
|
supportActionBar.setDisplayShowTitleEnabled(true)
|
||||||
|
setTitle(getString(R.string.tab_subscriptions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupBroadcastReceiver() {
|
||||||
|
if (activity == null) return
|
||||||
|
|
||||||
|
if (subscriptionBroadcastReceiver != null) {
|
||||||
|
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
val filters = IntentFilter()
|
||||||
|
filters.addAction(EXPORT_COMPLETE_ACTION)
|
||||||
|
filters.addAction(IMPORT_COMPLETE_ACTION)
|
||||||
|
subscriptionBroadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
items_list?.post {
|
||||||
|
importExportItem.isExpanded = false
|
||||||
|
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onImportFromServiceSelected(serviceId: Int) {
|
||||||
|
val fragmentManager = fm
|
||||||
|
NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onImportPreviousSelected() {
|
||||||
|
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onExportSelected() {
|
||||||
|
val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date())
|
||||||
|
val exportName = "newpipe_subscriptions_$date.json"
|
||||||
|
val exportFile = File(Environment.getExternalStorageDirectory(), exportName)
|
||||||
|
|
||||||
|
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
if (data != null && data.data != null && resultCode == Activity.RESULT_OK) {
|
||||||
|
if (requestCode == REQUEST_EXPORT_CODE) {
|
||||||
|
val exportFile = Utils.getFileForUri(data.data!!)
|
||||||
|
if (!exportFile.parentFile.canWrite() || !exportFile.parentFile.canRead()) {
|
||||||
|
Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
activity.startService(Intent(activity, SubscriptionsExportService::class.java)
|
||||||
|
.putExtra(KEY_FILE_PATH, exportFile.absolutePath))
|
||||||
|
}
|
||||||
|
} else if (requestCode == REQUEST_IMPORT_CODE) {
|
||||||
|
val path = Utils.getFileForUri(data.data!!).absolutePath
|
||||||
|
ImportConfirmationDialog.show(this, Intent(activity, SubscriptionsImportService::class.java)
|
||||||
|
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
||||||
|
.putExtra(KEY_VALUE, path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment Views
|
||||||
|
//////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun setupInitialLayout() {
|
||||||
|
Section().apply {
|
||||||
|
val carouselAdapter = GroupAdapter<ViewHolder>()
|
||||||
|
|
||||||
|
carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.ALL))
|
||||||
|
carouselAdapter.add(feedGroupsSection)
|
||||||
|
carouselAdapter.add(FeedGroupAddItem())
|
||||||
|
|
||||||
|
carouselAdapter.setOnItemClickListener { item, _ ->
|
||||||
|
listenerFeedGroups.selected(item)
|
||||||
|
}
|
||||||
|
carouselAdapter.setOnItemLongClickListener { item, _ ->
|
||||||
|
if (item is FeedGroupCardItem) {
|
||||||
|
if (item.groupId == -1L) {
|
||||||
|
return@setOnItemLongClickListener false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listenerFeedGroups.held(item)
|
||||||
|
return@setOnItemLongClickListener true
|
||||||
|
}
|
||||||
|
|
||||||
|
feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter)
|
||||||
|
add(Section(HeaderItem(getString(R.string.fragment_whats_new)), listOf(feedGroupsCarousel)))
|
||||||
|
|
||||||
|
groupAdapter.add(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
|
||||||
|
subscriptionsSection.setHideWhenEmpty(true)
|
||||||
|
|
||||||
|
importExportItem = FeedImportExportItem(
|
||||||
|
{ onImportPreviousSelected() },
|
||||||
|
{ onImportFromServiceSelected(it) },
|
||||||
|
{ onExportSelected() },
|
||||||
|
importExportItemExpandedState)
|
||||||
|
groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
|
||||||
|
super.initViews(rootView, savedInstanceState)
|
||||||
|
|
||||||
|
items_list.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
items_list.adapter = groupAdapter
|
||||||
|
|
||||||
|
viewModel = ViewModelProviders.of(this).get(SubscriptionViewModel::class.java)
|
||||||
|
viewModel.stateLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleResult) })
|
||||||
|
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleFeedGroups) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLongTapDialog(selectedItem: ChannelInfoItem) {
|
||||||
|
val commands = arrayOf(
|
||||||
|
getString(R.string.share),
|
||||||
|
getString(R.string.unsubscribe)
|
||||||
|
)
|
||||||
|
|
||||||
|
val actions = DialogInterface.OnClickListener { _, i ->
|
||||||
|
when (i) {
|
||||||
|
0 -> ShareUtils.shareUrl(requireContext(), selectedItem.name, selectedItem.url)
|
||||||
|
1 -> deleteChannel(selectedItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bannerView = View.inflate(requireContext(), R.layout.dialog_title, null)
|
||||||
|
bannerView.isSelected = true
|
||||||
|
bannerView.itemTitleView.text = selectedItem.name
|
||||||
|
bannerView.itemAdditionalDetails.visibility = View.GONE
|
||||||
|
|
||||||
|
AlertDialog.Builder(requireContext())
|
||||||
|
.setCustomTitle(bannerView)
|
||||||
|
.setItems(commands, actions)
|
||||||
|
.create()
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteChannel(selectedItem: ChannelInfoItem) {
|
||||||
|
disposables.add(subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doInitialLoadLogic() = Unit
|
||||||
|
override fun startLoading(forceLoad: Boolean) = Unit
|
||||||
|
|
||||||
|
private val listenerFeedGroups = object : OnClickGesture<Item<*>>() {
|
||||||
|
override fun selected(selectedItem: Item<*>?) {
|
||||||
|
when (selectedItem) {
|
||||||
|
is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name)
|
||||||
|
is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun held(selectedItem: Item<*>?) {
|
||||||
|
when (selectedItem) {
|
||||||
|
is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val listenerChannelItem = object : OnClickGesture<ChannelInfoItem>() {
|
||||||
|
override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(fm,
|
||||||
|
selectedItem.serviceId, selectedItem.url, selectedItem.name)
|
||||||
|
|
||||||
|
override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleResult(result: SubscriptionState) {
|
||||||
|
super.handleResult(result)
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is SubscriptionState.LoadedState -> {
|
||||||
|
result.subscriptions.forEach {
|
||||||
|
if (it is ChannelItem) {
|
||||||
|
it.gesturesListener = listenerChannelItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionsSection.update(result.subscriptions)
|
||||||
|
subscriptionsSection.setHideWhenEmpty(false)
|
||||||
|
|
||||||
|
if (itemsListState != null) {
|
||||||
|
items_list.layoutManager?.onRestoreInstanceState(itemsListState)
|
||||||
|
itemsListState = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SubscriptionState.ErrorState -> {
|
||||||
|
result.error?.let { onError(result.error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFeedGroups(groups: List<Group>) {
|
||||||
|
feedGroupsSection.update(groups)
|
||||||
|
|
||||||
|
if (feedGroupsListState != null) {
|
||||||
|
feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState)
|
||||||
|
feedGroupsListState = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Contract
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun showLoading() {
|
||||||
|
super.showLoading()
|
||||||
|
animateView(items_list, false, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hideLoading() {
|
||||||
|
super.hideLoading()
|
||||||
|
animateView(items_list, true, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment Error Handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onError(exception: Throwable): Boolean {
|
||||||
|
if (super.onError(exception)) return true
|
||||||
|
|
||||||
|
onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Grid Mode
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// TODO: Re-implement grid mode selection
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val REQUEST_EXPORT_CODE = 666
|
||||||
|
private const val REQUEST_IMPORT_CODE = 667
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
package org.schabi.newpipe.local.subscription
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.NewPipeDatabase
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionDAO
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||||
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
|
||||||
|
class SubscriptionManager(context: Context) {
|
||||||
|
private val database = NewPipeDatabase.getInstance(context)
|
||||||
|
private val subscriptionTable = database.subscriptionDAO()
|
||||||
|
private val feedDatabaseManager = FeedDatabaseManager(context)
|
||||||
|
|
||||||
|
fun subscriptionTable(): SubscriptionDAO = subscriptionTable
|
||||||
|
fun subscriptions() = subscriptionTable.all
|
||||||
|
|
||||||
|
fun upsertAll(infoList: List<ChannelInfo>): List<SubscriptionEntity> {
|
||||||
|
val listEntities = subscriptionTable.upsertAll(
|
||||||
|
infoList.map { SubscriptionEntity.from(it) })
|
||||||
|
|
||||||
|
database.runInTransaction {
|
||||||
|
infoList.forEachIndexed { index, info ->
|
||||||
|
feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listEntities
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url)
|
||||||
|
.flatMapCompletable {
|
||||||
|
Completable.fromRunnable {
|
||||||
|
it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
|
||||||
|
subscriptionTable.update(it)
|
||||||
|
feedDatabaseManager.upsertAll(it.uid, info.relatedItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFromInfo(subscriptionId: Long, info: ChannelInfo) {
|
||||||
|
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
|
||||||
|
subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
|
||||||
|
|
||||||
|
subscriptionTable.update(subscriptionEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSubscription(serviceId: Int, url: String): Completable {
|
||||||
|
return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) {
|
||||||
|
database.runInTransaction {
|
||||||
|
val subscriptionId = subscriptionTable.insert(subscriptionEntity)
|
||||||
|
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
|
||||||
|
subscriptionTable.delete(subscriptionEntity)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,162 +0,0 @@
|
||||||
package org.schabi.newpipe.local.subscription;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.MainActivity;
|
|
||||||
import org.schabi.newpipe.NewPipeDatabase;
|
|
||||||
import org.schabi.newpipe.database.AppDatabase;
|
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.Executor;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import io.reactivex.Completable;
|
|
||||||
import io.reactivex.CompletableSource;
|
|
||||||
import io.reactivex.Flowable;
|
|
||||||
import io.reactivex.Maybe;
|
|
||||||
import io.reactivex.Scheduler;
|
|
||||||
import io.reactivex.functions.Function;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription Service singleton:
|
|
||||||
* Provides a basis for channel Subscriptions.
|
|
||||||
* Provides access to subscription table in database as well as
|
|
||||||
* up-to-date observations on the subscribed channels
|
|
||||||
*/
|
|
||||||
public class SubscriptionService {
|
|
||||||
|
|
||||||
private static volatile SubscriptionService instance;
|
|
||||||
|
|
||||||
public static SubscriptionService getInstance(@NonNull Context context) {
|
|
||||||
SubscriptionService result = instance;
|
|
||||||
if (result == null) {
|
|
||||||
synchronized (SubscriptionService.class) {
|
|
||||||
result = instance;
|
|
||||||
if (result == null) {
|
|
||||||
instance = (result = new SubscriptionService(context));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode());
|
|
||||||
protected static final boolean DEBUG = MainActivity.DEBUG;
|
|
||||||
private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500;
|
|
||||||
private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4;
|
|
||||||
|
|
||||||
private final AppDatabase db;
|
|
||||||
private final Flowable<List<SubscriptionEntity>> subscription;
|
|
||||||
|
|
||||||
private final Scheduler subscriptionScheduler;
|
|
||||||
|
|
||||||
private SubscriptionService(Context context) {
|
|
||||||
db = NewPipeDatabase.getInstance(context.getApplicationContext());
|
|
||||||
subscription = getSubscriptionInfos();
|
|
||||||
|
|
||||||
final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE);
|
|
||||||
subscriptionScheduler = Schedulers.from(subscriptionExecutor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Part of subscription observation pipeline
|
|
||||||
*
|
|
||||||
* @see SubscriptionService#getSubscription()
|
|
||||||
*/
|
|
||||||
private Flowable<List<SubscriptionEntity>> getSubscriptionInfos() {
|
|
||||||
return subscriptionTable().getAll()
|
|
||||||
// Wait for a period of infrequent updates and return the latest update
|
|
||||||
.debounce(SUBSCRIPTION_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS)
|
|
||||||
.share() // Share allows multiple subscribers on the same observable
|
|
||||||
.replay(1) // Replay synchronizes subscribers to the last emitted result
|
|
||||||
.autoConnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides an observer to the latest update to the subscription table.
|
|
||||||
* <p>
|
|
||||||
* This observer may be subscribed multiple times, where each subscriber obtains
|
|
||||||
* the latest synchronized changes available, effectively share the same data
|
|
||||||
* across all subscribers.
|
|
||||||
* <p>
|
|
||||||
* This observer has a debounce cooldown, meaning if multiple updates are observed
|
|
||||||
* in the cooldown interval, only the latest changes are emitted to the subscribers.
|
|
||||||
* This reduces the amount of observations caused by frequent updates to the database.
|
|
||||||
*/
|
|
||||||
@androidx.annotation.NonNull
|
|
||||||
public Flowable<List<SubscriptionEntity>> getSubscription() {
|
|
||||||
return subscription;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Maybe<ChannelInfo> getChannelInfo(final SubscriptionEntity subscriptionEntity) {
|
|
||||||
if (DEBUG) Log.d(TAG, "getChannelInfo() called with: subscriptionEntity = [" + subscriptionEntity + "]");
|
|
||||||
|
|
||||||
return Maybe.fromSingle(ExtractorHelper
|
|
||||||
.getChannelInfo(subscriptionEntity.getServiceId(), subscriptionEntity.getUrl(), false))
|
|
||||||
.subscribeOn(subscriptionScheduler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the database access interface for subscription table.
|
|
||||||
*/
|
|
||||||
public SubscriptionDAO subscriptionTable() {
|
|
||||||
return db.subscriptionDAO();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Completable updateChannelInfo(final ChannelInfo info) {
|
|
||||||
final Function<List<SubscriptionEntity>, CompletableSource> update = new Function<List<SubscriptionEntity>, CompletableSource>() {
|
|
||||||
@Override
|
|
||||||
public CompletableSource apply(@NonNull List<SubscriptionEntity> subscriptionEntities) {
|
|
||||||
if (DEBUG) Log.d(TAG, "updateChannelInfo() called with: subscriptionEntities = [" + subscriptionEntities + "]");
|
|
||||||
if (subscriptionEntities.size() == 1) {
|
|
||||||
SubscriptionEntity subscription = subscriptionEntities.get(0);
|
|
||||||
|
|
||||||
// Subscriber count changes very often, making this check almost unnecessary.
|
|
||||||
// Consider removing it later.
|
|
||||||
if (!isSubscriptionUpToDate(info, subscription)) {
|
|
||||||
subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount());
|
|
||||||
|
|
||||||
return Completable.fromRunnable(() -> subscriptionTable().update(subscription));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Completable.complete();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return subscriptionTable().getSubscription(info.getServiceId(), info.getUrl())
|
|
||||||
.firstOrError()
|
|
||||||
.flatMapCompletable(update);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<SubscriptionEntity> upsertAll(final List<ChannelInfo> infoList) {
|
|
||||||
final List<SubscriptionEntity> entityList = new ArrayList<>();
|
|
||||||
for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info));
|
|
||||||
|
|
||||||
return subscriptionTable().upsertAll(entityList);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) {
|
|
||||||
return equalsAndNotNull(info.getUrl(), entity.getUrl()) &&
|
|
||||||
info.getServiceId() == entity.getServiceId() &&
|
|
||||||
info.getName().equals(entity.getName()) &&
|
|
||||||
equalsAndNotNull(info.getAvatarUrl(), entity.getAvatarUrl()) &&
|
|
||||||
equalsAndNotNull(info.getDescription(), entity.getDescription()) &&
|
|
||||||
info.getSubscriberCount() == entity.getSubscriberCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean equalsAndNotNull(final Object o1, final Object o2) {
|
|
||||||
return (o1 != null && o2 != null)
|
|
||||||
&& o1.equals(o2);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
package org.schabi.newpipe.local.subscription
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import com.xwray.groupie.Group
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
import org.schabi.newpipe.local.subscription.item.ChannelItem
|
||||||
|
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
|
||||||
|
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class SubscriptionViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
val stateLiveData = MutableLiveData<SubscriptionState>()
|
||||||
|
val feedGroupsLiveData = MutableLiveData<List<Group>>()
|
||||||
|
|
||||||
|
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
|
||||||
|
private var subscriptionManager = SubscriptionManager(application)
|
||||||
|
|
||||||
|
private var feedGroupItemsDisposable = feedDatabaseManager.groups()
|
||||||
|
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
.map { it.map(::FeedGroupCardItem) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(
|
||||||
|
{ feedGroupsLiveData.postValue(it) },
|
||||||
|
{ stateLiveData.postValue(SubscriptionState.ErrorState(it)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
private var stateItemsDisposable = subscriptionManager.subscriptions()
|
||||||
|
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
.map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(
|
||||||
|
{ stateLiveData.postValue(SubscriptionState.LoadedState(it)) },
|
||||||
|
{ stateLiveData.postValue(SubscriptionState.ErrorState(it)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
stateItemsDisposable.dispose()
|
||||||
|
feedGroupItemsDisposable.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SubscriptionState {
|
||||||
|
data class LoadedState(val subscriptions: List<Group>) : SubscriptionState()
|
||||||
|
data class ErrorState(val error: Throwable? = null) : SubscriptionState()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.decoration
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||||
|
|
||||||
|
private val marginStartEnd: Int
|
||||||
|
private val marginTopBottom: Int
|
||||||
|
private val marginBetweenItems: Int
|
||||||
|
|
||||||
|
init {
|
||||||
|
with(context.resources) {
|
||||||
|
marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin)
|
||||||
|
marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin)
|
||||||
|
marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||||
|
val childAdapterPosition = parent.getChildAdapterPosition(child)
|
||||||
|
val childAdapterCount = parent.adapter?.itemCount ?: 0
|
||||||
|
|
||||||
|
outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom)
|
||||||
|
|
||||||
|
if (childAdapterPosition == 0) {
|
||||||
|
outRect.left = marginStartEnd
|
||||||
|
} else if (childAdapterPosition == childAdapterCount - 1) {
|
||||||
|
outRect.right = marginStartEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,355 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.dialog
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.xwray.groupie.GroupAdapter
|
||||||
|
import com.xwray.groupie.Section
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import icepick.Icepick
|
||||||
|
import icepick.State
|
||||||
|
import kotlinx.android.synthetic.main.dialog_feed_group_create.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.FeedDialogEvent
|
||||||
|
import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
|
||||||
|
import org.schabi.newpipe.local.subscription.item.HeaderTextSideItem
|
||||||
|
import org.schabi.newpipe.local.subscription.item.PickerIconItem
|
||||||
|
import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
class FeedGroupDialog : DialogFragment() {
|
||||||
|
private lateinit var viewModel: FeedGroupDialogViewModel
|
||||||
|
private var groupId: Long = NO_GROUP_SELECTED
|
||||||
|
private var groupIcon: FeedGroupIcon? = null
|
||||||
|
|
||||||
|
sealed class ScreenState : Serializable {
|
||||||
|
object InitialScreen : ScreenState()
|
||||||
|
object SubscriptionsPicker : ScreenState()
|
||||||
|
object IconPickerList : ScreenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@State @JvmField var selectedIcon: FeedGroupIcon? = null
|
||||||
|
@State @JvmField var selectedSubscriptions: HashSet<Long> = HashSet()
|
||||||
|
@State @JvmField var currentScreen: ScreenState = ScreenState.InitialScreen
|
||||||
|
|
||||||
|
@State @JvmField var subscriptionsListState: Parcelable? = null
|
||||||
|
@State @JvmField var iconsListState: Parcelable? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
Icepick.restoreInstanceState(this, savedInstanceState)
|
||||||
|
|
||||||
|
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
|
||||||
|
groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
|
return inflater.inflate(R.layout.dialog_feed_group_create, container)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
return object : Dialog(requireActivity(), theme) {
|
||||||
|
override fun onBackPressed() {
|
||||||
|
if (currentScreen !is ScreenState.InitialScreen) {
|
||||||
|
showInitialScreen()
|
||||||
|
} else {
|
||||||
|
super.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
|
||||||
|
iconsListState = icon_selector.layoutManager?.onSaveInstanceState()
|
||||||
|
subscriptionsListState = subscriptions_selector.layoutManager?.onSaveInstanceState()
|
||||||
|
|
||||||
|
Icepick.saveInstanceState(this, outState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
viewModel = ViewModelProviders.of(this, FeedGroupDialogViewModel.Factory(requireContext(), groupId))
|
||||||
|
.get(FeedGroupDialogViewModel::class.java)
|
||||||
|
|
||||||
|
viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup))
|
||||||
|
viewModel.subscriptionsLiveData.observe(viewLifecycleOwner, Observer { setupSubscriptionPicker(it.first, it.second) })
|
||||||
|
viewModel.successLiveData.observe(viewLifecycleOwner, Observer {
|
||||||
|
when (it) {
|
||||||
|
is FeedDialogEvent.SuccessEvent -> dismiss()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setupIconPicker()
|
||||||
|
|
||||||
|
delete_button.setOnClickListener { viewModel.deleteGroup() }
|
||||||
|
|
||||||
|
cancel_button.setOnClickListener {
|
||||||
|
if (currentScreen !is ScreenState.InitialScreen) {
|
||||||
|
showInitialScreen()
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group_name_input_container.error = null
|
||||||
|
group_name_input.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) {}
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||||
|
if (group_name_input_container.isErrorEnabled && !s.isNullOrBlank()) {
|
||||||
|
group_name_input_container.error = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
confirm_button.setOnClickListener {
|
||||||
|
if (currentScreen is ScreenState.InitialScreen) {
|
||||||
|
val name = group_name_input.text.toString().trim()
|
||||||
|
val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL
|
||||||
|
|
||||||
|
if (name.isBlank()) {
|
||||||
|
group_name_input_container.error = getString(R.string.feed_group_dialog_empty_name)
|
||||||
|
group_name_input.text = null
|
||||||
|
group_name_input.requestFocus()
|
||||||
|
return@setOnClickListener
|
||||||
|
} else {
|
||||||
|
group_name_input_container.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSubscriptions.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
when (groupId) {
|
||||||
|
NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions)
|
||||||
|
else -> viewModel.updateGroup(name, icon, selectedSubscriptions)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showInitialScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (currentScreen) {
|
||||||
|
is ScreenState.InitialScreen -> showInitialScreen()
|
||||||
|
is ScreenState.IconPickerList -> showIconPicker()
|
||||||
|
is ScreenState.SubscriptionsPicker -> showSubscriptionsPicker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Setup
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) {
|
||||||
|
val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL
|
||||||
|
val name = feedGroupEntity?.name ?: ""
|
||||||
|
groupIcon = feedGroupEntity?.icon
|
||||||
|
|
||||||
|
icon_preview.setImageResource((if (selectedIcon == null) icon else selectedIcon!!).getDrawableRes(requireContext()))
|
||||||
|
|
||||||
|
if (group_name_input.text.isNullOrBlank()) {
|
||||||
|
group_name_input.setText(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSubscriptionPicker(subscriptions: List<SubscriptionEntity>, selectedSubscriptions: Set<Long>) {
|
||||||
|
this.selectedSubscriptions.addAll(selectedSubscriptions)
|
||||||
|
val useGridLayout = subscriptions.isNotEmpty()
|
||||||
|
|
||||||
|
val groupAdapter = GroupAdapter<ViewHolder>()
|
||||||
|
groupAdapter.spanCount = if (useGridLayout) 4 else 1
|
||||||
|
|
||||||
|
val selectedCountText = getString(R.string.feed_group_dialog_selection_count, this.selectedSubscriptions.size)
|
||||||
|
selected_subscription_count_view.text = selectedCountText
|
||||||
|
|
||||||
|
val headerInfoItem = HeaderTextSideItem(getString(R.string.tab_subscriptions), selectedCountText)
|
||||||
|
groupAdapter.add(headerInfoItem)
|
||||||
|
|
||||||
|
Section().apply {
|
||||||
|
addAll(subscriptions.map {
|
||||||
|
val isSelected = this@FeedGroupDialog.selectedSubscriptions.contains(it.uid)
|
||||||
|
PickerSubscriptionItem(it, isSelected)
|
||||||
|
})
|
||||||
|
setPlaceholder(EmptyPlaceholderItem())
|
||||||
|
|
||||||
|
groupAdapter.add(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions_selector.apply {
|
||||||
|
if (useGridLayout) {
|
||||||
|
layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount, RecyclerView.VERTICAL, false).apply {
|
||||||
|
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||||
|
override fun getSpanSize(position: Int) =
|
||||||
|
if (position == 0) 4 else 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter = groupAdapter
|
||||||
|
|
||||||
|
if (subscriptionsListState != null) {
|
||||||
|
layoutManager?.onRestoreInstanceState(subscriptionsListState)
|
||||||
|
subscriptionsListState = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupAdapter.setOnItemClickListener { item, _ ->
|
||||||
|
when (item) {
|
||||||
|
is PickerSubscriptionItem -> {
|
||||||
|
val subscriptionId = item.subscriptionEntity.uid
|
||||||
|
|
||||||
|
val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) {
|
||||||
|
this.selectedSubscriptions.remove(subscriptionId)
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
this.selectedSubscriptions.add(subscriptionId)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
item.isSelected = isSelected
|
||||||
|
item.notifyChanged(PickerSubscriptionItem.UPDATE_SELECTED)
|
||||||
|
|
||||||
|
val updateSelectedCountText = getString(R.string.feed_group_dialog_selection_count, this.selectedSubscriptions.size)
|
||||||
|
selected_subscription_count_view.text = updateSelectedCountText
|
||||||
|
headerInfoItem.infoText = updateSelectedCountText
|
||||||
|
headerInfoItem.notifyChanged(HeaderTextSideItem.UPDATE_INFO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select_channel_button.setOnClickListener {
|
||||||
|
subscriptions_selector.scrollToPosition(0)
|
||||||
|
showSubscriptionsPicker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupIconPicker() {
|
||||||
|
val groupAdapter = GroupAdapter<ViewHolder>()
|
||||||
|
groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(requireContext(), it) })
|
||||||
|
|
||||||
|
icon_selector.apply {
|
||||||
|
layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false)
|
||||||
|
adapter = groupAdapter
|
||||||
|
|
||||||
|
if (iconsListState != null) {
|
||||||
|
layoutManager?.onRestoreInstanceState(iconsListState)
|
||||||
|
iconsListState = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupAdapter.setOnItemClickListener { item, _ ->
|
||||||
|
when (item) {
|
||||||
|
is PickerIconItem -> {
|
||||||
|
selectedIcon = item.icon
|
||||||
|
icon_preview.setImageResource(item.iconRes)
|
||||||
|
|
||||||
|
showInitialScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
icon_preview.setOnClickListener {
|
||||||
|
icon_selector.scrollToPosition(0)
|
||||||
|
showIconPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupId == NO_GROUP_SELECTED) {
|
||||||
|
val icon = selectedIcon ?: FeedGroupIcon.ALL
|
||||||
|
icon_preview.setImageResource(icon.getDrawableRes(requireContext()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Screen Selector
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun showInitialScreen() {
|
||||||
|
currentScreen = ScreenState.InitialScreen
|
||||||
|
animateView(icon_selector, false, 0)
|
||||||
|
animateView(subscriptions_selector, false, 0)
|
||||||
|
animateView(options_root, true, 250)
|
||||||
|
|
||||||
|
separator.visibility = View.GONE
|
||||||
|
confirm_button.setText(if (groupId == NO_GROUP_SELECTED) R.string.create else android.R.string.ok)
|
||||||
|
delete_button.visibility = if (groupId == NO_GROUP_SELECTED) View.GONE else View.VISIBLE
|
||||||
|
cancel_button.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showIconPicker() {
|
||||||
|
currentScreen = ScreenState.IconPickerList
|
||||||
|
animateView(icon_selector, true, 250)
|
||||||
|
animateView(subscriptions_selector, false, 0)
|
||||||
|
animateView(options_root, false, 0)
|
||||||
|
|
||||||
|
separator.visibility = View.VISIBLE
|
||||||
|
confirm_button.setText(android.R.string.ok)
|
||||||
|
delete_button.visibility = View.GONE
|
||||||
|
cancel_button.visibility = View.GONE
|
||||||
|
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSubscriptionsPicker() {
|
||||||
|
currentScreen = ScreenState.SubscriptionsPicker
|
||||||
|
animateView(icon_selector, false, 0)
|
||||||
|
animateView(subscriptions_selector, true, 250)
|
||||||
|
animateView(options_root, false, 0)
|
||||||
|
|
||||||
|
separator.visibility = View.VISIBLE
|
||||||
|
confirm_button.setText(android.R.string.ok)
|
||||||
|
delete_button.visibility = View.GONE
|
||||||
|
cancel_button.visibility = View.GONE
|
||||||
|
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Utils
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun hideKeyboard() {
|
||||||
|
val inputMethodManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
inputMethodManager.hideSoftInputFromWindow(group_name_input.windowToken, InputMethodManager.RESULT_UNCHANGED_SHOWN)
|
||||||
|
group_name_input.clearFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_GROUP_ID = "KEY_GROUP_ID"
|
||||||
|
private const val NO_GROUP_SELECTED = -1L
|
||||||
|
|
||||||
|
fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog {
|
||||||
|
val dialog = FeedGroupDialog()
|
||||||
|
|
||||||
|
dialog.arguments = Bundle().apply {
|
||||||
|
putLong(KEY_GROUP_ID, groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import io.reactivex.functions.BiFunction
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||||
|
|
||||||
|
|
||||||
|
class FeedGroupDialogViewModel(applicationContext: Context, val groupId: Long = -1) : ViewModel() {
|
||||||
|
class Factory(val context: Context, val groupId: Long = -1) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||||
|
return FeedGroupDialogViewModel(context.applicationContext, groupId) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
|
||||||
|
private var subscriptionManager = SubscriptionManager(applicationContext)
|
||||||
|
|
||||||
|
val groupLiveData = MutableLiveData<FeedGroupEntity>()
|
||||||
|
val subscriptionsLiveData = MutableLiveData<Pair<List<SubscriptionEntity>, Set<Long>>>()
|
||||||
|
val successLiveData = MutableLiveData<FeedDialogEvent>()
|
||||||
|
|
||||||
|
private val disposables = CompositeDisposable()
|
||||||
|
|
||||||
|
private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(groupLiveData::postValue)
|
||||||
|
|
||||||
|
private var subscriptionsDisposable = Flowable
|
||||||
|
.combineLatest(subscriptionManager.subscriptions(), feedDatabaseManager.subscriptionIdsForGroup(groupId),
|
||||||
|
BiFunction { t1: List<SubscriptionEntity>, t2: List<Long> -> t1 to t2.toSet() })
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(subscriptionsLiveData::postValue)
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
subscriptionsDisposable.dispose()
|
||||||
|
feedGroupDisposable.dispose()
|
||||||
|
disposables.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set<Long>) {
|
||||||
|
disposables.add(feedDatabaseManager.createGroup(name, selectedIcon)
|
||||||
|
.flatMapCompletable { feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList()) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { successLiveData.postValue(FeedDialogEvent.SuccessEvent) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set<Long>) {
|
||||||
|
disposables.add(feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList())
|
||||||
|
.andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon)))
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { successLiveData.postValue(FeedDialogEvent.SuccessEvent) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGroup() {
|
||||||
|
disposables.add(feedDatabaseManager.deleteGroup(groupId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { successLiveData.postValue(FeedDialogEvent.SuccessEvent) })
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class FeedDialogEvent {
|
||||||
|
object SuccessEvent : FeedDialogEvent()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.nostra13.universalimageloader.core.ImageLoader
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.list_channel_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||||
|
import org.schabi.newpipe.util.ImageDisplayConstants
|
||||||
|
import org.schabi.newpipe.util.Localization
|
||||||
|
import org.schabi.newpipe.util.OnClickGesture
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelItem(
|
||||||
|
private val infoItem: ChannelInfoItem,
|
||||||
|
private val subscriptionId: Long = -1L,
|
||||||
|
private var itemVersion: ItemVersion = ItemVersion.NORMAL,
|
||||||
|
var gesturesListener: OnClickGesture<ChannelInfoItem>? = null
|
||||||
|
) : Item() {
|
||||||
|
|
||||||
|
override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId
|
||||||
|
|
||||||
|
enum class ItemVersion { NORMAL, MINI, GRID }
|
||||||
|
|
||||||
|
override fun getLayout(): Int = when (itemVersion) {
|
||||||
|
ItemVersion.NORMAL -> R.layout.list_channel_item
|
||||||
|
ItemVersion.MINI -> R.layout.list_channel_mini_item
|
||||||
|
ItemVersion.GRID -> R.layout.list_channel_grid_item
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {
|
||||||
|
viewHolder.itemTitleView.text = infoItem.name
|
||||||
|
viewHolder.itemAdditionalDetails.text = getDetailLine(viewHolder.root.context)
|
||||||
|
if (itemVersion == ItemVersion.NORMAL) viewHolder.itemChannelDescriptionView.text = infoItem.description
|
||||||
|
|
||||||
|
ImageLoader.getInstance().displayImage(infoItem.thumbnailUrl, viewHolder.itemThumbnailView,
|
||||||
|
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS)
|
||||||
|
|
||||||
|
gesturesListener?.run {
|
||||||
|
viewHolder.containerView.setOnClickListener { selected(infoItem) }
|
||||||
|
viewHolder.containerView.setOnLongClickListener { held(infoItem); true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDetailLine(context: Context): String {
|
||||||
|
var details = if (infoItem.subscriberCount >= 0) {
|
||||||
|
Localization.shortSubscriberCount(context, infoItem.subscriberCount)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.subscribers_count_not_available)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemVersion == ItemVersion.NORMAL) {
|
||||||
|
if (infoItem.streamCount >= 0) {
|
||||||
|
val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount)
|
||||||
|
details = Localization.concatenateStrings(details, formattedVideoAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSpanSize(spanCount: Int, position: Int): Int {
|
||||||
|
return if (itemVersion == ItemVersion.GRID) 1 else spanCount
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class EmptyPlaceholderItem : Item() {
|
||||||
|
override fun getLayout(): Int = R.layout.list_empty_view
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class FeedGroupAddItem : Item() {
|
||||||
|
override fun getLayout(): Int = R.layout.feed_group_add_new_item
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.feed_group_card_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
|
||||||
|
data class FeedGroupCardItem(
|
||||||
|
val groupId: Long = -1,
|
||||||
|
val name: String,
|
||||||
|
val icon: FeedGroupIcon
|
||||||
|
) : Item() {
|
||||||
|
constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon)
|
||||||
|
|
||||||
|
override fun getId(): Long {
|
||||||
|
return if (groupId == -1L) super.getId() else groupId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.feed_group_card_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {
|
||||||
|
viewHolder.title.text = name
|
||||||
|
viewHolder.icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.view.View
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.xwray.groupie.GroupAdapter
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.feed_item_carousel.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration
|
||||||
|
|
||||||
|
class FeedGroupCarouselItem(context: Context, private val carouselAdapter: GroupAdapter<ViewHolder>) : Item() {
|
||||||
|
private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context)
|
||||||
|
|
||||||
|
private var linearLayoutManager: LinearLayoutManager? = null
|
||||||
|
private var listState: Parcelable? = null
|
||||||
|
|
||||||
|
override fun getLayout() = R.layout.feed_item_carousel
|
||||||
|
|
||||||
|
fun onSaveInstanceState(): Parcelable? {
|
||||||
|
listState = linearLayoutManager?.onSaveInstanceState()
|
||||||
|
return listState
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRestoreInstanceState(state: Parcelable?) {
|
||||||
|
linearLayoutManager?.onRestoreInstanceState(state)
|
||||||
|
listState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewHolder(itemView: View): ViewHolder {
|
||||||
|
val viewHolder = super.createViewHolder(itemView)
|
||||||
|
|
||||||
|
linearLayoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false)
|
||||||
|
|
||||||
|
viewHolder.recycler_view.apply {
|
||||||
|
layoutManager = linearLayoutManager
|
||||||
|
adapter = carouselAdapter
|
||||||
|
addItemDecoration(feedGroupCarouselDecoration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return viewHolder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {
|
||||||
|
viewHolder.recycler_view.apply { adapter = carouselAdapter }
|
||||||
|
linearLayoutManager?.onRestoreInstanceState(listState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unbind(viewHolder: ViewHolder) {
|
||||||
|
super.unbind(viewHolder)
|
||||||
|
|
||||||
|
listState = linearLayoutManager?.onSaveInstanceState()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.feed_import_export_group.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils
|
||||||
|
import org.schabi.newpipe.util.ServiceHelper
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
|
import org.schabi.newpipe.views.CollapsibleView
|
||||||
|
|
||||||
|
class FeedImportExportItem(
|
||||||
|
val onImportPreviousSelected: () -> Unit,
|
||||||
|
val onImportFromServiceSelected: (Int) -> Unit,
|
||||||
|
val onExportSelected: () -> Unit,
|
||||||
|
var isExpanded: Boolean = false
|
||||||
|
) : Item() {
|
||||||
|
companion object {
|
||||||
|
const val REFRESH_EXPANDED_STATUS = 123
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||||
|
if (payloads.contains(REFRESH_EXPANDED_STATUS)) {
|
||||||
|
viewHolder.import_export_options.apply { if (isExpanded) expand() else collapse() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
super.bind(viewHolder, position, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.feed_import_export_group
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {
|
||||||
|
if (viewHolder.import_from_options.childCount == 0) setupImportFromItems(viewHolder.import_from_options)
|
||||||
|
if (viewHolder.export_to_options.childCount == 0) setupExportToItems(viewHolder.export_to_options)
|
||||||
|
|
||||||
|
expandIconListener?.let { viewHolder.import_export_options.removeListener(it) }
|
||||||
|
expandIconListener = CollapsibleView.StateListener { newState ->
|
||||||
|
AnimationUtils.animateRotation(viewHolder.import_export_expand_icon,
|
||||||
|
250, if (newState == CollapsibleView.COLLAPSED) 0 else 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewHolder.import_export_options.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED
|
||||||
|
viewHolder.import_export_expand_icon.rotation = if (isExpanded) 180F else 0F
|
||||||
|
viewHolder.import_export_options.ready()
|
||||||
|
|
||||||
|
viewHolder.import_export_options.addListener(expandIconListener)
|
||||||
|
viewHolder.import_export.setOnClickListener {
|
||||||
|
viewHolder.import_export_options.switchState()
|
||||||
|
isExpanded = viewHolder.import_export_options.currentState == CollapsibleView.EXPANDED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unbind(holder: ViewHolder) {
|
||||||
|
super.unbind(holder)
|
||||||
|
expandIconListener?.let { holder.import_export_options.removeListener(it) }
|
||||||
|
expandIconListener = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private var expandIconListener: CollapsibleView.StateListener? = null
|
||||||
|
|
||||||
|
private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View {
|
||||||
|
val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null)
|
||||||
|
val titleView = itemRoot.findViewById<TextView>(android.R.id.text1)
|
||||||
|
val iconView = itemRoot.findViewById<ImageView>(android.R.id.icon1)
|
||||||
|
|
||||||
|
titleView.text = title
|
||||||
|
iconView.setImageResource(icon)
|
||||||
|
|
||||||
|
container.addView(itemRoot)
|
||||||
|
return itemRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupImportFromItems(listHolder: ViewGroup) {
|
||||||
|
val previousBackupItem = addItemView(listHolder.context.getString(R.string.previous_export),
|
||||||
|
ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_backup), listHolder)
|
||||||
|
previousBackupItem.setOnClickListener { onImportPreviousSelected() }
|
||||||
|
|
||||||
|
val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE
|
||||||
|
val services = listHolder.context.resources.getStringArray(R.array.service_list)
|
||||||
|
for (serviceName in services) {
|
||||||
|
try {
|
||||||
|
val service = NewPipe.getService(serviceName)
|
||||||
|
|
||||||
|
val subscriptionExtractor = service.subscriptionExtractor ?: continue
|
||||||
|
|
||||||
|
val supportedSources = subscriptionExtractor.supportedSources
|
||||||
|
if (supportedSources.isEmpty()) continue
|
||||||
|
|
||||||
|
val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder)
|
||||||
|
val iconView = itemView.findViewById<ImageView>(android.R.id.icon1)
|
||||||
|
iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
|
||||||
|
|
||||||
|
itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) }
|
||||||
|
} catch (e: ExtractionException) {
|
||||||
|
throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupExportToItems(listHolder: ViewGroup) {
|
||||||
|
val previousBackupItem = addItemView(listHolder.context.getString(R.string.file),
|
||||||
|
ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_save), listHolder)
|
||||||
|
previousBackupItem.setOnClickListener { onExportSelected() }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.view.View.OnClickListener
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.header_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class HeaderItem(val title: String, private val onClickListener: (() -> Unit)? = null) : Item() {
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.header_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {
|
||||||
|
viewHolder.header_title.text = title
|
||||||
|
|
||||||
|
val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null
|
||||||
|
viewHolder.root.setOnClickListener(listener)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.view.View.OnClickListener
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.header_with_text_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class HeaderTextSideItem(
|
||||||
|
val title: String,
|
||||||
|
var infoText: String? = null,
|
||||||
|
private val onClickListener: (() -> Unit)? = null
|
||||||
|
) : Item() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val UPDATE_INFO = 123
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.header_with_text_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||||
|
if (payloads.contains(UPDATE_INFO)) {
|
||||||
|
viewHolder.header_info.text = infoText
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
super.bind(viewHolder, position, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {
|
||||||
|
viewHolder.header_title.text = title
|
||||||
|
viewHolder.header_info.text = infoText
|
||||||
|
|
||||||
|
val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null
|
||||||
|
viewHolder.root.setOnClickListener(listener)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.picker_icon_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
|
||||||
|
class PickerIconItem(context: Context, val icon: FeedGroupIcon) : Item() {
|
||||||
|
@DrawableRes val iconRes: Int = icon.getDrawableRes(context)
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.picker_icon_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {
|
||||||
|
viewHolder.icon_view.setImageResource(iconRes)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.nostra13.universalimageloader.core.DisplayImageOptions
|
||||||
|
import com.nostra13.universalimageloader.core.ImageLoader
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.ViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.picker_subscription_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||||
|
import org.schabi.newpipe.util.ImageDisplayConstants
|
||||||
|
|
||||||
|
data class PickerSubscriptionItem(val subscriptionEntity: SubscriptionEntity, var isSelected: Boolean = false) : Item() {
|
||||||
|
companion object {
|
||||||
|
const val UPDATE_SELECTED = 123
|
||||||
|
|
||||||
|
val IMAGE_LOADING_OPTIONS: DisplayImageOptions = ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.picker_subscription_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||||
|
if (payloads.contains(UPDATE_SELECTED)) {
|
||||||
|
animateView(viewHolder.selected_highlight, AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, isSelected, 150)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
super.bind(viewHolder, position, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: ViewHolder, position: Int) {
|
||||||
|
ImageLoader.getInstance().displayImage(subscriptionEntity.avatarUrl, viewHolder.thumbnail_view, IMAGE_LOADING_OPTIONS)
|
||||||
|
|
||||||
|
viewHolder.title_view.text = subscriptionEntity.name
|
||||||
|
viewHolder.selected_highlight.visibility = if (isSelected) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unbind(viewHolder: ViewHolder) {
|
||||||
|
super.unbind(viewHolder)
|
||||||
|
|
||||||
|
viewHolder.selected_highlight.animate().setListener(null).cancel()
|
||||||
|
viewHolder.selected_highlight.visibility = View.GONE
|
||||||
|
viewHolder.selected_highlight.alpha = 1F
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getId(): Long {
|
||||||
|
return subscriptionEntity.uid
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,10 +34,9 @@ import android.widget.Toast;
|
||||||
import org.reactivestreams.Publisher;
|
import org.reactivestreams.Publisher;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||||
import org.schabi.newpipe.report.ErrorActivity;
|
import org.schabi.newpipe.report.ErrorActivity;
|
||||||
import org.schabi.newpipe.report.UserAction;
|
import org.schabi.newpipe.report.UserAction;
|
||||||
import org.schabi.newpipe.local.subscription.ImportExportEventListener;
|
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -57,7 +56,7 @@ public abstract class BaseImportExportService extends Service {
|
||||||
protected NotificationManagerCompat notificationManager;
|
protected NotificationManagerCompat notificationManager;
|
||||||
protected NotificationCompat.Builder notificationBuilder;
|
protected NotificationCompat.Builder notificationBuilder;
|
||||||
|
|
||||||
protected SubscriptionService subscriptionService;
|
protected SubscriptionManager subscriptionManager;
|
||||||
protected final CompositeDisposable disposables = new CompositeDisposable();
|
protected final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
|
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
|
||||||
|
|
||||||
|
@ -70,7 +69,7 @@ public abstract class BaseImportExportService extends Service {
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
subscriptionService = SubscriptionService.getInstance(this);
|
subscriptionManager = new SubscriptionManager(this);
|
||||||
setupNotification();
|
setupNotification();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package org.schabi.newpipe.local.subscription;
|
package org.schabi.newpipe.local.subscription.services;
|
||||||
|
|
||||||
public interface ImportExportEventListener {
|
public interface ImportExportEventListener {
|
||||||
/**
|
/**
|
|
@ -17,7 +17,7 @@
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.schabi.newpipe.local.subscription;
|
package org.schabi.newpipe.local.subscription.services;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
|
@ -29,7 +29,6 @@ import org.reactivestreams.Subscription;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||||
import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
|
@ -96,7 +95,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
|
||||||
private void startExport() {
|
private void startExport() {
|
||||||
showToast(R.string.export_ongoing);
|
showToast(R.string.export_ongoing);
|
||||||
|
|
||||||
subscriptionService.subscriptionTable()
|
subscriptionManager.subscriptionTable()
|
||||||
.getAll()
|
.getAll()
|
||||||
.take(1)
|
.take(1)
|
||||||
.map(subscriptionEntities -> {
|
.map(subscriptionEntities -> {
|
||||||
|
|
|
@ -33,7 +33,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||||
import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
|
|
||||||
import org.schabi.newpipe.util.Constants;
|
import org.schabi.newpipe.util.Constants;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
|
|
||||||
|
@ -180,6 +179,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
||||||
|
|
||||||
.observeOn(Schedulers.io())
|
.observeOn(Schedulers.io())
|
||||||
.doOnNext(getNotificationsConsumer())
|
.doOnNext(getNotificationsConsumer())
|
||||||
|
|
||||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||||
.map(upsertBatch())
|
.map(upsertBatch())
|
||||||
|
|
||||||
|
@ -204,6 +204,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(Throwable error) {
|
public void onError(Throwable error) {
|
||||||
|
Log.e(TAG, "Got an error!", error);
|
||||||
handleError(error);
|
handleError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,7 +243,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
||||||
if (n.isOnNext()) infoList.add(n.getValue());
|
if (n.isOnNext()) infoList.add(n.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
return subscriptionService.upsertAll(infoList);
|
return subscriptionManager.upsertAll(infoList);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ public enum UserAction {
|
||||||
REQUESTED_PLAYLIST("requested playlist"),
|
REQUESTED_PLAYLIST("requested playlist"),
|
||||||
REQUESTED_KIOSK("requested kiosk"),
|
REQUESTED_KIOSK("requested kiosk"),
|
||||||
REQUESTED_COMMENTS("requested comments"),
|
REQUESTED_COMMENTS("requested comments"),
|
||||||
|
REQUESTED_FEED("requested feed"),
|
||||||
DELETE_FROM_HISTORY("delete from history"),
|
DELETE_FROM_HISTORY("delete from history"),
|
||||||
PLAY_STREAM("Play stream"),
|
PLAY_STREAM("Play stream"),
|
||||||
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
||||||
|
|
|
@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||||
import org.schabi.newpipe.report.ErrorActivity;
|
import org.schabi.newpipe.report.ErrorActivity;
|
||||||
import org.schabi.newpipe.report.UserAction;
|
import org.schabi.newpipe.report.UserAction;
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Vector;
|
import java.util.Vector;
|
||||||
|
@ -99,8 +99,8 @@ public class SelectChannelFragment extends DialogFragment {
|
||||||
emptyView.setVisibility(View.GONE);
|
emptyView.setVisibility(View.GONE);
|
||||||
|
|
||||||
|
|
||||||
SubscriptionService subscriptionService = SubscriptionService.getInstance(getContext());
|
SubscriptionManager subscriptionManager = new SubscriptionManager(getContext());
|
||||||
subscriptionService.getSubscription().toObservable()
|
subscriptionManager.subscriptions().toObservable()
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(getSubscriptionObserver());
|
.subscribe(getSubscriptionObserver());
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
package org.schabi.newpipe.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default duration when using throttle functions across the app, in milliseconds.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_THROTTLE_TIMEOUT = 120L
|
|
@ -343,9 +343,13 @@ public class NavigationHelper {
|
||||||
.commit();
|
.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void openWhatsNewFragment(FragmentManager fragmentManager) {
|
public static void openFeedFragment(FragmentManager fragmentManager) {
|
||||||
|
openFeedFragment(fragmentManager, -1, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void openFeedFragment(FragmentManager fragmentManager, long groupId, @Nullable String groupName) {
|
||||||
defaultTransaction(fragmentManager)
|
defaultTransaction(fragmentManager)
|
||||||
.replace(R.id.fragment_holder, new FeedFragment())
|
.replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName))
|
||||||
.addToBackStack(null)
|
.addToBackStack(null)
|
||||||
.commit();
|
.commit();
|
||||||
}
|
}
|
||||||
|
|
|
@ -99,6 +99,17 @@ public class ThemeHelper {
|
||||||
return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme;
|
return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a min-width dialog theme styled according to the (default) selected theme.
|
||||||
|
*
|
||||||
|
* @param context context to get the selected theme
|
||||||
|
* @return the dialog style (the default one)
|
||||||
|
*/
|
||||||
|
@StyleRes
|
||||||
|
public static int getMinWidthDialogTheme(Context context) {
|
||||||
|
return isLightThemeSelected(context) ? R.style.LightDialogMinWidthTheme : R.style.DarkDialogMinWidthTheme;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the selected theme styled according to the serviceId.
|
* Return the selected theme styled according to the serviceId.
|
||||||
*
|
*
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_focused="true" android:drawable="@color/selected_background_color"/>
|
||||||
|
<item android:drawable="@color/transparent_background_color"/>
|
||||||
|
</selector>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/black_border_color"
|
||||||
|
android:dashGap="4dp"
|
||||||
|
android:dashWidth="4dp"/>
|
||||||
|
</shape>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/dark_border_color"
|
||||||
|
android:dashGap="4dp"
|
||||||
|
android:dashWidth="4dp"/>
|
||||||
|
</shape>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/light_border_color"
|
||||||
|
android:dashGap="4dp"
|
||||||
|
android:dashWidth="4dp"/>
|
||||||
|
</shape>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M10,2H14L13.21,9.91L19.66,5.27L21.66,8.73L14.42,12L21.66,15.27L19.66,18.73L13.21,14.09L14,22H10L10.79,14.09L4.34,18.73L2.34,15.27L9.58,12L2.34,8.73L4.34,5.27L10.79,9.91L10,2Z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M10,2H14L13.21,9.91L19.66,5.27L21.66,8.73L14.42,12L21.66,15.27L19.66,18.73L13.21,14.09L14,22H10L10.79,14.09L4.34,18.73L2.34,15.27L9.58,12L2.34,8.73L4.34,5.27L10.79,9.91L10,2Z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99zM6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16zM17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM5,11l1.5,-4.5h11L19,11L5,11z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99zM6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16zM17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM5,11l1.5,-4.5h11L19,11L5,11z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M21,2L3,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h7v2L8,20v2h8v-2h-2v-2h7c1.1,0 2,-0.9 2,-2L23,4c0,-1.1 -0.9,-2 -2,-2zM21,16L3,16L3,4h18v12z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M21,2L3,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h7v2L8,20v2h8v-2h-2v-2h7c1.1,0 2,-0.9 2,-2L23,4c0,-1.1 -0.9,-2 -2,-2zM21,16L3,16L3,4h18v12z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM15.5,11c0.83,0 1.5,-0.67 1.5,-1.5S16.33,8 15.5,8 14,8.67 14,9.5s0.67,1.5 1.5,1.5zM8.5,11c0.83,0 1.5,-0.67 1.5,-1.5S9.33,8 8.5,8 7,8.67 7,9.5 7.67,11 8.5,11zM12,17.5c2.33,0 4.31,-1.46 5.11,-3.5L6.89,14c0.8,2.04 2.78,3.5 5.11,3.5z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM15.5,11c0.83,0 1.5,-0.67 1.5,-1.5S16.33,8 15.5,8 14,8.67 14,9.5s0.67,1.5 1.5,1.5zM8.5,11c0.83,0 1.5,-0.67 1.5,-1.5S9.33,8 8.5,8 7,8.67 7,9.5 7.67,11 8.5,11zM12,17.5c2.33,0 4.31,-1.46 5.11,-3.5L6.89,14c0.8,2.04 2.78,3.5 5.11,3.5z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,10.9c-0.61,0 -1.1,0.49 -1.1,1.1s0.49,1.1 1.1,1.1c0.61,0 1.1,-0.49 1.1,-1.1s-0.49,-1.1 -1.1,-1.1zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM14.19,14.19L6,18l3.81,-8.19L18,6l-3.81,8.19z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,10.9c-0.61,0 -1.1,0.49 -1.1,1.1s0.49,1.1 1.1,1.1c0.61,0 1.1,-0.49 1.1,-1.1s-0.49,-1.1 -1.1,-1.1zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM14.19,14.19L6,18l3.81,-8.19L18,6l-3.81,8.19z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M18.06,22.99h1.66c0.84,0 1.53,-0.64 1.63,-1.46L23,5.05h-5L18,1h-1.97v4.05h-4.97l0.3,2.34c1.71,0.47 3.31,1.32 4.27,2.26 1.44,1.42 2.43,2.89 2.43,5.29v8.05zM1,21.99L1,21h15.03v0.99c0,0.55 -0.45,1 -1.01,1L2.01,22.99c-0.56,0 -1.01,-0.45 -1.01,-1zM16.03,14.99c0,-8 -15.03,-8 -15.03,0h15.03zM1.02,17h15v2h-15z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M18.06,22.99h1.66c0.84,0 1.53,-0.64 1.63,-1.46L23,5.05h-5L18,1h-1.97v4.05h-4.97l0.3,2.34c1.71,0.47 3.31,1.32 4.27,2.26 1.44,1.42 2.43,2.89 2.43,5.29v8.05zM1,21.99L1,21h15.03v0.99c0,0.55 -0.45,1 -1.01,1L2.01,22.99c-0.56,0 -1.01,-0.45 -1.01,-1zM16.03,14.99c0,-8 -15.03,-8 -15.03,0h15.03zM1.02,17h15v2h-15z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M20.57,14.86L22,13.43 20.57,12 17,15.57 8.43,7 12,3.43 10.57,2 9.14,3.43 7.71,2 5.57,4.14 4.14,2.71 2.71,4.14l1.43,1.43L2,7.71l1.43,1.43L2,10.57 3.43,12 7,8.43 15.57,17 12,20.57 13.43,22l1.43,-1.43L16.29,22l2.14,-2.14 1.43,1.43 1.43,-1.43 -1.43,-1.43L22,16.29z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M20.57,14.86L22,13.43 20.57,12 17,15.57 8.43,7 12,3.43 10.57,2 9.14,3.43 7.71,2 5.57,4.14 4.14,2.71 2.71,4.14l1.43,1.43L2,7.71l1.43,1.43L2,10.57 3.43,12 7,8.43 15.57,17 12,20.57 13.43,22l1.43,-1.43L16.29,22l2.14,-2.14 1.43,1.43 1.43,-1.43 -1.43,-1.43L22,16.29z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,15 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M14.5,10.5m-1.25,0a1.25,1.25 0,1 1,2.5 0a1.25,1.25 0,1 1,-2.5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M9.5,10.5m-1.25,0a1.25,1.25 0,1 1,2.5 0a1.25,1.25 0,1 1,-2.5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M22.94,12.66c0.04,-0.21 0.06,-0.43 0.06,-0.66s-0.02,-0.45 -0.06,-0.66c-0.25,-1.51 -1.36,-2.74 -2.81,-3.17 -0.53,-1.12 -1.28,-2.1 -2.19,-2.91C16.36,3.85 14.28,3 12,3s-4.36,0.85 -5.94,2.26c-0.92,0.81 -1.67,1.8 -2.19,2.91 -1.45,0.43 -2.56,1.65 -2.81,3.17 -0.04,0.21 -0.06,0.43 -0.06,0.66s0.02,0.45 0.06,0.66c0.25,1.51 1.36,2.74 2.81,3.17 0.52,1.11 1.27,2.09 2.17,2.89C7.62,20.14 9.71,21 12,21s4.38,-0.86 5.97,-2.28c0.9,-0.8 1.65,-1.79 2.17,-2.89 1.44,-0.43 2.55,-1.65 2.8,-3.17zM19,14c-0.1,0 -0.19,-0.02 -0.29,-0.03 -0.2,0.67 -0.49,1.29 -0.86,1.86C16.6,17.74 14.45,19 12,19s-4.6,-1.26 -5.85,-3.17c-0.37,-0.57 -0.66,-1.19 -0.86,-1.86 -0.1,0.01 -0.19,0.03 -0.29,0.03 -1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2c0.1,0 0.19,0.02 0.29,0.03 0.2,-0.67 0.49,-1.29 0.86,-1.86C7.4,6.26 9.55,5 12,5s4.6,1.26 5.85,3.17c0.37,0.57 0.66,1.19 0.86,1.86 0.1,-0.01 0.19,-0.03 0.29,-0.03 1.1,0 2,0.9 2,2s-0.9,2 -2,2zM7.5,14c0.76,1.77 2.49,3 4.5,3s3.74,-1.23 4.5,-3h-9z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,15 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M14.5,10.5m-1.25,0a1.25,1.25 0,1 1,2.5 0a1.25,1.25 0,1 1,-2.5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M9.5,10.5m-1.25,0a1.25,1.25 0,1 1,2.5 0a1.25,1.25 0,1 1,-2.5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M22.94,12.66c0.04,-0.21 0.06,-0.43 0.06,-0.66s-0.02,-0.45 -0.06,-0.66c-0.25,-1.51 -1.36,-2.74 -2.81,-3.17 -0.53,-1.12 -1.28,-2.1 -2.19,-2.91C16.36,3.85 14.28,3 12,3s-4.36,0.85 -5.94,2.26c-0.92,0.81 -1.67,1.8 -2.19,2.91 -1.45,0.43 -2.56,1.65 -2.81,3.17 -0.04,0.21 -0.06,0.43 -0.06,0.66s0.02,0.45 0.06,0.66c0.25,1.51 1.36,2.74 2.81,3.17 0.52,1.11 1.27,2.09 2.17,2.89C7.62,20.14 9.71,21 12,21s4.38,-0.86 5.97,-2.28c0.9,-0.8 1.65,-1.79 2.17,-2.89 1.44,-0.43 2.55,-1.65 2.8,-3.17zM19,14c-0.1,0 -0.19,-0.02 -0.29,-0.03 -0.2,0.67 -0.49,1.29 -0.86,1.86C16.6,17.74 14.45,19 12,19s-4.6,-1.26 -5.85,-3.17c-0.37,-0.57 -0.66,-1.19 -0.86,-1.86 -0.1,0.01 -0.19,0.03 -0.29,0.03 -1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2c0.1,0 0.19,0.02 0.29,0.03 0.2,-0.67 0.49,-1.29 0.86,-1.86C7.4,6.26 9.55,5 12,5s4.6,1.26 5.85,3.17c0.37,0.57 0.66,1.19 0.86,1.86 0.1,-0.01 0.19,-0.03 0.29,-0.03 1.1,0 2,0.9 2,2s-0.9,2 -2,2zM7.5,14c0.76,1.77 2.49,3 4.5,3s3.74,-1.23 4.5,-3h-9z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,8H4A2,2 0 0,0 2,10V14A2,2 0 0,0 4,16H5V20A1,1 0 0,0 6,21H8A1,1 0 0,0 9,20V16H12L17,20V4L12,8M21.5,12C21.5,13.71 20.54,15.26 19,16V8C20.53,8.75 21.5,10.3 21.5,12Z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,8H4A2,2 0 0,0 2,10V14A2,2 0 0,0 4,16H5V20A1,1 0 0,0 6,21H8A1,1 0 0,0 9,20V16H12L17,20V4L12,8M21.5,12C21.5,13.71 20.54,15.26 19,16V8C20.53,8.75 21.5,10.3 21.5,12Z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.81 -4.7,-4.4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.81 -4.7,-4.4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M19.44,9.03L15.41,5H11v2h3.59l2,2H5c-2.8,0 -5,2.2 -5,5s2.2,5 5,5c2.46,0 4.45,-1.69 4.9,-4h1.65l2.77,-2.77c-0.21,0.54 -0.32,1.14 -0.32,1.77 0,2.8 2.2,5 5,5s5,-2.2 5,-5c0,-2.65 -1.97,-4.77 -4.56,-4.97zM7.82,15C7.4,16.15 6.28,17 5,17c-1.63,0 -3,-1.37 -3,-3s1.37,-3 3,-3c1.28,0 2.4,0.85 2.82,2H5v2h2.82zM19,17c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M19.44,9.03L15.41,5H11v2h3.59l2,2H5c-2.8,0 -5,2.2 -5,5s2.2,5 5,5c2.46,0 4.45,-1.69 4.9,-4h1.65l2.77,-2.77c-0.21,0.54 -0.32,1.14 -0.32,1.77 0,2.8 2.2,5 5,5s5,-2.2 5,-5c0,-2.65 -1.97,-4.77 -4.56,-4.97zM7.82,15C7.4,16.15 6.28,17 5,17c-1.63,0 -3,-1.37 -3,-3s1.37,-3 3,-3c1.28,0 2.4,0.85 2.82,2H5v2h2.82zM19,17c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M18,4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4h-4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M18,4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4h-4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5L1,19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45L17,19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5L1,19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45L17,19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,21 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M4.5,9.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M9,5.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M15,5.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M19.5,9.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M17.34,14.86c-0.87,-1.02 -1.6,-1.89 -2.48,-2.91 -0.46,-0.54 -1.05,-1.08 -1.75,-1.32 -0.11,-0.04 -0.22,-0.07 -0.33,-0.09 -0.25,-0.04 -0.52,-0.04 -0.78,-0.04s-0.53,0 -0.79,0.05c-0.11,0.02 -0.22,0.05 -0.33,0.09 -0.7,0.24 -1.28,0.78 -1.75,1.32 -0.87,1.02 -1.6,1.89 -2.48,2.91 -1.31,1.31 -2.92,2.76 -2.62,4.79 0.29,1.02 1.02,2.03 2.33,2.32 0.73,0.15 3.06,-0.44 5.54,-0.44h0.18c2.48,0 4.81,0.58 5.54,0.44 1.31,-0.29 2.04,-1.31 2.33,-2.32 0.31,-2.04 -1.3,-3.49 -2.61,-4.8z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,21 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M4.5,9.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M9,5.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M15,5.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M19.5,9.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M17.34,14.86c-0.87,-1.02 -1.6,-1.89 -2.48,-2.91 -0.46,-0.54 -1.05,-1.08 -1.75,-1.32 -0.11,-0.04 -0.22,-0.07 -0.33,-0.09 -0.25,-0.04 -0.52,-0.04 -0.78,-0.04s-0.53,0 -0.79,0.05c-0.11,0.02 -0.22,0.05 -0.33,0.09 -0.7,0.24 -1.28,0.78 -1.75,1.32 -0.87,1.02 -1.6,1.89 -2.48,2.91 -1.31,1.31 -2.92,2.76 -2.62,4.79 0.29,1.02 1.02,2.03 2.33,2.32 0.73,0.15 3.06,-0.44 5.54,-0.44h0.18c2.48,0 4.81,0.58 5.54,0.44 1.31,-0.29 2.04,-1.31 2.33,-2.32 0.31,-2.04 -1.3,-3.49 -2.61,-4.8z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M3.24,6.15C2.51,6.43 2,7.17 2,8v12c0,1.1 0.89,2 2,2h16c1.11,0 2,-0.9 2,-2L22,8c0,-1.11 -0.89,-2 -2,-2L8.3,6l8.26,-3.34L15.88,1 3.24,6.15zM7,20c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM20,12h-2v-2h-2v2L4,12L4,8h16v4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M3.24,6.15C2.51,6.43 2,7.17 2,8v12c0,1.1 0.89,2 2,2h16c1.11,0 2,-0.9 2,-2L22,8c0,-1.11 -0.89,-2 -2,-2L8.3,6l8.26,-3.34L15.88,1 3.24,6.15zM7,20c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM20,12h-2v-2h-2v2L4,12L4,8h16v4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:viewportHeight="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:viewportHeight="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M11,9L9,9L9,2L7,2v7L5,9L5,2L3,2v7c0,2.12 1.66,3.84 3.75,3.97L6.75,22h2.5v-9.03C11.34,12.84 13,11.12 13,9L13,2h-2v7zM16,6v8h2.5v8L21,22L21,2c-2.76,0 -5,2.24 -5,4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M11,9L9,9L9,2L7,2v7L5,9L5,2L3,2v7c0,2.12 1.66,3.84 3.75,3.97L6.75,22h2.5v-9.03C11.34,12.84 13,11.12 13,9L13,2h-2v7zM16,6v8h2.5v8L21,22L21,2c-2.76,0 -5,2.24 -5,4z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M5,13.18v4L12,21l7,-3.82v-4L12,17l-7,-3.82zM12,3L1,9l11,6 9,-4.91V17h2V9L12,3z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M5,13.18v4L12,21l7,-3.82v-4L12,17l-7,-3.82zM12,3L1,9l11,6 9,-4.91V17h2V9L12,3z"/>
|
||||||
|
</vector>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue