Notifications about new streams

This commit is contained in:
Vasiliy 2019-05-08 20:17:54 +03:00 committed by Koitharu
parent 6a1d81fcf3
commit da9bd1d420
No known key found for this signature in database
GPG key ID: 8E861F8CE6E7CE27
40 changed files with 1090 additions and 27 deletions

View file

@ -108,6 +108,7 @@ ext {
leakCanaryVersion = '2.5' leakCanaryVersion = '2.5'
stethoVersion = '1.6.0' stethoVersion = '1.6.0'
mockitoVersion = '3.6.0' mockitoVersion = '3.6.0'
workVersion = '2.5.0'
} }
configurations { configurations {
@ -213,6 +214,8 @@ dependencies {
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.webkit:webkit:1.4.0' implementation 'androidx.webkit:webkit:1.4.0'
implementation 'com.google.android.material:material:1.2.1' implementation 'com.google.android.material:material:1.2.1'
implementation "androidx.work:work-runtime:${workVersion}"
implementation "androidx.work:work-rxjava2:${workVersion}"
/** Third-party libraries **/ /** Third-party libraries **/
// Instance state boilerplate elimination // Instance state boilerplate elimination

View file

@ -40,6 +40,12 @@
android:title="@string/settings_category_notification_title" android:title="@string/settings_category_notification_title"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<PreferenceScreen
android:fragment="org.schabi.newpipe.settings.NotificationsSettingsFragment"
android:icon="@drawable/ic_notifications"
android:title="@string/notifications"
app:iconSpaceReserved="false" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.schabi.newpipe.settings.UpdateSettingsFragment" android:fragment="org.schabi.newpipe.settings.UpdateSettingsFragment"
android:icon="@drawable/ic_cloud_download" android:icon="@drawable/ic_cloud_download"

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe; package org.schabi.newpipe;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.util.Log; import android.util.Log;
@ -108,7 +110,6 @@ public class App extends MultiDexApplication {
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false)); && prefs.getBoolean(getString(R.string.show_image_indicators_key), false));
configureRxJavaErrorHandler(); configureRxJavaErrorHandler();
// Check for new version // Check for new version
disposable = CheckForNewAppVersion.checkNewVersion(this); disposable = CheckForNewAppVersion.checkNewVersion(this);
} }
@ -249,9 +250,20 @@ public class App extends MultiDexApplication {
.setDescription(getString(R.string.hash_channel_description)) .setDescription(getString(R.string.hash_channel_description))
.build(); .build();
final NotificationChannel newStreamsChannel = new NotificationChannel(
getString(R.string.streams_notification_channel_id),
getString(R.string.streams_notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
);
newStreamsChannel.setDescription(
getString(R.string.streams_notification_channel_description)
);
newStreamsChannel.enableVibration(false);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel, notificationManager.createNotificationChannels(
appUpdateChannel, hashChannel)); Arrays.asList(mainChannel, appUpdateChannel, hashChannel, newStreamsChannel)
);
} }
protected boolean isDisposedRxExceptionsReported() { protected boolean isDisposedRxExceptionsReported() {

View file

@ -69,6 +69,7 @@ import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment;
import org.schabi.newpipe.notifications.NotificationWorker;
import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.event.OnKeyDownListener; import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.helper.PlayerHolder;
@ -158,11 +159,11 @@ public class MainActivity extends AppCompatActivity {
} catch (final Exception e) { } catch (final Exception e) {
ErrorActivity.reportUiErrorInSnackbar(this, "Setting up drawer", e); ErrorActivity.reportUiErrorInSnackbar(this, "Setting up drawer", e);
} }
if (DeviceUtils.isTv(this)) { if (DeviceUtils.isTv(this)) {
FocusOverlayView.setupFocusObserver(this); FocusOverlayView.setupFocusObserver(this);
} }
openMiniPlayerUponPlayerStarted(); openMiniPlayerUponPlayerStarted();
NotificationWorker.schedule(this);
} }
private void setupDrawer() throws Exception { private void setupDrawer() throws Exception {

View file

@ -1,5 +1,11 @@
package org.schabi.newpipe; package org.schabi.newpipe;
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -8,11 +14,6 @@ import androidx.room.Room;
import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.AppDatabase;
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
public final class NewPipeDatabase { public final class NewPipeDatabase {
private static volatile AppDatabase databaseInstance; private static volatile AppDatabase databaseInstance;
@ -23,7 +24,7 @@ public final class NewPipeDatabase {
private static AppDatabase getDatabase(final Context context) { private static AppDatabase getDatabase(final Context context) {
return Room return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build(); .build();
} }

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe.database; package org.schabi.newpipe.database;
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
import androidx.room.Database; import androidx.room.Database;
import androidx.room.RoomDatabase; import androidx.room.RoomDatabase;
import androidx.room.TypeConverters; import androidx.room.TypeConverters;
@ -27,8 +29,6 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.database.subscription.SubscriptionDAO; import org.schabi.newpipe.database.subscription.SubscriptionDAO;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import static org.schabi.newpipe.database.Migrations.DB_VER_4;
@TypeConverters({Converters.class}) @TypeConverters({Converters.class})
@Database( @Database(
entities = { entities = {
@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4;
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class FeedLastUpdatedEntity.class
}, },
version = DB_VER_4 version = DB_VER_5
) )
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db"; public static final String DATABASE_NAME = "newpipe.db";

View file

@ -22,6 +22,7 @@ public final class Migrations {
public static final int DB_VER_2 = 2; public static final int DB_VER_2 = 2;
public static final int DB_VER_3 = 3; public static final int DB_VER_3 = 3;
public static final int DB_VER_4 = 4; public static final int DB_VER_4 = 4;
public static final int DB_VER_5 = 5;
private static final String TAG = Migrations.class.getName(); private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG; public static final boolean DEBUG = MainActivity.DEBUG;
@ -179,5 +180,14 @@ public final class Migrations {
} }
}; };
private Migrations() { } public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
+ "INTEGER NOT NULL DEFAULT 0");
}
};
private Migrations() {
}
} }

