diff --git a/Extensions-Lib b/Extensions-Lib index b3673d8..ae30f76 160000 --- a/Extensions-Lib +++ b/Extensions-Lib @@ -1 +1 @@ -Subproject commit b3673d879408e2924228db5eb5a65a22f6f1d88f +Subproject commit ae30f763931ae7dff9bba8f095945b91d6eff176 diff --git a/app/build.gradle b/app/build.gradle index 7fee017..11ff721 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -140,6 +140,10 @@ dependencies { // Tap Target View for Tutorial implementation 'uk.co.samuelwall:material-tap-target-prompt:3.1.1' + + // work manager for notifications + implementation 'androidx.work:work-runtime:2.6.0-alpha02' + implementation 'androidx.concurrent:concurrent-futures:1.1.0' } // ~ Utility functions ~ diff --git a/app/schemas/io.github.shadow578.tenshi.notifications.db.SentNotificationsDB/1.json b/app/schemas/io.github.shadow578.tenshi.notifications.db.SentNotificationsDB/1.json new file mode 100644 index 0000000..f9990fd --- /dev/null +++ b/app/schemas/io.github.shadow578.tenshi.notifications.db.SentNotificationsDB/1.json @@ -0,0 +1,40 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "cdf9447cf2d482f8a6abb2a12ff1e49c", + "entities": [ + { + "tableName": "sent_notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identifier` TEXT NOT NULL, `expiration` INTEGER NOT NULL, PRIMARY KEY(`identifier`))", + "fields": [ + { + "fieldPath": "notificationIdentifier", + "columnName": "identifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTimestamp", + "columnName": "expiration", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "identifier" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cdf9447cf2d482f8a6abb2a12ff1e49c')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.github.shadow578.tenshi.notifications.db.SentNotificationsDB/2.json b/app/schemas/io.github.shadow578.tenshi.notifications.db.SentNotificationsDB/2.json new file mode 100644 index 0000000..a5af89a --- /dev/null +++ b/app/schemas/io.github.shadow578.tenshi.notifications.db.SentNotificationsDB/2.json @@ -0,0 +1,46 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "6ea776789be3023e82ca90a41cc45937", + "entities": [ + { + "tableName": "sent_notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identifier` INTEGER NOT NULL, `description` TEXT NOT NULL, `expiration` INTEGER NOT NULL, PRIMARY KEY(`identifier`))", + "fields": [ + { + "fieldPath": "notificationIdentifier", + "columnName": "identifier", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTimestamp", + "columnName": "expiration", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "identifier" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6ea776789be3023e82ca90a41cc45937')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.github.shadow578.tenshi.notifications.db.SentNotificationsDB/3.json b/app/schemas/io.github.shadow578.tenshi.notifications.db.SentNotificationsDB/3.json new file mode 100644 index 0000000..e070e42 --- /dev/null +++ b/app/schemas/io.github.shadow578.tenshi.notifications.db.SentNotificationsDB/3.json @@ -0,0 +1,52 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "cde411f7c5bcec3795d7ae88e871b4c8", + "entities": [ + { + "tableName": "sent_notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identifier` INTEGER NOT NULL, `description` TEXT NOT NULL, `expiration` INTEGER NOT NULL, `is_scheduled` INTEGER NOT NULL, PRIMARY KEY(`identifier`))", + "fields": [ + { + "fieldPath": "notificationIdentifier", + "columnName": "identifier", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTimestamp", + "columnName": "expiration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isScheduledNotification", + "columnName": "is_scheduled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "identifier" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cde411f7c5bcec3795d7ae88e871b4c8')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 51bd8d4..06e751d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + @@ -16,8 +17,8 @@ android:allowBackup="true" android:hardwareAccelerated="true" android:icon="@mipmap/ic_launcher" - android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/shared_app_label" + android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/TenshiTheme"> + android:host="${redirect_host}" + android:scheme="${redirect_scheme}" /> - - - - + + + + + android:pathPrefix="/anime/" + android:scheme="https" /> - + android:label="@string/fullscreen_img_title" + android:parentActivityName=".ui.AnimeDetailsActivity" /> - - - + + + android:label="Settings" + android:parentActivityName=".ui.MainActivity" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index 79075d3..e9ad6a6 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/io/github/shadow578/tenshi/TenshiApp.java b/app/src/main/java/io/github/shadow578/tenshi/TenshiApp.java index 110d3c1..3c93927 100644 --- a/app/src/main/java/io/github/shadow578/tenshi/TenshiApp.java +++ b/app/src/main/java/io/github/shadow578/tenshi/TenshiApp.java @@ -28,6 +28,10 @@ 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.notifications.TenshiNotificationChannel; +import io.github.shadow578.tenshi.notifications.TenshiNotificationManager; +import io.github.shadow578.tenshi.notifications.db.SentNotificationsDB; +import io.github.shadow578.tenshi.notifications.workers.NotificationWorkerHelper; import io.github.shadow578.tenshi.ui.MainActivity; import io.github.shadow578.tenshi.ui.SearchActivity; import io.github.shadow578.tenshi.ui.oobe.OnboardingActivity; @@ -95,6 +99,16 @@ public class TenshiApp extends Application { */ private TenshiDB database; + /** + * sent notifications database + */ + private SentNotificationsDB notifyDatabase; + + /** + * notification manager + */ + private TenshiNotificationManager notificationManager; + @Override public void onCreate() { super.onCreate(); @@ -107,9 +121,20 @@ public void onCreate() { TenshiPrefs.init(getApplicationContext()); tryAuthInit(); + // init notifications api + notificationManager = new TenshiNotificationManager(getApplicationContext()); + + // right now, no further configuration is required. + // because of this, the configure function just accepts all channels + TenshiNotificationChannel.registerAll(getApplicationContext(), (value, channel) -> true); + // init database and start cleanup database = TenshiDB.create(getApplicationContext()); - cleanupDatabase(); + cleanupAnimeDatabase(); + + // cleanup notification database + notifyDatabase = SentNotificationsDB.create(getApplicationContext()); + cleanupNotifyDatabase(); // init and find content adapters contentAdapterManager = new ContentAdapterManager(getApplicationContext(), new ContentAdapterManager.IPersistentStorageProvider() { @@ -127,6 +152,9 @@ public void setPersistentStorage(@NonNull String uniqueName, int animeId, @NonNu contentAdapterManager.discoverAndInit(false); contentAdapterManager.addOnDiscoveryEndCallback(p -> Log.i("Tenshi", fmt("Discovery finished with %d content adapters found", contentAdapterManager.getAdapterCount()))); + + // register workers + NotificationWorkerHelper.registerNotificationWorkers(getApplicationContext()); } /** @@ -173,12 +201,22 @@ private void initAppShortcuts() { } /** - * cleanup the database + * cleanup the anime database */ - public void cleanupDatabase() { + public void cleanupAnimeDatabase() { async(() -> { final int removedEntities = database.cleanupDatabase(); - Log.i("Tenshi", fmt("Database cleanup finished with %d entities removed", removedEntities)); + Log.i("Tenshi", fmt("anime database cleanup finished with %d entities removed", removedEntities)); + }); + } + + /** + * cleanup the notifications database + */ + private void cleanupNotifyDatabase() { + async(() -> { + final int removedEntries = notifyDatabase.notificationsDB().removeExpired(); + Log.i("Tenshi", fmt("notification database cleanup finished with %d entries removed", removedEntries)); }); } @@ -225,12 +263,13 @@ public void setTokenAndTryAuthInit(@Nullable Token t) { createRetrofit(); } } + /** * invalidate and remove the saved auth token, saved user data and preferences, then redirect to the Login activity * * @param ctx the context to start the login activity from. has to be another activity, on which .finish() is called */ - public void logoutAndLogin(@NonNull Activity ctx){ + public void logoutAndLogin(@NonNull Activity ctx) { // delete user data and config deleteUserData(); TenshiPrefs.clear(); @@ -428,6 +467,22 @@ public static TenshiDB getDB() { return INSTANCE.database; } + /** + * @return the sent notifications database instance + */ + @NonNull + public static SentNotificationsDB getNotifyDB() { + return INSTANCE.notifyDatabase; + } + + /** + * @return the notification manager + */ + @NonNull + public static TenshiNotificationManager getNotifyManager() { + return INSTANCE.notificationManager; + } + /** * @return is a user authenticated and we have a access token? */ diff --git a/app/src/main/java/io/github/shadow578/tenshi/db/TenshiDB.java b/app/src/main/java/io/github/shadow578/tenshi/db/TenshiDB.java index e5f6a96..3a7bb2f 100644 --- a/app/src/main/java/io/github/shadow578/tenshi/db/TenshiDB.java +++ b/app/src/main/java/io/github/shadow578/tenshi/db/TenshiDB.java @@ -10,6 +10,7 @@ import java.io.File; import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import io.github.shadow578.tenshi.db.dao.AnimeContentAdapterDao; @@ -92,7 +93,7 @@ public static File getDatabasePath(@NonNull Context ctx) { public int cleanupDatabase() { // get target age // = one month old entries - final LocalDateTime killAge = DateHelper.getLocalTime().minusMonths(1); + final ZonedDateTime killAge = DateHelper.getLocalTime().minusMonths(1); // find all anime and users that were last accessed before the target time final List allToKill = accessDB().getBefore(killAge); diff --git a/app/src/main/java/io/github/shadow578/tenshi/db/dao/LastAccessInfoDao.java b/app/src/main/java/io/github/shadow578/tenshi/db/dao/LastAccessInfoDao.java index b78de50..2c8b369 100644 --- a/app/src/main/java/io/github/shadow578/tenshi/db/dao/LastAccessInfoDao.java +++ b/app/src/main/java/io/github/shadow578/tenshi/db/dao/LastAccessInfoDao.java @@ -7,6 +7,7 @@ import androidx.room.Transaction; import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import io.github.shadow578.tenshi.db.model.LastAccessInfo; @@ -24,7 +25,7 @@ public abstract class LastAccessInfoDao { * @return the access info */ @Transaction - public List getBefore(LocalDateTime time) { + public List 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" />