From efe5f546a20f4b04f9dc19cf6711eb7eeceaff8e Mon Sep 17 00:00:00 2001 From: rebelonion <87634197+rebelonion@users.noreply.github.com> Date: Tue, 27 Feb 2024 02:13:06 -0600 Subject: [PATCH] feat: reply notifications --- app/src/main/java/ani/dantotsu/App.kt | 16 +++ .../connections/comments/CommentsAPI.kt | 60 +++++++++++ .../dantotsu/media/MediaDetailsActivity.kt | 25 ++++- .../media/comments/CommentsFragment.kt | 32 +++++- .../dantotsu/notifications/MediaNameFetch.kt | 71 +++++++++++++ .../notifications/NotificationWorker.kt | 100 ++++++++++++++++++ .../dantotsu/settings/saving/Preferences.kt | 1 + .../data/notification/Notifications.kt | 82 +++----------- 8 files changed, 316 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/ani/dantotsu/notifications/MediaNameFetch.kt create mode 100644 app/src/main/java/ani/dantotsu/notifications/NotificationWorker.kt diff --git a/app/src/main/java/ani/dantotsu/App.kt b/app/src/main/java/ani/dantotsu/App.kt index 5456fc9c..bd664906 100644 --- a/app/src/main/java/ani/dantotsu/App.kt +++ b/app/src/main/java/ani/dantotsu/App.kt @@ -6,10 +6,13 @@ import android.content.Context import android.os.Bundle import androidx.multidex.MultiDex import androidx.multidex.MultiDexApplication +import androidx.work.Constraints +import androidx.work.PeriodicWorkRequest import ani.dantotsu.aniyomi.anime.custom.AppModule import ani.dantotsu.aniyomi.anime.custom.PreferenceModule import ani.dantotsu.connections.comments.CommentsAPI import ani.dantotsu.connections.crashlytics.CrashlyticsInterface +import ani.dantotsu.notifications.NotificationWorker import ani.dantotsu.others.DisabledReports import ani.dantotsu.parsers.AnimeSources import ani.dantotsu.parsers.MangaSources @@ -115,6 +118,19 @@ class App : MultiDexApplication() { commentsScope.launch { CommentsAPI.fetchAuthToken() } + + val constraints = Constraints.Builder() + .setRequiredNetworkType(androidx.work.NetworkType.CONNECTED) + .build() + val recurringWork = PeriodicWorkRequest.Builder(NotificationWorker::class.java, + 15, java.util.concurrent.TimeUnit.MINUTES) + .setConstraints(constraints) + .build() + androidx.work.WorkManager.getInstance(this).enqueueUniquePeriodicWork( + NotificationWorker.WORK_NAME, + androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + recurringWork + ) } diff --git a/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt b/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt index d3792fb4..c807fe3b 100644 --- a/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt +++ b/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt @@ -77,6 +77,28 @@ object CommentsAPI { return parsed } + suspend fun getSingleComment(id: Int): Comment? { + val url = "$address/comments/$id" + val request = requestBuilder() + val json = try { + request.get(url) + } catch (e: IOException) { + snackString("Failed to fetch comment") + return null + } + if (!json.text.startsWith("{")) return null + val res = json.code == 200 + if (!res && json.code != 404) { + errorReason(json.code, json.text) + } + val parsed = try { + Json.decodeFromString(json.text) + } catch (e: Exception) { + return null + } + return parsed + } + suspend fun vote(commentId: Int, voteType: Int): Boolean { val url = "$address/comments/vote/$commentId/$voteType" val request = requestBuilder() @@ -212,6 +234,27 @@ object CommentsAPI { return res } + suspend fun getNotifications(): NotificationResponse? { + val url = "$address/notification/reply" + val request = requestBuilder() + val json = try { + request.get(url) + } catch (e: IOException) { + return null + } + if (!json.text.startsWith("{")) return null + val res = json.code == 200 + if (!res) { + return null + } + val parsed = try { + Json.decodeFromString(json.text) + } catch (e: Exception) { + return null + } + return parsed + } + suspend fun fetchAuthToken() { if (authToken != null) return val MAX_RETRIES = 5 @@ -311,6 +354,23 @@ data class ErrorResponse( val message: String ) +@Serializable +data class NotificationResponse( + @SerialName("notifications") + val notifications: List +) + +@Serializable +data class Notification( + @SerialName("username") + val username: String, + @SerialName("media_id") + val mediaId: Int, + @SerialName("comment_id") + val commentId: Int +) + + @Serializable data class AuthResponse( @SerialName("authToken") diff --git a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt index 3e40b5c0..5f82bcfa 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt @@ -55,6 +55,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlin.math.abs @@ -72,6 +74,15 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) var media: Media = intent.getSerialized("media") ?: mediaSingleton ?: emptyMedia() + val id = intent.getIntExtra("mediaId", -1) + if (id != -1) { + runBlocking { + withContext(Dispatchers.IO) { + media = + Anilist.query.getMedia(id, false) ?: emptyMedia() + } + } + } if (media.name == "No media found") { snackString(media.name) onBackPressedDispatcher.onBackPressed() @@ -314,19 +325,19 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi progress() } } - adult = media.isAdult tabLayout.menu.clear() if (media.anime != null) { viewPager.adapter = - ViewPagerAdapter(supportFragmentManager, lifecycle, SupportedMedia.ANIME, media) + ViewPagerAdapter(supportFragmentManager, lifecycle, SupportedMedia.ANIME, media, intent.getIntExtra("commentId", -1)) tabLayout.inflateMenu(R.menu.anime_menu_detail) } else if (media.manga != null) { viewPager.adapter = ViewPagerAdapter( supportFragmentManager, lifecycle, if (media.format == "NOVEL") SupportedMedia.NOVEL else SupportedMedia.MANGA, - media + media, + intent.getIntExtra("commentId", -1) ) if (media.format == "NOVEL") { tabLayout.inflateMenu(R.menu.novel_menu_detail) @@ -358,6 +369,10 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi model.continueMedia = PrefManager.getVal(PrefName.ContinueMedia) selected = 1 } + val frag = intent.getStringExtra("FRAGMENT_TO_LOAD") + if (frag != null) { + selected = 2 + } val live = Refresh.activity.getOrPut(this.hashCode()) { MutableLiveData(true) } live.observe(this) { @@ -417,7 +432,8 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi fragmentManager: FragmentManager, lifecycle: Lifecycle, private val mediaType: SupportedMedia, - private val media: Media + private val media: Media, + private val commentId: Int ) : FragmentStateAdapter(fragmentManager, lifecycle) { @@ -435,6 +451,7 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi val bundle = Bundle() bundle.putInt("mediaId", media.id) bundle.putString("mediaName", media.mainName()) + if (commentId != -1) bundle.putInt("commentId", commentId) fragment.arguments = bundle fragment } diff --git a/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt b/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt index 7947740d..9b5fe5f9 100644 --- a/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt @@ -119,7 +119,12 @@ class CommentsFragment : Fragment() { binding.commentsList.layoutManager = LinearLayoutManager(activity) lifecycleScope.launch { - loadAndDisplayComments() + val commentId = arguments?.getInt("commentId") + if (commentId != null && commentId > 0) { + loadSingleComment(commentId) + } else { + loadAndDisplayComments() + } } binding.commentSort.setOnClickListener { view -> @@ -395,6 +400,31 @@ class CommentsFragment : Fragment() { adapter.add(section) } + private suspend fun loadSingleComment(commentId: Int) { + binding.commentsProgressBar.visibility = View.VISIBLE + binding.commentsList.visibility = View.GONE + adapter.clear() + section.clear() + + val comment = withContext(Dispatchers.IO) { + CommentsAPI.getSingleComment(commentId) + } + if (comment != null) { + withContext(Dispatchers.Main) { + section.add( + CommentItem( + comment, + buildMarkwon(), + section, + this@CommentsFragment, + backgroundColor, + 0 + ) + ) + } + } + } + private fun sortComments(comments: List?): List { if (comments == null) return emptyList() return when (PrefManager.getVal(PrefName.CommentSortOrder, "newest")) { diff --git a/app/src/main/java/ani/dantotsu/notifications/MediaNameFetch.kt b/app/src/main/java/ani/dantotsu/notifications/MediaNameFetch.kt new file mode 100644 index 00000000..a4795df9 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/MediaNameFetch.kt @@ -0,0 +1,71 @@ +package ani.dantotsu.notifications + +import ani.dantotsu.client +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class MediaNameFetch { + companion object { + private fun queryBuilder(mediaIds: List): String { + var query = "{" + mediaIds.forEachIndexed { index, mediaId -> + query += """ + media$index: Media(id: $mediaId) { + id + title { + romaji + } + } + """.trimIndent() + } + query += "}" + return query + } + + suspend fun fetchMediaTitles(ids: List): Map { + return try { + val url = "https://graphql.anilist.co/" + val data = mapOf( + "query" to queryBuilder(ids), + ) + withContext(Dispatchers.IO) { + val response = client.post( + url, + headers = mapOf( + "Content-Type" to "application/json", + "Accept" to "application/json" + ), + data = data + ) + val mediaResponse = parseMediaResponseWithGson(response.text) + val mediaMap = mutableMapOf() + mediaResponse.data.forEach { (_, mediaItem) -> + mediaMap[mediaItem.id] = mediaItem.title.romaji + } + mediaMap + } + } catch (e: Exception) { + val errorMap = mutableMapOf() + ids.forEach { errorMap[it] = "Unknown" } + errorMap + } + } + + private fun parseMediaResponseWithGson(response: String): MediaResponse { + val gson = Gson() + val type = object : TypeToken() {}.type + return gson.fromJson(response, type) + } + + data class MediaResponse(val data: Map) + data class MediaItem( + val id: Int, + val title: MediaTitle + ) + + data class MediaTitle(val romaji: String) + + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/NotificationWorker.kt b/app/src/main/java/ani/dantotsu/notifications/NotificationWorker.kt new file mode 100644 index 00000000..0a594485 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/NotificationWorker.kt @@ -0,0 +1,100 @@ +package ani.dantotsu.notifications + +import android.Manifest +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import ani.dantotsu.R +import ani.dantotsu.connections.comments.CommentsAPI +import ani.dantotsu.media.MediaDetailsActivity +import eu.kanade.tachiyomi.data.notification.Notifications +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class NotificationWorker(appContext: Context, workerParams: WorkerParameters) : + Worker(appContext, workerParams) { + override fun doWork(): Result { + val scope = CoroutineScope(Dispatchers.IO) + scope.launch { + val notifications = CommentsAPI.getNotifications() + val mediaIds = notifications?.notifications?.map { it.mediaId } + val names = MediaNameFetch.fetchMediaTitles(mediaIds ?: emptyList()) + notifications?.notifications?.forEach { + val title = "New Comment Reply" + val mediaName = names[it.mediaId] ?: "Unknown" + val message = "${it.username} replied to your comment in $mediaName" + val notification = createNotification( + NotificationType.COMMENT_REPLY, + message, + title, + it.mediaId, + it.commentId + ) + + if (ActivityCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + NotificationManagerCompat.from(applicationContext) + .notify( + NotificationType.COMMENT_REPLY.id, + Notifications.ID_COMMENT_REPLY, + notification + ) + } + + } + } + return Result.success() + } + + private fun createNotification( + notificationType: NotificationType, + message: String, + title: String, + mediaId: Int, + commentId: Int + ): android.app.Notification { + val notification = when (notificationType) { + NotificationType.COMMENT_REPLY -> { + val intent = Intent(applicationContext, MediaDetailsActivity::class.java).apply { + putExtra("FRAGMENT_TO_LOAD", "COMMENTS") + putExtra("mediaId", mediaId) + putExtra("commentId", commentId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent = PendingIntent.getActivity( + applicationContext, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val builder = NotificationCompat.Builder(applicationContext, notificationType.id) + .setContentTitle(title) + .setContentText(message) + .setSmallIcon(R.drawable.ic_round_comment_24) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + builder.build() + } + } + return notification + } + + enum class NotificationType(val id: String) { + COMMENT_REPLY(Notifications.CHANNEL_COMMENTS), + } + + companion object { + const val WORK_NAME = "ani.dantotsu.notifications.NotificationWorker" + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt index bbe0dcf9..dfa0c0b7 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt @@ -33,6 +33,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files MangaSourcesOrder(Pref(Location.General, List::class, listOf())), MangaSearchHistory(Pref(Location.General, Set::class, setOf())), NovelSourcesOrder(Pref(Location.General, List::class, listOf())), + NotificationInterval(Pref(Location.General, Int::class, 0)), //User Interface UseOLED(Pref(Location.UI, Boolean::class, false)), diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index 6696d3cf..5ffc6cc3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -19,18 +19,6 @@ object Notifications { const val CHANNEL_COMMON = "common_channel" const val ID_DOWNLOAD_IMAGE = 2 - /** - * Notification channel and ids used by the library updater. - */ - private const val GROUP_LIBRARY = "group_library" - const val CHANNEL_LIBRARY_PROGRESS = "library_progress_channel" - const val ID_LIBRARY_PROGRESS = -101 - const val ID_LIBRARY_SIZE_WARNING = -103 - const val CHANNEL_LIBRARY_ERROR = "library_errors_channel" - const val ID_LIBRARY_ERROR = -102 - const val CHANNEL_LIBRARY_SKIPPED = "library_skipped_channel" - const val ID_LIBRARY_SKIPPED = -104 - /** * Notification channel and ids used by the downloader. */ @@ -51,23 +39,19 @@ object Notifications { const val GROUP_NEW_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS" const val GROUP_NEW_EPISODES = "eu.kanade.tachiyomi.NEW_EPISODES" - /** - * Notification channel and ids used by the backup/restore system. - */ - private const val GROUP_BACKUP_RESTORE = "group_backup_restore" - const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel" - const val ID_BACKUP_PROGRESS = -501 - const val ID_RESTORE_PROGRESS = -503 - const val CHANNEL_BACKUP_RESTORE_COMPLETE = "backup_restore_complete_channel_v2" - const val ID_BACKUP_COMPLETE = -502 - const val ID_RESTORE_COMPLETE = -504 - /** * Notification channel used for Incognito Mode */ const val CHANNEL_INCOGNITO_MODE = "incognito_mode_channel" const val ID_INCOGNITO_MODE = -701 + /** + * Notification channel used for comment notifications + */ + const val CHANNEL_COMMENTS = "comments_channel" + const val ID_COMMENT_REPLY = -801 + + /** * Notification channel and ids used for app and extension updates. */ @@ -88,6 +72,12 @@ object Notifications { "updates_ext_channel", "downloader_cache_renewal", "crash_logs_channel", + "backup_restore_complete_channel_v2", + "backup_restore_progress_channel", + "group_backup_restore", + "library_skipped_channel", + "library_errors_channel", + "library_progress_channel", ) /** @@ -102,43 +92,11 @@ object Notifications { // Delete old notification channels deprecatedChannels.forEach(notificationManager::deleteNotificationChannel) - notificationManager.createNotificationChannelGroupsCompat( - listOf( - buildNotificationChannelGroup(GROUP_BACKUP_RESTORE) { - setName("Backup & Restore") - }, - buildNotificationChannelGroup(GROUP_DOWNLOADER) { - setName("Downloader") - }, - buildNotificationChannelGroup(GROUP_LIBRARY) { - setName("Library") - }, - buildNotificationChannelGroup(GROUP_APK_UPDATES) { - setName("App & Extension Updates") - }, - ), - ) - notificationManager.createNotificationChannelsCompat( listOf( buildNotificationChannel(CHANNEL_COMMON, IMPORTANCE_LOW) { setName("Common") }, - buildNotificationChannel(CHANNEL_LIBRARY_PROGRESS, IMPORTANCE_LOW) { - setName("Library Progress") - setGroup(GROUP_LIBRARY) - setShowBadge(false) - }, - buildNotificationChannel(CHANNEL_LIBRARY_ERROR, IMPORTANCE_LOW) { - setName("Library Errors") - setGroup(GROUP_LIBRARY) - setShowBadge(false) - }, - buildNotificationChannel(CHANNEL_LIBRARY_SKIPPED, IMPORTANCE_LOW) { - setName("Library Skipped") - setGroup(GROUP_LIBRARY) - setShowBadge(false) - }, buildNotificationChannel(CHANNEL_NEW_CHAPTERS_EPISODES, IMPORTANCE_DEFAULT) { setName("New Chapters & Episodes") }, @@ -152,20 +110,12 @@ object Notifications { setGroup(GROUP_DOWNLOADER) setShowBadge(false) }, - buildNotificationChannel(CHANNEL_BACKUP_RESTORE_PROGRESS, IMPORTANCE_LOW) { - setName("Backup & Restore Progress") - setGroup(GROUP_BACKUP_RESTORE) - setShowBadge(false) - }, - buildNotificationChannel(CHANNEL_BACKUP_RESTORE_COMPLETE, IMPORTANCE_HIGH) { - setName("Backup & Restore Complete") - setGroup(GROUP_BACKUP_RESTORE) - setShowBadge(false) - setSound(null, null) - }, buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) { setName("Incognito Mode") }, + buildNotificationChannel(CHANNEL_COMMENTS, IMPORTANCE_HIGH) { + setName("Comments") + }, buildNotificationChannel(CHANNEL_APP_UPDATE, IMPORTANCE_DEFAULT) { setGroup(GROUP_APK_UPDATES) setName("App Updates")