View file

@ -39,6 +39,9 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long> internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
@Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
internal abstract fun exists(serviceId: Long, url: String?): Boolean
@Query( @Query(
""" """
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration

View file

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

View file

@ -26,6 +26,7 @@ public class SubscriptionEntity {
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url"; public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
public static final String SUBSCRIPTION_DESCRIPTION = "description"; public static final String SUBSCRIPTION_DESCRIPTION = "description";
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
private long uid = 0; private long uid = 0;
@ -48,6 +49,9 @@ public class SubscriptionEntity {
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
private String description; private String description;
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
private int notificationMode;
@Ignore @Ignore
public static SubscriptionEntity from(@NonNull final ChannelInfo info) { public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
final SubscriptionEntity result = new SubscriptionEntity(); final SubscriptionEntity result = new SubscriptionEntity();
@ -114,6 +118,15 @@ public class SubscriptionEntity {
this.description = description; this.description = description;
} }
@NotificationMode
public int getNotificationMode() {
return notificationMode;
}
public void setNotificationMode(@NotificationMode final int notificationMode) {
this.notificationMode = notificationMode;
}
@Ignore @Ignore
public void setData(final String n, final String au, final String d, final Long sc) { public void setData(final String n, final String au, final String d, final Long sc) {
this.setName(n); this.setName(n);

View file

@ -1,6 +1,11 @@
package org.schabi.newpipe.fragments.list.channel; package org.schabi.newpipe.fragments.list.channel;
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
import android.content.Context; import android.content.Context;
import android.graphics.Color;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
@ -19,9 +24,11 @@ import androidx.appcompat.app.ActionBar;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.viewbinding.ViewBinding; import androidx.viewbinding.ViewBinding;
import com.google.android.material.snackbar.Snackbar;
import com.jakewharton.rxbinding4.view.RxView; import com.jakewharton.rxbinding4.view.RxView;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.NotificationMode;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.databinding.ChannelHeaderBinding; import org.schabi.newpipe.databinding.ChannelHeaderBinding;
import org.schabi.newpipe.databinding.FragmentChannelBinding; import org.schabi.newpipe.databinding.FragmentChannelBinding;
@ -37,6 +44,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.notifications.NotificationHelper;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
@ -60,10 +68,6 @@ import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
implements View.OnClickListener { implements View.OnClickListener {
@ -84,6 +88,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
private PlaylistControlBinding playlistControlBinding; private PlaylistControlBinding playlistControlBinding;
private MenuItem menuRssButton; private MenuItem menuRssButton;
private MenuItem menuNotifyButton;
public static ChannelFragment getInstance(final int serviceId, final String url, public static ChannelFragment getInstance(final int serviceId, final String url,
final String name) { final String name) {
@ -181,6 +186,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
+ "menu = [" + menu + "], inflater = [" + inflater + "]"); + "menu = [" + menu + "], inflater = [" + inflater + "]");
} }
menuRssButton = menu.findItem(R.id.menu_item_rss); menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
} }
} }
@ -197,6 +203,11 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
case R.id.action_settings: case R.id.action_settings:
NavigationHelper.openSettings(requireContext()); NavigationHelper.openSettings(requireContext());
break; break;
case R.id.menu_item_notify:
final boolean value = !item.isChecked();
item.setEnabled(false);
setNotify(value);
break;
case R.id.menu_item_rss: case R.id.menu_item_rss:
openRssFeed(); openRssFeed();
break; break;
@ -238,15 +249,22 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
.subscribe(getSubscribeUpdateMonitor(info), onError)); .subscribe(getSubscribeUpdateMonitor(info), onError));
disposables.add(observable disposables.add(observable
// Some updates are very rapid .map(List::isEmpty)
// (for example when calling the updateSubscription(info)) .distinctUntilChanged()
// so only update the UI for the latest emission
// ("sync" the subscribe button's state)
.debounce(100, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe((List<SubscriptionEntity> subscriptionEntities) -> .subscribe((Boolean isEmpty) -> updateSubscribeButton(!isEmpty), onError));
updateSubscribeButton(!subscriptionEntities.isEmpty()), onError));
disposables.add(observable
.map(List::isEmpty)
.filter(x -> NotificationHelper.isNewStreamsNotificationsEnabled(requireContext()))
.distinctUntilChanged()
.skip(1)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(isEmpty -> {
if (!isEmpty) {
showNotifySnackbar();
}
}, onError));
} }
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription, private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
@ -326,6 +344,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
info.getAvatarUrl(), info.getAvatarUrl(),
info.getDescription(), info.getDescription(),
info.getSubscriberCount()); info.getSubscriberCount());
updateNotifyButton(null);
subscribeButtonMonitor = monitorSubscribeButton( subscribeButtonMonitor = monitorSubscribeButton(
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
} else { } else {
@ -333,6 +352,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
Log.d(TAG, "Found subscription to this channel!"); Log.d(TAG, "Found subscription to this channel!");
} }
final SubscriptionEntity subscription = subscriptionEntities.get(0); final SubscriptionEntity subscription = subscriptionEntities.get(0);
updateNotifyButton(subscription);
subscribeButtonMonitor = monitorSubscribeButton( subscribeButtonMonitor = monitorSubscribeButton(
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription)); headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
} }
@ -375,6 +395,41 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
AnimationType.LIGHT_SCALE_AND_ALPHA); AnimationType.LIGHT_SCALE_AND_ALPHA);
} }
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
if (menuNotifyButton == null) {
return;
}
if (subscription == null) {
menuNotifyButton.setVisible(false);
} else {
menuNotifyButton.setEnabled(
NotificationHelper.isNewStreamsNotificationsEnabled(requireContext())
);
menuNotifyButton.setChecked(
subscription.getNotificationMode() != NotificationMode.DISABLED
);
menuNotifyButton.setVisible(true);
}
}
private void setNotify(final boolean isEnabled) {
final int mode = isEnabled ? NotificationMode.ENABLED_DEFAULT : NotificationMode.DISABLED;
disposables.add(
subscriptionManager.updateNotificationMode(currentInfo.getServiceId(),
currentInfo.getUrl(), mode)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
);
}
private void showNotifySnackbar() {
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
.setAction(R.string.get_notified, v -> setNotify(true))
.setActionTextColor(Color.YELLOW)
.show();
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Load and handle // Load and handle
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/

View file

@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.extractor.ListInfo
@ -14,6 +16,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.feed.FeedInfo import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.util.ExtractorHelper
class SubscriptionManager(context: Context) { class SubscriptionManager(context: Context) {
private val database = NewPipeDatabase.getInstance(context) private val database = NewPipeDatabase.getInstance(context)
@ -66,6 +69,16 @@ class SubscriptionManager(context: Context) {
} }
} }
fun updateNotificationMode(serviceId: Int, url: String?, @NotificationMode mode: Int): Completable {
return subscriptionTable().getSubscription(serviceId, url!!)
.flatMapCompletable { entity: SubscriptionEntity ->
Completable.fromAction {
entity.notificationMode = mode
subscriptionTable().update(entity)
}.andThen(rememberLastStream(entity))
}
}
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) { fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) {
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
@ -94,4 +107,14 @@ class SubscriptionManager(context: Context) {
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
subscriptionTable.delete(subscriptionEntity) subscriptionTable.delete(subscriptionEntity)
} }
private fun rememberLastStream(subscription: SubscriptionEntity): Completable {
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } }
.flatMapCompletable { entities ->
Completable.fromAction {
database.streamDAO().upsertAll(entities)
}
}.onErrorComplete()
}
} }

