mirror of
				https://github.com/TeamNewPipe/NewPipe
				synced 2025-10-30 23:03:00 +00:00 
			
		
		
		
	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:
		| @@ -79,6 +79,11 @@ android { | ||||
|         sourceCompatibility JavaVersion.VERSION_1_8 | ||||
|         targetCompatibility JavaVersion.VERSION_1_8 | ||||
|     } | ||||
|  | ||||
|     // Required and used only by groupie | ||||
|     androidExtensions { | ||||
|         experimental = true | ||||
|     } | ||||
| } | ||||
|  | ||||
| ext { | ||||
| @@ -111,6 +116,13 @@ dependencies { | ||||
|     implementation "androidx.cardview:cardview:${androidxLibVersion}" | ||||
|     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 | ||||
|     implementation 'com.grack:nanojson:1.1' | ||||
|     implementation 'org.jsoup:jsoup:1.9.2' | ||||
|   | ||||
| @@ -564,7 +564,7 @@ | ||||
|             "notNull": true | ||||
|           }, | ||||
|           { | ||||
|             "fieldPath": "iconId", | ||||
|             "fieldPath": "icon", | ||||
|             "columnName": "icon_id", | ||||
|             "affinity": "INTEGER", | ||||
|             "notNull": true | ||||
|   | ||||
| @@ -74,6 +74,7 @@ | ||||
|  | ||||
|         <service android:name=".local.subscription.services.SubscriptionsImportService"/> | ||||
|         <service android:name=".local.subscription.services.SubscriptionsExportService"/> | ||||
|         <service android:name=".local.feed.service.FeedLoadService"/> | ||||
|  | ||||
|         <activity | ||||
|             android:name=".PanicResponderActivity" | ||||
|   | ||||
| @@ -240,7 +240,7 @@ public class MainActivity extends AppCompatActivity { | ||||
|                 NavigationHelper.openSubscriptionFragment(getSupportFragmentManager()); | ||||
|                 break; | ||||
|             case ITEM_ID_FEED: | ||||
|                 NavigationHelper.openWhatsNewFragment(getSupportFragmentManager()); | ||||
|                 NavigationHelper.openFeedFragment(getSupportFragmentManager()); | ||||
|                 break; | ||||
|             case ITEM_ID_BOOKMARKS: | ||||
|                 NavigationHelper.openBookmarksFragment(getSupportFragmentManager()); | ||||
|   | ||||
| @@ -3,6 +3,7 @@ package org.schabi.newpipe.database; | ||||
| import androidx.room.TypeConverter; | ||||
|  | ||||
| import org.schabi.newpipe.extractor.stream.StreamType; | ||||
| import org.schabi.newpipe.local.subscription.FeedGroupIcon; | ||||
|  | ||||
| import java.util.Date; | ||||
|  | ||||
| @@ -37,4 +38,18 @@ public class Converters { | ||||
|     public static String stringOf(StreamType streamType) { | ||||
|         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 org.schabi.newpipe.database.feed.model.FeedEntity | ||||
| import org.schabi.newpipe.database.stream.model.StreamEntity | ||||
| import java.util.* | ||||
|  | ||||
| @Dao | ||||
| abstract class FeedDAO { | ||||
| @@ -19,7 +20,9 @@ abstract class FeedDAO { | ||||
|         INNER JOIN feed f | ||||
|         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>> | ||||
|  | ||||
| @@ -36,12 +39,45 @@ abstract class FeedDAO { | ||||
|         ON fg.uid = fgs.group_id | ||||
|  | ||||
|         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>> | ||||
|  | ||||
|     @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) | ||||
|  | ||||
|     @Insert(onConflict = OnConflictStrategy.FAIL) | ||||
|     @Insert(onConflict = OnConflictStrategy.IGNORE) | ||||
|     abstract fun insertAll(entities: List<FeedEntity>): List<Long> | ||||
| } | ||||
|   | ||||
| @@ -2,16 +2,43 @@ package org.schabi.newpipe.database.feed.dao | ||||
|  | ||||
| import androidx.room.* | ||||
| import io.reactivex.Flowable | ||||
| import io.reactivex.Maybe | ||||
| import org.schabi.newpipe.database.feed.model.FeedGroupEntity | ||||
| import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity | ||||
|  | ||||
| @Dao | ||||
| abstract class FeedGroupDAO { | ||||
|     @Query("DELETE FROM feed_group") | ||||
|     abstract fun deleteAll(): Int | ||||
|  | ||||
|     @Query("SELECT * FROM feed_group") | ||||
|     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) | ||||
|     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.PrimaryKey | ||||
| import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE | ||||
| import org.schabi.newpipe.local.subscription.FeedGroupIcon | ||||
|  | ||||
| @Entity(tableName = FEED_GROUP_TABLE) | ||||
| data class FeedGroupEntity( | ||||
| @@ -15,7 +16,7 @@ data class FeedGroupEntity( | ||||
|         var name: String, | ||||
|  | ||||
|         @ColumnInfo(name = ICON) | ||||
|         var iconId: Int | ||||
|         var icon: FeedGroupIcon | ||||
| ) { | ||||
|     companion object { | ||||
|         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)}) | ||||
| public class SubscriptionEntity { | ||||
|  | ||||
|     public final static String SUBSCRIPTION_UID         = "uid"; | ||||
|     final static String SUBSCRIPTION_TABLE              = "subscriptions"; | ||||
|     final static String SUBSCRIPTION_SERVICE_ID         = "service_id"; | ||||
|     final static String SUBSCRIPTION_URL                = "url"; | ||||
|     final static String SUBSCRIPTION_NAME               = "name"; | ||||
|     final static String SUBSCRIPTION_AVATAR_URL         = "avatar_url"; | ||||
|     final static String SUBSCRIPTION_SUBSCRIBER_COUNT   = "subscriber_count"; | ||||
|     final static String SUBSCRIPTION_DESCRIPTION        = "description"; | ||||
|     public static final String SUBSCRIPTION_UID                = "uid"; | ||||
|     public static final String SUBSCRIPTION_TABLE              = "subscriptions"; | ||||
|     public static final String SUBSCRIPTION_SERVICE_ID         = "service_id"; | ||||
|     public static final String SUBSCRIPTION_URL                = "url"; | ||||
|     public static final String SUBSCRIPTION_NAME               = "name"; | ||||
|     public static final String SUBSCRIPTION_AVATAR_URL         = "avatar_url"; | ||||
|     public static final String SUBSCRIPTION_SUBSCRIBER_COUNT   = "subscriber_count"; | ||||
|     public static final String SUBSCRIPTION_DESCRIPTION        = "description"; | ||||
|  | ||||
|     @PrimaryKey(autoGenerate = true) | ||||
|     private long uid = 0; | ||||
|   | ||||
| @@ -59,7 +59,10 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem | ||||
|     @Override | ||||
|     public void onAttach(Context context) { | ||||
|         super.onAttach(context); | ||||
|         infoListAdapter = new InfoListAdapter(activity); | ||||
|  | ||||
|         if (infoListAdapter == null) { | ||||
|             infoListAdapter = new InfoListAdapter(activity); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
| @@ -78,7 +81,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem | ||||
|     @Override | ||||
|     public void onDestroy() { | ||||
|         super.onDestroy(); | ||||
|         StateSaver.onDestroy(savedState); | ||||
|         if (useDefaultStateSaving) StateSaver.onDestroy(savedState); | ||||
|         PreferenceManager.getDefaultSharedPreferences(activity) | ||||
|                 .unregisterOnSharedPreferenceChangeListener(this); | ||||
|     } | ||||
| @@ -103,6 +106,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     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 | ||||
|     public String generateSuffix() { | ||||
| @@ -112,26 +125,28 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem | ||||
|  | ||||
|     @Override | ||||
|     public void writeTo(Queue<Object> objectsToSave) { | ||||
|         objectsToSave.add(infoListAdapter.getItemsList()); | ||||
|         if (useDefaultStateSaving) objectsToSave.add(infoListAdapter.getItemsList()); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     @SuppressWarnings("unchecked") | ||||
|     public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception { | ||||
|         infoListAdapter.getItemsList().clear(); | ||||
|         infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll()); | ||||
|         if (useDefaultStateSaving) { | ||||
|             infoListAdapter.getItemsList().clear(); | ||||
|             infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onSaveInstanceState(Bundle bundle) { | ||||
|         super.onSaveInstanceState(bundle); | ||||
|         savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this); | ||||
|         if (useDefaultStateSaving) savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void onRestoreInstanceState(@NonNull Bundle 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.stream.StreamInfoItem; | ||||
| 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.PlayQueue; | ||||
| import org.schabi.newpipe.report.UserAction; | ||||
| @@ -66,7 +66,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> { | ||||
|  | ||||
|     private final CompositeDisposable disposables = new CompositeDisposable(); | ||||
|     private Disposable subscribeButtonMonitor; | ||||
|     private SubscriptionService subscriptionService; | ||||
|     private SubscriptionManager subscriptionManager; | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
|     // Views | ||||
| @@ -109,7 +109,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> { | ||||
|     @Override | ||||
|     public void onAttach(Context context) { | ||||
|         super.onAttach(context); | ||||
|         subscriptionService = SubscriptionService.getInstance(activity); | ||||
|         subscriptionManager = new SubscriptionManager(activity); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
| @@ -212,8 +212,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> { | ||||
|                         0); | ||||
|         }; | ||||
|  | ||||
|         final Observable<List<SubscriptionEntity>> observable = subscriptionService.subscriptionTable() | ||||
|                 .getSubscription(info.getServiceId(), info.getUrl()) | ||||
|         final Observable<List<SubscriptionEntity>> observable = subscriptionManager.subscriptionTable() | ||||
|                 .getSubscriptionFlowable(info.getServiceId(), info.getUrl()) | ||||
|                 .toObservable(); | ||||
|  | ||||
|         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) -> { | ||||
|             subscriptionService.subscriptionTable().insert(subscription); | ||||
|             subscriptionManager.insertSubscription(subscription, info); | ||||
|             return o; | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) { | ||||
|         return (@NonNull Object o) -> { | ||||
|             subscriptionService.subscriptionTable().delete(subscription); | ||||
|             subscriptionManager.deleteSubscription(subscription); | ||||
|             return o; | ||||
|         }; | ||||
|     } | ||||
| @@ -258,7 +258,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> { | ||||
|                         "Updating Subscription for " + info.getUrl(), | ||||
|                         R.string.subscription_update_failed); | ||||
|  | ||||
|         disposables.add(subscriptionService.updateChannelInfo(info) | ||||
|         disposables.add(subscriptionManager.updateChannelInfo(info) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe(onComplete, onError)); | ||||
| @@ -288,7 +288,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> { | ||||
|     private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) { | ||||
|         return (List<SubscriptionEntity> subscriptionEntities) -> { | ||||
|             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 (subscriptionEntities.isEmpty()) { | ||||
| @@ -300,7 +300,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> { | ||||
|                         info.getAvatarUrl(), | ||||
|                         info.getDescription(), | ||||
|                         info.getSubscriberCount()); | ||||
|                 subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel)); | ||||
|                 subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel, info)); | ||||
|             } else { | ||||
|                 if (DEBUG) Log.d(TAG, "Found subscription to this channel!"); | ||||
|                 final SubscriptionEntity subscription = subscriptionEntities.get(0); | ||||
|   | ||||
| @@ -122,7 +122,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde | ||||
|         this.useGridVariant = useGridVariant; | ||||
|     } | ||||
|  | ||||
|     public void addInfoItemList(@Nullable final List<InfoItem> data) { | ||||
|     public void addInfoItemList(@Nullable final List<? extends InfoItem> data) { | ||||
|         if (data == null) { | ||||
|             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) { | ||||
|         if (data == null) { | ||||
|             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; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										288
									
								
								app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										288
									
								
								app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -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.schabi.newpipe.R; | ||||
| 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.UserAction; | ||||
| import org.schabi.newpipe.local.subscription.ImportExportEventListener; | ||||
| import org.schabi.newpipe.local.subscription.SubscriptionService; | ||||
|  | ||||
| import java.io.FileNotFoundException; | ||||
| import java.io.IOException; | ||||
| @@ -57,7 +56,7 @@ public abstract class BaseImportExportService extends Service { | ||||
|     protected NotificationManagerCompat notificationManager; | ||||
|     protected NotificationCompat.Builder notificationBuilder; | ||||
|  | ||||
|     protected SubscriptionService subscriptionService; | ||||
|     protected SubscriptionManager subscriptionManager; | ||||
|     protected final CompositeDisposable disposables = new CompositeDisposable(); | ||||
|     protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create(); | ||||
|  | ||||
| @@ -70,7 +69,7 @@ public abstract class BaseImportExportService extends Service { | ||||
|     @Override | ||||
|     public void onCreate() { | ||||
|         super.onCreate(); | ||||
|         subscriptionService = SubscriptionService.getInstance(this); | ||||
|         subscriptionManager = new SubscriptionManager(this); | ||||
|         setupNotification(); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| package org.schabi.newpipe.local.subscription; | ||||
| package org.schabi.newpipe.local.subscription.services; | ||||
| 
 | ||||
| public interface ImportExportEventListener { | ||||
|     /** | ||||
| @@ -17,7 +17,7 @@ | ||||
|  * 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; | ||||
| 
 | ||||
| @@ -29,7 +29,6 @@ import org.reactivestreams.Subscription; | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.database.subscription.SubscriptionEntity; | ||||
| import org.schabi.newpipe.extractor.subscription.SubscriptionItem; | ||||
| import org.schabi.newpipe.local.subscription.ImportExportJsonHelper; | ||||
|  | ||||
| import java.io.File; | ||||
| import java.io.FileNotFoundException; | ||||
| @@ -96,7 +95,7 @@ public class SubscriptionsExportService extends BaseImportExportService { | ||||
|     private void startExport() { | ||||
|         showToast(R.string.export_ongoing); | ||||
|  | ||||
|         subscriptionService.subscriptionTable() | ||||
|         subscriptionManager.subscriptionTable() | ||||
|                 .getAll() | ||||
|                 .take(1) | ||||
|                 .map(subscriptionEntities -> { | ||||
|   | ||||
| @@ -33,7 +33,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; | ||||
| import org.schabi.newpipe.extractor.NewPipe; | ||||
| import org.schabi.newpipe.extractor.channel.ChannelInfo; | ||||
| 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.ExtractorHelper; | ||||
|  | ||||
| @@ -180,6 +179,7 @@ public class SubscriptionsImportService extends BaseImportExportService { | ||||
|  | ||||
|                 .observeOn(Schedulers.io()) | ||||
|                 .doOnNext(getNotificationsConsumer()) | ||||
|  | ||||
|                 .buffer(BUFFER_COUNT_BEFORE_INSERT) | ||||
|                 .map(upsertBatch()) | ||||
|  | ||||
| @@ -204,6 +204,7 @@ public class SubscriptionsImportService extends BaseImportExportService { | ||||
|  | ||||
|             @Override | ||||
|             public void onError(Throwable error) { | ||||
|                 Log.e(TAG, "Got an error!", error); | ||||
|                 handleError(error); | ||||
|             } | ||||
|  | ||||
| @@ -242,7 +243,7 @@ public class SubscriptionsImportService extends BaseImportExportService { | ||||
|                 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_KIOSK("requested kiosk"), | ||||
|     REQUESTED_COMMENTS("requested comments"), | ||||
|     REQUESTED_FEED("requested feed"), | ||||
|     DELETE_FROM_HISTORY("delete from history"), | ||||
|     PLAY_STREAM("Play stream"), | ||||
|     DOWNLOAD_POSTPROCESSING("download post-processing"), | ||||
|   | ||||
| @@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| 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.UserAction; | ||||
| import org.schabi.newpipe.local.subscription.SubscriptionService; | ||||
|  | ||||
| import java.util.List; | ||||
| import java.util.Vector; | ||||
| @@ -99,8 +99,8 @@ public class SelectChannelFragment extends DialogFragment { | ||||
|         emptyView.setVisibility(View.GONE); | ||||
|  | ||||
|  | ||||
|         SubscriptionService subscriptionService = SubscriptionService.getInstance(getContext()); | ||||
|         subscriptionService.getSubscription().toObservable() | ||||
|         SubscriptionManager subscriptionManager = new SubscriptionManager(getContext()); | ||||
|         subscriptionManager.subscriptions().toObservable() | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe(getSubscriptionObserver()); | ||||
|   | ||||
							
								
								
									
										6
									
								
								app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||
|     } | ||||
|  | ||||
|     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) | ||||
|                 .replace(R.id.fragment_holder, new FeedFragment()) | ||||
|                 .replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName)) | ||||
|                 .addToBackStack(null) | ||||
|                 .commit(); | ||||
|     } | ||||
|   | ||||
| @@ -99,6 +99,17 @@ public class ThemeHelper { | ||||
|         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. | ||||
|      * | ||||
|   | ||||
							
								
								
									
										5
									
								
								app/src/main/res/drawable/dark_focused_selector.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								app/src/main/res/drawable/dark_focused_selector.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										8
									
								
								app/src/main/res/drawable/dashed_border_black.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/src/main/res/drawable/dashed_border_black.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										8
									
								
								app/src/main/res/drawable/dashed_border_dark.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/src/main/res/drawable/dashed_border_dark.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										8
									
								
								app/src/main/res/drawable/dashed_border_light.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/src/main/res/drawable/dashed_border_light.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_asterisk_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_asterisk_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_asterisk_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_asterisk_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_car_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_car_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_car_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_car_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_computer_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_computer_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_computer_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_computer_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_edit_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_edit_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_edit_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_edit_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_emoticon_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_emoticon_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_emoticon_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_emoticon_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_explore_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_explore_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_explore_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_explore_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_fastfood_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_fastfood_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_fastfood_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_fastfood_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_fitness_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_fitness_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_fitness_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_fitness_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_heart_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_heart_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_heart_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_heart_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										15
									
								
								app/src/main/res/drawable/ic_kids_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/src/main/res/drawable/ic_kids_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										15
									
								
								app/src/main/res/drawable/ic_kids_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/src/main/res/drawable/ic_kids_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_megaphone_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_megaphone_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_megaphone_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_megaphone_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_mic_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_mic_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_mic_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_mic_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_money_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_money_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_money_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_money_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_motorcycle_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_motorcycle_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_motorcycle_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_motorcycle_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_movie_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_movie_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_movie_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_movie_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_music_note_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_music_note_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_music_note_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_music_note_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_people_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_people_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_people_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_people_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_person_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_person_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_person_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_person_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										21
									
								
								app/src/main/res/drawable/ic_pets_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/src/main/res/drawable/ic_pets_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										21
									
								
								app/src/main/res/drawable/ic_pets_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/src/main/res/drawable/ic_pets_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_radio_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_radio_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_radio_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_radio_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_refresh_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_refresh_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_refresh_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_refresh_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_restaurant_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_restaurant_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_restaurant_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_restaurant_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_school_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_school_black_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
							
								
								
									
										9
									
								
								app/src/main/res/drawable/ic_school_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/src/main/res/drawable/ic_school_white_24dp.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -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
		Reference in New Issue
	
	Block a user
	 Mauricio Colli
					Mauricio Colli