getBefore(ZonedDateTime time) {
return _getAccess(DateHelper.toEpoch(time));
}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/mal/model/BroadcastInfo.java b/app/src/main/java/io/github/shadow578/tenshi/mal/model/BroadcastInfo.java
index 493b740..cb1099b 100644
--- a/app/src/main/java/io/github/shadow578/tenshi/mal/model/BroadcastInfo.java
+++ b/app/src/main/java/io/github/shadow578/tenshi/mal/model/BroadcastInfo.java
@@ -1,12 +1,15 @@
package io.github.shadow578.tenshi.mal.model;
+import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import com.google.gson.annotations.SerializedName;
import java.time.LocalTime;
+import java.time.ZonedDateTime;
import io.github.shadow578.tenshi.mal.model.type.DayOfWeek;
+import io.github.shadow578.tenshi.util.DateHelper;
/**
* information about the broadcast schedule of a {@link Anime}
@@ -25,4 +28,25 @@ public final class BroadcastInfo {
@SerializedName("start_time")
@ColumnInfo(name = "start_time")
public LocalTime startTime;
+
+
+ /**
+ * get the next time this broadcast is scheduled.
+ * this does not check for the end of a anime, only for the broadcast time and weekday
+ *
+ * @param start the start date and time to check from
+ * @return the next scheduled broadcast
+ */
+ @NonNull
+ public ZonedDateTime getNextBroadcast(@NonNull ZonedDateTime start) {
+ // change the time to be correct
+ ZonedDateTime nextBroadcast = start.with(startTime);
+
+ // increment current date until we find a date with the right weekday
+ while (!DateHelper.convertDayOfWeek(nextBroadcast.getDayOfWeek()).equals(weekday))
+ nextBroadcast = nextBroadcast.plusDays(1);
+
+ // this should be the next scheduled broadcast
+ return nextBroadcast;
+ }
}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/mal/model/type/DayOfWeek.java b/app/src/main/java/io/github/shadow578/tenshi/mal/model/type/DayOfWeek.java
index b01e544..5f695f8 100644
--- a/app/src/main/java/io/github/shadow578/tenshi/mal/model/type/DayOfWeek.java
+++ b/app/src/main/java/io/github/shadow578/tenshi/mal/model/type/DayOfWeek.java
@@ -1,5 +1,7 @@
package io.github.shadow578.tenshi.mal.model.type;
+import androidx.annotation.NonNull;
+
import com.google.gson.annotations.SerializedName;
/**
@@ -46,5 +48,57 @@ public enum DayOfWeek {
* sunday
*/
@SerializedName("sunday")
- Sunday
+ Sunday;
+
+ /**
+ * @return the next weekday
+ */
+ @NonNull
+ public DayOfWeek next() {
+ switch (this) {
+ default:
+ // this won't ever happen, so it doesn't matter
+ case Monday:
+ return Tuesday;
+ case Tuesday:
+ return Wednesday;
+ case Wednesday:
+ return Thursday;
+ case Thursday:
+ return Friday;
+ case Friday:
+ return Saturday;
+ case Saturday:
+ return Sunday;
+ case Sunday:
+ return Monday;
+ }
+ }
+
+ /**
+ * @return the previous weekday
+ */
+ @NonNull
+ public DayOfWeek previous() {
+ switch (this) {
+ default:
+ // this won't ever happen, so it doesn't matter
+ case Monday:
+ return Sunday;
+ case Tuesday:
+ return Monday;
+ case Wednesday:
+ return Tuesday;
+ case Thursday:
+ return Wednesday;
+ case Friday:
+ return Thursday;
+ case Saturday:
+ return Friday;
+ case Sunday:
+ return Saturday;
+ }
+ }
+
+
}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/NotificationPublisher.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/NotificationPublisher.java
new file mode 100644
index 0000000..55b04d7
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/NotificationPublisher.java
@@ -0,0 +1,91 @@
+package io.github.shadow578.tenshi.notifications;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationManagerCompat;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.isNull;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.notNull;
+
+/**
+ * publishes a notification in a broadcast receiver
+ */
+public class NotificationPublisher extends BroadcastReceiver {
+
+ /**
+ * intent action for publishing a new notification
+ */
+ private static final String ACTION_PUBLISH_NOTIFICATION = "io.github.shadow578.tenshi.notifications.PUBLISH_NOTIFICATION";
+
+ /**
+ * the notification id of the notification to publish
+ */
+ private static final String EXTRA_NOTIFICATION_ID = "notificationId";
+
+ /**
+ * the actual notification to publish
+ */
+ private static final String EXTRA_NOTIFICATION_CONTENT = "notificationData";
+
+ /**
+ * create a intent for publishing a notification
+ *
+ * @param ctx the context to work in
+ * @param notificationId the notification id to use
+ * @param notification the notification
+ * @return the intent for the broadcast
+ */
+ @NonNull
+ public static Intent getIntent(@NonNull Context ctx, int notificationId, @NonNull Notification notification) {
+ final Intent nfIntent = new Intent(ctx, NotificationPublisher.class);
+ nfIntent.setAction(ACTION_PUBLISH_NOTIFICATION);
+ nfIntent.putExtra(EXTRA_NOTIFICATION_ID, notificationId);
+ nfIntent.putExtra(EXTRA_NOTIFICATION_CONTENT, notification);
+ return nfIntent;
+ }
+
+ /**
+ * create a pending intent for publishing a notification.
+ * if a intent for the same notification id was already created, it is updated.
+ *
+ * @param ctx the context to work in
+ * @param notificationId the notification id to use
+ * @param notification the notification
+ * @return the intent for the broadcast
+ */
+ @NonNull
+ public static PendingIntent getPendingIntent(@NonNull Context ctx, int notificationId, @NonNull Notification notification) {
+ final Intent nfIntent = getIntent(ctx, notificationId, notification);
+ return PendingIntent.getBroadcast(ctx, notificationId, nfIntent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ @Override
+ public void onReceive(Context ctx, Intent intent) {
+ // ensure context and intent are not null
+ // and the intent has the right action
+ if (notNull(ctx)
+ && notNull(intent)
+ && ACTION_PUBLISH_NOTIFICATION.equals(intent.getAction())) {
+ // looking good, get values from extra
+ final Notification notification = intent.getParcelableExtra(EXTRA_NOTIFICATION_CONTENT);
+ final int notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0);
+
+ // make sure we have a notification to publish
+ // default id is fine
+ if (isNull(notification)) {
+ Log.e("Tenshi", "ScheduledNotificationsPublisher received empty notification (id " + notificationId + ")");
+ return;
+ }
+
+ // publish notification
+ NotificationManagerCompat.from(ctx)
+ .notify(notificationId, notification);
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/TenshiNotificationChannel.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/TenshiNotificationChannel.java
new file mode 100644
index 0000000..d234163
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/TenshiNotificationChannel.java
@@ -0,0 +1,167 @@
+package io.github.shadow578.tenshi.notifications;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.StringRes;
+import androidx.core.app.NotificationManagerCompat;
+
+import io.github.shadow578.tenshi.R;
+import io.github.shadow578.tenshi.extensionslib.lang.BiFunction;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.isNull;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.nullOrWhitespace;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.withRet;
+
+/**
+ * Tenshi notification channels registry and management.
+ *
+ * all channels registered should be registered on app start using {@link #registerAll(Context, BiFunction)}, so
+ * they don't have to be initialized before sending a notification
+ */
+public enum TenshiNotificationChannel {
+ /**
+ * default notification channel.
+ *
+ * only for use when testing stuff (and the actual channel is not setup yet) or for notifications that are normally not shown
+ */
+ Default("io.github.shadow578.tenshi.notifications.DEFAULT",
+ R.string.notify_channel_default_name,
+ R.string.notify_channel_default_desc);
+
+// region boring background stuff
+ /**
+ * notification channel ID
+ */
+ @NonNull
+ private final String id;
+
+ /**
+ * display name of this channel. stringRes
+ */
+ @Nullable
+ @StringRes
+ private final Integer nameRes;
+
+ /**
+ * display description of this channel. stringRes
+ */
+ @Nullable
+ @StringRes
+ private final Integer descRes;
+
+ /**
+ * define a new notification channel. the channel will have no description and a fallback name
+ *
+ * @param id the channel ID
+ */
+ @SuppressWarnings("unused")
+ TenshiNotificationChannel(@NonNull String id) {
+ this.id = id;
+ nameRes = null;
+ descRes = null;
+ }
+
+ /**
+ * define a new notification channel. the channel will have no description
+ *
+ * @param id the channel ID
+ * @param name the display name of the channel
+ */
+ @SuppressWarnings("unused")
+ TenshiNotificationChannel(@NonNull String id, @StringRes int name) {
+ this.id = id;
+ nameRes = name;
+ descRes = null;
+ }
+
+ /**
+ * define a new notification channel
+ *
+ * @param id the channel ID
+ * @param name the display name of the channel
+ * @param desc the display description of the channel
+ */
+ TenshiNotificationChannel(@NonNull String id, @StringRes int name, @StringRes int desc) {
+ this.id = id;
+ nameRes = name;
+ descRes = desc;
+ }
+
+ /**
+ * @return id of this channel definition
+ */
+ @NonNull
+ @Override
+ public String toString() {
+ return id();
+ }
+
+ /**
+ * @return id of this channel definition
+ */
+ @NonNull
+ public String id() {
+ return id;
+ }
+
+ /**
+ * create the notification channel from the definition
+ *
+ * @param ctx the context to resolve strings in
+ * @return the channel, with id, name, desc and importance set
+ */
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ @NonNull
+ private NotificationChannel createChannel(@NonNull Context ctx) {
+ // get name and description
+ final String chId = id();
+ final String chName = withRet(nameRes, chId, ctx::getString);
+ final String chDesc = withRet(descRes, "", ctx::getString);
+
+ // create the channel
+ final NotificationChannel ch = new NotificationChannel(chId, chName, NotificationManager.IMPORTANCE_DEFAULT);
+ if (!nullOrWhitespace(chDesc))
+ ch.setDescription(chDesc);
+ return ch;
+ }
+
+ /**
+ * register all notification channels
+ *
+ * @param ctx the context to register in
+ * @param cfgFunc channel configuration function. while the display name and description can be handled by the channel definition itself, this function
+ * allows for changes to the notification channels before they are registered, aswell as disabling channels completely.
+ * signature of the function:
+ *
+ * boolean cfgFunc({@link TenshiNotificationChannel} value, {@link NotificationChannel} channel);
+ *
+ * where the return value indicates if the channel should be registered.
+ * if set to null, all channels are registered with default values
+ */
+ public static void registerAll(@NonNull Context ctx, @Nullable BiFunction cfgFunc) {
+ // check SDK level
+ // notification channels are only supported on API 26 and up
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
+ return;
+
+ // get notification manager
+ final NotificationManagerCompat notifyMgr = NotificationManagerCompat.from(ctx);
+
+ // register channels
+ for (TenshiNotificationChannel ch : TenshiNotificationChannel.values()) {
+ // create the channel and pass to configure function
+ final NotificationChannel channel = ch.createChannel(ctx);
+ if (isNull(cfgFunc) || cfgFunc.invoke(ch, channel)) {
+ // configure is OK or skipped, register the channel
+ notifyMgr.createNotificationChannel(channel);
+ }
+ }
+ }
+ //endregion
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/TenshiNotificationManager.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/TenshiNotificationManager.java
new file mode 100644
index 0000000..10e380d
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/TenshiNotificationManager.java
@@ -0,0 +1,190 @@
+package io.github.shadow578.tenshi.notifications;
+
+import android.app.AlarmManager;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.AlarmManagerCompat;
+import androidx.core.app.NotificationCompat;
+
+import java.time.ZonedDateTime;
+import java.util.Random;
+
+import io.github.shadow578.tenshi.R;
+import io.github.shadow578.tenshi.util.DateHelper;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.cast;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.isNull;
+
+/**
+ * manager to send and schedule notifications
+ */
+public class TenshiNotificationManager {
+
+ /**
+ * context from init
+ */
+ @NonNull
+ private final Context ctx;
+
+ /**
+ * random
+ */
+ @NonNull
+ private final Random rnd = new Random();
+
+ public TenshiNotificationManager(@NonNull Context ctx) {
+ this.ctx = ctx;
+ }
+
+ /**
+ * create a new notification builder in the given channel, with the app icon set as {@link NotificationCompat.Builder#setSmallIcon(int)}
+ *
+ * @param channel the channel for the notification
+ * @return the notification builder
+ */
+ public NotificationCompat.Builder notificationBuilder(@NonNull TenshiNotificationChannel channel) {
+ return new NotificationCompat.Builder(ctx, channel.id())
+ .setSmallIcon(R.drawable.ic_notify_icon_24);
+ }
+
+ /**
+ * get a random notification id
+ *
+ * @return random notification id
+ */
+ public int randomId() {
+ return rnd.nextInt();
+ }
+
+ // region immediately
+
+ /**
+ * immediately send a notification
+ *
+ * @param notification the notification
+ */
+ public void sendNow(@NonNull Notification notification) {
+ sendNow(randomId(), notification);
+ }
+
+ /**
+ * immediately send a notification
+ *
+ * @param notificationId the notification id
+ * @param notification the notification
+ */
+ public void sendNow(int notificationId, @NonNull Notification notification) {
+ ctx.sendBroadcast(NotificationPublisher.getIntent(ctx, notificationId, notification));
+ }
+
+ // endregion
+
+ // region scheduled
+
+ /**
+ * send a notification after a delay
+ * {@link #sendAt(int, Notification, long)}
+ *
+ * @param notification the notification
+ * @param delay the millisecond delay until publishing the notification
+ */
+ public void sendIn(@NonNull Notification notification, long delay) {
+ sendIn(randomId(), notification, delay);
+ }
+
+ /**
+ * send a notification after a delay
+ * {@link #sendAt(int, Notification, long)}
+ *
+ * @param notificationId the notification id
+ * @param notification the notification
+ * @param delay the millisecond delay until publishing the notification
+ */
+ public void sendIn(int notificationId, @NonNull Notification notification, long delay) {
+ sendAt(notificationId, notification, System.currentTimeMillis() + delay);
+ }
+
+ /**
+ * send a notification at a specific time
+ * {@link #sendAt(int, Notification, long)}
+ *
+ * @param notification the notification
+ * @param time the time to send the notification at.
+ */
+ public void sendAt(@NonNull Notification notification, @NonNull ZonedDateTime time) {
+ sendAt(randomId(), notification, time);
+ }
+
+ /**
+ * send a notification at a specific time
+ * {@link #sendAt(int, Notification, long)}
+ *
+ * @param notificationId the notification id
+ * @param notification the notification
+ * @param time the time to send the notification at.
+ */
+ public void sendAt(int notificationId, @NonNull Notification notification, @NonNull ZonedDateTime time) {
+ // calculate millis timestamp to send at, in the system timezone
+ // multiply by 1000 to get to millis timestamp
+ final long timestampMillis = DateHelper.toEpoch(time) * 1000;
+
+ // send notification
+ sendAt(notificationId, notification, timestampMillis);
+ }
+
+ /**
+ * send a notification at a specific time, using AlarmManager.
+ *
+ * @param notification the notification
+ * @param timestamp the time to send the notification at. see {@link System#currentTimeMillis()}
+ */
+ public void sendAt(@NonNull Notification notification, long timestamp) {
+ sendAt(randomId(), notification, timestamp);
+ }
+
+ /**
+ * send a notification at a specific time, using AlarmManager.
+ *
+ * @param notificationId the notification id
+ * @param notification the notification
+ * @param timestamp the time to send the notification at. see {@link System#currentTimeMillis()}
+ */
+ public void sendAt(int notificationId, @NonNull Notification notification, long timestamp) {
+ // ensure the time is not in the past
+ // if the time is in the past, log a error and adjust the target to send right away.
+ if (System.currentTimeMillis() >= timestamp) {
+ Log.e("Tenshi", "NotificationHelper#sendAt() target time is in the past! sending asap");
+ timestamp = System.currentTimeMillis() + 10000;
+ }
+
+ //TODO log sendat time
+ final ZonedDateTime time = DateHelper.fromEpoc(timestamp / 1000);
+ Log.i("TenshiNotify", "schedule notification for timestamp " + timestamp + "(ms); time is " + time.toString());
+
+ // create target intent
+ final PendingIntent intent = NotificationPublisher.getPendingIntent(ctx, notificationId, notification);
+
+ // get alarm manager
+ final AlarmManager alarmManager = cast(ctx.getSystemService(Context.ALARM_SERVICE));
+ if (isNull(alarmManager)) {
+ Log.e("Tenshi", "failed to get alarm manager!");
+ return;
+ }
+
+ // set the alarm
+ AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, AlarmManager.RTC_WAKEUP, timestamp, intent);
+
+ //TODO: alarm manager does not persist between reboots.
+ // this means that we'd have to store, and re- schedule, any notifications that are still pending when the device boots.
+ // in theory, this would be really simple, right?
+ // just store the notification in a database with the time it should be sent, and register a BOOT_COMPLETED listener to reschedule them...
+ // well, turns out that storing (or serializing) the notification object isn't really possible easily...
+ // so, maybe this will be added someday, but for now it's fine
+ }
+
+ // endregion
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/boot/NotificationBootListener.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/boot/NotificationBootListener.java
new file mode 100644
index 0000000..8413d94
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/boot/NotificationBootListener.java
@@ -0,0 +1,30 @@
+package io.github.shadow578.tenshi.notifications.boot;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.util.Log;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.isNull;
+
+/**
+ * boot listener for clearing scheduled notifications from the notifications database on reboots
+ */
+public class NotificationBootListener extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context ctx, Intent intent) {
+ // validate intent action
+ if (isNull(intent) || !intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED))
+ return;
+
+ // start service
+ final Intent serviceIntent = new Intent(ctx, NotificationBootService.class);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+ ctx.startForegroundService(serviceIntent);
+ else
+ ctx.startService(serviceIntent);
+
+ Log.i("Tenshi", "NotificationBootListener received ON_BOOT_COMPLETED, service started.");
+ }
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/boot/NotificationBootService.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/boot/NotificationBootService.java
new file mode 100644
index 0000000..04eb8bf
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/boot/NotificationBootService.java
@@ -0,0 +1,50 @@
+package io.github.shadow578.tenshi.notifications.boot;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import io.github.shadow578.tenshi.TenshiApp;
+import io.github.shadow578.tenshi.notifications.db.SentNotificationsDB;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.async;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.fmt;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.notNull;
+
+/**
+ * service started by {@link NotificationBootListener} to clear scheduled notifications
+ * from the notifications database on reboot
+ */
+public class NotificationBootService extends Service {
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ // cannot bind this service
+ return null;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ Log.i("Tenshi", "NotificationBootService started");
+
+ // get or create DB instance if we don't have one
+ final SentNotificationsDB db;
+ if (notNull(TenshiApp.INSTANCE))
+ db = TenshiApp.getNotifyDB();
+ else
+ db = SentNotificationsDB.create(getApplicationContext());
+
+ // clear all scheduled so they can be re- sent
+ async(() -> db.notificationsDB().removeScheduled(), (removed) -> {
+ Log.i("Tenshi", fmt("removed %d scheduled notification entries in ON_BOOT", removed));
+
+ // stop this service
+ stopSelf();
+ });
+
+ return super.onStartCommand(intent, flags, startId);
+ }
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/db/SentNotificationInfo.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/db/SentNotificationInfo.java
new file mode 100644
index 0000000..e7bbfec
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/db/SentNotificationInfo.java
@@ -0,0 +1,117 @@
+package io.github.shadow578.tenshi.notifications.db;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.StringJoiner;
+
+import io.github.shadow578.tenshi.util.DateHelper;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.fmt;
+
+/**
+ * info class to hold information to identify a notification
+ */
+@Entity(tableName = "sent_notifications")
+public class SentNotificationInfo {
+
+ /**
+ * create a notification info from notification details
+ *
+ * @param timeToLive how long the info should live
+ * @param notificationId the notification ID
+ * @param isScheduledNotification is this a notification that is scheduled? see {@link #isScheduledNotification}
+ * @param contentTitle the title of the notification
+ * @param contentText the main text of the notification
+ * @param channelID the channel id the notification is posted in
+ * @param extra additional notification parameter. may be empty
+ * @return the notification info
+ */
+ @NonNull
+ public static SentNotificationInfo create(@NonNull Duration timeToLive,
+ int notificationId,
+ boolean isScheduledNotification,
+ @NonNull String contentTitle,
+ @NonNull String contentText,
+ @NonNull String channelID,
+ @NonNull String... extra) {
+ // create identifier
+ final SentNotificationInfo info = new SentNotificationInfo();
+ final StringJoiner extraBuilder = new StringJoiner("; ");
+ for (String e : extra)
+ extraBuilder.add(e);
+
+ info.description = fmt("id: %d; title: %s; text: %s; channel: %s; extra: %s",
+ notificationId, contentTitle, contentText, channelID,
+ extraBuilder.toString());
+ info.notificationIdentifier = longHash(info.description);
+
+ // set expiration
+ info.expirationTimestamp = DateHelper.toEpoch(DateHelper.getLocalTime().plus(timeToLive));
+ info.isScheduledNotification = isScheduledNotification;
+ return info;
+ }
+
+ /**
+ * calculate the hash of a string, but using a 64bit hash instead of 32 bit
+ *
+ * @param string the string to hash
+ * @return the hash of the string
+ */
+ private static long longHash(String string) {
+ long h = 1125899906842597L;
+ for (char c : string.toCharArray()) {
+ h = 31 * h + c;
+ }
+ return h;
+ }
+
+ /**
+ * the unique notification identifier. this is unique for each notification with equal content
+ */
+ @PrimaryKey
+ @ColumnInfo(name = "identifier")
+ public long notificationIdentifier;
+
+ /**
+ * cleartext notification identifier
+ * TODO for testing only, remove in production
+ */
+ @NonNull
+ public String description = "";
+
+ /**
+ * when this notification expires
+ */
+ @ColumnInfo(name = "expiration")
+ public long expirationTimestamp;
+
+ /**
+ * is this a scheduled notification?
+ * scheduled notifications are expired on device reboot, as the
+ * {@link io.github.shadow578.tenshi.notifications.TenshiNotificationManager} does not
+ * handle re- scheduling notifications on reboot
+ */
+ @ColumnInfo(name = "is_scheduled")
+ public boolean isScheduledNotification;
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o)
+ return true;
+ if (o == null || getClass() != o.getClass())
+ return false;
+
+ final SentNotificationInfo that = (SentNotificationInfo) o;
+ return notificationIdentifier == that.notificationIdentifier;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(notificationIdentifier);
+ }
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/db/SentNotificationsDAO.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/db/SentNotificationsDAO.java
new file mode 100644
index 0000000..266efe3
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/db/SentNotificationsDAO.java
@@ -0,0 +1,134 @@
+package io.github.shadow578.tenshi.notifications.db;
+
+import androidx.room.Dao;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+import androidx.room.Transaction;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import io.github.shadow578.tenshi.util.DateHelper;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.notNull;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.nullOrEmpty;
+
+/**
+ * DAO for accessing sent notification info
+ */
+@Dao
+public abstract class SentNotificationsDAO {
+
+ /**
+ * remove all notification info that are expired
+ *
+ * @return the number of removed entries
+ */
+ @Transaction
+ public int removeExpired() {
+ // get epoch from time
+ final long epochNow = DateHelper.toEpoch(DateHelper.getLocalTime());
+
+ // find all that expired
+ final List expired = _getExpired(epochNow);
+
+ // cancel if none are expired
+ if (nullOrEmpty(expired))
+ return 0;
+
+ // map to a list of identifiers
+ final List expiredIdentifiers = expired.stream()
+ .map((info) -> info.notificationIdentifier)
+ .collect(Collectors.toList());
+
+ // remove all expired
+ _deleteAll(expiredIdentifiers);
+ return expired.size();
+ }
+
+ /**
+ * remove all scheduled notifications from the db
+ *
+ * @return the number of removed entries
+ */
+ @Transaction
+ public int removeScheduled() {
+ // find all that are scheduled
+ final List scheduled = _getScheduled();
+
+ // cancel if none found
+ if (nullOrEmpty(scheduled))
+ return 0;
+
+ // map to a list of identifiers
+ final List expiredIdentifiers = scheduled.stream()
+ .map((info) -> info.notificationIdentifier)
+ .collect(Collectors.toList());
+
+ // remove all expired
+ _deleteAll(expiredIdentifiers);
+ return scheduled.size();
+ }
+
+ /**
+ * insert a info into the database if it was not present
+ *
+ * @param info the info to insert
+ * @return was the info inserted? if it was already present, the this will be false
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ @Transaction
+ public boolean insertIfNotPresent(SentNotificationInfo info) {
+ // find info with that id, abort if found
+ final SentNotificationInfo sentInfo = getInfo(info.notificationIdentifier);
+ if (notNull(sentInfo) && sentInfo.equals(info))
+ return false;
+
+ // not found, insert
+ _insert(info);
+ return true;
+ }
+
+ /**
+ * get a notification info with the given identifier
+ *
+ * @param id the identifier to find
+ * @return the notification info, or null if not found
+ */
+ @Query("SELECT * FROM sent_notifications WHERE identifier = :id")
+ public abstract SentNotificationInfo getInfo(long id);
+
+ /**
+ * insert a notification info into the db
+ *
+ * @param info the notification info to insert
+ */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ protected abstract void _insert(SentNotificationInfo info);
+
+ /**
+ * get all notification info that should be expired
+ *
+ * @param epochNow the current epoch time
+ * @return a list of all expired notification info
+ */
+ @Query("SELECT * FROM sent_notifications WHERE expiration < :epochNow")
+ protected abstract List _getExpired(long epochNow);
+
+ /**
+ * get all notification info that have the scheduled flag
+ *
+ * @return a list of all scheduled notification info
+ */
+ @Query("SELECT * FROM sent_notifications WHERE is_scheduled = 1")
+ protected abstract List _getScheduled();
+
+ /**
+ * delete all notification info with the given identifiers
+ *
+ * @param identifiersToDelete the list of identifiers to delete
+ */
+ @Query("DELETE FROM sent_notifications WHERE identifier IN (:identifiersToDelete)")
+ protected abstract void _deleteAll(List identifiersToDelete);
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/db/SentNotificationsDB.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/db/SentNotificationsDB.java
new file mode 100644
index 0000000..9be70f6
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/db/SentNotificationsDB.java
@@ -0,0 +1,50 @@
+package io.github.shadow578.tenshi.notifications.db;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.room.Database;
+import androidx.room.Room;
+import androidx.room.RoomDatabase;
+
+import java.io.File;
+
+/**
+ * database to store information about sent notifications
+ */
+@Database(entities = {
+ SentNotificationInfo.class
+}, version = 3)
+public abstract class SentNotificationsDB extends RoomDatabase {
+ /**
+ * database name
+ */
+ public static final String DB_NAME = "notify_db";
+
+ /**
+ * create a new instance of the database
+ *
+ * @param ctx the context to work in
+ * @return the database instance
+ */
+ public static SentNotificationsDB create(@NonNull Context ctx) {
+ return Room.databaseBuilder(ctx, SentNotificationsDB.class, DB_NAME)
+ .fallbackToDestructiveMigration()
+ .build();
+ }
+
+ /**
+ * get the absolute path to the database file
+ *
+ * @param ctx the context to work in
+ * @return the path to the database file
+ */
+ public static File getDatabasePath(@NonNull Context ctx) {
+ return ctx.getDatabasePath(DB_NAME);
+ }
+
+ /**
+ * @return dao for accessing sent notification info
+ */
+ public abstract SentNotificationsDAO notificationsDB();
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/AiringAnimeWorker.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/AiringAnimeWorker.java
new file mode 100644
index 0000000..274263b
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/AiringAnimeWorker.java
@@ -0,0 +1,272 @@
+package io.github.shadow578.tenshi.notifications.workers;
+
+import android.app.Notification;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.work.Constraints;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import io.github.shadow578.tenshi.BuildConfig;
+import io.github.shadow578.tenshi.mal.model.Anime;
+import io.github.shadow578.tenshi.mal.model.BroadcastInfo;
+import io.github.shadow578.tenshi.mal.model.UserLibraryEntry;
+import io.github.shadow578.tenshi.mal.model.type.BroadcastStatus;
+import io.github.shadow578.tenshi.mal.model.type.LibraryEntryStatus;
+import io.github.shadow578.tenshi.notifications.TenshiNotificationChannel;
+import io.github.shadow578.tenshi.notifications.db.SentNotificationInfo;
+import io.github.shadow578.tenshi.util.DateHelper;
+import io.github.shadow578.tenshi.util.TenshiPrefs;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.fmt;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.isNull;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.listOf;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.notNull;
+
+/**
+ * notification worker for currently airing anime updates
+ */
+public class AiringAnimeWorker extends WorkerBase {
+ /**
+ * get the constrains that are placed on the execution of this worker
+ *
+ * @param ctx context to work in
+ * @return the constrains
+ */
+ @NonNull
+ public static Constraints getConstrains(@NonNull Context ctx) {
+ //TODO constrains configuration in props
+ return new Constraints.Builder()
+ .setRequiresBatteryNotLow(true)
+ .build();
+ }
+
+ /**
+ * @param ctx the context to run in
+ * @return should this worker in in the given context?
+ */
+ public static boolean shouldEnable(@NonNull Context ctx) {
+ TenshiPrefs.init(ctx);
+ return TenshiPrefs.getBool(TenshiPrefs.Key.EnableNotifications, false);
+ }
+
+ /**
+ * @param ctx context to run in
+ * @return a list of all categories to check in this worker
+ */
+ @NonNull
+ public static List getCategories(@NonNull Context ctx) {
+ //TODO load from prefs
+ return listOf(LibraryEntryStatus.Watching, LibraryEntryStatus.PlanToWatch);
+ }
+
+ public AiringAnimeWorker(@NotNull Context context, @NotNull WorkerParameters workerParams) {
+ super(context, workerParams);
+ }
+
+ /**
+ * wrapped function for {@link Worker#doWork()}.
+ * only runs if {@link #shouldRun()} is true
+ *
+ * @return work result
+ */
+ @Override
+ protected Result run() {
+ try {
+ // run the checks
+ checkAiring();
+
+ if(BuildConfig.DEBUG) {
+ //TODO dev testing
+ // write run time to prefs
+ appendRunInfo(DateHelper.getLocalTime().toString() + " success");
+ }
+ return Result.success();
+ } catch (Exception e) {
+ // idk, retry on error
+ e.printStackTrace();
+
+ if (BuildConfig.DEBUG) {
+ //TODO dev testing
+ // write error info to prefs instead of last run date
+ StringWriter b = new StringWriter();
+ e.printStackTrace(new PrintWriter(b));
+ appendRunInfo(DateHelper.getLocalTime().toString() + " failed (" + e.toString() + ":" + b.toString() + ")");
+
+ // send a notification with the error
+ Notification n = getNotifyManager().notificationBuilder(TenshiNotificationChannel.Default)
+ .setContentTitle("MediaUpdateNotificationsWorker exception")
+ .setContentText(e.toString() + ": " + b.toString())
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(e.toString() + ": " + b.toString()))
+ .build();
+ getNotifyManager().sendNow(n);
+ }
+
+ return Result.retry();
+ }
+ }
+
+ /**
+ * check all anime with library category {@link #getCategories(Context)} that are currently in the db for
+ * if they air soon
+ */
+ private void checkAiring() {
+ //get nsfw preference
+ requirePrefs();
+ final boolean showNSFW = TenshiPrefs.getBool(TenshiPrefs.Key.NSFW, false);
+
+ // get all anime we want to check if they are airing soon
+ final List animeForAiringCheck = getEntries(getDB(), getCategories(getApplicationContext()), showNSFW);
+
+ // get the current time and weekday in japan
+ final ZonedDateTime now = DateHelper.getJapanTime();
+ final LocalDate nowDate = now.toLocalDate();
+
+ // check each entry
+ for (UserLibraryEntry a : animeForAiringCheck) {
+ final Anime anime = a.anime;
+
+ //TODO overwrite broadcast schedule for Higehiro to be on the current weekday
+ // as I keep missing the notification for testing
+ if (TenshiPrefs.getBool(TenshiPrefs.Key.DEV_EnableHigehiroOverwrite, false)
+ && anime.animeId == 40938) {
+ anime.broadcastInfo = new BroadcastInfo();
+ anime.broadcastInfo.weekday = DateHelper.convertDayOfWeek(now.getDayOfWeek());
+ anime.broadcastInfo.startTime = now.toLocalTime().plusMinutes(5);
+ anime.endDate = LocalDate.of(2022, 12, 24);
+ anime.broadcastStatus = BroadcastStatus.CurrentlyAiring;
+ Log.w("Tenshi", "MediaUpdateNotification overwrite for 40938 / Higehiro: air on " + anime.broadcastInfo.weekday.name() + " at " + anime.broadcastInfo.startTime.toString());
+ }
+
+ // check if anime broadcast info is valid
+ // and anime is currently airing
+ if (isNull(anime.broadcastStatus)
+ || !anime.broadcastStatus.equals(BroadcastStatus.CurrentlyAiring)
+ || isNull(anime.broadcastInfo)
+ || isNull(anime.broadcastInfo.startTime)
+ || isNull(anime.broadcastInfo.weekday))
+ continue;
+
+ // check if anime start date is set and not within the next 7 days
+ if (notNull(anime.startDate)
+ && nowDate.until(anime.startDate, ChronoUnit.DAYS) > 7)
+ continue;
+
+ // check if anime end date is set and in the past
+ // (anime already ended)
+ if (notNull(anime.endDate)
+ && nowDate.until(anime.endDate, ChronoUnit.DAYS) < 0)
+ continue;
+
+ // get the next broadcast day and time
+ final ZonedDateTime nextBroadcast = anime.broadcastInfo.getNextBroadcast(now);
+
+ // check if less than 2h but not in the past
+ final long untilNextBroadcast = now.until(nextBroadcast, ChronoUnit.MINUTES);
+ if (untilNextBroadcast >= 0 && untilNextBroadcast <= 180) {
+ // airs soon, schedule notification for then
+ // check how long ago the start date was.
+ // if it's in less than a week (< 7) and not in the past (>= 0), assume this is the premiere of the anime
+ boolean isPremiere = false;
+ if (notNull(anime.startDate)) {
+ final long timeSinceStart = nowDate.until(anime.startDate, ChronoUnit.DAYS);
+ isPremiere = timeSinceStart >= 0 && timeSinceStart < 7;
+ }
+
+ // send notification
+ sendNotificationFor(a, nextBroadcast, isPremiere);
+ }
+ }
+ }
+
+ /**
+ * send a notification for a airing anime
+ *
+ * @param libraryEntry the anime library entry to send the notification for
+ * @param nextBroadcast the next scheduled broadcast time of the anime
+ * @param isPremiere is this broadcast the first broadcast?
+ */
+ private void sendNotificationFor(@NonNull UserLibraryEntry libraryEntry, @NonNull ZonedDateTime nextBroadcast, boolean isPremiere) {
+ // get anime from entry
+ final Anime a = libraryEntry.anime;
+
+ // get notification content
+ final String title, text;
+ if (isPremiere) {
+ title = "Upcoming Anime premiere";
+ text = fmt("%s will premiere soon!", a.title);
+
+ } else {
+ title = "Anime will air soon";
+ text = fmt("%s will air soon!", a.title);
+ }
+
+ // check if already in db, do not send if it is
+ // otherwise insert
+ if (!getNotifyDB().notificationsDB().insertIfNotPresent(SentNotificationInfo.create(Duration.ofDays(7),
+ a.animeId,
+ true,
+ title,
+ text,
+ TenshiNotificationChannel.Default.id(),
+ "at: " + nextBroadcast.toString()))) {
+ // sent this notification already, do not sent again
+ return;
+ }
+
+ // create notification
+ //TODO channel and content hardcode
+ final Notification notification = getNotifyManager().notificationBuilder(TenshiNotificationChannel.Default)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(text))
+ .setContentIntent(getDetailsOpenIntent(a.animeId))
+ .setAutoCancel(true)
+ .build();
+
+ // schedule the notification
+ getNotifyManager().sendAt(a.animeId, notification, nextBroadcast);
+ }
+
+ /**
+ * @return should this worker run?
+ */
+ @Override
+ protected boolean shouldRun() {
+ return shouldEnable(getApplicationContext());
+ }
+
+ /**
+ * TODO testing, remove asap
+ */
+ private void appendRunInfo(String s) {
+ List ri = TenshiPrefs.getObject(TenshiPrefs.Key.DEV_AiringAnimeWorkerLog, List.class, new ArrayList());
+ int toRemove = ri.size() - 9;
+ Iterator i = ri.iterator();
+ while (toRemove > 0 && i.hasNext()) {
+ i.next();
+ i.remove();
+ toRemove--;
+ }
+
+ ri.add(s);
+ TenshiPrefs.setObject(TenshiPrefs.Key.DEV_AiringAnimeWorkerLog, ri);
+ }
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/DBUpdateWorker.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/DBUpdateWorker.java
new file mode 100644
index 0000000..1f35060
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/DBUpdateWorker.java
@@ -0,0 +1,334 @@
+package io.github.shadow578.tenshi.notifications.workers;
+
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.concurrent.futures.CallbackToFutureAdapter;
+import androidx.work.Constraints;
+import androidx.work.ListenableWorker;
+import androidx.work.NetworkType;
+import androidx.work.WorkerParameters;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+import io.github.shadow578.tenshi.BuildConfig;
+import io.github.shadow578.tenshi.TenshiApp;
+import io.github.shadow578.tenshi.db.TenshiDB;
+import io.github.shadow578.tenshi.mal.AuthInterceptor;
+import io.github.shadow578.tenshi.mal.CacheInterceptor;
+import io.github.shadow578.tenshi.mal.MALErrorInterceptor;
+import io.github.shadow578.tenshi.mal.MalService;
+import io.github.shadow578.tenshi.mal.Urls;
+import io.github.shadow578.tenshi.mal.model.Token;
+import io.github.shadow578.tenshi.mal.model.UserLibraryEntry;
+import io.github.shadow578.tenshi.mal.model.UserLibraryList;
+import io.github.shadow578.tenshi.mal.model.type.LibraryEntryStatus;
+import io.github.shadow578.tenshi.mal.model.type.LibrarySortMode;
+import io.github.shadow578.tenshi.util.TenshiPrefs;
+import io.github.shadow578.tenshi.util.converter.GSONLocalDateAdapter;
+import io.github.shadow578.tenshi.util.converter.GSONLocalTimeAdapter;
+import io.github.shadow578.tenshi.util.converter.GSONZonedDateTimeAdapter;
+import io.github.shadow578.tenshi.util.converter.RetrofitEnumConverterFactory;
+import okhttp3.Cache;
+import okhttp3.OkHttpClient;
+import okhttp3.logging.HttpLoggingInterceptor;
+import retrofit2.Response;
+import retrofit2.Retrofit;
+import retrofit2.converter.gson.GsonConverterFactory;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.async;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.concat;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.elvisEmpty;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.fmt;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.isNull;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.notNull;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.nullOrEmpty;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.nullOrWhitespace;
+
+/**
+ * worker for updating the database entries
+ */
+public class DBUpdateWorker extends ListenableWorker {
+ /**
+ * get the constrains that are placed on the execution of this worker
+ *
+ * @param ctx context to work in
+ * @return the constrains
+ */
+ @NonNull
+ public static Constraints getConstrains(@NonNull Context ctx) {
+ //TODO constrains configuration in props
+
+ // set base constraints
+ final Constraints.Builder constraints = new Constraints.Builder()
+ .setRequiresBatteryNotLow(true);
+
+ // set network type constraint
+ TenshiPrefs.init(ctx);
+ if (TenshiPrefs.getBool(TenshiPrefs.Key.AllowNotificationUpdatesOnMeteredConnection, false)) {
+ constraints.setRequiredNetworkType(NetworkType.UNMETERED);
+ } else {
+ constraints.setRequiredNetworkType(NetworkType.CONNECTED);
+ }
+
+ return constraints.build();
+ }
+
+ /**
+ * @param ctx the context to run in
+ * @return should this worker in in the given context?
+ */
+ public static boolean shouldEnable(@NonNull Context ctx) {
+ return AiringAnimeWorker.shouldEnable(ctx) || RelatedAnimeWorker.shouldEnable(ctx);
+ }
+
+ /**
+ * @param ctx context to run in
+ * @return a list of all categories to check in this worker
+ */
+ @NonNull
+ public static List getCategories(@NonNull Context ctx) {
+ final HashSet categories = new HashSet<>();
+ if (AiringAnimeWorker.shouldEnable(ctx))
+ categories.addAll(AiringAnimeWorker.getCategories(ctx));
+
+ if (RelatedAnimeWorker.shouldEnable(ctx))
+ categories.addAll(RelatedAnimeWorker.getCategories(ctx));
+
+ return new ArrayList<>(categories);
+ }
+
+ public DBUpdateWorker(@NotNull Context context, @NotNull WorkerParameters workerParams) {
+ super(context, workerParams);
+ }
+
+ /**
+ * the MAL service instance
+ */
+ private MalService mal;
+
+ /**
+ * the database instance.
+ */
+ private TenshiDB db = null;
+
+ @NonNull
+ @Override
+ public ListenableFuture startWork() {
+ return CallbackToFutureAdapter.getFuture(completer -> {
+ // initialize requirements
+ if (!initMal()) {
+ // MAL init failed
+ completer.set(Result.failure());
+ } else {
+ // MAL initialized, fetch anime
+ async(() -> {
+ try {
+ for (LibraryEntryStatus category : getCategories(getApplicationContext())) {
+ // prepare list
+ final List entries = new ArrayList<>();
+
+ // fetch data
+ try {
+ fetchCategory(category, entries);
+ } catch (IOException e) {
+ //idk, print to log or something
+ e.printStackTrace();
+ }
+
+ // insert entries into DB (this updates them)
+ getDB().animeDB().insertLibraryAnime(entries);
+ }
+ } finally {
+ // all categories are done, mark work as finished
+ completer.set(Result.success());
+ }
+ });
+ }
+
+ // this string is only used for debugging, as a 'label' of the future
+ return "DBUpdateWorker_Future";
+ });
+ }
+
+ /**
+ * fetch all anime in a library category from MAL.
+ * handles paging as well
+ *
+ * @param category the category to fetch
+ * @param entries the list of all entries found so far. entries from the response are added to this list
+ * @throws IOException if the API call fails
+ */
+ private void fetchCategory(@NonNull LibraryEntryStatus category, @NonNull List entries) throws IOException {
+ // prepare fields
+ final String ANIME_FIELDS = "id,title,alternative_titles,start_date,end_date,broadcast,status";
+ final String FIELDS_TO_FETCH = concat(ANIME_FIELDS, ",related_anime{", ANIME_FIELDS, "}");
+
+ // fetch a category from MAL, synchronous
+ final Response response = mal.getCurrentUserLibrary(category, LibrarySortMode.anime_title, FIELDS_TO_FETCH, 1)
+ .execute();
+
+ handleResponse(response, entries);
+ }
+
+ /**
+ * fetch the next page of a user library request. called by {@link #handleResponse(Response, List)}
+ *
+ * @param nextPage the url of the next page to fetch
+ * @param entries the list of all entries found so far. entries from the response are added to this list
+ * @throws IOException if the API call fails
+ */
+ private void fetchNextPage(@NonNull String nextPage, @NonNull List entries) throws IOException {
+ // fetch next page from MAL, synchronous
+ final Response response = mal.getUserAnimeListPage(nextPage)
+ .execute();
+
+ handleResponse(response, entries);
+ }
+
+ /**
+ * handle a user library response.
+ * if the response contains information for the next page, the next page is fetched using {@link #fetchNextPage(String, List)}.
+ *
+ * @param response the response to handle
+ * @param entries the list of all entries found so far. entries from the response are added to this list
+ * @throws IOException if fetching of the next page fails
+ */
+ private void handleResponse(@NonNull Response response, @NonNull List entries) throws IOException {
+ if (response.isSuccessful()) {
+ // get response
+ UserLibraryList library = response.body();
+ if (notNull(library)) {
+ // insert anime into the list
+ entries.addAll(library.items);
+
+ // load next page
+ if (notNull(library.paging) && !nullOrWhitespace(library.paging.nextPage))
+ fetchNextPage(library.paging.nextPage, entries);
+ }
+ }
+ }
+
+ // region create MAL Api
+
+ /**
+ * create the MAL service retrofit instance.
+ * only valid if isUserAuthenticated is true
+ */
+ private boolean initMal() {
+ // init okhttp client
+ final OkHttpClient client = createOkHttpClient();
+ if (isNull(client))
+ return false;
+
+ // init retrofit
+ final Retrofit retrofit = new Retrofit.Builder()
+ .baseUrl(Urls.API)
+ .addConverterFactory(new RetrofitEnumConverterFactory())
+ .addConverterFactory(GsonConverterFactory.create(getGson()))
+ .client(client)
+ .build();
+
+ // create service
+ mal = retrofit.create(MalService.class);
+ return true;
+ }
+
+ /**
+ * create a OKHttp client with the required interceptors (for auth, ...)
+ * On DEBUG builds, a logging interceptor is added aswell
+ *
+ * @return the OKHttp client instance
+ */
+ private OkHttpClient createOkHttpClient() {
+ // load token from prefs
+ TenshiPrefs.init(getApplicationContext());
+ final Token tokenObj = TenshiPrefs.getObject(TenshiPrefs.Key.AuthToken, Token.class, null);
+ if (isNull(tokenObj))
+ return null;
+
+ // get token and token type
+ final String tokenType = elvisEmpty(tokenObj.type, "Bearer");
+ final String token = tokenObj.token;
+ if (nullOrEmpty(token))
+ return null;
+
+ // prepare interceptors
+ final AuthInterceptor authInterceptor = new AuthInterceptor(tokenType, token);
+ final CacheInterceptor cacheInterceptor = new CacheInterceptor(getApplicationContext());
+ final HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();
+ loggingInterceptor.setLevel(BuildConfig.DEBUG ? HttpLoggingInterceptor.Level.BODY : HttpLoggingInterceptor.Level.NONE);
+
+ // create MAL error interceptor to give info on api errors
+ final MALErrorInterceptor errorInterceptor = new MALErrorInterceptor(
+ err -> Log.e("Tenshi", fmt("error in MAL request: %s %s (hint: %s)", err.error, err.message, err.hint)));
+
+ // create OkHttp client
+ return new OkHttpClient.Builder()
+ .cache(getCache())
+ .addInterceptor(authInterceptor)
+ .addInterceptor(cacheInterceptor)
+ .addInterceptor(loggingInterceptor)
+ .addInterceptor(errorInterceptor)
+ .build();
+ }
+
+ /**
+ * create a cache for the OKHttp client
+ *
+ * @return the cache
+ */
+ private Cache getCache() {
+ long cacheSize = 50 * 1024 * 1024; // 50MiB
+ return new Cache(getApplicationContext().getCacheDir(), cacheSize);
+ }
+
+ /**
+ * @return the global GSON instance, with additional adapters
+ */
+ @NonNull
+ public static Gson getGson() {
+ return new GsonBuilder()
+ .registerTypeAdapter(LocalDate.class, new GSONLocalDateAdapter().nullSafe())
+ .registerTypeAdapter(LocalTime.class, new GSONLocalTimeAdapter().nullSafe())
+ .registerTypeAdapter(ZonedDateTime.class, new GSONZonedDateTimeAdapter().nullSafe())
+ .create();
+
+ }
+ // endregion
+
+ /**
+ * get the tenshi anime database instance.
+ * if the app is not running ({@link TenshiApp#getDB()} not possible) this initializes the database on the first call.
+ *
+ * @return the database instance
+ */
+ @NonNull
+ private TenshiDB getDB() {
+ if (notNull(db))
+ return db;
+
+ // try to get database from TenshiApp first
+ if (notNull(TenshiApp.INSTANCE)) {
+ db = TenshiApp.getDB();
+ return db;
+ }
+
+ // app not running, create on demand
+ db = TenshiDB.create(getApplicationContext());
+ return db;
+ }
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/NotificationWorkerHelper.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/NotificationWorkerHelper.java
new file mode 100644
index 0000000..6368f6f
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/NotificationWorkerHelper.java
@@ -0,0 +1,144 @@
+package io.github.shadow578.tenshi.notifications.workers;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkManager;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.isNull;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.notNull;
+
+/**
+ * class to manage registration of notification workers
+ */
+public final class NotificationWorkerHelper {
+
+ /**
+ * unique name for the notification worker(s)
+ */
+ private static final String NOTIFICATIONS_WORKER_UNIQUE_NAME = "io.github.shadow578.tenshi.NOTIFICATIONS_WORKER";
+
+ /**
+ * register the notification workers
+ *
+ * @param ctx the context to work in
+ */
+ public static void registerNotificationWorkers(@NonNull Context ctx) {
+ // skip all if no worker is even enabled
+ if (!DBUpdateWorker.shouldEnable(ctx)
+ && !AiringAnimeWorker.shouldEnable(ctx)
+ && !RelatedAnimeWorker.shouldEnable(ctx))
+ return;
+
+ // create the workers
+ final OneTimeWorkRequest dbUpdate = createDBUpdate(ctx);
+ final OneTimeWorkRequest airingAnime = createAiringAnime(ctx);
+ final OneTimeWorkRequest relatedAnime = createRelatedAnime(ctx);
+
+ // make the reschedule task wait 1.5h before firing, delaying the re- run of the tasks by that time
+ final OneTimeWorkRequest reschedule = new OneTimeWorkRequest.Builder(RescheduleWorker.class)
+ .setInitialDelay(90, TimeUnit.MINUTES)
+ .build();
+
+ // do not enqueue anything if both airingAnime and relatedAnime workers are null
+ if (isNull(airingAnime) && isNull(relatedAnime))
+ return;
+
+ // create list of all notification workers
+ final List notifyWorkers = new ArrayList<>();
+ if (notNull(airingAnime))
+ notifyWorkers.add(airingAnime);
+ if (notNull(relatedAnime))
+ notifyWorkers.add(relatedAnime);
+
+ // enqueue the work
+ if (notNull(dbUpdate)) {
+ WorkManager.getInstance(ctx)
+ .beginUniqueWork(NOTIFICATIONS_WORKER_UNIQUE_NAME, ExistingWorkPolicy.REPLACE, dbUpdate)
+ .then(notifyWorkers)
+ .then(reschedule)
+ .enqueue();
+ } else {
+ WorkManager.getInstance(ctx)
+ .beginUniqueWork(NOTIFICATIONS_WORKER_UNIQUE_NAME, ExistingWorkPolicy.REPLACE, notifyWorkers)
+ .then(reschedule)
+ .enqueue();
+ }
+ }
+
+ /**
+ * create the work request for {@link DBUpdateWorker}
+ *
+ * @param ctx the context to create in
+ * @return the work request. null if this worker should not run
+ */
+ @Nullable
+ private static OneTimeWorkRequest createDBUpdate(@NonNull Context ctx) {
+ if (DBUpdateWorker.shouldEnable(ctx))
+ return new OneTimeWorkRequest.Builder(DBUpdateWorker.class)
+ .setConstraints(DBUpdateWorker.getConstrains(ctx))
+ .build();
+
+ return null;
+ }
+
+ /**
+ * create the work request for {@link AiringAnimeWorker}
+ *
+ * @param ctx the context to create in
+ * @return the work request. null if this worker should not run
+ */
+ @Nullable
+ private static OneTimeWorkRequest createAiringAnime(@NonNull Context ctx) {
+ if (AiringAnimeWorker.shouldEnable(ctx))
+ return new OneTimeWorkRequest.Builder(AiringAnimeWorker.class)
+ .setConstraints(AiringAnimeWorker.getConstrains(ctx))
+ .build();
+
+ return null;
+ }
+
+ /**
+ * create the work request for {@link RelatedAnimeWorker}
+ *
+ * @param ctx the context to create in
+ * @return the work request. null if this worker should not run
+ */
+ @Nullable
+ private static OneTimeWorkRequest createRelatedAnime(@NonNull Context ctx) {
+ if (RelatedAnimeWorker.shouldEnable(ctx))
+ return new OneTimeWorkRequest.Builder(RelatedAnimeWorker.class)
+ .setConstraints(RelatedAnimeWorker.getConstrains(ctx))
+ .build();
+
+ return null;
+ }
+
+ /**
+ * simple worker that only calls {@link #registerNotificationWorkers(Context)}
+ */
+ public static class RescheduleWorker extends Worker {
+ public RescheduleWorker(@NonNull @NotNull Context context, @NonNull @NotNull WorkerParameters workerParams) {
+ super(context, workerParams);
+ }
+
+ @NotNull
+ @Override
+ public Result doWork() {
+ // reschedule all workers again
+ registerNotificationWorkers(getApplicationContext());
+ return Result.success();
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/RelatedAnimeWorker.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/RelatedAnimeWorker.java
new file mode 100644
index 0000000..beff3e9
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/RelatedAnimeWorker.java
@@ -0,0 +1,242 @@
+package io.github.shadow578.tenshi.notifications.workers;
+
+import android.app.Notification;
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.work.Constraints;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import io.github.shadow578.tenshi.BuildConfig;
+import io.github.shadow578.tenshi.mal.model.Anime;
+import io.github.shadow578.tenshi.mal.model.RelatedMedia;
+import io.github.shadow578.tenshi.mal.model.UserLibraryEntry;
+import io.github.shadow578.tenshi.mal.model.type.BroadcastStatus;
+import io.github.shadow578.tenshi.mal.model.type.LibraryEntryStatus;
+import io.github.shadow578.tenshi.notifications.TenshiNotificationChannel;
+import io.github.shadow578.tenshi.notifications.db.SentNotificationInfo;
+import io.github.shadow578.tenshi.util.DateHelper;
+import io.github.shadow578.tenshi.util.TenshiPrefs;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.fmt;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.isNull;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.listOf;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.notNull;
+
+/**
+ * notification worker for related airing anime updates
+ */
+public class RelatedAnimeWorker extends WorkerBase {
+ /**
+ * get the constrains that are placed on the execution of this worker
+ *
+ * @param ctx context to work in
+ * @return the constrains
+ */
+ @NonNull
+ public static Constraints getConstrains(@NonNull Context ctx) {
+ //TODO constrains configuration in props
+ return new Constraints.Builder()
+ .setRequiresBatteryNotLow(true)
+ .build();
+ }
+
+ /**
+ * @param ctx the context to run in
+ * @return should this worker in in the given context?
+ */
+ public static boolean shouldEnable(@NonNull Context ctx) {
+ TenshiPrefs.init(ctx);
+ return TenshiPrefs.getBool(TenshiPrefs.Key.EnableNotifications, false);
+ }
+
+ /**
+ * @param ctx context to run in
+ * @return a list of all categories to check in this worker
+ */
+ @NonNull
+ public static List getCategories(@NonNull Context ctx) {
+ //TODO load from prefs
+ return listOf(LibraryEntryStatus.Watching, LibraryEntryStatus.PlanToWatch, LibraryEntryStatus.Completed);
+ }
+
+ public RelatedAnimeWorker(@NotNull Context context, @NotNull WorkerParameters workerParams) {
+ super(context, workerParams);
+ }
+
+ /**
+ * wrapped function for {@link Worker#doWork()}.
+ * only runs if {@link #shouldRun()} is true
+ *
+ * @return work result
+ */
+ @Override
+ protected Result run() {
+ try {
+ // run the checks
+ checkRelated();
+
+ if(BuildConfig.DEBUG) {
+ // write run time to prefs
+ appendRunInfo(DateHelper.getLocalTime().toString() + " success");
+ }
+
+ return Result.success();
+ } catch (Exception e) {
+ // idk, retry on error
+ e.printStackTrace();
+
+ if(BuildConfig.DEBUG) {
+ //TODO dev testing
+ // write error info to prefs instead of last run date
+ StringWriter b = new StringWriter();
+ e.printStackTrace(new PrintWriter(b));
+ appendRunInfo(DateHelper.getLocalTime().toString() + " failed (" + e.toString() + ":" + b.toString() + ")");
+
+ // send a notification with the error
+ Notification n = getNotifyManager().notificationBuilder(TenshiNotificationChannel.Default)
+ .setContentTitle("MediaUpdateNotificationsWorker exception")
+ .setContentText(e.toString() + ": " + b.toString())
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(e.toString() + ": " + b.toString()))
+ .build();
+ getNotifyManager().sendNow(n);
+ }
+
+ return Result.retry();
+ }
+ }
+
+ /**
+ * check all anime with a library category of {@link #getCategories(Context)} for new
+ * related anime that air soon
+ */
+ private void checkRelated() {
+ //get nsfw preference
+ requirePrefs();
+ final boolean showNSFW = TenshiPrefs.getBool(TenshiPrefs.Key.NSFW, false);
+
+ // get all anime we want to check if any of their related anime will premiere soon
+ final List animeForAiringCheck = getEntries(getDB(), getCategories(getApplicationContext()), showNSFW);
+
+ // get the current time and weekday in japan
+ final ZonedDateTime now = DateHelper.getJapanTime();
+ final LocalDate nowDate = now.toLocalDate();
+
+ // check each entry's related anime
+ for (UserLibraryEntry parent : animeForAiringCheck)
+ if (notNull(parent.anime.relatedAnime))
+ for (RelatedMedia related : parent.anime.relatedAnime) {
+ final Anime anime = related.relatedAnime;
+
+ // check if anime broadcast info is valid
+ // and anime is not yet aired
+ if (isNull(anime.broadcastStatus)
+ || !anime.broadcastStatus.equals(BroadcastStatus.NotYetAired)
+ || isNull(anime.broadcastInfo)
+ || isNull(anime.broadcastInfo.startTime)
+ || isNull(anime.broadcastInfo.weekday)
+ || isNull(anime.startDate))
+ continue;
+
+ // check if anime end date is set and in the past
+ // (anime already ended)
+ if (notNull(anime.endDate)
+ && nowDate.until(anime.endDate, ChronoUnit.DAYS) < 0)
+ continue;
+
+ // check if less than 1 week until the start date
+ if (nowDate.until(anime.startDate, ChronoUnit.DAYS) > 7)
+ continue;
+
+ // this anime premiers within the next 7 days,
+ // send notification
+ sendNotification(parent, related);
+ }
+ }
+
+ /**
+ * send a notification for a soon to air related anime
+ *
+ * @param parentEntry the parent anime of the related anime
+ * @param relatedMedia the related anime that will soon air
+ */
+ private void sendNotification(@NonNull UserLibraryEntry parentEntry, @NonNull RelatedMedia relatedMedia) {
+ // get anime
+ final Anime a = relatedMedia.relatedAnime;
+
+ // check broadcast info not null (never is, but needed to make AS shut up)
+ if (isNull(a.broadcastInfo))
+ return;
+
+ // get notification content
+ final String title = "Upcoming related Anime";
+ final String text = fmt("%s will air next %s! %ncheck it out now.", a.title, a.broadcastInfo.weekday);
+
+ // check if already in db, do not send if it is
+ // otherwise insert
+ if (!getNotifyDB().notificationsDB().insertIfNotPresent(SentNotificationInfo.create(Duration.ofDays(7),
+ a.animeId,
+ false,
+ title,
+ text,
+ TenshiNotificationChannel.Default.id()))) {
+ // sent this notification already, do not sent again
+ return;
+ }
+
+ // create notification
+ //TODO channel and content hardcode
+ final Notification notification = getNotifyManager().notificationBuilder(TenshiNotificationChannel.Default)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(text))
+ .setContentIntent(getDetailsOpenIntent(a.animeId))
+ .setAutoCancel(true)
+ .build();
+
+ // and schedule it
+ getNotifyManager().sendNow(a.animeId, notification);
+ }
+
+ /**
+ * @return should this worker run?
+ */
+ @Override
+ protected boolean shouldRun() {
+ return shouldEnable(getApplicationContext());
+ }
+
+
+ /**
+ * TODO testing, remove asap
+ */
+ private void appendRunInfo(String s) {
+ List ri = TenshiPrefs.getObject(TenshiPrefs.Key.DEV_RelatedAnimeWorkerLog, List.class, new ArrayList());
+ int toRemove = ri.size() - 9;
+ Iterator i = ri.iterator();
+ while (toRemove > 0 && i.hasNext()) {
+ i.next();
+ i.remove();
+ toRemove--;
+ }
+
+ ri.add(s);
+ TenshiPrefs.setObject(TenshiPrefs.Key.DEV_RelatedAnimeWorkerLog, ri);
+ }
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/WorkerBase.java b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/WorkerBase.java
new file mode 100644
index 0000000..7a47e98
--- /dev/null
+++ b/app/src/main/java/io/github/shadow578/tenshi/notifications/workers/WorkerBase.java
@@ -0,0 +1,187 @@
+package io.github.shadow578.tenshi.notifications.workers;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import io.github.shadow578.tenshi.TenshiApp;
+import io.github.shadow578.tenshi.db.TenshiDB;
+import io.github.shadow578.tenshi.mal.model.UserLibraryEntry;
+import io.github.shadow578.tenshi.mal.model.type.LibraryEntryStatus;
+import io.github.shadow578.tenshi.mal.model.type.LibrarySortMode;
+import io.github.shadow578.tenshi.notifications.TenshiNotificationChannel;
+import io.github.shadow578.tenshi.notifications.TenshiNotificationManager;
+import io.github.shadow578.tenshi.notifications.db.SentNotificationsDB;
+import io.github.shadow578.tenshi.ui.AnimeDetailsActivity;
+import io.github.shadow578.tenshi.util.TenshiPrefs;
+
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.notNull;
+import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.nullOrEmpty;
+
+/**
+ * base class for notification- related background workers
+ */
+public abstract class WorkerBase extends Worker {
+
+ /**
+ * the database instance.
+ */
+ private TenshiDB db = null;
+
+ /**
+ * notification database instance
+ */
+ private SentNotificationsDB notificationsDB = null;
+
+ /**
+ * notification manager instance.
+ */
+ private TenshiNotificationManager notifyManager = null;
+
+ public WorkerBase(@NotNull Context context, @NotNull WorkerParameters workerParams) {
+ super(context, workerParams);
+ }
+
+ @NonNull
+ @Override
+ public Result doWork() {
+ if (!shouldRun())
+ return Result.success();
+
+ return run();
+ }
+
+ /**
+ * wrapped function for {@link Worker#doWork()}.
+ * only runs if {@link #shouldRun()} is true
+ *
+ * @return work result
+ */
+ protected abstract Result run();
+
+ /**
+ * @return should this worker run?
+ */
+ protected abstract boolean shouldRun();
+
+ /**
+ * load all anime with the given status from the database
+ *
+ * @param db the database instance
+ * @param statuses the statuses to load
+ * @param nsfw include NSFW entries?
+ * @return the list of all loaded entries
+ */
+ @NonNull
+ protected List getEntries(@NonNull TenshiDB db, @NonNull List statuses, boolean nsfw) {
+ final ArrayList entries = new ArrayList<>();
+ for (LibraryEntryStatus status : statuses) {
+ // load from DB and add
+ final List e = db.animeDB().getUserLibrary(status, LibrarySortMode.anime_id, nsfw);
+ if (!nullOrEmpty(e))
+ entries.addAll(e);
+ }
+ return entries;
+ }
+
+ /**
+ * creates a pending intent to open the details page of a anime
+ *
+ * @param animeId the anime ID
+ * @return the pending intent for opening the details page
+ */
+ @NonNull
+ protected PendingIntent getDetailsOpenIntent(int animeId) {
+ // create intent to open details
+ final Intent detailsIntent = new Intent(getApplicationContext(), AnimeDetailsActivity.class);
+ detailsIntent.putExtra(AnimeDetailsActivity.EXTRA_ANIME_ID, animeId);
+
+ // create pending intent
+ return PendingIntent.getActivity(getApplicationContext(), animeId, detailsIntent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ /**
+ * initialize TenshiPrefs
+ */
+ protected void requirePrefs() {
+ TenshiPrefs.init(getApplicationContext());
+ }
+
+ /**
+ * get the tenshi anime database instance.
+ * if the app is not running ({@link TenshiApp#getDB()} not possible) this initializes the database on the first call.
+ *
+ * @return the database instance
+ */
+ @NonNull
+ protected TenshiDB getDB() {
+ if (notNull(db))
+ return db;
+
+ // try to get database from TenshiApp first
+ if (notNull(TenshiApp.INSTANCE)) {
+ db = TenshiApp.getDB();
+ return db;
+ }
+
+ // app not running, create on demand
+ db = TenshiDB.create(getApplicationContext());
+ return db;
+ }
+
+ /**
+ * get the notification database instance.
+ * database is created on demand
+ *
+ * @return the database instance
+ */
+ @NonNull
+ protected SentNotificationsDB getNotifyDB() {
+ if (notNull(notificationsDB))
+ return notificationsDB;
+
+ // try to get database from TenshiApp first
+ if (notNull(TenshiApp.INSTANCE)) {
+ notificationsDB = TenshiApp.getNotifyDB();
+ return notificationsDB;
+ }
+
+ // app not running, create on demand
+ notificationsDB = SentNotificationsDB.create(getApplicationContext());
+ return notificationsDB;
+ }
+
+ /**
+ * get the tenshi notification manager instance.
+ * if the app is not running ({@link TenshiApp#getNotifyManager()} ()} not possible) this initializes the manager on the first call.
+ *
+ * @return the notification manager
+ */
+ @NonNull
+ protected TenshiNotificationManager getNotifyManager() {
+ if (notNull(notifyManager))
+ return notifyManager;
+
+ // try to get manager from TenshiApp first
+ if (notNull(TenshiApp.INSTANCE)) {
+ notifyManager = TenshiApp.getNotifyManager();
+ return notifyManager;
+ }
+
+ // app not running, create on demand
+ notifyManager = new TenshiNotificationManager(getApplicationContext());
+
+ // also have to register notification channels
+ TenshiNotificationChannel.registerAll(getApplicationContext(), (value, channel) -> true);
+ return notifyManager;
+ }
+}
diff --git a/app/src/main/java/io/github/shadow578/tenshi/ui/oobe/InitialConfigurationFragment.java b/app/src/main/java/io/github/shadow578/tenshi/ui/oobe/InitialConfigurationFragment.java
index 8eb8cc3..6eeb170 100644
--- a/app/src/main/java/io/github/shadow578/tenshi/ui/oobe/InitialConfigurationFragment.java
+++ b/app/src/main/java/io/github/shadow578/tenshi/ui/oobe/InitialConfigurationFragment.java
@@ -56,6 +56,11 @@ public void onViewCreated(@NonNull View v, @Nullable Bundle savedInstanceState)
TenshiPrefs.setBool(TenshiPrefs.Key.AnimeDetailsNoLibTutorialFinished, skipTut);
TenshiPrefs.setBool(TenshiPrefs.Key.AnimeDetailsInLibTutorialFinished, skipTut);
});
+
+ // notifications opt- in
+ b.enableNotificationsPreview.setOnCheckedChangeListener((buttonView, enableNotifications) -> {
+ TenshiPrefs.setBool(TenshiPrefs.Key.EnableNotifications, enableNotifications);
+ });
}
//region theme
@@ -131,7 +136,7 @@ private View createThemePreview(@NonNull TenshiPrefs.Theme theme) {
// inflate the preview layout with the overwritten view
final RecyclerAnimeBigBinding tpb = RecyclerAnimeBigBinding.inflate(LayoutInflater.from(themeCtx));
- tpb.animeMainPoster.setImageDrawable(ContextCompat.getDrawable(baseCtx, R.drawable.ic_splash));
+ tpb.animeMainPoster.setImageDrawable(ContextCompat.getDrawable(baseCtx, R.drawable.ic_icon_24));
// update the layout params to include margins and fill the container
final int marginPxs = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, themeCtx.getResources().getDisplayMetrics());
diff --git a/app/src/main/java/io/github/shadow578/tenshi/ui/settings/DeveloperSettingsFragment.java b/app/src/main/java/io/github/shadow578/tenshi/ui/settings/DeveloperSettingsFragment.java
index 131ea8f..09a9a21 100644
--- a/app/src/main/java/io/github/shadow578/tenshi/ui/settings/DeveloperSettingsFragment.java
+++ b/app/src/main/java/io/github/shadow578/tenshi/ui/settings/DeveloperSettingsFragment.java
@@ -1,6 +1,9 @@
package io.github.shadow578.tenshi.ui.settings;
import android.app.Activity;
+import android.app.DatePickerDialog;
+import android.app.Notification;
+import android.app.TimePickerDialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
@@ -21,6 +24,9 @@
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Map;
@@ -30,6 +36,9 @@
import io.github.shadow578.tenshi.db.TenshiDB;
import io.github.shadow578.tenshi.extensionslib.content.Constants;
import io.github.shadow578.tenshi.extensionslib.content.ContentAdapterWrapper;
+import io.github.shadow578.tenshi.notifications.TenshiNotificationChannel;
+import io.github.shadow578.tenshi.notifications.db.SentNotificationsDB;
+import io.github.shadow578.tenshi.util.DateHelper;
import io.github.shadow578.tenshi.util.TenshiPrefs;
import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.async;
@@ -43,6 +52,8 @@
/**
* the developer options preference screen
+ *
+ * TODO: use methods for common functions ("labels", "buttons")
*/
public class DeveloperSettingsFragment extends PreferenceFragmentCompat {
@@ -51,16 +62,33 @@ public class DeveloperSettingsFragment extends PreferenceFragmentCompat {
*/
private static final int REQUEST_CHOOSE_DB_EXPORT_PATH = 21;
+ /**
+ * request id for document chooser used when choosing the notification database export path
+ */
+ private static final int REQUEST_CHOOSE_NOTIFY_DB_EXPORT_PATH = 22;
+
+ /**
+ * context. setup before any setup*() functions are called.
+ */
+ private Context ctx;
+
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
- // export database to file
+ // export anime database to file
if (requestCode == REQUEST_CHOOSE_DB_EXPORT_PATH
&& resultCode == Activity.RESULT_OK
&& notNull(data)
&& notNull(data.getData()))
- exportDatabaseTo(data.getData());
+ exportDatabaseTo(TenshiDB.getDatabasePath(requireContext()).getAbsolutePath(), data.getData());
+
+ // export notify database to file
+ if (requestCode == REQUEST_CHOOSE_NOTIFY_DB_EXPORT_PATH
+ && resultCode == Activity.RESULT_OK
+ && notNull(data)
+ && notNull(data.getData()))
+ exportDatabaseTo(SentNotificationsDB.getDatabasePath(requireContext()).getAbsolutePath(), data.getData());
}
@Override
@@ -69,13 +97,15 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setPreferencesFromResource(R.xml.settings_dev_screen, rootKey);
// init dynamic stuff
- final Context ctx = requireContext();
+ ctx = requireContext();
setupVersionInfo();
setupThrowExceptionFunctions();
setupUtilFunctions();
- setupDatabaseFunctions(ctx);
- setupSharedPrefsFunctions(ctx);
- setupContentAdapterFunctions(ctx);
+ setupNotificationFunctions();
+ setupDatabaseFunctions();
+ setupNotifyDatabaseFunctions();
+ setupSharedPrefsFunctions();
+ setupContentAdapterFunctions();
}
/**
@@ -143,11 +173,86 @@ private void setupUtilFunctions() {
}
/**
- * setup debug functions for database
+ * setup notification test functions
+ */
+ private void setupNotificationFunctions() {
+ // notification now
+ final Preference notifyNow = findPreference("dbg_notification_now");
+ with(notifyNow, notify -> notify.setOnPreferenceClickListener(preference -> {
+ TenshiApp.getNotifyManager().sendNow(getTestNotification("dbg_notification_now"));
+ Toast.makeText(ctx, "send notification", Toast.LENGTH_SHORT).show();
+ return true;
+ }));
+
+ // notification in 30s
+ final Preference notify30s = findPreference("dbg_notification_thirty_seconds");
+ with(notify30s, notify -> notify.setOnPreferenceClickListener(preference -> {
+ TenshiApp.getNotifyManager().sendIn(getTestNotification("dbg_notification_thirty_seconds"),
+ 30_000);
+ Toast.makeText(ctx, "scheduled notification in 30s", Toast.LENGTH_SHORT).show();
+ return true;
+ }));
+
+ // notification in 5m
+ final Preference notify5m = findPreference("dbg_notification_five_minutes");
+ with(notify5m, notify -> notify.setOnPreferenceClickListener(preference -> {
+ TenshiApp.getNotifyManager().sendIn(getTestNotification("dbg_notification_five_minutes"),
+ 5 * 60 * 1_000);
+ Toast.makeText(ctx, "scheduled notification in 5min", Toast.LENGTH_SHORT).show();
+ return true;
+ }));
+
+ // notification at time
+ // yes, this is callback hell :(
+ final Preference notifyAt = findPreference("dbg_notification_at_time");
+ with(notifyAt, notify -> notify.setOnPreferenceClickListener(preference -> {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
+ // select date
+ final DatePickerDialog dateDialog = new DatePickerDialog(ctx);
+ dateDialog.setOnDateSetListener((view, year, month, dayOfMonth) -> {
+ // select time
+ final ZonedDateTime now = DateHelper.getLocalTime();
+ final TimePickerDialog timeDialog = new TimePickerDialog(ctx, (view1, hourOfDay, minute) -> {
+ // both date and time have been selected, build the target datetime
+ final LocalDateTime target = LocalDateTime.of(year, month + 1, dayOfMonth, hourOfDay, minute, 0);
+
+ // schedule the notification
+ TenshiApp.getNotifyManager().sendAt(getTestNotification("dbg_notification_at_time at " + target.toString()),
+ target.atZone(ZoneId.systemDefault()));
+ Toast.makeText(ctx, "scheduled notification for " + target.toString(), Toast.LENGTH_SHORT).show();
+ },
+ now.getHour(),
+ now.getMinute(),
+ true);
+ timeDialog.show();
+ });
+ dateDialog.show();
+ } else {
+ Toast.makeText(ctx, "only supported on Android N and above!", Toast.LENGTH_SHORT).show();
+ }
+ return true;
+ }));
+ }
+
+ /**
+ * creates a test notification with the given text int the {@link TenshiNotificationChannel#Default} channel
*
- * @param ctx the context to work in
+ * @param text the text of the notification
+ * @return the notification
*/
- private void setupDatabaseFunctions(@NonNull Context ctx) {
+ @NonNull
+ private Notification getTestNotification(@NonNull String text) {
+ // create notification
+ return TenshiApp.getNotifyManager().notificationBuilder(TenshiNotificationChannel.Default)
+ .setContentTitle("Test Notification")
+ .setContentText(text)
+ .build();
+ }
+
+ /**
+ * setup debug functions for database
+ */
+ private void setupDatabaseFunctions() {
// setup 'export database' button
final Preference exportDbPref = findPreference("dbg_export_database");
final String dbPath = TenshiDB.getDatabasePath(ctx).getAbsolutePath();
@@ -185,12 +290,41 @@ private void setupDatabaseFunctions(@NonNull Context ctx) {
}));
}
+ /**
+ * setup debug functions for database
+ */
+ private void setupNotifyDatabaseFunctions() {
+ // setup 'export database' button
+ final Preference exportDbPref = findPreference("dbg_export_notify_database");
+ final String dbPath = SentNotificationsDB.getDatabasePath(ctx).getAbsolutePath();
+ with(exportDbPref, exportDb -> {
+ exportDb.setSummary(dbPath);
+ exportDb.setOnPreferenceClickListener(preference -> {
+ // print path to log
+ Log.e("Tenshi", "Database Path: " + dbPath);
+
+ // start document chooser
+ final Intent exportIntent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ exportIntent.setType("*/*");
+ startActivityForResult(exportIntent, REQUEST_CHOOSE_NOTIFY_DB_EXPORT_PATH);
+ return true;
+ });
+ });
+
+ // setup 'delete database' button
+ final Preference deleteDbPref = findPreference("dbg_delete_notify_database");
+ with(deleteDbPref,
+ deleteDb -> deleteDb.setOnPreferenceClickListener(preference -> {
+ async(() -> TenshiApp.getNotifyDB().clearAllTables());
+ Toast.makeText(ctx, "Deleted Database", Toast.LENGTH_SHORT).show();
+ return true;
+ }));
+ }
+
/**
* setup debug functions for shared preferences
- *
- * @param ctx the context to work in
*/
- private void setupSharedPrefsFunctions(@NonNull Context ctx) {
+ private void setupSharedPrefsFunctions() {
// find preferences container for key enum
final PreferenceCategory enumPref = findPreference("dbg_prefs_of_enum");
with(enumPref, container -> {
@@ -285,10 +419,8 @@ private void setupSharedPrefsFunctions(@NonNull Context ctx) {
/**
* setup debug functions for content adapters
- *
- * @param ctx the context to work in
*/
- private void setupContentAdapterFunctions(@NonNull Context ctx) {
+ private void setupContentAdapterFunctions() {
// find preferences container for key enum
final PreferenceCategory enumPref = findPreference("dbg_content_adapters_container");
with(enumPref, container -> {
@@ -326,13 +458,13 @@ private void setupContentAdapterFunctions(@NonNull Context ctx) {
}
/**
- * export the database to a file
+ * export a database to a file
*
* @param targetPath the path of the file to export to
*/
- private void exportDatabaseTo(@NonNull Uri targetPath) {
+ private void exportDatabaseTo(@NonNull String dbPath, @NonNull Uri targetPath) {
// open output and database file and start copy
- try (final FileInputStream dbIn = new FileInputStream(TenshiDB.getDatabasePath(requireContext()));
+ try (final FileInputStream dbIn = new FileInputStream(dbPath);
final OutputStream out = requireContext().getContentResolver().openOutputStream(targetPath)) {
// copy file
final byte[] buf = new byte[1024];
diff --git a/app/src/main/java/io/github/shadow578/tenshi/util/DateHelper.java b/app/src/main/java/io/github/shadow578/tenshi/util/DateHelper.java
index 7c030cf..ea1fa1d 100644
--- a/app/src/main/java/io/github/shadow578/tenshi/util/DateHelper.java
+++ b/app/src/main/java/io/github/shadow578/tenshi/util/DateHelper.java
@@ -3,17 +3,17 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import java.time.Instant;
import java.time.LocalDate;
-import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Period;
import java.time.ZoneId;
-import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import io.github.shadow578.tenshi.mal.model.Season;
+import io.github.shadow578.tenshi.mal.model.type.DayOfWeek;
import io.github.shadow578.tenshi.mal.model.type.YearSeason;
import static io.github.shadow578.tenshi.extensionslib.lang.LanguageUtil.isNull;
@@ -23,46 +23,63 @@ public class DateHelper {
// region get date and time
+ /**
+ * UTC timezone
+ */
+ public static final ZoneId UTC_ZONE = ZoneId.of("UTC");
+
+ /**
+ * default timezone of the device
+ */
+ public static final ZoneId DEVICE_ZONE = ZoneId.systemDefault();
+
+ /**
+ * timezone of Asia/Tokyo
+ */
+ public static final ZoneId JAPAN_ZONE = ZoneId.of("Asia/Tokyo");
+
/**
* the date and time in the device's time zone
*/
- private static LocalDateTime local() {
- return LocalDateTime.now();
+ private static ZonedDateTime local() {
+ return ZonedDateTime.now();
}
/**
* the date and time in Tokyo
*/
- private static LocalDateTime jp() {
- return LocalDateTime.now(ZoneId.of("Asia/Tokyo"));
+ private static ZonedDateTime jp() {
+ return local().withZoneSameInstant(JAPAN_ZONE);
}
/**
- * get the epoch value of a datetime
+ * get the epoch value of a datetime in the UTC time zone
*
+ * @param time the time to convert. may be in any timezone
* @return the epoch value (seconds)
*/
- public static long toEpoch(@NonNull LocalDateTime time) {
- return time.toEpochSecond(ZoneOffset.UTC);
+ public static long toEpoch(@NonNull ZonedDateTime time) {
+ return time.withZoneSameInstant(UTC_ZONE).toEpochSecond();
}
/**
- * convert a epoch value to the device's local date and time
+ * convert a epoch value to a date and time in the devices timezone
*
- * @param epoch the epoch value (seconds)
- * @return the local datetime
+ * @param epoch the epoch value (seconds, in UTC) as returned by {@link #toEpoch(ZonedDateTime)}
+ * @return the datetime in the local zone
*/
@NonNull
- public static LocalDateTime fromEpoc(long epoch) {
- return LocalDateTime.ofEpochSecond(epoch, 0, ZoneOffset.UTC);
+ public static ZonedDateTime fromEpoc(long epoch) {
+ return ZonedDateTime.ofInstant(Instant.ofEpochSecond(epoch), UTC_ZONE).withZoneSameInstant(DEVICE_ZONE);
}
/**
* get the number of years between the date and now
+ *
* @param date the date to check
* @return the number of years between now and the date
*/
- public static int getYearsToNow(LocalDate date){
+ public static int getYearsToNow(LocalDate date) {
return Period.between(date, getLocalTime().toLocalDate()).getYears();
}
@@ -71,7 +88,7 @@ public static int getYearsToNow(LocalDate date){
*
* @return date and time in the local timezone
*/
- public static LocalDateTime getLocalTime() {
+ public static ZonedDateTime getLocalTime() {
return local();
}
@@ -80,7 +97,7 @@ public static LocalDateTime getLocalTime() {
*
* @return date and time in Tokyo
*/
- public static LocalDateTime getJapanTime() {
+ public static ZonedDateTime getJapanTime() {
return jp();
}
@@ -128,6 +145,35 @@ public static YearSeason getYearSeason() {
return YearSeason.Fall;
}
}
+
+ /**
+ * convert from {@link java.time.DayOfWeek} to {@link DayOfWeek}
+ *
+ * @param javaDoW the java8 day of week
+ * @return the MAL model day of week
+ */
+ @NonNull
+ public static DayOfWeek convertDayOfWeek(@NonNull java.time.DayOfWeek javaDoW) {
+ switch (javaDoW) {
+ default:
+ // any other day just defaults to monday
+ // really doesn't matter what this would default to
+ case MONDAY:
+ return DayOfWeek.Monday;
+ case TUESDAY:
+ return DayOfWeek.Tuesday;
+ case WEDNESDAY:
+ return DayOfWeek.Wednesday;
+ case THURSDAY:
+ return DayOfWeek.Thursday;
+ case FRIDAY:
+ return DayOfWeek.Friday;
+ case SATURDAY:
+ return DayOfWeek.Saturday;
+ case SUNDAY:
+ return DayOfWeek.Sunday;
+ }
+ }
// endregion
// region formatting
diff --git a/app/src/main/java/io/github/shadow578/tenshi/util/GlideHelper.java b/app/src/main/java/io/github/shadow578/tenshi/util/GlideHelper.java
index 22f7952..f868ef1 100644
--- a/app/src/main/java/io/github/shadow578/tenshi/util/GlideHelper.java
+++ b/app/src/main/java/io/github/shadow578/tenshi/util/GlideHelper.java
@@ -29,7 +29,7 @@ public class GlideHelper {
@NonNull
public static RequestBuilder glide(@NonNull Context context, @Nullable String imgUrl)
{
- return glide(context, imgUrl, R.drawable.ic_splash);
+ return glide(context, imgUrl, R.drawable.ic_icon_24);
}
/**
diff --git a/app/src/main/java/io/github/shadow578/tenshi/util/TenshiPrefs.java b/app/src/main/java/io/github/shadow578/tenshi/util/TenshiPrefs.java
index 776d4e0..87abb35 100644
--- a/app/src/main/java/io/github/shadow578/tenshi/util/TenshiPrefs.java
+++ b/app/src/main/java/io/github/shadow578/tenshi/util/TenshiPrefs.java
@@ -82,7 +82,25 @@ public enum Key {
/**
* did the user finish / skip the anime details activity tutorial for anime in the user library?
*/
- AnimeDetailsInLibTutorialFinished
+ AnimeDetailsInLibTutorialFinished,
+
+ /**
+ * enable anime update notifications
+ */
+ EnableNotifications,
+
+ /**
+ * if notification updates should run when on a mobile data or otherwise metered connection
+ */
+ AllowNotificationUpdatesOnMeteredConnection,
+
+ /**
+ * keys to hold the logging info of the two notification workers
+ * TODO should remove
+ */
+ DEV_AiringAnimeWorkerLog,
+ DEV_RelatedAnimeWorkerLog,
+ DEV_EnableHigehiroOverwrite
}
/**
diff --git a/app/src/main/res/drawable/ic_icon_24.xml b/app/src/main/res/drawable/ic_icon_24.xml
new file mode 100644
index 0000000..ff335ac
--- /dev/null
+++ b/app/src/main/res/drawable/ic_icon_24.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notify_icon_24.xml b/app/src/main/res/drawable/ic_notify_icon_24.xml
new file mode 100644
index 0000000..bbdf69a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notify_icon_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_splash.xml b/app/src/main/res/drawable/ic_splash.xml
deleted file mode 100644
index 62f898a..0000000
--- a/app/src/main/res/drawable/ic_splash.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml
index 1b15594..31e5830 100644
--- a/app/src/main/res/drawable/splash_background.xml
+++ b/app/src/main/res/drawable/splash_background.xml
@@ -4,6 +4,6 @@
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_initial_configuration.xml b/app/src/main/res/layout/fragment_initial_configuration.xml
index ffbf924..a9b58b9 100644
--- a/app/src/main/res/layout/fragment_initial_configuration.xml
+++ b/app/src/main/res/layout/fragment_initial_configuration.xml
@@ -120,12 +120,33 @@
android:text="@string/oobe_config_other_settings_title"
android:textStyle="bold" />
+
+
+
+
+
+
@@ -140,6 +161,7 @@
android:layout_marginEnd="16dp"
android:text="@string/oobe_config_skip_tutorial" />
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 7d01022..f113e70 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
index 4167896..6f65bec 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index 7e90e89..8a9157f 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
index c833605..4080728 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
index 2cb9096..47043a0 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index 7428f8b..c32b041 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 1cb4bff..effbce6 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
index 09c8335..f81dd87 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
index e00d67b..033bb7c 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 064cad4..c035312 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
index be35972..f95dd20 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
index 8e71757..0ff9849 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 98a7922..7f16544 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
index 62644e8..8be49f6 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
index 56817be..3ec0012 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b8e5967..102a01d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -128,6 +128,7 @@
@string/settings_dialog_confirm_nsfw_no
@string/settings_dialog_confirm_nsfw_yes
Skip App Tutorial
+ @string/settings_notifications_toggle_title
Randomly Crash the App
Use way too much data
@@ -158,6 +159,12 @@
Show NSFW
NSFW Content is visible
NSFW Content is filtered
+ Enable Notifications (Preview)
+ You\'ll receive notifications about Anime Updates
+ You won\'t receive any notifications
+ Update on metered connection
+ Notifications will update when on mobile data connection
+ Notifications will only update when connected to WiFi
Reset Tutorial
Lets you repeat the app tutorial
Logout
@@ -181,9 +188,12 @@
Profile
Settings
+ Notifications
Actions
About
+ Warning: Notifications are still a preview feature.
Expect notification- related bugs when enabling notifications.
(Other parts of the app should not be affected)
+
Confirm Age
To enable NSFW content, you have to be of legal age. \nAre you 18 years or older?
Yes, I\'m 18+
@@ -226,4 +236,11 @@
Remove from Library
No longer like this anime? Remove it from your library
+
+
+
+ Default
+ Fallback Notification channel. Tenshi uses this channel if no other channel is available. So don\'t expect many notifications on this.
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/settings_dev_screen.xml b/app/src/main/res/xml/settings_dev_screen.xml
index a8a324f..70b6b05 100644
--- a/app/src/main/res/xml/settings_dev_screen.xml
+++ b/app/src/main/res/xml/settings_dev_screen.xml
@@ -70,8 +70,41 @@
android:title="Pretend Auth token expired" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+ android:title="@string/settings_tut_reset_title" />