View file

@ -0,0 +1,46 @@
package org.schabi.newpipe.notifications
import android.content.Context
import android.content.Intent
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.NavigationHelper
data class ChannelUpdates(
val serviceId: Int,
val url: String,
val avatarUrl: String,
val name: String,
val streams: List<StreamInfoItem>
) {
val id = url.hashCode()
val isNotEmpty: Boolean
get() = streams.isNotEmpty()
val size = streams.size
fun getText(context: Context): String {
val separator = context.resources.getString(R.string.enumeration_comma) + " "
return streams.joinToString(separator) { it.name }
}
fun createOpenChannelIntent(context: Context?): Intent {
return NavigationHelper.getChannelIntent(context, serviceId, url)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
companion object {
fun from(channel: ChannelInfo, streams: List<StreamInfoItem>): ChannelUpdates {
return ChannelUpdates(
channel.serviceId,
channel.url,
channel.avatarUrl,
channel.name,
streams
)
}
}
}

View file

@ -0,0 +1,137 @@
package org.schabi.newpipe.notifications;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class NotificationHelper {
private final Context context;
private final NotificationManager manager;
private final CompositeDisposable disposable;
public NotificationHelper(final Context context) {
this.context = context;
this.disposable = new CompositeDisposable();
this.manager = (NotificationManager) context.getSystemService(
Context.NOTIFICATION_SERVICE
);
}
public Context getContext() {
return context;
}
/**
* Check whether notifications are not disabled by user via system settings.
*
* @param context Context
* @return true if notifications are allowed, false otherwise
*/
public static boolean isNotificationsEnabledNative(final Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
final String channelId = context.getString(R.string.streams_notification_channel_id);
final NotificationManager manager = (NotificationManager) context
.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager != null) {
final NotificationChannel channel = manager.getNotificationChannel(channelId);
return channel != null
&& channel.getImportance() != NotificationManager.IMPORTANCE_NONE;
} else {
return false;
}
} else {
return NotificationManagerCompat.from(context).areNotificationsEnabled();
}
}
public static boolean isNewStreamsNotificationsEnabled(@NonNull final Context context) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.enable_streams_notifications), false)
&& isNotificationsEnabledNative(context);
}
public static void openNativeSettingsScreen(final Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
final String channelId = context.getString(R.string.streams_notification_channel_id);
final Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId);
context.startActivity(intent);
} else {
final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.parse("package:" + context.getPackageName()));
context.startActivity(intent);
}
}
public void notify(final ChannelUpdates data) {
final String summary = context.getResources().getQuantityString(
R.plurals.new_streams, data.getSize(), data.getSize()
);
final NotificationCompat.Builder builder = new NotificationCompat.Builder(context,
context.getString(R.string.streams_notification_channel_id))
.setContentTitle(
context.getString(R.string.notification_title_pattern,
data.getName(),
summary)
)
.setContentText(data.getText(context))
.setNumber(data.getSize())
.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setSmallIcon(R.drawable.ic_stat_newpipe)
.setLargeIcon(BitmapFactory.decodeResource(context.getResources(),
R.drawable.ic_newpipe_triangle_white))
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
.setColorized(true)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_SOCIAL);
final NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle();
for (final StreamInfoItem stream : data.getStreams()) {
style.addLine(stream.getName());
}
style.setSummaryText(summary);
style.setBigContentTitle(data.getName());
builder.setStyle(style);
builder.setContentIntent(PendingIntent.getActivity(
context,
data.getId(),
data.createOpenChannelIntent(context),
0
));
disposable.add(
Single.create(new NotificationIcon(context, data.getAvatarUrl()))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doAfterTerminate(() -> manager.notify(data.getId(), builder.build()))
.subscribe(builder::setLargeIcon, throwable -> {
if (BuildConfig.DEBUG) {
throwable.printStackTrace();
}
})
);
}
}

