rpc fix
This commit is contained in:
parent
af326c8258
commit
cf2d9ad654
16 changed files with 1131 additions and 544 deletions
|
@ -29,7 +29,7 @@ android {
|
|||
debug {
|
||||
applicationIdSuffix ".beta"
|
||||
manifestPlaceholders = [icon_placeholder: "@mipmap/ic_launcher_beta"]
|
||||
debuggable true
|
||||
debuggable false
|
||||
}
|
||||
release {
|
||||
manifestPlaceholders = [icon_placeholder: "@mipmap/ic_launcher"]
|
||||
|
|
|
@ -276,6 +276,10 @@
|
|||
<service android:name=".download.manga.MangaDownloaderService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service android:name=".connections.discord.DiscordService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -43,6 +43,7 @@ import ani.dantotsu.connections.anilist.AnilistHomeViewModel
|
|||
import ani.dantotsu.databinding.ActivityMainBinding
|
||||
import ani.dantotsu.databinding.ItemNavbarBinding
|
||||
import ani.dantotsu.databinding.SplashScreenBinding
|
||||
import ani.dantotsu.download.manga.MangaDownloaderService
|
||||
import ani.dantotsu.download.manga.OfflineMangaFragment
|
||||
import ani.dantotsu.home.AnimeFragment
|
||||
import ani.dantotsu.home.HomeFragment
|
||||
|
|
|
@ -19,9 +19,16 @@ import kotlinx.coroutines.launch
|
|||
|
||||
suspend fun getUserId(context: Context, block: () -> Unit) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
if (Discord.userid == null && Discord.token != null) {
|
||||
if (!Discord.getUserData())
|
||||
snackString(context.getString(R.string.error_loading_discord_user_data))
|
||||
val sharedPref = context.getSharedPreferences(
|
||||
context.getString(R.string.preference_file_key),
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val token = sharedPref.getString("discord_token", null)
|
||||
val userid = sharedPref.getString("discord_id", null)
|
||||
if (userid == null && token != null) {
|
||||
/*if (!Discord.getUserData())
|
||||
snackString(context.getString(R.string.error_loading_discord_user_data))*/
|
||||
//TODO: Discord.getUserData()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ object Discord {
|
|||
var userid: String? = null
|
||||
var avatar: String? = null
|
||||
|
||||
private const val TOKEN = "discord_token"
|
||||
const val TOKEN = "discord_token"
|
||||
|
||||
fun getSavedToken(context: Context): Boolean {
|
||||
val sharedPref = context.getSharedPreferences(
|
||||
|
@ -61,7 +61,7 @@ object Discord {
|
|||
}
|
||||
|
||||
private var rpc : RPC? = null
|
||||
suspend fun getUserData() = tryWithSuspend(true) {
|
||||
/*suspend fun getUserData() = tryWithSuspend(true) {
|
||||
if(rpc==null) {
|
||||
val rpc = RPC(token!!, Dispatchers.IO).also { rpc = it }
|
||||
val user: User = rpc.getUserData()
|
||||
|
@ -70,7 +70,7 @@ object Discord {
|
|||
rpc.close()
|
||||
true
|
||||
} else true
|
||||
} ?: false
|
||||
} ?: false*/
|
||||
|
||||
|
||||
fun warning(context: Context) = CustomBottomDialog().apply {
|
||||
|
@ -97,16 +97,20 @@ object Discord {
|
|||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun defaultRPC(): RPC? {
|
||||
const val application_Id = "1163925779692912771"
|
||||
const val small_Image: String = "mp:attachments/1167176318266380288/1176997397797277856/logo-best_of_both.png"
|
||||
/*fun defaultRPC(): RPC? {
|
||||
return token?.let {
|
||||
RPC(it, Dispatchers.IO).apply {
|
||||
applicationId = "1163925779692912771"
|
||||
applicationId = application_Id
|
||||
smallImage = RPC.Link(
|
||||
"Dantotsu",
|
||||
"mp:attachments/1167176318266380288/1176997397797277856/logo-best_of_both.png"
|
||||
small_Image
|
||||
)
|
||||
buttons.add(RPC.Link("Stream on Dantotsu", "https://github.com/rebelonion/Dantotsu/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,444 @@
|
|||
package ani.dantotsu.connections.discord
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.widget.Button
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import ani.dantotsu.MainActivity
|
||||
import ani.dantotsu.R
|
||||
import ani.dantotsu.connections.discord.serializers.Activity
|
||||
import ani.dantotsu.connections.discord.serializers.Presence
|
||||
import ani.dantotsu.connections.discord.serializers.User
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.io.File
|
||||
import java.io.OutputStreamWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
|
||||
class DiscordService : Service() {
|
||||
private var heartbeat : Int = 0
|
||||
private var sequence : Int? = null
|
||||
private var sessionId : String = ""
|
||||
private var resume = false
|
||||
private lateinit var logFile : File
|
||||
private lateinit var webSocket: WebSocket
|
||||
private lateinit var heartbeatThread : Thread
|
||||
private lateinit var client : OkHttpClient
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
var presenceStore = ""
|
||||
val json = Json {
|
||||
encodeDefaults = true
|
||||
allowStructuredMapKeys = true
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
var log = ""
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
log("Service onCreate()")
|
||||
val powerManager = baseContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "discordRPC:backgroundPresence")
|
||||
wakeLock.acquire()
|
||||
log("WakeLock Acquired")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val serviceChannel = NotificationChannel(
|
||||
"discordPresence",
|
||||
"Discord Presence Service Channel",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(serviceChannel)
|
||||
}
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_MAIN
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
val pendingIntent =
|
||||
PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
val builder = NotificationCompat.Builder(this, "discordPresence")
|
||||
.setSmallIcon(R.mipmap.ic_launcher_round)
|
||||
.setContentTitle("Discord Presence")
|
||||
.setContentText("Running in the background")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
startForeground(1, builder.build())
|
||||
log("Foreground service started, notification shown")
|
||||
client = OkHttpClient()
|
||||
client.newWebSocket(
|
||||
Request.Builder().url("wss://gateway.discord.gg/?v=10&encoding=json").build(),
|
||||
DiscordWebSocketListener()
|
||||
)
|
||||
client.dispatcher.executorService.shutdown()
|
||||
SERVICE_RUNNING = true
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
log("Service onStartCommand()")
|
||||
if(intent != null) {
|
||||
if (intent.hasExtra("presence")) {
|
||||
log("Service onStartCommand() setPresence")
|
||||
var lPresence = intent.getStringExtra("presence")
|
||||
if (this::webSocket.isInitialized) webSocket.send(lPresence!!)
|
||||
presenceStore = lPresence!!
|
||||
}else{
|
||||
log("Service onStartCommand() no presence")
|
||||
DiscordServiceRunningSingleton.running = false
|
||||
stopSelf()
|
||||
}
|
||||
if (intent.hasExtra(ACTION_STOP_SERVICE)) {
|
||||
log("Service onStartCommand() stopService")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
log("Service Destroyed")
|
||||
if (DiscordServiceRunningSingleton.running){
|
||||
log("Accidental Service Destruction, restarting service")
|
||||
val intent = Intent(baseContext, DiscordService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
baseContext.startForegroundService(intent)
|
||||
} else {
|
||||
baseContext.startService(intent)
|
||||
}
|
||||
} else {
|
||||
setPresence(json.encodeToString(
|
||||
Presence.Response(
|
||||
3,
|
||||
Presence(status = "offline")
|
||||
)
|
||||
))
|
||||
wakeLock.release()
|
||||
}
|
||||
SERVICE_RUNNING = false
|
||||
if(this::webSocket.isInitialized) webSocket.close(1000, "Closed by user")
|
||||
//saveLogToFile()
|
||||
}
|
||||
|
||||
fun saveProfile(response: String) {
|
||||
val sharedPref = baseContext.getSharedPreferences(
|
||||
baseContext.getString(R.string.preference_file_key),
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val user = json.decodeFromString<User.Response>(response).d.user
|
||||
log("User data: $user")
|
||||
with(sharedPref.edit()) {
|
||||
putString("discord_username", user.username)
|
||||
putString("discord_id", user.id)
|
||||
putString("discord_avatar", user.avatar)
|
||||
apply()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? = null
|
||||
|
||||
inner class DiscordWebSocketListener : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
super.onOpen(webSocket, response)
|
||||
this@DiscordService.webSocket = webSocket
|
||||
log("WebSocket: Opened")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
super.onMessage(webSocket, text)
|
||||
val json = JsonParser.parseString(text).asJsonObject
|
||||
log("WebSocket: Received op code ${json.get("op")}")
|
||||
when (json.get("op").asInt) {
|
||||
0 -> {
|
||||
if(json.has("s")) {
|
||||
log("WebSocket: Sequence ${json.get("s")} Received")
|
||||
sequence = json.get("s").asInt
|
||||
}
|
||||
if (json.get("t").asString != "READY") return
|
||||
saveProfile(text)
|
||||
log(text)
|
||||
sessionId = json.get("d").asJsonObject.get("session_id").asString
|
||||
log("WebSocket: SessionID ${json.get("d").asJsonObject.get("session_id")} Received")
|
||||
if(presenceStore.isNotEmpty()) setPresence(presenceStore)
|
||||
sendBroadcast(Intent("ServiceToConnectButton"))
|
||||
}
|
||||
1 -> {
|
||||
log("WebSocket: Received Heartbeat request, sending heartbeat")
|
||||
heartbeatThread.interrupt()
|
||||
heartbeatSend(webSocket, sequence)
|
||||
heartbeatThread = Thread(HeartbeatRunnable())
|
||||
heartbeatThread.start()
|
||||
}
|
||||
7 -> {
|
||||
resume = true
|
||||
log("WebSocket: Requested to Restart, restarting")
|
||||
webSocket.close(1000, "Requested to Restart by the server")
|
||||
client = OkHttpClient()
|
||||
client.newWebSocket(
|
||||
Request.Builder().url("wss://gateway.discord.gg/?v=10&encoding=json").build(),
|
||||
DiscordWebSocketListener()
|
||||
)
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
9 -> {
|
||||
log("WebSocket: Invalid Session, restarting")
|
||||
webSocket.close(1000, "Invalid Session")
|
||||
Thread.sleep(5000)
|
||||
client = OkHttpClient()
|
||||
client.newWebSocket(
|
||||
Request.Builder().url("wss://gateway.discord.gg/?v=10&encoding=json").build(),
|
||||
DiscordWebSocketListener()
|
||||
)
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
10 -> {
|
||||
heartbeat = json.get("d").asJsonObject.get("heartbeat_interval").asInt
|
||||
heartbeatThread = Thread(HeartbeatRunnable())
|
||||
heartbeatThread.start()
|
||||
if(resume) {
|
||||
log("WebSocket: Resuming because server requested")
|
||||
resume()
|
||||
resume = false
|
||||
} else {
|
||||
identify(webSocket, baseContext)
|
||||
log("WebSocket: Identified")
|
||||
}
|
||||
}
|
||||
11 -> {
|
||||
log("WebSocket: Heartbeat ACKed")
|
||||
heartbeatThread = Thread(HeartbeatRunnable())
|
||||
heartbeatThread.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun identify(webSocket: WebSocket, context: Context) {
|
||||
val properties = JsonObject()
|
||||
properties.addProperty("os","linux")
|
||||
properties.addProperty("browser","unknown")
|
||||
properties.addProperty("device","unknown")
|
||||
val d = JsonObject()
|
||||
d.addProperty("token", getToken(context))
|
||||
d.addProperty("intents", 0)
|
||||
d.add("properties", properties)
|
||||
val payload = JsonObject()
|
||||
payload.addProperty("op",2)
|
||||
payload.add("d", d)
|
||||
webSocket.send(payload.toString())
|
||||
}
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
super.onFailure(webSocket, t, response)
|
||||
t.message?.let { Log.d("WebSocket", "onFailure() $it") }
|
||||
log("WebSocket: Error, onFailure() reason: ${t.message}")
|
||||
client = OkHttpClient()
|
||||
client.newWebSocket(
|
||||
Request.Builder().url("wss://gateway.discord.gg/?v=10&encoding=json").build(),
|
||||
DiscordWebSocketListener()
|
||||
)
|
||||
client.dispatcher.executorService.shutdown()
|
||||
if(!heartbeatThread.isInterrupted) { heartbeatThread.interrupt() }
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
super.onClosing(webSocket, code, reason)
|
||||
Log.d("WebSocket", "onClosing() $code $reason")
|
||||
if(!heartbeatThread.isInterrupted) { heartbeatThread.interrupt() }
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
super.onClosed(webSocket, code, reason)
|
||||
Log.d("WebSocket", "onClosed() $code $reason")
|
||||
if(code >= 4000) {
|
||||
log("WebSocket: Error, code: $code reason: $reason")
|
||||
client = OkHttpClient()
|
||||
client.newWebSocket(
|
||||
Request.Builder().url("wss://gateway.discord.gg/?v=10&encoding=json").build(),
|
||||
DiscordWebSocketListener()
|
||||
)
|
||||
client.dispatcher.executorService.shutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getToken(context: Context): String {
|
||||
val sharedPref = context.getSharedPreferences(
|
||||
context.getString(R.string.preference_file_key),
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val token = sharedPref.getString(Discord.TOKEN, null)
|
||||
if(token == null) {
|
||||
log("WebSocket: Token not found")
|
||||
errorNotification("Could not set the presence", "token not found")
|
||||
return ""
|
||||
}
|
||||
else{
|
||||
return token
|
||||
}
|
||||
}
|
||||
fun heartbeatSend( webSocket: WebSocket, seq: Int? ) {
|
||||
val json = JsonObject()
|
||||
json.addProperty("op",1)
|
||||
json.addProperty("d", seq)
|
||||
webSocket.send(json.toString())
|
||||
}
|
||||
private fun errorNotification(title : String, text: String) {
|
||||
val intent = Intent(this@DiscordService, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_MAIN
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
val pendingIntent =
|
||||
PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
val builder = NotificationCompat.Builder(this@DiscordService, "discordPresence")
|
||||
.setSmallIcon(R.mipmap.ic_launcher_round)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
//TODO: Request permission
|
||||
return
|
||||
}
|
||||
notificationManager.notify(2, builder.build())
|
||||
log("Error Notified")
|
||||
}
|
||||
|
||||
fun saveSimpleTestPresence() {
|
||||
val file = File(baseContext.cacheDir, "payload")
|
||||
//fill with test payload
|
||||
val payload = JsonObject()
|
||||
payload.addProperty("op", 3)
|
||||
payload.add("d", JsonObject().apply {
|
||||
addProperty("status", "online")
|
||||
addProperty("afk", false)
|
||||
add("activities", JsonArray().apply {
|
||||
add(JsonObject().apply {
|
||||
addProperty("name", "Test")
|
||||
addProperty("type", 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
file.writeText(payload.toString())
|
||||
log("WebSocket: Simple Test Presence Saved")
|
||||
}
|
||||
|
||||
fun setPresence(String: String) {
|
||||
log("WebSocket: Sending Presence payload")
|
||||
log(String)
|
||||
webSocket.send(String)
|
||||
}
|
||||
|
||||
fun log(string: String) {
|
||||
Log.d("WebSocket_Discord", string)
|
||||
//log += "${SimpleDateFormat("HH:mm:ss").format(Calendar.getInstance().time)} $string\n"
|
||||
}
|
||||
|
||||
fun saveLogToFile() {
|
||||
val fileName = "log_${System.currentTimeMillis()}.txt"
|
||||
|
||||
// ContentValues to store file metadata
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "text/plain")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/")
|
||||
}
|
||||
}
|
||||
|
||||
// Inserting the file in the MediaStore
|
||||
val resolver = baseContext.contentResolver
|
||||
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
} else {
|
||||
val directory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val file = File(directory, fileName)
|
||||
|
||||
// Make sure the Downloads directory exists
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs()
|
||||
}
|
||||
|
||||
// Use FileProvider to get the URI for the file
|
||||
val authority = "${baseContext.packageName}.provider" // Adjust with your app's package name
|
||||
Uri.fromFile(file)
|
||||
}
|
||||
|
||||
// Writing to the file
|
||||
uri?.let {
|
||||
resolver.openOutputStream(it).use { outputStream ->
|
||||
OutputStreamWriter(outputStream).use { writer ->
|
||||
writer.write(log)
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
log("Error saving log file")
|
||||
}
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
log("Sending Resume payload")
|
||||
val d = JsonObject()
|
||||
d.addProperty("token", getToken(baseContext))
|
||||
d.addProperty("session_id", sessionId)
|
||||
d.addProperty("seq", sequence)
|
||||
val json = JsonObject()
|
||||
json.addProperty("op", 6)
|
||||
json.add("d", d)
|
||||
log(json.toString())
|
||||
webSocket.send(json.toString())
|
||||
}
|
||||
|
||||
inner class HeartbeatRunnable : Runnable {
|
||||
override fun run() {
|
||||
try {
|
||||
Thread.sleep(heartbeat.toLong())
|
||||
heartbeatSend(webSocket, sequence)
|
||||
log("WebSocket: Heartbeat Sent")
|
||||
} catch (e:InterruptedException) {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
var SERVICE_RUNNING = false
|
||||
const val ACTION_STOP_SERVICE = "ACTION_STOP_SERVICE"
|
||||
}
|
||||
}
|
||||
|
||||
object DiscordServiceRunningSingleton {
|
||||
var running = false
|
||||
|
||||
}
|
|
@ -7,6 +7,7 @@ import android.os.Bundle
|
|||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import ani.dantotsu.R
|
||||
|
@ -67,11 +68,11 @@ class Login : AppCompatActivity() {
|
|||
|
||||
private fun login(token: String) {
|
||||
if (token.isEmpty() || token == "null"){
|
||||
snackString("Failed to retrieve token")
|
||||
Toast.makeText(this, "Failed to retrieve token", Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
snackString("Logged in successfully")
|
||||
Toast.makeText(this, "Logged in successfully", Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
saveToken(this, token)
|
||||
startMainActivity(this@Login)
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
package ani.dantotsu.connections.discord
|
||||
|
||||
import android.widget.Toast
|
||||
import ani.dantotsu.connections.discord.serializers.*
|
||||
import ani.dantotsu.currContext
|
||||
import ani.dantotsu.logger
|
||||
import ani.dantotsu.snackString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Job
|
||||
|
@ -32,7 +36,12 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) {
|
|||
allowStructuredMapKeys = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
enum class Type {
|
||||
PLAYING, STREAMING, LISTENING, WATCHING, COMPETING
|
||||
}
|
||||
|
||||
data class Link(val label: String, val url: String)
|
||||
/*
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, SECONDS)
|
||||
.readTimeout(10, SECONDS)
|
||||
|
@ -56,16 +65,11 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) {
|
|||
var startTimestamp: Long? = null
|
||||
var stopTimestamp: Long? = null
|
||||
|
||||
enum class Type {
|
||||
PLAYING, STREAMING, LISTENING, WATCHING, COMPETING
|
||||
}
|
||||
|
||||
var buttons = mutableListOf<Link>()
|
||||
|
||||
data class Link(val label: String, val url: String)
|
||||
|
||||
private suspend fun createPresence(): String {
|
||||
return json.encodeToString(Presence.Response(
|
||||
val j = json.encodeToString(Presence.Response(
|
||||
3,
|
||||
Presence(
|
||||
activities = listOf(
|
||||
|
@ -95,16 +99,12 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) {
|
|||
status = status
|
||||
)
|
||||
))
|
||||
logger("Presence: $j")
|
||||
return j
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KizzyApi(val id: String)
|
||||
val api = "https://kizzy-api.vercel.app/image?url="
|
||||
private suspend fun String.discordUrl(): String? {
|
||||
if (startsWith("mp:")) return this
|
||||
val json = app.get("$api$this").parsedSafe<KizzyApi>()
|
||||
return json?.id
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun sendIdentify() {
|
||||
val response = Identity.Response(
|
||||
|
@ -138,6 +138,7 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) {
|
|||
}
|
||||
}
|
||||
if (!started) whenStarted = {
|
||||
snackString("Discord message sent")
|
||||
send.invoke()
|
||||
whenStarted = null
|
||||
}
|
||||
|
@ -185,21 +186,21 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) {
|
|||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
println("Discord Message : $text")
|
||||
|
||||
val map = json.decodeFromString<Res>(text)
|
||||
seq = map.s
|
||||
|
||||
when (map.op) {
|
||||
10 -> {
|
||||
map.d as JsonObject
|
||||
heartbeatInterval = map.d["heartbeat_interval"]!!.jsonPrimitive.long
|
||||
sendHeartBeat()
|
||||
sendIdentify()
|
||||
snackString(map.t)
|
||||
}
|
||||
|
||||
0 -> if (map.t == "READY") {
|
||||
val user = json.decodeFromString<User.Response>(text).d.user
|
||||
snackString(map.t)
|
||||
started = true
|
||||
whenStarted?.invoke(user)
|
||||
}
|
||||
|
@ -207,6 +208,7 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) {
|
|||
1 -> {
|
||||
if (scope.isActive) scope.cancel()
|
||||
webSocket.send("{\"op\":1, \"d\":$seq}")
|
||||
snackString(map.t)
|
||||
}
|
||||
|
||||
11 -> sendHeartBeat()
|
||||
|
@ -214,6 +216,7 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) {
|
|||
9 -> {
|
||||
sendHeartBeat()
|
||||
sendIdentify()
|
||||
snackString(map.t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -232,6 +235,68 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) {
|
|||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
companion object {
|
||||
data class RPCData(
|
||||
val applicationId: String? = null,
|
||||
val type: Type? = null,
|
||||
val activityName: String? = null,
|
||||
val details: String? = null,
|
||||
val state: String? = null,
|
||||
val largeImage: Link? = null,
|
||||
val smallImage: Link? = null,
|
||||
val status: String? = null,
|
||||
val startTimestamp: Long? = null,
|
||||
val stopTimestamp: Long? = null,
|
||||
val buttons: MutableList<Link> = mutableListOf()
|
||||
)
|
||||
@Serializable
|
||||
data class KizzyApi(val id: String)
|
||||
val api = "https://kizzy-api.vercel.app/image?url="
|
||||
private suspend fun String.discordUrl(): String? {
|
||||
if (startsWith("mp:")) return this
|
||||
val json = app.get("$api$this").parsedSafe<KizzyApi>()
|
||||
return json?.id
|
||||
}
|
||||
suspend fun createPresence(data: RPCData): String {
|
||||
val json = Json {
|
||||
encodeDefaults = true
|
||||
allowStructuredMapKeys = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
return json.encodeToString(Presence.Response(
|
||||
3,
|
||||
Presence(
|
||||
activities = listOf(
|
||||
Activity(
|
||||
name = data.activityName,
|
||||
state = data.state,
|
||||
details = data.details,
|
||||
type = data.type?.ordinal,
|
||||
timestamps = if (data.startTimestamp != null)
|
||||
Activity.Timestamps(data.startTimestamp, data.stopTimestamp)
|
||||
else null,
|
||||
assets = Activity.Assets(
|
||||
largeImage = data.largeImage?.url?.discordUrl(),
|
||||
largeText = data.largeImage?.label,
|
||||
smallImage = data.smallImage?.url?.discordUrl(),
|
||||
smallText = data.smallImage?.label
|
||||
),
|
||||
buttons = data.buttons.map { it.label },
|
||||
metadata = Activity.Metadata(
|
||||
buttonUrls = data.buttons.map { it.url }
|
||||
),
|
||||
applicationId = data.applicationId,
|
||||
)
|
||||
),
|
||||
afk = true,
|
||||
since = data.startTimestamp,
|
||||
status = data.status
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -5,56 +5,57 @@ import kotlinx.serialization.descriptors.*
|
|||
import kotlinx.serialization.encoding.*
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
|
||||
@Serializable
|
||||
data class User (
|
||||
val verified: Boolean,
|
||||
val verified: Boolean? = null,
|
||||
val username: String,
|
||||
|
||||
@SerialName("purchased_flags")
|
||||
val purchasedFlags: Long,
|
||||
val purchasedFlags: Long? = null,
|
||||
|
||||
@SerialName("public_flags")
|
||||
val publicFlags: Long,
|
||||
val publicFlags: Long? = null,
|
||||
|
||||
val pronouns: String,
|
||||
val pronouns: String? = null,
|
||||
|
||||
@SerialName("premium_type")
|
||||
val premiumType: Long,
|
||||
val premiumType: Long? = null,
|
||||
|
||||
val premium: Boolean,
|
||||
val phone: String,
|
||||
val premium: Boolean? = null,
|
||||
val phone: String? = null,
|
||||
|
||||
@SerialName("nsfw_allowed")
|
||||
val nsfwAllowed: Boolean,
|
||||
val nsfwAllowed: Boolean? = null,
|
||||
|
||||
val mobile: Boolean,
|
||||
val mobile: Boolean? = null,
|
||||
|
||||
@SerialName("mfa_enabled")
|
||||
val mfaEnabled: Boolean,
|
||||
val mfaEnabled: Boolean? = null,
|
||||
|
||||
val id: String,
|
||||
|
||||
@SerialName("global_name")
|
||||
val globalName: String,
|
||||
val globalName: String? = null,
|
||||
|
||||
val flags: Long,
|
||||
val email: String,
|
||||
val discriminator: String,
|
||||
val desktop: Boolean,
|
||||
val bio: String,
|
||||
val flags: Long? = null,
|
||||
val email: String? = null,
|
||||
val discriminator: String? = null,
|
||||
val desktop: Boolean? = null,
|
||||
val bio: String? = null,
|
||||
|
||||
@SerialName("banner_color")
|
||||
val bannerColor: String,
|
||||
val bannerColor: String? = null,
|
||||
|
||||
val banner: JsonElement? = null,
|
||||
|
||||
@SerialName("avatar_decoration")
|
||||
val avatarDecoration: JsonElement? = null,
|
||||
|
||||
val avatar: String,
|
||||
val avatar: String? = null,
|
||||
|
||||
@SerialName("accent_color")
|
||||
val accentColor: Long
|
||||
val accentColor: Long? = null
|
||||
) {
|
||||
@Serializable
|
||||
data class Response(
|
||||
|
|
|
@ -62,6 +62,9 @@ import ani.dantotsu.*
|
|||
import ani.dantotsu.R
|
||||
import ani.dantotsu.connections.anilist.Anilist
|
||||
import ani.dantotsu.connections.discord.Discord
|
||||
import ani.dantotsu.connections.discord.DiscordService
|
||||
import ani.dantotsu.connections.discord.DiscordService.Companion.ACTION_STOP_SERVICE
|
||||
import ani.dantotsu.connections.discord.DiscordServiceRunningSingleton
|
||||
import ani.dantotsu.connections.discord.RPC
|
||||
import ani.dantotsu.connections.updateProgress
|
||||
import ani.dantotsu.databinding.ActivityExoplayerBinding
|
||||
|
@ -813,14 +816,14 @@ class ExoplayerView : AppCompatActivity(), Player.Listener {
|
|||
|
||||
fun fastForward() {
|
||||
isFastForwarding = true
|
||||
exoPlayer.setPlaybackSpeed(2f)
|
||||
exoPlayer.setPlaybackSpeed(exoPlayer.playbackParameters.speed * 2)
|
||||
snackString("Playing at 2x speed")
|
||||
}
|
||||
|
||||
fun stopFastForward() {
|
||||
if (isFastForwarding) {
|
||||
isFastForwarding = false
|
||||
exoPlayer.setPlaybackSpeed(1f)
|
||||
exoPlayer.setPlaybackSpeed(exoPlayer.playbackParameters.speed / 2)
|
||||
snackString("Playing at normal speed")
|
||||
}
|
||||
}
|
||||
|
@ -862,6 +865,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener {
|
|||
override fun onLongClick(event: MotionEvent) {
|
||||
if (settings.fastforward) fastForward()
|
||||
}
|
||||
|
||||
override fun onDoubleClick(event: MotionEvent) {
|
||||
doubleTap(true, event)
|
||||
}
|
||||
|
@ -994,22 +998,40 @@ class ExoplayerView : AppCompatActivity(), Player.Listener {
|
|||
playbackPosition = loadData("${media.id}_${ep.number}", this) ?: 0
|
||||
initPlayer()
|
||||
preloading = false
|
||||
rpc = Discord.defaultRPC()
|
||||
rpc?.send {
|
||||
type = RPC.Type.WATCHING
|
||||
activityName = media.userPreferredName
|
||||
val context = this
|
||||
|
||||
lifecycleScope.launch {
|
||||
val presence = RPC.createPresence(RPC.Companion.RPCData(
|
||||
applicationId = Discord.application_Id,
|
||||
type = RPC.Type.WATCHING,
|
||||
activityName = media.userPreferredName,
|
||||
details = ep.title?.takeIf { it.isNotEmpty() } ?: getString(
|
||||
R.string.episode_num,
|
||||
ep.number
|
||||
),
|
||||
state = "Episode : ${ep.number}/${media.anime?.totalEpisodes ?: "??"}",
|
||||
largeImage = media.cover?.let { RPC.Link(media.userPreferredName, it) },
|
||||
smallImage = RPC.Link(
|
||||
"Dantotsu",
|
||||
Discord.small_Image
|
||||
),
|
||||
buttons = mutableListOf(
|
||||
RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""),
|
||||
RPC.Link(
|
||||
"Stream on Dantotsu",
|
||||
"https://github.com/rebelonion/Dantotsu/"
|
||||
)
|
||||
state = "Episode : ${ep.number}/${media.anime?.totalEpisodes ?: "??"}"
|
||||
media.cover?.let { cover ->
|
||||
largeImage = RPC.Link(media.userPreferredName, cover)
|
||||
}
|
||||
media.shareLink?.let { link ->
|
||||
buttons.add(0, RPC.Link(getString(R.string.view_anime), link))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val intent = Intent(context, DiscordService::class.java).apply {
|
||||
putExtra("presence", presence)
|
||||
}
|
||||
DiscordServiceRunningSingleton.running = true
|
||||
startService(intent)
|
||||
}
|
||||
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
@ -1278,6 +1300,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener {
|
|||
}
|
||||
|
||||
val builder = MediaItem.Builder().setUri(video!!.file.url).setMimeType(mimeType)
|
||||
logger("url: ${video!!.file.url}")
|
||||
logger("mimeType: $mimeType")
|
||||
|
||||
if (sub != null) {
|
||||
val listofnotnullsubs = immutableListOf(sub).filterNotNull()
|
||||
|
@ -1404,7 +1428,12 @@ class ExoplayerView : AppCompatActivity(), Player.Listener {
|
|||
exoPlayer.release()
|
||||
VideoCache.release()
|
||||
mediaSession?.release()
|
||||
rpc?.close()
|
||||
val stopIntent = Intent(this, DiscordService::class.java).apply {
|
||||
putExtra(ACTION_STOP_SERVICE, true)
|
||||
}
|
||||
DiscordServiceRunningSingleton.running = false
|
||||
startService(stopIntent)
|
||||
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
|
@ -1593,13 +1622,15 @@ class ExoplayerView : AppCompatActivity(), Player.Listener {
|
|||
playerView.player?.trackSelectionParameters =
|
||||
playerView.player?.trackSelectionParameters?.buildUpon()
|
||||
?.setOverrideForType(
|
||||
TrackSelectionOverride(it.mediaTrackGroup, it.length - 1))
|
||||
TrackSelectionOverride(it.mediaTrackGroup, it.length - 1)
|
||||
)
|
||||
?.build()!!
|
||||
} else if (it.type == 3) {
|
||||
playerView.player?.trackSelectionParameters =
|
||||
playerView.player?.trackSelectionParameters?.buildUpon()
|
||||
?.addOverride(
|
||||
TrackSelectionOverride(it.mediaTrackGroup, listOf()))
|
||||
TrackSelectionOverride(it.mediaTrackGroup, listOf())
|
||||
)
|
||||
?.build()!!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package ani.dantotsu.media.manga.mangareader
|
|||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
|
@ -25,12 +26,15 @@ import androidx.viewpager2.widget.ViewPager2
|
|||
import ani.dantotsu.*
|
||||
import ani.dantotsu.connections.anilist.Anilist
|
||||
import ani.dantotsu.connections.discord.Discord
|
||||
import ani.dantotsu.connections.discord.DiscordService
|
||||
import ani.dantotsu.connections.discord.DiscordServiceRunningSingleton
|
||||
import ani.dantotsu.connections.discord.RPC
|
||||
import ani.dantotsu.connections.updateProgress
|
||||
import ani.dantotsu.databinding.ActivityMangaReaderBinding
|
||||
import ani.dantotsu.media.Media
|
||||
import ani.dantotsu.media.MediaDetailsViewModel
|
||||
import ani.dantotsu.media.MediaSingleton
|
||||
import ani.dantotsu.media.anime.ExoplayerView
|
||||
import ani.dantotsu.media.manga.MangaCache
|
||||
import ani.dantotsu.media.manga.MangaChapter
|
||||
import ani.dantotsu.media.manga.MangaNameAdapter
|
||||
|
@ -121,7 +125,11 @@ class MangaReaderActivity : AppCompatActivity() {
|
|||
|
||||
override fun onDestroy() {
|
||||
mangaCache.clear()
|
||||
rpc?.close()
|
||||
val stopIntent = Intent(this, DiscordService::class.java).apply {
|
||||
putExtra(DiscordService.ACTION_STOP_SERVICE, true)
|
||||
}
|
||||
DiscordServiceRunningSingleton.running = false
|
||||
startService(stopIntent)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
@ -300,19 +308,36 @@ ThemeManager(this).applyTheme()
|
|||
binding.mangaReaderNextChap.text = chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: ""
|
||||
binding.mangaReaderPrevChap.text = chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: ""
|
||||
applySettings()
|
||||
rpc?.close()
|
||||
rpc = Discord.defaultRPC()
|
||||
rpc?.send {
|
||||
type = RPC.Type.WATCHING
|
||||
activityName = media.userPreferredName
|
||||
details = chap.title?.takeIf { it.isNotEmpty() } ?: getString(R.string.chapter_num, chap.number)
|
||||
state = "${chap.number}/${media.manga?.totalChapters ?: "??"}"
|
||||
media.cover?.let { cover ->
|
||||
largeImage = RPC.Link(media.userPreferredName, cover)
|
||||
}
|
||||
media.shareLink?.let { link ->
|
||||
buttons.add(0, RPC.Link(getString(R.string.view_manga), link))
|
||||
val context = this
|
||||
lifecycleScope.launch {
|
||||
val presence = RPC.createPresence(RPC.Companion.RPCData(
|
||||
applicationId = Discord.application_Id,
|
||||
type = RPC.Type.WATCHING,
|
||||
activityName = media.userPreferredName,
|
||||
details = chap.title?.takeIf { it.isNotEmpty() } ?: getString(R.string.chapter_num, chap.number),
|
||||
state = "${chap.number}/${media.manga?.totalChapters ?: "??"}",
|
||||
largeImage = media.cover?.let { cover ->
|
||||
RPC.Link(media.userPreferredName, cover)
|
||||
},
|
||||
smallImage = RPC.Link(
|
||||
"Dantotsu",
|
||||
Discord.small_Image
|
||||
),
|
||||
buttons = mutableListOf(
|
||||
RPC.Link(getString(R.string.view_manga), media.shareLink ?: ""),
|
||||
RPC.Link(
|
||||
"Stream on Dantotsu",
|
||||
"https://github.com/rebelonion/Dantotsu/"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val intent = Intent(context, DiscordService::class.java).apply {
|
||||
putExtra("presence", presence)
|
||||
}
|
||||
DiscordServiceRunningSingleton.running = true
|
||||
startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -578,11 +578,14 @@ OS Version: $CODENAME $RELEASE ($SDK_INT)
|
|||
}
|
||||
|
||||
if (Discord.token != null) {
|
||||
if (Discord.avatar != null) {
|
||||
binding.settingsDiscordAvatar.loadImage(Discord.avatar)
|
||||
val id = getSharedPreferences(getString(R.string.preference_file_key), Context.MODE_PRIVATE).getString("discord_id", null)
|
||||
val avatar = getSharedPreferences(getString(R.string.preference_file_key), Context.MODE_PRIVATE).getString("discord_avatar", null)
|
||||
val username = getSharedPreferences(getString(R.string.preference_file_key), Context.MODE_PRIVATE).getString("discord_username", null)
|
||||
if (id != null && avatar != null) {
|
||||
binding.settingsDiscordAvatar.loadImage("https://cdn.discordapp.com/avatars/$id/$avatar.png")
|
||||
}
|
||||
binding.settingsDiscordUsername.visibility = View.VISIBLE
|
||||
binding.settingsDiscordUsername.text = Discord.userid ?: Discord.token?.replace(Regex("."),"*")
|
||||
binding.settingsDiscordUsername.text = username ?: Discord.token?.replace(Regex("."),"*")
|
||||
binding.settingsDiscordLogin.setText(R.string.logout)
|
||||
binding.settingsDiscordLogin.setOnClickListener {
|
||||
Discord.removeSavedToken(this)
|
||||
|
|
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.anime
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import ani.dantotsu.snackString
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.anime.api.AnimeExtensionGithubApi
|
||||
|
@ -116,7 +117,7 @@ class AnimeExtensionManager(
|
|||
api.findExtensions()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
withUIContext { context.toast("Failed to get extensions list") }
|
||||
withUIContext { snackString("Failed to get extensions list") }
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.manga
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import ani.dantotsu.snackString
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.extension.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.manga.api.MangaExtensionGithubApi
|
||||
|
@ -116,7 +117,7 @@ class MangaExtensionManager(
|
|||
api.findExtensions()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
withUIContext { context.toast("Failed to get manga extensions") }
|
||||
withUIContext { snackString("Failed to get manga extensions") }
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
|
|
@ -97,9 +97,7 @@
|
|||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:numColumns="auto_fit"
|
||||
android:paddingLeft="8dp"
|
||||
android:paddingRight="8dp"
|
||||
android:columnWidth="108dp"
|
||||
android:columnWidth="128dp"
|
||||
android:verticalSpacing="10dp"
|
||||
android:horizontalSpacing="10dp"
|
||||
android:padding="10dp"
|
||||
|
|
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue