feat: reply notifications

This commit is contained in:
rebelonion 2024-02-27 02:13:06 -06:00
parent a8bd9ef97b
commit efe5f546a2
8 changed files with 316 additions and 71 deletions

View file

@ -6,10 +6,13 @@ import android.content.Context
import android.os.Bundle import android.os.Bundle
import androidx.multidex.MultiDex import androidx.multidex.MultiDex
import androidx.multidex.MultiDexApplication 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.AppModule
import ani.dantotsu.aniyomi.anime.custom.PreferenceModule import ani.dantotsu.aniyomi.anime.custom.PreferenceModule
import ani.dantotsu.connections.comments.CommentsAPI import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.notifications.NotificationWorker
import ani.dantotsu.others.DisabledReports import ani.dantotsu.others.DisabledReports
import ani.dantotsu.parsers.AnimeSources import ani.dantotsu.parsers.AnimeSources
import ani.dantotsu.parsers.MangaSources import ani.dantotsu.parsers.MangaSources
@ -115,6 +118,19 @@ class App : MultiDexApplication() {
commentsScope.launch { commentsScope.launch {
CommentsAPI.fetchAuthToken() 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
)
} }

View file

@ -77,6 +77,28 @@ object CommentsAPI {
return parsed 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<Comment>(json.text)
} catch (e: Exception) {
return null
}
return parsed
}
suspend fun vote(commentId: Int, voteType: Int): Boolean { suspend fun vote(commentId: Int, voteType: Int): Boolean {
val url = "$address/comments/vote/$commentId/$voteType" val url = "$address/comments/vote/$commentId/$voteType"
val request = requestBuilder() val request = requestBuilder()
@ -212,6 +234,27 @@ object CommentsAPI {
return res 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<NotificationResponse>(json.text)
} catch (e: Exception) {
return null
}
return parsed
}
suspend fun fetchAuthToken() { suspend fun fetchAuthToken() {
if (authToken != null) return if (authToken != null) return
val MAX_RETRIES = 5 val MAX_RETRIES = 5
@ -311,6 +354,23 @@ data class ErrorResponse(
val message: String val message: String
) )
@Serializable
data class NotificationResponse(
@SerialName("notifications")
val notifications: List<Notification>
)
@Serializable
data class Notification(
@SerialName("username")
val username: String,
@SerialName("media_id")
val mediaId: Int,
@SerialName("comment_id")
val commentId: Int
)
@Serializable @Serializable
data class AuthResponse( data class AuthResponse(
@SerialName("authToken") @SerialName("authToken")

View file

@ -55,6 +55,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlin.math.abs import kotlin.math.abs
@ -72,6 +74,15 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
var media: Media = intent.getSerialized("media") ?: mediaSingleton ?: emptyMedia() 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") { if (media.name == "No media found") {
snackString(media.name) snackString(media.name)
onBackPressedDispatcher.onBackPressed() onBackPressedDispatcher.onBackPressed()
@ -314,19 +325,19 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
progress() progress()
} }
} }
adult = media.isAdult adult = media.isAdult
tabLayout.menu.clear() tabLayout.menu.clear()
if (media.anime != null) { if (media.anime != null) {
viewPager.adapter = 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) tabLayout.inflateMenu(R.menu.anime_menu_detail)
} else if (media.manga != null) { } else if (media.manga != null) {
viewPager.adapter = ViewPagerAdapter( viewPager.adapter = ViewPagerAdapter(
supportFragmentManager, supportFragmentManager,
lifecycle, lifecycle,
if (media.format == "NOVEL") SupportedMedia.NOVEL else SupportedMedia.MANGA, if (media.format == "NOVEL") SupportedMedia.NOVEL else SupportedMedia.MANGA,
media media,
intent.getIntExtra("commentId", -1)
) )
if (media.format == "NOVEL") { if (media.format == "NOVEL") {
tabLayout.inflateMenu(R.menu.novel_menu_detail) tabLayout.inflateMenu(R.menu.novel_menu_detail)
@ -358,6 +369,10 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
model.continueMedia = PrefManager.getVal(PrefName.ContinueMedia) model.continueMedia = PrefManager.getVal(PrefName.ContinueMedia)
selected = 1 selected = 1
} }
val frag = intent.getStringExtra("FRAGMENT_TO_LOAD")
if (frag != null) {
selected = 2
}
val live = Refresh.activity.getOrPut(this.hashCode()) { MutableLiveData(true) } val live = Refresh.activity.getOrPut(this.hashCode()) { MutableLiveData(true) }
live.observe(this) { live.observe(this) {
@ -417,7 +432,8 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
fragmentManager: FragmentManager, fragmentManager: FragmentManager,
lifecycle: Lifecycle, lifecycle: Lifecycle,
private val mediaType: SupportedMedia, private val mediaType: SupportedMedia,
private val media: Media private val media: Media,
private val commentId: Int
) : ) :
FragmentStateAdapter(fragmentManager, lifecycle) { FragmentStateAdapter(fragmentManager, lifecycle) {
@ -435,6 +451,7 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
val bundle = Bundle() val bundle = Bundle()
bundle.putInt("mediaId", media.id) bundle.putInt("mediaId", media.id)
bundle.putString("mediaName", media.mainName()) bundle.putString("mediaName", media.mainName())
if (commentId != -1) bundle.putInt("commentId", commentId)
fragment.arguments = bundle fragment.arguments = bundle
fragment fragment
} }

View file

@ -119,7 +119,12 @@ class CommentsFragment : Fragment() {
binding.commentsList.layoutManager = LinearLayoutManager(activity) binding.commentsList.layoutManager = LinearLayoutManager(activity)
lifecycleScope.launch { lifecycleScope.launch {
loadAndDisplayComments() val commentId = arguments?.getInt("commentId")
if (commentId != null && commentId > 0) {
loadSingleComment(commentId)
} else {
loadAndDisplayComments()
}
} }
binding.commentSort.setOnClickListener { view -> binding.commentSort.setOnClickListener { view ->
@ -395,6 +400,31 @@ class CommentsFragment : Fragment() {
adapter.add(section) 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<Comment>?): List<Comment> { private fun sortComments(comments: List<Comment>?): List<Comment> {
if (comments == null) return emptyList() if (comments == null) return emptyList()
return when (PrefManager.getVal(PrefName.CommentSortOrder, "newest")) { return when (PrefManager.getVal(PrefName.CommentSortOrder, "newest")) {

View file

@ -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<Int>): 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<Int>): Map<Int, String> {
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<Int, String>()
mediaResponse.data.forEach { (_, mediaItem) ->
mediaMap[mediaItem.id] = mediaItem.title.romaji
}
mediaMap
}
} catch (e: Exception) {
val errorMap = mutableMapOf<Int, String>()
ids.forEach { errorMap[it] = "Unknown" }
errorMap
}
}
private fun parseMediaResponseWithGson(response: String): MediaResponse {
val gson = Gson()
val type = object : TypeToken<MediaResponse>() {}.type
return gson.fromJson(response, type)
}
data class MediaResponse(val data: Map<String, MediaItem>)
data class MediaItem(
val id: Int,
val title: MediaTitle
)
data class MediaTitle(val romaji: String)
}
}

View file

@ -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"
}
}

View file

@ -33,6 +33,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files
MangaSourcesOrder(Pref(Location.General, List::class, listOf<String>())), MangaSourcesOrder(Pref(Location.General, List::class, listOf<String>())),
MangaSearchHistory(Pref(Location.General, Set::class, setOf<String>())), MangaSearchHistory(Pref(Location.General, Set::class, setOf<String>())),
NovelSourcesOrder(Pref(Location.General, List::class, listOf<String>())), NovelSourcesOrder(Pref(Location.General, List::class, listOf<String>())),
NotificationInterval(Pref(Location.General, Int::class, 0)),
//User Interface //User Interface
UseOLED(Pref(Location.UI, Boolean::class, false)), UseOLED(Pref(Location.UI, Boolean::class, false)),

View file

@ -19,18 +19,6 @@ object Notifications {
const val CHANNEL_COMMON = "common_channel" const val CHANNEL_COMMON = "common_channel"
const val ID_DOWNLOAD_IMAGE = 2 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. * 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_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS"
const val GROUP_NEW_EPISODES = "eu.kanade.tachiyomi.NEW_EPISODES" 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 * Notification channel used for Incognito Mode
*/ */
const val CHANNEL_INCOGNITO_MODE = "incognito_mode_channel" const val CHANNEL_INCOGNITO_MODE = "incognito_mode_channel"
const val ID_INCOGNITO_MODE = -701 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. * Notification channel and ids used for app and extension updates.
*/ */
@ -88,6 +72,12 @@ object Notifications {
"updates_ext_channel", "updates_ext_channel",
"downloader_cache_renewal", "downloader_cache_renewal",
"crash_logs_channel", "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 // Delete old notification channels
deprecatedChannels.forEach(notificationManager::deleteNotificationChannel) 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( notificationManager.createNotificationChannelsCompat(
listOf( listOf(
buildNotificationChannel(CHANNEL_COMMON, IMPORTANCE_LOW) { buildNotificationChannel(CHANNEL_COMMON, IMPORTANCE_LOW) {
setName("Common") 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) { buildNotificationChannel(CHANNEL_NEW_CHAPTERS_EPISODES, IMPORTANCE_DEFAULT) {
setName("New Chapters & Episodes") setName("New Chapters & Episodes")
}, },
@ -152,20 +110,12 @@ object Notifications {
setGroup(GROUP_DOWNLOADER) setGroup(GROUP_DOWNLOADER)
setShowBadge(false) 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) { buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) {
setName("Incognito Mode") setName("Incognito Mode")
}, },
buildNotificationChannel(CHANNEL_COMMENTS, IMPORTANCE_HIGH) {
setName("Comments")
},
buildNotificationChannel(CHANNEL_APP_UPDATE, IMPORTANCE_DEFAULT) { buildNotificationChannel(CHANNEL_APP_UPDATE, IMPORTANCE_DEFAULT) {
setGroup(GROUP_APK_UPDATES) setGroup(GROUP_APK_UPDATES)
setName("App Updates") setName("App Updates")