View file

@ -0,0 +1,60 @@
package org.schabi.newpipe.notifications;
import android.app.ActivityManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.view.View;
import com.nostra13.universalimageloader.core.ImageLoader;
import com.nostra13.universalimageloader.core.assist.FailReason;
import com.nostra13.universalimageloader.core.assist.ImageSize;
import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.SingleEmitter;
import io.reactivex.rxjava3.core.SingleOnSubscribe;
final class NotificationIcon implements SingleOnSubscribe<Bitmap> {
private final String url;
private final int size;
NotificationIcon(final Context context, final String url) {
this.url = url;
this.size = getIconSize(context);
}
@Override
public void subscribe(@NonNull final SingleEmitter<Bitmap> emitter) throws Throwable {
ImageLoader.getInstance().loadImage(
url,
new ImageSize(size, size),
new SimpleImageLoadingListener() {
@Override
public void onLoadingFailed(final String imageUri,
final View view,
final FailReason failReason) {
emitter.onError(failReason.getCause());
}
@Override
public void onLoadingComplete(final String imageUri,
final View view,
final Bitmap loadedImage) {
emitter.onSuccess(loadedImage);
}
}
);
}
private static int getIconSize(final Context context) {
final ActivityManager activityManager = (ActivityManager) context.getSystemService(
Context.ACTIVITY_SERVICE
);
final int size2 = activityManager != null ? activityManager.getLauncherLargeIconSize() : 0;
final int size1 = context.getResources()
.getDimensionPixelSize(android.R.dimen.app_icon_size);
return Math.max(size2, size1);
}
}

View file

@ -0,0 +1,82 @@
package org.schabi.newpipe.notifications
import android.content.Context
import androidx.preference.PreferenceManager
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.RxWorker
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import io.reactivex.BackpressureStrategy
import io.reactivex.Flowable
import io.reactivex.Single
import org.schabi.newpipe.R
import java.util.concurrent.TimeUnit
class NotificationWorker(
appContext: Context,
workerParams: WorkerParameters
) : RxWorker(appContext, workerParams) {
private val notificationHelper by lazy {
NotificationHelper(appContext)
}
override fun createWork() = if (isEnabled(applicationContext)) {
Flowable.create(
SubscriptionUpdates(applicationContext),
BackpressureStrategy.BUFFER
).doOnNext { notificationHelper.notify(it) }
.toList()
.map { Result.success() }
.onErrorReturnItem(Result.failure())
} else Single.just(Result.success())
companion object {
private const val TAG = "notifications"
private fun isEnabled(context: Context): Boolean {
return PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(
context.getString(R.string.enable_streams_notifications),
false
) && NotificationHelper.isNotificationsEnabledNative(context)
}
fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(
if (options.isRequireNonMeteredNetwork) {
NetworkType.UNMETERED
} else {
NetworkType.CONNECTED
}
).build()
val request = PeriodicWorkRequest.Builder(
NotificationWorker::class.java,
options.interval,
TimeUnit.MILLISECONDS
).setConstraints(constraints)
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
TAG,
if (force) {
ExistingPeriodicWorkPolicy.REPLACE
} else {
ExistingPeriodicWorkPolicy.KEEP
},
request
)
}
@JvmStatic
fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context))
}
}

View file

@ -0,0 +1,33 @@
package org.schabi.newpipe.notifications
import android.content.Context
import androidx.preference.PreferenceManager
import org.schabi.newpipe.R
import java.util.concurrent.TimeUnit
data class ScheduleOptions(
val interval: Long,
val isRequireNonMeteredNetwork: Boolean
) {
companion object {
fun from(context: Context): ScheduleOptions {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return ScheduleOptions(
interval = TimeUnit.HOURS.toMillis(
preferences.getString(
context.getString(R.string.streams_notifications_interval_key),
context.getString(R.string.streams_notifications_interval_default)
)?.toLongOrNull() ?: context.getString(
R.string.streams_notifications_interval_default
).toLong()
),
isRequireNonMeteredNetwork = preferences.getString(
context.getString(R.string.streams_notifications_network_key),
context.getString(R.string.streams_notifications_network_default)
) == context.getString(R.string.streams_notifications_network_wifi)
)
}
}
}

