diff --git a/app/build.gradle b/app/build.gradle index 1219aeb33..61a0cdc2b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -108,6 +108,7 @@ ext { leakCanaryVersion = '2.5' stethoVersion = '1.6.0' mockitoVersion = '3.6.0' + workVersion = '2.5.0' } configurations { @@ -213,6 +214,8 @@ dependencies { implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.webkit:webkit:1.4.0' implementation 'com.google.android.material:material:1.2.1' + implementation "androidx.work:work-runtime:${workVersion}" + implementation "androidx.work:work-rxjava2:${workVersion}" /** Third-party libraries **/ // Instance state boilerplate elimination diff --git a/app/src/debug/res/xml/main_settings.xml b/app/src/debug/res/xml/main_settings.xml index d482d033c..4e812bb1c 100644 --- a/app/src/debug/res/xml/main_settings.xml +++ b/app/src/debug/res/xml/main_settings.xml @@ -40,6 +40,12 @@ android:title="@string/settings_category_notification_title" app:iconSpaceReserved="false" /> + + { @Insert(onConflict = OnConflictStrategy.IGNORE) internal abstract fun silentInsertAllInternal(streams: List): List + @Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId") + internal abstract fun exists(serviceId: Long, url: String?): Boolean + @Query( """ SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java new file mode 100644 index 000000000..d817032ee --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java @@ -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 +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java index 1cf38dbca..0e4bda490 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java @@ -26,6 +26,7 @@ public class SubscriptionEntity { public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url"; public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; public static final String SUBSCRIPTION_DESCRIPTION = "description"; + public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode"; @PrimaryKey(autoGenerate = true) private long uid = 0; @@ -48,6 +49,9 @@ public class SubscriptionEntity { @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) private String description; + @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE) + private int notificationMode; + @Ignore public static SubscriptionEntity from(@NonNull final ChannelInfo info) { final SubscriptionEntity result = new SubscriptionEntity(); @@ -114,6 +118,15 @@ public class SubscriptionEntity { this.description = description; } + @NotificationMode + public int getNotificationMode() { + return notificationMode; + } + + public void setNotificationMode(@NotificationMode final int notificationMode) { + this.notificationMode = notificationMode; + } + @Ignore public void setData(final String n, final String au, final String d, final Long sc) { this.setName(n); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 548ae7b2c..754036dfd 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -1,6 +1,11 @@ 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.graphics.Color; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; @@ -19,9 +24,11 @@ import androidx.appcompat.app.ActionBar; import androidx.core.content.ContextCompat; import androidx.viewbinding.ViewBinding; +import com.google.android.material.snackbar.Snackbar; import com.jakewharton.rxbinding4.view.RxView; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.subscription.NotificationMode; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.databinding.ChannelHeaderBinding; 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.ktx.AnimationType; 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.PlayQueue; 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.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 implements View.OnClickListener { @@ -84,6 +88,7 @@ public class ChannelFragment extends BaseListInfoFragment private PlaylistControlBinding playlistControlBinding; private MenuItem menuRssButton; + private MenuItem menuNotifyButton; public static ChannelFragment getInstance(final int serviceId, final String url, final String name) { @@ -181,6 +186,7 @@ public class ChannelFragment extends BaseListInfoFragment + "menu = [" + menu + "], inflater = [" + inflater + "]"); } 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 case R.id.action_settings: NavigationHelper.openSettings(requireContext()); break; + case R.id.menu_item_notify: + final boolean value = !item.isChecked(); + item.setEnabled(false); + setNotify(value); + break; case R.id.menu_item_rss: openRssFeed(); break; @@ -238,15 +249,22 @@ public class ChannelFragment extends BaseListInfoFragment .subscribe(getSubscribeUpdateMonitor(info), onError)); disposables.add(observable - // Some updates are very rapid - // (for example when calling the updateSubscription(info)) - // so only update the UI for the latest emission - // ("sync" the subscribe button's state) - .debounce(100, TimeUnit.MILLISECONDS) + .map(List::isEmpty) + .distinctUntilChanged() .observeOn(AndroidSchedulers.mainThread()) - .subscribe((List subscriptionEntities) -> - updateSubscribeButton(!subscriptionEntities.isEmpty()), onError)); + .subscribe((Boolean isEmpty) -> updateSubscribeButton(!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 mapOnSubscribe(final SubscriptionEntity subscription, @@ -326,6 +344,7 @@ public class ChannelFragment extends BaseListInfoFragment info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); + updateNotifyButton(null); subscribeButtonMonitor = monitorSubscribeButton( headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); } else { @@ -333,6 +352,7 @@ public class ChannelFragment extends BaseListInfoFragment Log.d(TAG, "Found subscription to this channel!"); } final SubscriptionEntity subscription = subscriptionEntities.get(0); + updateNotifyButton(subscription); subscribeButtonMonitor = monitorSubscribeButton( headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription)); } @@ -375,6 +395,41 @@ public class ChannelFragment extends BaseListInfoFragment 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 //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt index fb9cffa98..442a867b3 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.NewPipeDatabase 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.SubscriptionEntity 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.stream.StreamInfoItem import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.util.ExtractorHelper class SubscriptionManager(context: 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) { val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) @@ -94,4 +107,14 @@ class SubscriptionManager(context: Context) { fun deleteSubscription(subscriptionEntity: 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() + } } diff --git a/app/src/main/java/org/schabi/newpipe/notifications/ChannelUpdates.kt b/app/src/main/java/org/schabi/newpipe/notifications/ChannelUpdates.kt new file mode 100644 index 000000000..9a3b2cbf3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/notifications/ChannelUpdates.kt @@ -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 +) { + + 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): ChannelUpdates { + return ChannelUpdates( + channel.serviceId, + channel.url, + channel.avatarUrl, + channel.name, + streams + ) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/notifications/NotificationHelper.java b/app/src/main/java/org/schabi/newpipe/notifications/NotificationHelper.java new file mode 100644 index 000000000..6207cd613 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/notifications/NotificationHelper.java @@ -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(); + } + }) + ); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/notifications/NotificationIcon.java b/app/src/main/java/org/schabi/newpipe/notifications/NotificationIcon.java new file mode 100644 index 000000000..fc59b55f0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/notifications/NotificationIcon.java @@ -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 { + + 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 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); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/notifications/NotificationWorker.kt b/app/src/main/java/org/schabi/newpipe/notifications/NotificationWorker.kt new file mode 100644 index 000000000..24dbc82e0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/notifications/NotificationWorker.kt @@ -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)) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/notifications/ScheduleOptions.kt b/app/src/main/java/org/schabi/newpipe/notifications/ScheduleOptions.kt new file mode 100644 index 000000000..b0617b303 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/notifications/ScheduleOptions.kt @@ -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) + ) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/notifications/SubscriptionUpdates.kt b/app/src/main/java/org/schabi/newpipe/notifications/SubscriptionUpdates.kt new file mode 100644 index 000000000..6f7c3881b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/notifications/SubscriptionUpdates.kt @@ -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 { + + private val subscriptionManager = SubscriptionManager(context) + private val streamTable = NewPipeDatabase.getInstance(context).streamDAO() + + override fun subscribe(emitter: FlowableEmitter) { + 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 { + val streams = ArrayList(list.size) + for (o in list) { + if (o is StreamInfoItem) { + if (streamTable.exists(o.serviceId.toLong(), o.url)) { + break + } + streams.add(o) + } + } + return streams + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt new file mode 100644 index 000000000..62a819e64 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt @@ -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) { + var notified = 0 + for (subscription in subscriptions) { + if (subscription.notificationMode != NotificationMode.DISABLED) { + notified++ + } + } + val preference = findPreference(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") + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationsChannelsConfigFragment.java b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationsChannelsConfigFragment.java new file mode 100644 index 000000000..7aa0826e5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationsChannelsConfigFragment.java @@ -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() + ); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationsConfigAdapter.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationsConfigAdapter.kt new file mode 100644 index 000000000..44d2256af --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationsConfigAdapter.kt @@ -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() { + + 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) { + 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() { + + 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) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index eba24020f..859bfa31d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -571,6 +571,12 @@ public final class NavigationHelper { 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. * diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_newpipe.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_newpipe.xml new file mode 100644 index 000000000..e95f5b4ac --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_newpipe.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_newpipe.png b/app/src/main/res/drawable-hdpi/ic_stat_newpipe.png new file mode 100644 index 000000000..dc08d67ff Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_newpipe.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_newpipe.png b/app/src/main/res/drawable-mdpi/ic_stat_newpipe.png new file mode 100644 index 000000000..4af6c74df Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_newpipe.png differ diff --git a/app/src/main/res/drawable-night/ic_notifications.xml b/app/src/main/res/drawable-night/ic_notifications.xml new file mode 100644 index 000000000..3e8c858ef --- /dev/null +++ b/app/src/main/res/drawable-night/ic_notifications.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_newpipe.png b/app/src/main/res/drawable-xhdpi/ic_stat_newpipe.png new file mode 100644 index 000000000..5c5750ce5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_newpipe.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_newpipe.png b/app/src/main/res/drawable-xxhdpi/ic_stat_newpipe.png new file mode 100644 index 000000000..48748bfd2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_newpipe.png differ diff --git a/app/src/main/res/drawable/ic_notifications.xml b/app/src/main/res/drawable/ic_notifications.xml new file mode 100644 index 000000000..024381816 --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_channels_notifications.xml b/app/src/main/res/layout/fragment_channels_notifications.xml new file mode 100644 index 000000000..d1ae01bfe --- /dev/null +++ b/app/src/main/res/layout/fragment_channels_notifications.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_notification_config.xml b/app/src/main/res/layout/item_notification_config.xml new file mode 100644 index 000000000..b68692dd7 --- /dev/null +++ b/app/src/main/res/layout/item_notification_config.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/menu/menu_channel.xml b/app/src/main/res/menu/menu_channel.xml index af9020626..d6c54b680 100644 --- a/app/src/main/res/menu/menu_channel.xml +++ b/app/src/main/res/menu/menu_channel.xml @@ -17,6 +17,13 @@ android:title="@string/share" app:showAsAction="ifRoom" /> + + %s видео %s видео + + %s новое видео + %s новых видео + %s новых видео + Удалить этот элемент из истории поиска? Главная страница Пустая страница @@ -683,4 +688,20 @@ Удалять элементы смахиванием Не начинать просмотр видео в мини-плеере, но сразу переключиться в полноэкранный режим, если автовращение экрана заблокировано. Вы можете переключиться на мини-плеер, выйдя из полноэкранного режима. Начинать просмотр в полноэкранном режиме + Уведомления + Новые видео + Уведомления о новых видео в подписках + Частота проверки + Уведомлять о новых видео + Получать уведомления о новых видео из каналов, на которые Вы подписаны + Каждый час + Каждые 2 часа + Каждые 3 часа + Дважды в день + Каждый день + Тип подключения + Любая сеть + Уведомления отключены + Уведомлять + Вы подписались на канал \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e5180c51e..91166c754 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -443,4 +443,5 @@ 快进 / 快退的单位时间 清除下载历史记录 删除下载了的文件 + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 63ca8f827..1592b8b59 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -132,4 +132,5 @@ 使用粗略快查 添加到 選擇標籤 + \ No newline at end of file diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 9261dfae1..42d2233c8 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -1260,4 +1260,34 @@ recaptcha_cookies_key + enable_streams_notifications + streams_notifications_interval + 3 + + 1 + 2 + 3 + 12 + 24 + + + @string/every_hour + @string/every_two_hours + @string/every_three_hours + @string/twice_per_day + @string/every_day + + streams_notifications_network + any + wifi + @string/streams_notifications_network_wifi + + @string/streams_notifications_network_any + @string/streams_notifications_network_wifi + + + @string/any_network + @string/wifi_only + + streams_notifications_channels diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e5600b5e6..f84cc83b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - + @@ -180,6 +180,7 @@ Always Just Once File + Notifications newpipe NewPipe Notification Notifications for NewPipe background and popup players @@ -189,6 +190,9 @@ newpipeHash Video Hash Notification Notifications for video hashing progress + newpipeNewStreams + New streams + Notifications about new streams for subscriptions [Unknown] Switch to Background Switch to Popup @@ -309,6 +313,10 @@ No comments Comments are disabled + + %s new stream + %s new streams + Start Pause @@ -513,6 +521,17 @@ 240p 144p + + New streams notifications + Notify about new streams from subscriptions + Checking frequency + Every hour + Every 2 hours + Every 3 hours + Twice per day + Every day + Required network connection + Any network Updates Show a notification to prompt app update when a new version is available @@ -703,4 +722,10 @@ Error at Show Channel Details Loading Channel Details… + Notifications are disabled + Get notified + You now subscribed to this channel + , + %s • %s + %d/%d \ No newline at end of file diff --git a/app/src/main/res/xml/notifications_settings.xml b/app/src/main/res/xml/notifications_settings.xml new file mode 100644 index 000000000..4390dc48c --- /dev/null +++ b/app/src/main/res/xml/notifications_settings.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + diff --git a/app/src/release/res/xml/main_settings.xml b/app/src/release/res/xml/main_settings.xml index 1d5241102..e999aa8c4 100644 --- a/app/src/release/res/xml/main_settings.xml +++ b/app/src/release/res/xml/main_settings.xml @@ -1,5 +1,6 @@ - @@ -40,6 +41,12 @@ android:title="@string/settings_category_notification_title" app:iconSpaceReserved="false" /> + +