View file

@ -0,0 +1,53 @@
package org.schabi.newpipe.notifications
import android.content.Context
import io.reactivex.FlowableEmitter
import io.reactivex.FlowableOnSubscribe
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ExtractorHelper
class SubscriptionUpdates(context: Context) : FlowableOnSubscribe<ChannelUpdates?> {
private val subscriptionManager = SubscriptionManager(context)
private val streamTable = NewPipeDatabase.getInstance(context).streamDAO()
override fun subscribe(emitter: FlowableEmitter<ChannelUpdates?>) {
try {
val subscriptions = subscriptionManager.subscriptions().blockingFirst()
for (subscription in subscriptions) {
if (subscription.notificationMode != NotificationMode.DISABLED) {
val channel = ExtractorHelper.getChannelInfo(
subscription.serviceId,
subscription.url, true
).blockingGet()
val updates = ChannelUpdates.from(channel, filterStreams(channel.relatedItems))
if (updates.isNotEmpty) {
emitter.onNext(updates)
// prevent duplicated notifications
streamTable.upsertAll(updates.streams.map { StreamEntity(it) })
}
}
}
emitter.onComplete()
} catch (e: Exception) {
emitter.onError(e)
}
}
private fun filterStreams(list: List<*>): List<StreamInfoItem> {
val streams = ArrayList<StreamInfoItem>(list.size)
for (o in list) {
if (o is StreamInfoItem) {
if (streamTable.exists(o.serviceId.toLong(), o.url)) {
break
}
streams.add(o)
}
}
return streams
}
}

View file

@ -0,0 +1,112 @@
package org.schabi.newpipe.settings
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.graphics.Color
import android.os.Bundle
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import org.schabi.newpipe.R
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.error.ErrorActivity
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.notifications.NotificationHelper
import org.schabi.newpipe.notifications.NotificationWorker
import org.schabi.newpipe.notifications.ScheduleOptions
class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferenceChangeListener {
private var notificationWarningSnackbar: Snackbar? = null
private var loader: Disposable? = null
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.notifications_settings)
}
override fun onStart() {
super.onStart()
defaultPreferences.registerOnSharedPreferenceChangeListener(this)
}
override fun onStop() {
defaultPreferences.unregisterOnSharedPreferenceChangeListener(this)
super.onStop()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
val context = context ?: return
if (key == getString(R.string.streams_notifications_interval_key) || key == getString(R.string.streams_notifications_network_key)) {
NotificationWorker.schedule(context, ScheduleOptions.from(context), true)
}
}
override fun onResume() {
super.onResume()
val enabled = NotificationHelper.isNotificationsEnabledNative(context)
preferenceScreen.isEnabled = enabled
if (!enabled) {
if (notificationWarningSnackbar == null) {
notificationWarningSnackbar = Snackbar.make(
listView,
R.string.notifications_disabled,
Snackbar.LENGTH_INDEFINITE
).apply {
setAction(R.string.settings) { v ->
NotificationHelper.openNativeSettingsScreen(v.context)
}
setActionTextColor(Color.YELLOW)
addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar, event: Int) {
super.onDismissed(transientBottomBar, event)
notificationWarningSnackbar = null
}
})
show()
}
}
} else {
notificationWarningSnackbar?.dismiss()
notificationWarningSnackbar = null
}
loader?.dispose()
loader = SubscriptionManager(requireContext())
.subscriptions()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::updateSubscriptions, this::onError)
}
override fun onPause() {
loader?.dispose()
loader = null
super.onPause()
}
private fun updateSubscriptions(subscriptions: List<SubscriptionEntity>) {
var notified = 0
for (subscription in subscriptions) {
if (subscription.notificationMode != NotificationMode.DISABLED) {
notified++
}
}
val preference = findPreference<Preference>(getString(R.string.streams_notifications_channels_key))
if (preference != null) {
preference.summary = preference.context.getString(
R.string.streams_notifications_channels_summary,
notified,
subscriptions.size
)
}
}
private fun onError(e: Throwable) {
ErrorActivity.reportErrorInSnackbar(
this,
ErrorInfo(e, UserAction.SUBSCRIPTION_GET, "Get subscriptions list")
)
}
}

View file

@ -0,0 +1,84 @@
package org.schabi.newpipe.settings.notifications;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.NotificationMode;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class NotificationsChannelsConfigFragment extends Fragment
implements NotificationsConfigAdapter.ModeToggleListener {
private NotificationsConfigAdapter adapter;
@Nullable
private Disposable loader = null;
private CompositeDisposable updaters;
@Override
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
adapter = new NotificationsConfigAdapter(this);
updaters = new CompositeDisposable();
}
@Nullable
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_channels_notifications, container, false);
}
@Override
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
final RecyclerView recyclerView = view.findViewById(R.id.recycler_view);
recyclerView.setAdapter(adapter);
}
@Override
public void onActivityCreated(@Nullable final Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (loader != null) {
loader.dispose();
}
loader = new SubscriptionManager(requireContext())
.subscriptions()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(adapter::update);
}
@Override
public void onDestroy() {
if (loader != null) {
loader.dispose();
}
updaters.dispose();
super.onDestroy();
}
@Override
public void onModeToggle(final int position, @NotificationMode final int mode) {
final NotificationsConfigAdapter.SubscriptionItem subscription = adapter.getItem(position);
updaters.add(
new SubscriptionManager(requireContext())
.updateNotificationMode(subscription.getServiceId(),
subscription.getUrl(), mode)
.subscribeOn(Schedulers.io())
.subscribe()
);
}
}

View file

@ -0,0 +1,114 @@
package org.schabi.newpipe.settings.notifications
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckedTextView
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.schabi.newpipe.R
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.settings.notifications.NotificationsConfigAdapter.SubscriptionHolder
class NotificationsConfigAdapter(
private val listener: ModeToggleListener
) : RecyclerView.Adapter<SubscriptionHolder>() {
private val differ = AsyncListDiffer(this, DiffCallback())
init {
setHasStableIds(true)
}
override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): SubscriptionHolder {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.item_notification_config, viewGroup, false)
return SubscriptionHolder(view, listener)
}
override fun onBindViewHolder(subscriptionHolder: SubscriptionHolder, i: Int) {
subscriptionHolder.bind(differ.currentList[i])
}
fun getItem(position: Int): SubscriptionItem = differ.currentList[position]
override fun getItemCount() = differ.currentList.size
override fun getItemId(position: Int): Long {
return differ.currentList[position].id
}
fun update(newData: List<SubscriptionEntity>) {
differ.submitList(
newData.map {
SubscriptionItem(
id = it.uid,
title = it.name,
notificationMode = it.notificationMode,
serviceId = it.serviceId,
url = it.url
)
}
)
}
data class SubscriptionItem(
val id: Long,
val title: String,
@NotificationMode
val notificationMode: Int,
val serviceId: Int,
val url: String
)
class SubscriptionHolder(
itemView: View,
private val listener: ModeToggleListener
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
private val checkedTextView = itemView as CheckedTextView
init {
itemView.setOnClickListener(this)
}
fun bind(data: SubscriptionItem) {
checkedTextView.text = data.title
checkedTextView.isChecked = data.notificationMode != NotificationMode.DISABLED
}
override fun onClick(v: View) {
val mode = if (checkedTextView.isChecked) {
NotificationMode.DISABLED
} else {
NotificationMode.ENABLED_DEFAULT
}
listener.onModeToggle(adapterPosition, mode)
}
}
private class DiffCallback : DiffUtil.ItemCallback<SubscriptionItem>() {
override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? {
if (oldItem.notificationMode != newItem.notificationMode) {
return newItem.notificationMode
} else {
return super.getChangePayload(oldItem, newItem)
}
}
}
interface ModeToggleListener {
fun onModeToggle(position: Int, @NotificationMode mode: Int)
}
}

View file

@ -571,6 +571,12 @@ public final class NavigationHelper {
return getOpenIntent(context, url, service.getServiceId(), linkType); return getOpenIntent(context, url, service.getServiceId(), linkType);
} }
public static Intent getChannelIntent(final Context context,
final int serviceId,
final String url) {
return getOpenIntent(context, url, serviceId, StreamingService.LinkType.CHANNEL);
}
/** /**
* Start an activity to install Kore. * Start an activity to install Kore.
* *

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="98.91304"
android:viewportHeight="98.91304"
android:tint="#FFFFFF">
<group android:translateX="-6.7255435"
android:translateY="-0.54347825">
<path
android:pathData="m23.909,10.211v78.869c0,0 7.7,-4.556 12.4,-7.337V67.477,56.739 31.686c0,0 3.707,2.173 8.948,5.24 6.263,3.579 14.57,8.565 21.473,12.655 -9.358,5.483 -16.8,9.876 -22.496,13.234V77.053C57.974,68.927 75.176,58.762 90.762,49.581 75.551,40.634 57.144,29.768 43.467,21.715 31.963,14.94 23.909,10.211 23.909,10.211Z"
android:strokeWidth="1.2782383"
android:fillColor="#ffffff"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</FrameLayout>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<CheckedTextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?listPreferredItemHeightSmall"
android:background="?selectableItemBackground"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="2"
android:orientation="horizontal"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:drawableEnd="?android:listChoiceIndicatorMultiple"
tools:text="@tools:sample/lorem[4]" />

View file

@ -17,6 +17,13 @@
android:title="@string/share" android:title="@string/share"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_item_notify"
android:checkable="true"
android:visible="false"
android:title="@string/get_notified"
app:showAsAction="never" />
<item <item
android:id="@+id/action_settings" android:id="@+id/action_settings"
android:orderInCategory="1" android:orderInCategory="1"

View file

@ -169,6 +169,11 @@
<item quantity="many">%sвидео</item> <item quantity="many">%sвидео</item>
<item quantity="other">%sвидео</item> <item quantity="other">%sвидео</item>
</plurals> </plurals>
<plurals name="new_streams">
<item quantity="one">%s новое видео</item>
<item quantity="few">%s новых видео</item>
<item quantity="many">%s новых видео</item>
</plurals>
<string name="delete_item_search_history">Удалить этот элемент из истории поиска?</string> <string name="delete_item_search_history">Удалить этот элемент из истории поиска?</string>
<string name="main_page_content">Главная страница</string> <string name="main_page_content">Главная страница</string>
<string name="blank_page_summary">Пустая страница</string> <string name="blank_page_summary">Пустая страница</string>
@ -683,4 +688,20 @@
<string name="main_page_content_swipe_remove">Удалять элементы смахиванием</string> <string name="main_page_content_swipe_remove">Удалять элементы смахиванием</string>
<string name="start_main_player_fullscreen_summary">Не начинать просмотр видео в мини-плеере, но сразу переключиться в полноэкранный режим, если автовращение экрана заблокировано. Вы можете переключиться на мини-плеер, выйдя из полноэкранного режима.</string> <string name="start_main_player_fullscreen_summary">Не начинать просмотр видео в мини-плеере, но сразу переключиться в полноэкранный режим, если автовращение экрана заблокировано. Вы можете переключиться на мини-плеер, выйдя из полноэкранного режима.</string>
<string name="start_main_player_fullscreen_title">Начинать просмотр в полноэкранном режиме</string> <string name="start_main_player_fullscreen_title">Начинать просмотр в полноэкранном режиме</string>
<string name="notifications">Уведомления</string>
<string name="streams_notification_channel_name">Новые видео</string>
<string name="streams_notification_channel_description">Уведомления о новых видео в подписках</string>
<string name="streams_notifications_interval_title">Частота проверки</string>
<string name="enable_streams_notifications_title">Уведомлять о новых видео</string>
<string name="enable_streams_notifications_summary">Получать уведомления о новых видео из каналов, на которые Вы подписаны</string>
<string name="every_hour">Каждый час</string>
<string name="every_two_hours">Каждые 2 часа</string>
<string name="every_three_hours">Каждые 3 часа</string>
<string name="twice_per_day">Дважды в день</string>
<string name="every_day">Каждый день</string>
<string name="streams_notifications_network_title">Тип подключения</string>
<string name="any_network">Любая сеть</string>
<string name="notifications_disabled">Уведомления отключены</string>
<string name="get_notified">Уведомлять</string>
<string name="you_successfully_subscribed">Вы подписались на канал</string>
</resources> </resources>

View file

@ -443,4 +443,5 @@
<string name="seek_duration_title">快进 / 快退的单位时间</string> <string name="seek_duration_title">快进 / 快退的单位时间</string>
<string name="clear_download_history">清除下载历史记录</string> <string name="clear_download_history">清除下载历史记录</string>
<string name="delete_downloaded_files">删除下载了的文件</string> <string name="delete_downloaded_files">删除下载了的文件</string>
<string name="enumeration_comma"></string>
</resources> </resources>

View file

@ -132,4 +132,5 @@
<string name="use_inexact_seek_title">使用粗略快查</string> <string name="use_inexact_seek_title">使用粗略快查</string>
<string name="controls_add_to_playlist_title">添加到</string> <string name="controls_add_to_playlist_title">添加到</string>
<string name="tab_choose">選擇標籤</string> <string name="tab_choose">選擇標籤</string>
<string name="enumeration_comma"></string>
</resources> </resources>

View file

@ -1260,4 +1260,34 @@
</string-array> </string-array>
<string name="recaptcha_cookies_key" translatable="false">recaptcha_cookies_key</string> <string name="recaptcha_cookies_key" translatable="false">recaptcha_cookies_key</string>
<string name="enable_streams_notifications" translatable="false">enable_streams_notifications</string>
<string name="streams_notifications_interval_key" translatable="false">streams_notifications_interval</string>
<string name="streams_notifications_interval_default" translatable="false">3</string>
<string-array name="streams_notifications_interval_values">
<item>1</item>
<item>2</item>
<item>3</item>
<item>12</item>
<item>24</item>
</string-array>
<string-array name="streams_notifications_interval_description">
<item>@string/every_hour</item>
<item>@string/every_two_hours</item>
<item>@string/every_three_hours</item>
<item>@string/twice_per_day</item>
<item>@string/every_day</item>
</string-array>
<string name="streams_notifications_network_key" translatable="false">streams_notifications_network</string>
<string name="streams_notifications_network_any" translatable="false">any</string>
<string name="streams_notifications_network_wifi" translatable="false">wifi</string>
<string name="streams_notifications_network_default" translatable="false">@string/streams_notifications_network_wifi</string>
<string-array name="streams_notifications_network_values">
<item>@string/streams_notifications_network_any</item>
<item>@string/streams_notifications_network_wifi</item>
</string-array>
<string-array name="streams_notifications_network_description">
<item>@string/any_network</item>
<item>@string/wifi_only</item>
</string-array>
<string name="streams_notifications_channels_key" translatable="false">streams_notifications_channels</string>
</resources> </resources>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools" <resources xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MissingTranslation"> tools:ignore="MissingTranslation">
@ -180,6 +180,7 @@
<string name="always">Always</string> <string name="always">Always</string>
<string name="just_once">Just Once</string> <string name="just_once">Just Once</string>
<string name="file">File</string> <string name="file">File</string>
<string name="notifications">Notifications</string>
<string name="notification_channel_id" translatable="false">newpipe</string> <string name="notification_channel_id" translatable="false">newpipe</string>
<string name="notification_channel_name">NewPipe Notification</string> <string name="notification_channel_name">NewPipe Notification</string>
<string name="notification_channel_description">Notifications for NewPipe background and popup players</string> <string name="notification_channel_description">Notifications for NewPipe background and popup players</string>
@ -189,6 +190,9 @@
<string name="hash_channel_id" translatable="false">newpipeHash</string> <string name="hash_channel_id" translatable="false">newpipeHash</string>
<string name="hash_channel_name">Video Hash Notification</string> <string name="hash_channel_name">Video Hash Notification</string>
<string name="hash_channel_description">Notifications for video hashing progress</string> <string name="hash_channel_description">Notifications for video hashing progress</string>
<string name="streams_notification_channel_id" translatable="false">newpipeNewStreams</string>
<string name="streams_notification_channel_name">New streams</string>
<string name="streams_notification_channel_description">Notifications about new streams for subscriptions</string>
<string name="unknown_content">[Unknown]</string> <string name="unknown_content">[Unknown]</string>
<string name="switch_to_background">Switch to Background</string> <string name="switch_to_background">Switch to Background</string>
<string name="switch_to_popup">Switch to Popup</string> <string name="switch_to_popup">Switch to Popup</string>
@ -309,6 +313,10 @@
</plurals> </plurals>
<string name="no_comments">No comments</string> <string name="no_comments">No comments</string>
<string name="comments_are_disabled">Comments are disabled</string> <string name="comments_are_disabled">Comments are disabled</string>
<plurals name="new_streams">
<item quantity="one">%s new stream</item>
<item quantity="other">%s new streams</item>
</plurals>
<!-- Missions --> <!-- Missions -->
<string name="start">Start</string> <string name="start">Start</string>
<string name="pause">Pause</string> <string name="pause">Pause</string>
@ -513,6 +521,17 @@
<item>240p</item> <item>240p</item>
<item>144p</item> <item>144p</item>
</string-array> </string-array>
<!-- Notifications settings -->
<string name="enable_streams_notifications_title">New streams notifications</string>
<string name="enable_streams_notifications_summary">Notify about new streams from subscriptions</string>
<string name="streams_notifications_interval_title">Checking frequency</string>
<string name="every_hour">Every hour</string>
<string name="every_two_hours">Every 2 hours</string>
<string name="every_three_hours">Every 3 hours</string>
<string name="twice_per_day">Twice per day</string>
<string name="every_day">Every day</string>
<string name="streams_notifications_network_title">Required network connection</string>
<string name="any_network">Any network</string>
<!-- Updates Settings --> <!-- Updates Settings -->
<string name="updates_setting_title">Updates</string> <string name="updates_setting_title">Updates</string>
<string name="updates_setting_description">Show a notification to prompt app update when a new version is available</string> <string name="updates_setting_description">Show a notification to prompt app update when a new version is available</string>
@ -703,4 +722,10 @@
<!-- Show Channel Details --> <!-- Show Channel Details -->
<string name="error_show_channel_details">Error at Show Channel Details</string> <string name="error_show_channel_details">Error at Show Channel Details</string>
<string name="loading_channel_details">Loading Channel Details…</string> <string name="loading_channel_details">Loading Channel Details…</string>
<string name="notifications_disabled">Notifications are disabled</string>
<string name="get_notified">Get notified</string>
<string name="you_successfully_subscribed">You now subscribed to this channel</string>
<string name="enumeration_comma">,</string>
<string name="notification_title_pattern" translatable="false">%s • %s</string>
<string name="streams_notifications_channels_summary" translatable="false">%d/%d</string>
</resources> </resources>

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:key="general_preferences"
android:title="@string/notifications">
<SwitchPreference
android:defaultValue="false"
android:key="@string/enable_streams_notifications"
android:summary="@string/enable_streams_notifications_summary"
android:title="@string/enable_streams_notifications_title"
app:iconSpaceReserved="false" />
<ListPreference
android:defaultValue="@string/streams_notifications_interval_default"
android:dependency="@string/enable_streams_notifications"
android:entries="@array/streams_notifications_interval_description"
android:entryValues="@array/streams_notifications_interval_values"
android:key="@string/streams_notifications_interval_key"
android:summary="%s"
android:title="@string/streams_notifications_interval_title"
app:iconSpaceReserved="false" />
<ListPreference
android:defaultValue="@string/streams_notifications_network_default"
android:dependency="@string/enable_streams_notifications"
android:entries="@array/streams_notifications_network_description"
android:entryValues="@array/streams_notifications_network_values"
android:key="@string/streams_notifications_network_key"
android:summary="%s"
android:title="@string/streams_notifications_network_title"
app:iconSpaceReserved="false" />
<Preference
android:fragment="org.schabi.newpipe.settings.notifications.NotificationsChannelsConfigFragment"
android:dependency="@string/enable_streams_notifications"
android:key="@string/streams_notifications_channels_key"
android:title="@string/channels"
app:iconSpaceReserved="false" />
</PreferenceScreen>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" <PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:key="general_preferences" android:key="general_preferences"
android:title="@string/settings"> android:title="@string/settings">
@ -40,6 +41,12 @@
android:title="@string/settings_category_notification_title" android:title="@string/settings_category_notification_title"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<PreferenceScreen
android:fragment="org.schabi.newpipe.settings.NotificationsSettingsFragment"
android:icon="@drawable/ic_notifications"
android:title="@string/notifications"
app:iconSpaceReserved="false" />
<PreferenceScreen <PreferenceScreen
android:fragment="org.schabi.newpipe.settings.UpdateSettingsFragment" android:fragment="org.schabi.newpipe.settings.UpdateSettingsFragment"
android:icon="@drawable/ic_cloud_download" android:icon="@drawable/ic_cloud_download"

Binary file not shown.