Merge pull request #264 from rebelonion/dev

Dev
This commit is contained in:
rebel onion 2024-03-20 01:32:15 -05:00 committed by GitHub
commit 4035aee1f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 14581 additions and 1948 deletions

View file

@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
@ -39,7 +39,7 @@ jobs:
fi
echo "Commits since $LAST_SHA:"
# Accumulate commit logs in a shell variable
COMMIT_LOGS=$(git log $LAST_SHA..HEAD --pretty=format:"%h - %s")
COMMIT_LOGS=$(git log $LAST_SHA..HEAD --pretty=format:"● %s ~%an")
# URL-encode the newline characters for GitHub Actions
COMMIT_LOGS="${COMMIT_LOGS//'%'/'%25'}"
COMMIT_LOGS="${COMMIT_LOGS//$'\n'/'%0A'}"
@ -64,7 +64,7 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Setup JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
@ -83,16 +83,17 @@ jobs:
run: ./gradlew assembleGoogleAlpha -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/key.keystore -Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.KEY_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.KEY_PASSWORD }}
- name: Upload a Build Artifact
uses: actions/upload-artifact@v3.0.0
uses: actions/upload-artifact@v4.3.1
with:
name: Dantotsu
path: "app/build/outputs/apk/google/alpha/app-google-alpha.apk"
- name: Upload APK to Discord and Telegram
if: ${{ github.repository == 'rebelonion/Dantotsu' }}
shell: bash
run: |
#Discord
commit_messages=$(echo "$COMMIT_LOG" | sed 's/%0A/\n/g')
commit_messages=$(echo "$COMMIT_LOG" | sed 's/%0A/\n/g; s/^/\n/')
# Truncate commit messages if they are too long
max_length=1900 # Adjust this value as needed
if [ ${#commit_messages} -gt $max_length ]; then
@ -104,7 +105,7 @@ jobs:
#Telegram
curl -F "chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }}" \
-F "document=@app/build/outputs/apk/google/alpha/app-google-alpha.apk" \
-F "caption=[Alpha-Build: ${VERSION}] Change logs :${commit_messages}" \
-F "caption=Alpha-Build: ${VERSION}: ${commit_messages}" \
https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument
env:

3
.gitignore vendored
View file

@ -8,6 +8,9 @@ local.properties
# Log/OS Files
*.log
# Secrets
apikey.properties
# Android Studio generated files and folders
captures/
.externalNativeBuild/

View file

@ -6,24 +6,20 @@ plugins {
id 'com.google.devtools.ksp'
}
def gitCommitHash = providers.exec {
commandLine("git", "rev-parse", "--verify", "--short", "HEAD")
}.standardOutput.asText.get().trim()
android {
compileSdk 34
defaultConfig {
applicationId "ani.dantotsu"
minSdk 23
minSdk 21
targetSdk 34
versionCode((System.currentTimeMillis() / 60000).toInteger())
versionName "2.2.0"
versionName "3.0.0"
versionCode 220000000
signingConfig signingConfigs.debug
}
flavorDimensions "store"
flavorDimensions += "store"
productFlavors {
fdroid {
// F-Droid specific configuration
@ -43,18 +39,21 @@ android {
alpha {
applicationIdSuffix ".beta" // keep as beta by popular request
versionNameSuffix "-alpha01"
manifestPlaceholders = [icon_placeholder: "@mipmap/ic_launcher_alpha", icon_placeholder_round: "@mipmap/ic_launcher_alpha_round"]
manifestPlaceholders.icon_placeholder = "@mipmap/ic_launcher_alpha"
manifestPlaceholders.icon_placeholder_round = "@mipmap/ic_launcher_alpha_round"
debuggable System.getenv("CI") == null
isDefault true
}
debug {
applicationIdSuffix ".beta"
versionNameSuffix "-beta01"
manifestPlaceholders = [icon_placeholder: "@mipmap/ic_launcher_beta", icon_placeholder_round: "@mipmap/ic_launcher_beta_round"]
manifestPlaceholders.icon_placeholder = "@mipmap/ic_launcher_beta"
manifestPlaceholders.icon_placeholder_round = "@mipmap/ic_launcher_beta_round"
debuggable false
}
release {
manifestPlaceholders = [icon_placeholder: "@mipmap/ic_launcher", icon_placeholder_round: "@mipmap/ic_launcher_round"]
manifestPlaceholders.icon_placeholder = "@mipmap/ic_launcher"
manifestPlaceholders.icon_placeholder_round = "@mipmap/ic_launcher_round"
debuggable false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-gson.pro', 'proguard-rules.pro'
}
@ -77,12 +76,12 @@ android {
dependencies {
// FireBase
googleImplementation platform('com.google.firebase:firebase-bom:32.2.3')
googleImplementation 'com.google.firebase:firebase-analytics-ktx:21.5.0'
googleImplementation 'com.google.firebase:firebase-crashlytics-ktx:18.6.1'
googleImplementation platform('com.google.firebase:firebase-bom:32.7.4')
googleImplementation 'com.google.firebase:firebase-analytics-ktx:21.5.1'
googleImplementation 'com.google.firebase:firebase-crashlytics-ktx:18.6.2'
// Core
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.browser:browser:1.7.0'
implementation 'androidx.browser:browser:1.8.0'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
@ -91,9 +90,9 @@ dependencies {
implementation "androidx.work:work-runtime-ktx:2.9.0"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.google.code.gson:gson:2.10'
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.github.Blatzar:NiceHttp:0.4.4'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.webkit:webkit:1.10.0'
@ -106,7 +105,7 @@ dependencies {
implementation 'jp.wasabeef:glide-transformations:4.3.0'
// Exoplayer
ext.exo_version = '1.2.1'
ext.exo_version = '1.3.0'
implementation "androidx.media3:media3-exoplayer:$exo_version"
implementation "androidx.media3:media3-ui:$exo_version"
implementation "androidx.media3:media3-exoplayer-hls:$exo_version"
@ -119,14 +118,30 @@ dependencies {
// UI
implementation 'com.google.android.material:material:1.11.0'
implementation 'nl.joery.animatedbottombar:library:1.1.0'
implementation 'io.noties.markwon:core:4.6.2'
//implementation 'nl.joery.animatedbottombar:library:1.1.0'
implementation 'com.github.rebelonion:AnimatedBottomBar:v1.1.0'
implementation 'com.flaviofaria:kenburnsview:1.0.7'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.alexvasilkov:gesture-views:2.8.3'
implementation 'com.github.VipulOG:ebook-reader:0.1.6'
implementation 'androidx.paging:paging-runtime-ktx:3.2.1'
implementation 'com.github.eltos:simpledialogfragments:v3.7'
implementation 'com.github.AAChartModel:AAChartCore-Kotlin:93972bc'
// Markwon
ext.markwon_version = '4.6.2'
implementation "io.noties.markwon:core:$markwon_version"
implementation "io.noties.markwon:editor:$markwon_version"
implementation "io.noties.markwon:ext-strikethrough:$markwon_version"
implementation "io.noties.markwon:ext-tables:$markwon_version"
implementation "io.noties.markwon:ext-tasklist:$markwon_version"
implementation "io.noties.markwon:html:$markwon_version"
implementation "io.noties.markwon:image-glide:$markwon_version"
// Groupie
ext.groupie_version = '2.10.1'
implementation "com.github.lisawray.groupie:groupie:$groupie_version"
implementation "com.github.lisawray.groupie:groupie-viewbinding:$groupie_version"
// string matching
implementation 'me.xdrop:fuzzywuzzy:1.4.0'
@ -141,11 +156,10 @@ dependencies {
implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.12'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps'
implementation 'com.squareup.okio:okio:3.7.0'
implementation 'com.squareup.okio:okio:3.8.0'
implementation 'com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.12'
implementation 'ch.acra:acra-http:5.11.3'
implementation 'org.jsoup:jsoup:1.15.4'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.2'
implementation 'org.jsoup:jsoup:1.16.1'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.3'
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
implementation 'com.github.tachiyomiorg:unifile:17bec43'
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'

View file

@ -43,6 +43,25 @@
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
-keep class ani.dantotsu.** { *; }
-keep class ani.dantotsu.download.DownloadsManager { *; }
-keepattributes Signature
-keep class uy.kohesive.injekt.** { *; }
-keep class eu.kanade.tachiyomi.** { *; }
-keep class kotlin.** { *; }
-dontwarn kotlin.**
-keep class kotlinx.** { *; }
-keepclassmembers class uy.kohesive.injekt.api.FullTypeReference {
<init>(...);
}
-keep class com.google.gson.** { *; }
-keepattributes *Annotation*
-keepattributes EnclosingMethod
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class org.jsoup.** { *; }
-keepclassmembers class org.jsoup.nodes.Document { *; }
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault

View file

@ -11,13 +11,21 @@ import android.net.Uri
import android.os.Environment
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity
import ani.dantotsu.*
import ani.dantotsu.BuildConfig
import ani.dantotsu.Mapper
import ani.dantotsu.R
import ani.dantotsu.buildMarkwon
import ani.dantotsu.client
import ani.dantotsu.currContext
import ani.dantotsu.logError
import ani.dantotsu.openLinkInBrowser
import ani.dantotsu.settings.saving.PrefManager
import io.noties.markwon.Markwon
import io.noties.markwon.SoftBreakAddsNewLinePlugin
import ani.dantotsu.snackString
import ani.dantotsu.toast
import ani.dantotsu.tryWithSuspend
import ani.dantotsu.util.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
@ -25,9 +33,8 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.Locale
object AppUpdater {
suspend fun check(activity: FragmentActivity, post: Boolean = false) {
@ -39,7 +46,8 @@ object AppUpdater {
.parsed<JsonArray>().map {
Mapper.json.decodeFromJsonElement<GithubResponse>(it)
}
val r = res.filter { it.prerelease }.filter { !it.tagName.contains("fdroid") }.maxByOrNull {
val r = res.filter { it.prerelease }.filter { !it.tagName.contains("fdroid") }
.maxByOrNull {
it.timeStamp()
} ?: throw Exception("No Pre Release Found")
val v = r.tagName.substringAfter("v", "")
@ -50,7 +58,7 @@ object AppUpdater {
res to res.substringAfter("# ").substringBefore("\n")
}
logger("Git Version : $version")
Logger.log("Git Version : $version")
val dontShow = PrefManager.getCustomVal("dont_ask_for_update_$version", false)
if (compareVersion(version) && !dontShow && !activity.isDestroyed) activity.runOnUiThread {
CustomBottomDialog.newInstance().apply {
@ -61,8 +69,7 @@ object AppUpdater {
)
addView(
TextView(activity).apply {
val markWon = Markwon.builder(activity)
.usePlugin(SoftBreakAddsNewLinePlugin.create()).build()
val markWon = buildMarkwon(activity, false)
markWon.setMarkdown(this, md)
}
)
@ -161,21 +168,8 @@ object AppUpdater {
DownloadManager.EXTRA_DOWNLOAD_ID, id
) ?: id
val query = DownloadManager.Query()
query.setFilterById(downloadId)
val c = downloadManager.query(query)
if (c.moveToFirst()) {
val columnIndex = c.getColumnIndex(DownloadManager.COLUMN_STATUS)
if (DownloadManager.STATUS_SUCCESSFUL == c
.getInt(columnIndex)
) {
c.getColumnIndex(DownloadManager.COLUMN_MEDIAPROVIDER_URI)
val uri = Uri.parse(
c.getString(c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
)
openApk(this@downloadUpdate, uri)
}
downloadManager.getUriForDownloadedFile(downloadId)?.let {
openApk(this@downloadUpdate, it)
}
} catch (e: Exception) {
logError(e)
@ -190,16 +184,11 @@ object AppUpdater {
private fun openApk(context: Context, uri: Uri) {
try {
uri.path?.let {
val contentUri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".provider",
File(it)
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
data = contentUri
data = uri
}
context.startActivity(installIntent)
}

View file

@ -16,7 +16,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
@ -69,7 +69,7 @@
android:name="android.appwidget.provider"
android:resource="@xml/currently_airing_widget_info" />
</receiver>
<receiver android:name=".subcriptions.NotificationClickReceiver" />
<receiver android:name=".notifications.IncognitoNotificationClickReceiver" />
<activity
@ -104,7 +104,26 @@
android:parentActivityName=".MainActivity" />
<activity
android:name=".settings.ExtensionsActivity"
android:windowSoftInputMode="adjustResize|stateHidden"
android:parentActivityName=".MainActivity" />
<activity
android:name=".profile.ProfileActivity"
android:windowSoftInputMode="adjustResize|stateHidden"
android:parentActivityName=".MainActivity" />
<activity
android:name=".profile.FollowActivity"
android:windowSoftInputMode="adjustResize|stateHidden"
android:parentActivityName=".MainActivity" />
<activity
android:name=".profile.activity.FeedActivity"
android:label="Inbox Activity"
android:parentActivityName=".MainActivity" >
</activity>
<activity
android:name=".profile.activity.NotificationActivity"
android:label="Inbox Activity"
android:parentActivityName=".MainActivity" >
</activity>
<activity
android:name=".others.imagesearch.ImageSearchActivity"
android:parentActivityName=".MainActivity" />
@ -117,6 +136,8 @@
android:name=".media.CalendarActivity"
android:parentActivityName=".MainActivity" />
<activity android:name=".media.user.ListActivity" />
<activity android:name=".profile.SingleStatActivity"
android:parentActivityName=".profile.ProfileActivity"/>
<activity
android:name=".media.manga.mangareader.MangaReaderActivity"
android:excludeFromRecents="true"
@ -127,7 +148,8 @@
<activity
android:name=".media.MediaDetailsActivity"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.Dantotsu.NeverCutout" />
android:theme="@style/Theme.Dantotsu.NeverCutout"
android:windowSoftInputMode="adjustResize|stateHidden"/>
<activity android:name=".media.CharacterDetailsActivity" />
<activity android:name=".home.NoInternet" />
<activity
@ -209,6 +231,7 @@
<data android:host="discord.dantotsu.com" />
</intent-filter>
</activity>
<activity
android:name=".connections.anilist.UrlMedia"
android:configChanges="orientation|screenSize|layoutDirection"
@ -239,6 +262,17 @@
<data android:host="myanimelist.net" />
<data android:pathPrefix="/anime" />
</intent-filter>
<intent-filter android:label="@string/view_profile_in_dantotsu">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="anilist.co" />
<data android:pathPrefix="/user" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
@ -254,6 +288,15 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.ani" />
<data android:pathPattern=".*\\.sani" />
<data android:host="*" />
</intent-filter>
</activity>
<activity
android:name="eu.kanade.tachiyomi.extension.manga.util.MangaExtensionInstallActivity"
@ -264,14 +307,22 @@
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<receiver
android:name=".subcriptions.AlarmReceiver"
<receiver android:name=".notifications.AlarmPermissionStateReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
</intent-filter>
</receiver>
<receiver android:name=".notifications.BootCompletedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="Aani.dantotsu.ACTION_ALARM" />
</intent-filter>
</receiver>
<receiver android:name=".notifications.anilist.AnilistNotificationReceiver"/>
<receiver android:name=".notifications.comment.CommentNotificationReceiver"/>
<receiver android:name=".notifications.subscription.SubscriptionNotificationReceiver"/>
<meta-data
android:name="preloaded_fonts"

View file

@ -8,7 +8,9 @@ import androidx.multidex.MultiDex
import androidx.multidex.MultiDexApplication
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.TaskScheduler
import ani.dantotsu.others.DisabledReports
import ani.dantotsu.parsers.AnimeSources
import ani.dantotsu.parsers.MangaSources
@ -17,6 +19,8 @@ import ani.dantotsu.parsers.novel.NovelExtensionManager
import ani.dantotsu.settings.SettingsActivity
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.util.FinalExceptionHandler
import ani.dantotsu.util.Logger
import com.google.android.material.color.DynamicColors
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager
@ -28,7 +32,6 @@ import kotlinx.coroutines.launch
import logcat.AndroidLogcatLogger
import logcat.LogPriority
import logcat.LogcatLogger
import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -79,7 +82,9 @@ class App : MultiDexApplication() {
}
crashlytics.setCustomKey("device Info", SettingsActivity.getDeviceInfo())
Logger.init(this)
Thread.setDefaultUncaughtExceptionHandler(FinalExceptionHandler())
Logger.log("App: Logging started")
initializeNetwork(baseContext)
@ -95,29 +100,36 @@ class App : MultiDexApplication() {
val animeScope = CoroutineScope(Dispatchers.Default)
animeScope.launch {
animeExtensionManager.findAvailableExtensions()
logger("Anime Extensions: ${animeExtensionManager.installedExtensionsFlow.first()}")
Logger.log("Anime Extensions: ${animeExtensionManager.installedExtensionsFlow.first()}")
AnimeSources.init(animeExtensionManager.installedExtensionsFlow)
}
val mangaScope = CoroutineScope(Dispatchers.Default)
mangaScope.launch {
mangaExtensionManager.findAvailableExtensions()
logger("Manga Extensions: ${mangaExtensionManager.installedExtensionsFlow.first()}")
Logger.log("Manga Extensions: ${mangaExtensionManager.installedExtensionsFlow.first()}")
MangaSources.init(mangaExtensionManager.installedExtensionsFlow)
}
val novelScope = CoroutineScope(Dispatchers.Default)
novelScope.launch {
novelExtensionManager.findAvailableExtensions()
logger("Novel Extensions: ${novelExtensionManager.installedExtensionsFlow.first()}")
Logger.log("Novel Extensions: ${novelExtensionManager.installedExtensionsFlow.first()}")
NovelSources.init(novelExtensionManager.installedExtensionsFlow)
}
val commentsScope = CoroutineScope(Dispatchers.Default)
commentsScope.launch {
CommentsAPI.fetchAuthToken()
}
val useAlarmManager = PrefManager.getVal<Boolean>(PrefName.UseAlarmManager)
TaskScheduler.create(this, useAlarmManager).scheduleAllTasks(this)
}
private fun setupNotificationChannels() {
try {
Notifications.createChannels(this)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to modify notification channels" }
Logger.log("Failed to modify notification channels")
Logger.log(e)
}
}

View file

@ -1,11 +1,13 @@
package ani.dantotsu
import android.Manifest
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.app.Activity
import android.app.DatePickerDialog
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@ -16,32 +18,71 @@ import android.content.res.Configuration
import android.content.res.Resources.getSystem
import android.graphics.Bitmap
import android.graphics.Color
import android.Manifest
import android.graphics.drawable.Drawable
import android.media.MediaScannerConnection
import android.net.ConnectivityManager
import android.net.NetworkCapabilities.*
import android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH
import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
import android.net.NetworkCapabilities.TRANSPORT_ETHERNET
import android.net.NetworkCapabilities.TRANSPORT_LOWPAN
import android.net.NetworkCapabilities.TRANSPORT_USB
import android.net.NetworkCapabilities.TRANSPORT_VPN
import android.net.NetworkCapabilities.TRANSPORT_WIFI
import android.net.NetworkCapabilities.TRANSPORT_WIFI_AWARE
import android.net.Uri
import android.os.*
import android.os.Build
import android.os.Bundle
import android.os.CountDownTimer
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.provider.Settings
import android.telephony.TelephonyManager
import android.text.InputFilter
import android.text.Spanned
import android.util.AttributeSet
import android.util.TypedValue
import android.view.*
import android.view.GestureDetector
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewAnimationUtils
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.animation.*
import android.widget.*
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.view.animation.AnimationSet
import android.view.animation.OvershootInterpolator
import android.view.animation.ScaleAnimation
import android.view.animation.TranslateAnimation
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.DatePicker
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.content.FileProvider
import androidx.core.math.MathUtils.clamp
import androidx.core.view.*
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.RecyclerView
@ -52,15 +93,26 @@ import ani.dantotsu.connections.anilist.api.FuzzyDate
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.databinding.ItemCountDownBinding
import ani.dantotsu.media.Media
import ani.dantotsu.notifications.IncognitoNotificationClickReceiver
import ani.dantotsu.others.SpoilerPlugin
import ani.dantotsu.parsers.ShowResponse
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.settings.saving.internal.PreferenceKeystore
import ani.dantotsu.settings.saving.internal.PreferenceKeystore.Companion.generateSalt
import ani.dantotsu.subcriptions.NotificationClickReceiver
import ani.dantotsu.util.Logger
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade
import com.bumptech.glide.load.resource.gif.GifDrawable
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -68,15 +120,37 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.internal.ViewUtils
import com.google.android.material.snackbar.Snackbar
import eu.kanade.tachiyomi.data.notification.Notifications
import kotlinx.coroutines.*
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.SoftBreakAddsNewLinePlugin
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.ext.tasklist.TaskListPlugin
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.html.TagHandlerNoOp
import io.noties.markwon.image.AsyncDrawable
import io.noties.markwon.image.glide.GlideImagesPlugin
import jp.wasabeef.glide.transformations.BlurTransformation
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nl.joery.animatedbottombar.AnimatedBottomBar
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.*
import java.lang.Runnable
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.lang.reflect.Field
import java.util.*
import kotlin.math.*
import java.util.Calendar
import java.util.TimeZone
import java.util.Timer
import java.util.TimerTask
import kotlin.collections.set
import kotlin.math.log2
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
var statusBarHeight = 0
@ -108,12 +182,6 @@ fun currActivity(): Activity? {
var loadMedia: Int? = null
var loadIsMAL = false
fun logger(e: Any?, print: Boolean = true) {
if (print)
println(e)
}
fun initActivity(a: Activity) {
val window = a.window
WindowCompat.setDecorFitsSystemWindows(window, false)
@ -132,10 +200,13 @@ fun initActivity(a: Activity) {
if (navBarHeight == 0) {
ViewCompat.getRootWindowInsets(window.decorView.findViewById(android.R.id.content))
?.apply {
navBarHeight = this.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
navBarHeight = this.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
}
}
a.hideStatusBar()
WindowInsetsControllerCompat(
window,
window.decorView
).hide(WindowInsetsCompat.Type.statusBars())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && statusBarHeight == 0 && a.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {
window.decorView.rootWindowInsets?.displayCutout?.apply {
if (boundingRects.size > 0) {
@ -148,35 +219,60 @@ fun initActivity(a: Activity) {
val windowInsets =
ViewCompat.getRootWindowInsets(window.decorView.findViewById(android.R.id.content))
if (windowInsets != null) {
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
statusBarHeight = insets.top
navBarHeight = insets.bottom
statusBarHeight = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()).top
navBarHeight =
windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
}
}
if (a !is MainActivity) a.setNavigationTheme()
}
@Suppress("DEPRECATION")
fun Activity.hideSystemBars() {
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
}
}
@Suppress("DEPRECATION")
fun Activity.hideStatusBar() {
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
fun Activity.hideSystemBarsExtendView() {
WindowCompat.setDecorFitsSystemWindows(window, false)
hideSystemBars()
}
fun Activity.showSystemBars() {
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
controller.show(WindowInsetsCompat.Type.systemBars())
}
}
fun Activity.showSystemBarsRetractView() {
WindowCompat.setDecorFitsSystemWindows(window, true)
showSystemBars()
}
fun Activity.setNavigationTheme() {
val tv = TypedValue()
theme.resolveAttribute(android.R.attr.colorBackground, tv, true)
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && tv.isColorType)
|| (tv.type >= TypedValue.TYPE_FIRST_COLOR_INT && tv.type <= TypedValue.TYPE_LAST_COLOR_INT)
) {
window.navigationBarColor = tv.data
}
}
open class BottomSheetDialogFragment : BottomSheetDialogFragment() {
override fun onStart() {
super.onStart()
val window = dialog?.window
val decorView: View = window?.decorView ?: return
decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
dialog?.window?.let { window ->
WindowCompat.setDecorFitsSystemWindows(window, false)
val immersiveMode: Boolean = PrefManager.getVal(PrefName.ImmersiveMode)
if (immersiveMode) {
WindowInsetsControllerCompat(
window, window.decorView
).hide(WindowInsetsCompat.Type.statusBars())
}
if (this.resources.configuration.orientation != Configuration.ORIENTATION_PORTRAIT) {
val behavior = BottomSheetBehavior.from(requireView().parent as View)
behavior.state = BottomSheetBehavior.STATE_EXPANDED
@ -190,6 +286,7 @@ open class BottomSheetDialogFragment : BottomSheetDialogFragment() {
)
window.navigationBarColor = typedValue.data
}
}
override fun show(manager: FragmentManager, tag: String?) {
val ft = manager.beginTransaction()
@ -202,6 +299,7 @@ fun isOnline(context: Context): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return tryWith {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val cap = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
return@tryWith if (cap != null) {
when {
@ -217,6 +315,19 @@ fun isOnline(context: Context): Boolean {
else -> false
}
} else false
} else {
@Suppress("DEPRECATION")
return@tryWith connectivityManager.activeNetworkInfo?.run {
type == ConnectivityManager.TYPE_BLUETOOTH ||
type == ConnectivityManager.TYPE_ETHERNET ||
type == ConnectivityManager.TYPE_MOBILE ||
type == ConnectivityManager.TYPE_MOBILE_DUN ||
type == ConnectivityManager.TYPE_MOBILE_HIPRI ||
type == ConnectivityManager.TYPE_WIFI ||
type == ConnectivityManager.TYPE_WIMAX ||
type == ConnectivityManager.TYPE_VPN
} ?: false
}
} ?: false
}
@ -278,7 +389,7 @@ class InputFilterMinMax(
val input = (dest.toString() + source.toString()).toDouble()
if (isInRange(min, max, input)) return null
} catch (nfe: NumberFormatException) {
logger(nfe.stackTraceToString())
Logger.log(nfe)
}
return ""
}
@ -449,6 +560,7 @@ fun ImageView.loadImage(url: String?, size: Int = 0) {
}
fun ImageView.loadImage(file: FileUrl?, size: Int = 0) {
file?.url = PrefManager.getVal<String>(PrefName.ImageUrl).ifEmpty { file?.url ?: "" }
if (file?.url?.isNotEmpty() == true) {
tryWith {
val glideUrl = GlideUrl(file.url) { file.headers }
@ -579,9 +691,24 @@ fun View.circularReveal(ex: Int, ey: Int, subX: Boolean, time: Long) {
}
fun openLinkInBrowser(link: String?) {
tryWith {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
currContext()?.startActivity(intent)
link?.let {
try {
val emptyBrowserIntent = Intent(Intent.ACTION_VIEW).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
data = Uri.fromParts("http", "", null)
}
val sendIntent = Intent().apply {
action = Intent.ACTION_VIEW
addCategory(Intent.CATEGORY_BROWSABLE)
data = Uri.parse(link)
selector = emptyBrowserIntent
}
currContext()!!.startActivity(sendIntent)
} catch (e: ActivityNotFoundException) {
snackString("No browser found")
} catch (e: Exception) {
Logger.log(e)
}
}
}
@ -677,6 +804,7 @@ fun savePrefs(
}
fun downloadsPermission(activity: AppCompatActivity): Boolean {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true
val permissions = arrayOf(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE
@ -687,7 +815,11 @@ fun downloadsPermission(activity: AppCompatActivity): Boolean {
}.toTypedArray()
return if (requiredPermissions.isNotEmpty()) {
ActivityCompat.requestPermissions(activity, requiredPermissions, DOWNLOADS_PERMISSION_REQUEST_CODE)
ActivityCompat.requestPermissions(
activity,
requiredPermissions,
DOWNLOADS_PERMISSION_REQUEST_CODE
)
false
} else {
true
@ -728,7 +860,7 @@ fun saveImage(image: Bitmap, path: String, imageFileName: String): File? {
private fun scanFile(path: String, context: Context) {
MediaScannerConnection.scanFile(context, arrayOf(path), null) { p, _ ->
logger("Finished scanning $p")
Logger.log("Finished scanning $p")
}
}
@ -760,7 +892,9 @@ fun copyToClipboard(string: String, toast: Boolean = true) {
val clipboard = getSystemService(activity, ClipboardManager::class.java)
val clip = ClipData.newPlainText("label", string)
clipboard?.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
if (toast) snackString(activity.getString(R.string.copied_text, string))
}
}
@SuppressLint("SetTextI18n")
@ -868,7 +1002,7 @@ class EmptyAdapter(private val count: Int) : RecyclerView.Adapter<RecyclerView.V
fun toast(string: String?) {
if (string != null) {
logger(string)
Logger.log(string)
MainScope().launch {
Toast.makeText(currActivity()?.application ?: return@launch, string, Toast.LENGTH_SHORT)
.show()
@ -876,16 +1010,16 @@ fun toast(string: String?) {
}
}
fun snackString(s: String?, activity: Activity? = null, clipboard: String? = null) {
fun snackString(s: String?, activity: Activity? = null, clipboard: String? = null): Snackbar? {
try { //I have no idea why this sometimes crashes for some people...
if (s != null) {
(activity ?: currActivity())?.apply {
runOnUiThread {
val snackBar = Snackbar.make(
window.decorView.findViewById(android.R.id.content),
s,
Snackbar.LENGTH_SHORT
)
runOnUiThread {
snackBar.view.apply {
updateLayoutParams<FrameLayout.LayoutParams> {
gravity = (Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM)
@ -905,13 +1039,15 @@ fun snackString(s: String?, activity: Activity? = null, clipboard: String? = nul
}
snackBar.show()
}
return snackBar
}
logger(s)
Logger.log(s)
}
} catch (e: Exception) {
logger(e.stackTraceToString())
Logger.log(e)
Injekt.get<CrashlyticsInterface>().logException(e)
}
return null
}
open class NoPaddingArrayAdapter<T>(context: Context, layoutId: Int, items: List<T>) :
@ -1037,7 +1173,7 @@ fun incognitoNotification(context: Context) {
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val incognito: Boolean = PrefManager.getVal(PrefName.Incognito)
if (incognito) {
val intent = Intent(context, NotificationClickReceiver::class.java)
val intent = Intent(context, IncognitoNotificationClickReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_IMMUTABLE
@ -1055,6 +1191,28 @@ fun incognitoNotification(context: Context) {
}
}
fun hasNotificationPermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
} else {
NotificationManagerCompat.from(context).areNotificationsEnabled()
}
}
fun openSettings(context: Context, channelId: String?): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(
if (channelId != null) Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS
else Settings.ACTION_APP_NOTIFICATION_SETTINGS
).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
}
context.startActivity(intent)
true
} else false
}
suspend fun View.pop() {
currActivity()?.runOnUiThread {
ObjectAnimator.ofFloat(this@pop, "scaleX", 1f, 1.25f).setDuration(120).start()
@ -1067,3 +1225,100 @@ suspend fun View.pop() {
}
delay(100)
}
fun blurImage(imageView: ImageView, banner: String?) {
if (banner != null) {
val radius = PrefManager.getVal<Float>(PrefName.BlurRadius).toInt()
val sampling = PrefManager.getVal<Float>(PrefName.BlurSampling).toInt()
if (PrefManager.getVal(PrefName.BlurBanners)) {
val context = imageView.context
if (!(context as Activity).isDestroyed) {
val url = PrefManager.getVal<String>(PrefName.ImageUrl).ifEmpty { banner }
Glide.with(context as Context)
.load(GlideUrl(url))
.diskCacheStrategy(DiskCacheStrategy.ALL).override(400)
.apply(RequestOptions.bitmapTransform(BlurTransformation(radius, sampling)))
.into(imageView)
}
} else {
imageView.loadImage(banner)
}
} else {
imageView.setImageResource(R.drawable.linear_gradient_bg)
}
}
/**
* Builds the markwon instance with all the plugins
* @return the markwon instance
*/
fun buildMarkwon(
activity: Context,
userInputContent: Boolean = true,
fragment: Fragment? = null
): Markwon {
val glideContext = fragment?.let { Glide.with(it) } ?: Glide.with(activity)
val markwon = Markwon.builder(activity)
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder.linkResolver { _, link ->
copyToClipboard(link, true)
}
}
})
.usePlugin(SoftBreakAddsNewLinePlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(activity))
.usePlugin(TaskListPlugin.create(activity))
.usePlugin(SpoilerPlugin())
.usePlugin(HtmlPlugin.create { plugin ->
if (userInputContent) {
plugin.addHandler(
TagHandlerNoOp.create("h1", "h2", "h3", "h4", "h5", "h6", "hr", "pre", "a")
)
}
})
.usePlugin(GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore {
private val requestManager: RequestManager = glideContext.apply {
addDefaultRequestListener(object : RequestListener<Any> {
override fun onResourceReady(
resource: Any,
model: Any,
target: Target<Any>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
if (resource is GifDrawable) {
resource.start()
}
return false
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Any>,
isFirstResource: Boolean
): Boolean {
Logger.log("Image failed to load: $model")
Logger.log(e as Exception)
return false
}
})
}
override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> {
Logger.log("Loading image: ${drawable.destination}")
return requestManager.load(drawable.destination)
}
override fun cancel(target: Target<*>) {
Logger.log("Cancelling image load")
requestManager.clear(target)
}
}))
.build()
return markwon
}

View file

@ -2,7 +2,10 @@ package ani.dantotsu
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Intent
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.drawable.Animatable
import android.graphics.drawable.GradientDrawable
import android.net.Uri
@ -11,7 +14,8 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AnticipateInterpolator
@ -25,6 +29,8 @@ import androidx.core.animation.doOnEnd
import androidx.core.content.ContextCompat
import androidx.core.view.doOnAttach
import androidx.core.view.updateLayoutParams
import androidx.core.view.updateMargins
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
@ -32,6 +38,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.offline.Download
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.work.OneTimeWorkRequest
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.anilist.AnilistHomeViewModel
import ani.dantotsu.databinding.ActivityMainBinding
@ -43,18 +50,27 @@ import ani.dantotsu.home.LoginFragment
import ani.dantotsu.home.MangaFragment
import ani.dantotsu.home.NoInternet
import ani.dantotsu.media.MediaDetailsActivity
import ani.dantotsu.notifications.anilist.AnilistNotificationWorker
import ani.dantotsu.notifications.comment.CommentNotificationWorker
import ani.dantotsu.others.CustomBottomDialog
import ani.dantotsu.profile.ProfileActivity
import ani.dantotsu.profile.activity.FeedActivity
import ani.dantotsu.profile.activity.NotificationActivity
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefManager.asLiveBool
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.settings.saving.SharedPreferenceBooleanLiveData
import ani.dantotsu.subcriptions.Subscription.Companion.startSubscription
import ani.dantotsu.settings.saving.internal.PreferenceKeystore
import ani.dantotsu.settings.saving.internal.PreferencePackager
import ani.dantotsu.themes.ThemeManager
import ani.dantotsu.util.Logger
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
import eu.kanade.domain.source.service.SourcePreferences
import io.noties.markwon.Markwon
import io.noties.markwon.SoftBreakAddsNewLinePlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -84,6 +100,67 @@ class MainActivity : AppCompatActivity() {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
androidx.work.WorkManager.getInstance(this)
.enqueue(OneTimeWorkRequest.Companion.from(CommentNotificationWorker::class.java))
androidx.work.WorkManager.getInstance(this)
.enqueue(OneTimeWorkRequest.Companion.from(AnilistNotificationWorker::class.java))
val action = intent.action
val type = intent.type
if (Intent.ACTION_VIEW == action && type != null) {
val uri: Uri? = intent.data
try {
if (uri == null) {
throw Exception("Uri is null")
}
val jsonString =
contentResolver.openInputStream(uri)?.readBytes()
?: throw Exception("Error reading file")
val name =
DocumentFile.fromSingleUri(this, uri)?.name ?: "settings"
//.sani is encrypted, .ani is not
if (name.endsWith(".sani")) {
passwordAlertDialog { password ->
if (password != null) {
val salt = jsonString.copyOfRange(0, 16)
val encrypted = jsonString.copyOfRange(16, jsonString.size)
val decryptedJson = try {
PreferenceKeystore.decryptWithPassword(
password,
encrypted,
salt
)
} catch (e: Exception) {
toast("Incorrect password")
return@passwordAlertDialog
}
if (PreferencePackager.unpack(decryptedJson)) {
val intent = Intent(this, this.javaClass)
this.finish()
startActivity(intent)
}
} else {
toast("Password cannot be empty")
}
}
} else if (name.endsWith(".ani")) {
val decryptedJson = jsonString.toString(Charsets.UTF_8)
if (PreferencePackager.unpack(decryptedJson)) {
val intent = Intent(this, this.javaClass)
this.finish()
startActivity(intent)
}
} else {
toast("Invalid file type")
}
} catch (e: Exception) {
e.printStackTrace()
toast("Error importing settings")
}
}
val _bottomBar = findViewById<AnimatedBottomBar>(R.id.navbar)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@ -95,7 +172,6 @@ class MainActivity : AppCompatActivity() {
}
_bottomBar.background = ContextCompat.getDrawable(this, R.drawable.bottom_nav_gray)
val offset = try {
val statusBarHeightId = resources.getIdentifier("status_bar_height", "dimen", "android")
resources.getDimensionPixelSize(statusBarHeightId)
@ -144,11 +220,14 @@ class MainActivity : AppCompatActivity() {
finish()
}
doubleBackToExitPressedOnce = true
snackString(this@MainActivity.getString(R.string.back_to_exit))
Handler(Looper.getMainLooper()).postDelayed(
{ doubleBackToExitPressedOnce = false },
2000
)
snackString(this@MainActivity.getString(R.string.back_to_exit)).apply {
this?.addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
doubleBackToExitPressedOnce = false
}
})
}
}
val preferences: SourcePreferences = Injekt.get()
@ -205,6 +284,7 @@ class MainActivity : AppCompatActivity() {
binding.root.doOnAttach {
initActivity(this)
window.navigationBarColor = ContextCompat.getColor(this, android.R.color.transparent)
selectedOption = if (fragment != null) {
when (fragment) {
AnimeFragment::class.java.name -> 0
@ -217,7 +297,36 @@ class MainActivity : AppCompatActivity() {
}
binding.includedNavbar.navbarContainer.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = navBarHeight
}
}
intent.extras?.let { extras ->
val fragmentToLoad = extras.getString("FRAGMENT_TO_LOAD")
val mediaId = extras.getInt("mediaId", -1)
val commentId = extras.getInt("commentId", -1)
val activityId = extras.getInt("activityId", -1)
if (fragmentToLoad != null && mediaId != -1 && commentId != -1) {
val detailIntent = Intent(this, MediaDetailsActivity::class.java).apply {
putExtra("FRAGMENT_TO_LOAD", fragmentToLoad)
putExtra("mediaId", mediaId)
putExtra("commentId", commentId)
}
startActivity(detailIntent)
} else if (fragmentToLoad == "FEED" && activityId != -1) {
val feedIntent = Intent(this, FeedActivity::class.java).apply {
putExtra("FRAGMENT_TO_LOAD", "NOTIFICATIONS")
putExtra("activityId", activityId)
}
startActivity(feedIntent)
} else if (fragmentToLoad == "NOTIFICATIONS" && activityId != -1) {
Logger.log("MainActivity, onCreate: $activityId")
val notificationIntent = Intent(this, NotificationActivity::class.java).apply {
putExtra("FRAGMENT_TO_LOAD", "NOTIFICATIONS")
putExtra("activityId", activityId)
}
startActivity(notificationIntent)
}
}
val offlineMode: Boolean = PrefManager.getVal(PrefName.OfflineMode)
@ -255,6 +364,7 @@ class MainActivity : AppCompatActivity() {
mainViewPager.setCurrentItem(newIndex, false)
}
})
if (mainViewPager.getCurrentItem() != selectedOption) {
navbar.selectTabAt(selectedOption)
mainViewPager.post {
mainViewPager.setCurrentItem(
@ -262,6 +372,7 @@ class MainActivity : AppCompatActivity() {
false
)
}
}
} else {
binding.mainProgressBar.visibility = View.GONE
}
@ -288,8 +399,21 @@ class MainActivity : AppCompatActivity() {
snackString(this@MainActivity.getString(R.string.anilist_not_found))
}
}
delay(500)
startSubscription()
val username = intent.extras?.getString("username")
if (username != null) {
val nameInt = username.toIntOrNull()
if (nameInt != null) {
startActivity(
Intent(this@MainActivity, ProfileActivity::class.java)
.putExtra("userId", nameInt)
)
} else {
startActivity(
Intent(this@MainActivity, ProfileActivity::class.java)
.putExtra("username", username)
)
}
}
}
load = true
}
@ -326,27 +450,74 @@ class MainActivity : AppCompatActivity() {
}
}
}
//TODO: Remove this
GlobalScope.launch(Dispatchers.IO) {
lifecycleScope.launch(Dispatchers.IO) { //simple cleanup
val index = Helper.downloadManager(this@MainActivity).downloadIndex
val downloadCursor = index.getDownloads()
while (downloadCursor.moveToNext()) {
val download = downloadCursor.download
Log.e("Downloader", download.request.uri.toString())
Log.e("Downloader", download.request.id)
Log.e("Downloader", download.request.mimeType.toString())
Log.e("Downloader", download.request.data.size.toString())
Log.e("Downloader", download.bytesDownloaded.toString())
Log.e("Downloader", download.state.toString())
Log.e("Downloader", download.failureReason.toString())
if (download.state == Download.STATE_FAILED) { //simple cleanup
if (download.state == Download.STATE_FAILED) {
Helper.downloadManager(this@MainActivity).removeDownload(download.request.id)
}
}
}
}
override fun onRestart() {
super.onRestart()
window.navigationBarColor = ContextCompat.getColor(this, android.R.color.transparent)
}
private val Int.toPx get() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), Resources.getSystem().displayMetrics
).toInt()
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val params : ViewGroup.MarginLayoutParams =
binding.includedNavbar.navbar.layoutParams as ViewGroup.MarginLayoutParams
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE)
params.updateMargins(bottom = 8.toPx)
else
params.updateMargins(bottom = 32.toPx)
}
private fun passwordAlertDialog(callback: (CharArray?) -> Unit) {
val password = CharArray(16).apply { fill('0') }
// Inflate the dialog layout
val dialogView =
LayoutInflater.from(this).inflate(R.layout.dialog_user_agent, null)
dialogView.findViewById<TextInputEditText>(R.id.userAgentTextBox)?.hint = "Password"
val subtitleTextView = dialogView.findViewById<TextView>(R.id.subtitle)
subtitleTextView?.visibility = View.VISIBLE
subtitleTextView?.text = "Enter your password to decrypt the file"
val dialog = AlertDialog.Builder(this, R.style.MyPopup)
.setTitle("Enter Password")
.setView(dialogView)
.setPositiveButton("OK", null)
.setNegativeButton("Cancel") { dialog, _ ->
password.fill('0')
dialog.dismiss()
callback(null)
}
.create()
dialog.window?.setDimAmount(0.8f)
dialog.show()
// Override the positive button here
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val editText = dialog.findViewById<TextInputEditText>(R.id.userAgentTextBox)
if (editText?.text?.isNotBlank() == true) {
editText.text?.toString()?.trim()?.toCharArray(password)
dialog.dismiss()
callback(password)
} else {
toast("Password cannot be empty")
}
}
}
//ViewPager
private class ViewPagerAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :

View file

@ -5,6 +5,7 @@ import android.os.Build
import androidx.fragment.app.FragmentActivity
import ani.dantotsu.others.webview.CloudFlare
import ani.dantotsu.others.webview.WebViewBottomDialog
import ani.dantotsu.util.Logger
import com.lagradost.nicehttp.Requests
import com.lagradost.nicehttp.ResponseParser
import com.lagradost.nicehttp.addGenericDns
@ -104,6 +105,7 @@ fun logError(e: Throwable, post: Boolean = true, snackbar: Boolean = true) {
toast(e.localizedMessage)
}
e.printStackTrace()
Logger.log(e)
}
fun <T> tryWith(post: Boolean = false, snackbar: Boolean = true, call: () -> T): T? {
@ -134,7 +136,7 @@ suspend fun <T> tryWithSuspend(
* A url, which can also have headers
* **/
data class FileUrl(
val url: String,
var url: String,
val headers: Map<String, String> = mapOf()
) : Serializable {
companion object {

View file

@ -19,7 +19,7 @@ fun updateProgress(media: Media, number: String) {
if (Anilist.userid != null) {
CoroutineScope(Dispatchers.IO).launch {
val a = number.toFloatOrNull()?.toInt()
if ((a ?: 0) > (media.userProgress ?: 0)) {
if ((a ?: 0) > (media.userProgress ?: -1)) {
Anilist.mutation.editList(
media.id,
a,

View file

@ -11,8 +11,10 @@ import ani.dantotsu.currContext
import ani.dantotsu.openLinkInBrowser
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString
import ani.dantotsu.toast
import ani.dantotsu.tryWithSuspend
import ani.dantotsu.util.Logger
import java.util.Calendar
object Anilist {
@ -27,6 +29,7 @@ object Anilist {
var bg: String? = null
var episodesWatched: Int? = null
var chapterRead: Int? = null
var unreadNotificationCount: Int = 0
var genres: ArrayList<String>? = null
var tags: Map<Boolean, List<String>>? = null
@ -124,7 +127,8 @@ object Anilist {
show: Boolean = false,
cache: Int? = null
): T? {
return tryWithSuspend {
return try {
if (show) Logger.log("Anilist Query: $query")
if (rateLimitReset > System.currentTimeMillis() / 1000) {
toast("Rate limited. Try after ${rateLimitReset - (System.currentTimeMillis() / 1000)} seconds")
throw Exception("Rate limited after ${rateLimitReset - (System.currentTimeMillis() / 1000)} seconds")
@ -148,7 +152,7 @@ object Anilist {
cacheTime = cache ?: 10
)
val remaining = json.headers["X-RateLimit-Remaining"]?.toIntOrNull() ?: -1
Log.d("AnilistQuery", "Remaining requests: $remaining")
Logger.log("Remaining requests: $remaining")
if (json.code == 429) {
val retry = json.headers["Retry-After"]?.toIntOrNull() ?: -1
val passedLimitReset = json.headers["X-RateLimit-Reset"]?.toLongOrNull() ?: 0
@ -159,10 +163,14 @@ object Anilist {
toast("Rate limited. Try after $retry seconds")
throw Exception("Rate limited after $retry seconds")
}
if (!json.text.startsWith("{")) throw Exception(currContext()?.getString(R.string.anilist_down))
if (show) println("Response : ${json.text}")
if (!json.text.startsWith("{")) {throw Exception(currContext()?.getString(R.string.anilist_down))}
if (show) Logger.log("Anilist Response: ${json.text}")
json.parsed()
} else null
} catch (e: Exception) {
if (show) snackString("Error fetching Anilist data: ${e.message}")
Logger.log("Anilist Query Error: ${e.message}")
null
}
}
}

View file

@ -13,6 +13,23 @@ class AnilistMutations {
executeQuery<JsonObject>(query, variables)
}
suspend fun toggleFav(type: FavType, id: Int): Boolean {
val filter = when (type) {
FavType.ANIME -> "animeId"
FavType.MANGA -> "mangaId"
FavType.CHARACTER -> "characterId"
FavType.STAFF -> "staffId"
FavType.STUDIO -> "studioId"
}
val query = """mutation{ToggleFavourite($filter:$id){anime{pageInfo{total}}}}"""
val result = executeQuery<JsonObject>(query)
return result?.get("errors") == null && result != null
}
enum class FavType {
ANIME, MANGA, CHARACTER, STAFF, STUDIO
}
suspend fun editList(
mediaID: Int,
progress: Int? = null,

View file

@ -6,9 +6,12 @@ import ani.dantotsu.checkGenreTime
import ani.dantotsu.checkId
import ani.dantotsu.connections.anilist.Anilist.authorRoles
import ani.dantotsu.connections.anilist.Anilist.executeQuery
import ani.dantotsu.connections.anilist.api.FeedResponse
import ani.dantotsu.connections.anilist.api.FuzzyDate
import ani.dantotsu.connections.anilist.api.NotificationResponse
import ani.dantotsu.connections.anilist.api.Page
import ani.dantotsu.connections.anilist.api.Query
import ani.dantotsu.connections.anilist.api.ToggleLike
import ani.dantotsu.currContext
import ani.dantotsu.isOnline
import ani.dantotsu.logError
@ -20,6 +23,7 @@ import ani.dantotsu.others.MalScraper
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString
import ani.dantotsu.util.Logger
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
@ -31,23 +35,27 @@ import java.io.Serializable
import kotlin.system.measureTimeMillis
class AnilistQueries {
suspend fun getUserData(): Boolean {
val response: Query.Viewer?
measureTimeMillis {
response =
executeQuery("""{Viewer{name options{displayAdultContent}avatar{medium}bannerImage id mediaListOptions{rowOrder animeList{sectionOrder customLists}mangaList{sectionOrder customLists}}statistics{anime{episodesWatched}manga{chaptersRead}}}}""")
executeQuery("""{Viewer{name options{displayAdultContent}avatar{medium}bannerImage id mediaListOptions{rowOrder animeList{sectionOrder customLists}mangaList{sectionOrder customLists}}statistics{anime{episodesWatched}manga{chaptersRead}}unreadNotificationCount}}""")
}.also { println("time : $it") }
val user = response?.data?.user ?: return false
PrefManager.setVal(PrefName.AnilistUserName, user.name)
Anilist.userid = user.id
PrefManager.setVal(PrefName.AnilistUserId, user.id.toString())
Anilist.username = user.name
Anilist.bg = user.bannerImage
Anilist.avatar = user.avatar?.medium
Anilist.episodesWatched = user.statistics?.anime?.episodesWatched
Anilist.chapterRead = user.statistics?.manga?.chaptersRead
Anilist.adult = user.options?.displayAdultContent ?: false
Anilist.unreadNotificationCount = user.unreadNotificationCount ?: 0
val unread = PrefManager.getVal<Int>(PrefName.UnreadCommentNotifications)
Anilist.unreadNotificationCount += unread
return true
}
@ -64,7 +72,7 @@ class AnilistQueries {
media.cameFromContinue = false
val query =
"""{Media(id:${media.id}){id mediaListEntry{id status score(format:POINT_100) progress private notes repeat customLists updatedAt startedAt{year month day}completedAt{year month day}}isFavourite siteUrl idMal nextAiringEpisode{episode airingAt}source countryOfOrigin format duration season seasonYear startDate{year month day}endDate{year month day}genres studios(isMain:true){nodes{id name siteUrl}}description trailer { site id } synonyms tags { name rank isMediaSpoiler } characters(sort:[ROLE,FAVOURITES_DESC],perPage:25,page:1){edges{role node{id image{medium}name{userPreferred}}}}relations{edges{relationType(version:2)node{id idMal mediaListEntry{progress private score(format:POINT_100) status} episodes chapters nextAiringEpisode{episode} popularity meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}staffPreview: staff(perPage: 8, sort: [RELEVANCE, ID]) {edges{role node{id name{userPreferred}}}}recommendations(sort:RATING_DESC){nodes{mediaRecommendation{id idMal mediaListEntry{progress private score(format:POINT_100) status} episodes chapters nextAiringEpisode{episode}meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}externalLinks{url site}}}"""
"""{Media(id:${media.id}){id mediaListEntry{id status score(format:POINT_100)progress private notes repeat customLists updatedAt startedAt{year month day}completedAt{year month day}}isFavourite siteUrl idMal nextAiringEpisode{episode airingAt}source countryOfOrigin format duration season seasonYear startDate{year month day}endDate{year month day}genres studios(isMain:true){nodes{id name siteUrl}}description trailer{site id}synonyms tags{name rank isMediaSpoiler}characters(sort:[ROLE,FAVOURITES_DESC],perPage:25,page:1){edges{role node{id image{medium}name{userPreferred}isFavourite}}}relations{edges{relationType(version:2)node{id idMal mediaListEntry{progress private score(format:POINT_100)status}episodes chapters nextAiringEpisode{episode}popularity meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}staffPreview:staff(perPage:8,sort:[RELEVANCE,ID]){edges{role node{id image{large medium}name{userPreferred}}}}recommendations(sort:RATING_DESC){nodes{mediaRecommendation{id idMal mediaListEntry{progress private score(format:POINT_100)status}episodes chapters nextAiringEpisode{episode}meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}externalLinks{url site}}}"""
runBlocking {
val anilist = async {
var response = executeQuery<Query.Media>(query, force = true, show = true)
@ -121,6 +129,30 @@ class AnilistQueries {
name = i.node?.name?.userPreferred,
image = i.node?.image?.medium,
banner = media.banner ?: media.cover,
isFav = i.node?.isFavourite ?: false,
role = when (i.role.toString()) {
"MAIN" -> currContext()?.getString(R.string.main_role)
?: "MAIN"
"SUPPORTING" -> currContext()?.getString(R.string.supporting_role)
?: "SUPPORTING"
else -> i.role.toString()
}
)
)
}
}
}
if (fetchedMedia.staff != null) {
media.staff = arrayListOf()
fetchedMedia.staff?.edges?.forEach { i ->
i.node?.apply {
media.staff?.add(
Author(
id = id,
name = i.node?.name?.userPreferred,
image = i.node?.image?.medium,
role = when (i.role.toString()) {
"MAIN" -> currContext()?.getString(R.string.main_role)
?: "MAIN"
@ -211,8 +243,10 @@ class AnilistQueries {
fetchedMedia.staff?.edges?.find { authorRoles.contains(it.role?.trim()) }?.node?.let {
media.anime.author = Author(
it.id.toString(),
it.name?.userPreferred ?: "N/A"
it.id,
it.name?.userPreferred ?: "N/A",
it.image?.medium,
"AUTHOR"
)
}
@ -231,8 +265,10 @@ class AnilistQueries {
} else if (media.manga != null) {
fetchedMedia.staff?.edges?.find { authorRoles.contains(it.role?.trim()) }?.node?.let {
media.manga.author = Author(
it.id.toString(),
it.name?.userPreferred ?: "N/A"
it.id,
it.name?.userPreferred ?: "N/A",
it.image?.medium,
"AUTHOR"
)
}
}
@ -249,6 +285,7 @@ class AnilistQueries {
} else {
if (currContext()?.let { isOnline(it) } == true) {
snackString(currContext()?.getString(R.string.error_getting_data))
} else {
}
}
}
@ -262,6 +299,52 @@ class AnilistQueries {
return media
}
fun userMediaDetails(media: Media): Media {
val query =
"""{Media(id:${media.id}){id mediaListEntry{id status progress private repeat customLists updatedAt startedAt{year month day}completedAt{year month day}}isFavourite idMal}}"""
runBlocking {
val anilist = async {
var response = executeQuery<Query.Media>(query, force = true, show = true)
if (response != null) {
fun parse() {
val fetchedMedia = response?.data?.media ?: return
if (fetchedMedia.mediaListEntry != null) {
fetchedMedia.mediaListEntry?.apply {
media.userProgress = progress
media.isListPrivate = private ?: false
media.userListId = id
media.userStatus = status?.toString()
media.inCustomListsOf = customLists?.toMutableMap()
media.userRepeat = repeat ?: 0
media.userUpdatedAt = updatedAt?.toString()?.toLong()?.times(1000)
media.userCompletedAt = completedAt ?: FuzzyDate()
media.userStartedAt = startedAt ?: FuzzyDate()
}
} else {
media.isListPrivate = false
media.userStatus = null
media.userListId = null
media.userProgress = null
media.userRepeat = 0
media.userUpdatedAt = null
media.userCompletedAt = FuzzyDate()
media.userStartedAt = FuzzyDate()
}
}
if (response.data?.media != null) parse()
else {
response = executeQuery(query, force = true, useToken = false)
if (response?.data?.media != null) parse()
}
}
}
awaitAll(anilist)
}
return media
}
suspend fun continueMedia(type: String, planned: Boolean = false): ArrayList<Media> {
val returnArray = arrayListOf<Media>()
val map = mutableMapOf<Int, Media>()
@ -299,9 +382,17 @@ class AnilistQueries {
}
}
}
val set = PrefManager.getCustomVal<Set<Int>>("continue_$type", setOf()).toMutableSet()
if (set.isNotEmpty()) {
set.reversed().forEach {
if (type != "ANIME") {
returnArray.addAll(map.values)
return returnArray
}
val list = PrefManager.getNullableCustomVal(
"continueAnimeList",
listOf<Int>(),
List::class.java
) as List<Int>
if (list.isNotEmpty()) {
list.reversed().forEach {
if (map.containsKey(it)) returnArray.add(map[it]!!)
}
for (i in map) {
@ -315,12 +406,12 @@ class AnilistQueries {
return """ MediaListCollection(userId: ${Anilist.userid}, type: $type, status: $status , sort: UPDATED_TIME ) { lists { entries { progress private score(format:POINT_100) status media { id idMal type isAdult status chapters episodes nextAiringEpisode {episode} meanScore isFavourite format bannerImage coverImage{large} title { english romaji userPreferred } } } } } """
}
suspend fun favMedia(anime: Boolean): ArrayList<Media> {
suspend fun favMedia(anime: Boolean, id: Int? = Anilist.userid): ArrayList<Media> {
var hasNextPage = true
var page = 0
suspend fun getNextPage(page: Int): List<Media> {
val response = executeQuery<Query.User>("""{${favMediaQuery(anime, page)}}""")
val response = executeQuery<Query.User>("""{${favMediaQuery(anime, page, id)}}""")
val favourites = response?.data?.user?.favourites
val apiMediaList = if (anime) favourites?.anime else favourites?.manga
hasNextPage = apiMediaList?.pageInfo?.hasNextPage ?: false
@ -339,8 +430,8 @@ class AnilistQueries {
return responseArray
}
private fun favMediaQuery(anime: Boolean, page: Int): String {
return """User(id:${Anilist.userid}){id favourites{${if (anime) "anime" else "manga"}(page:$page){pageInfo{hasNextPage}edges{favouriteOrder node{id idMal isAdult mediaListEntry{ progress private score(format:POINT_100) status } chapters isFavourite format episodes nextAiringEpisode{episode}meanScore isFavourite format startDate{year month day} title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}}}"""
private fun favMediaQuery(anime: Boolean, page: Int, id: Int? = Anilist.userid): String {
return """User(id:${id}){id favourites{${if (anime) "anime" else "manga"}(page:$page){pageInfo{hasNextPage}edges{favouriteOrder node{id idMal isAdult mediaListEntry{ progress private score(format:POINT_100) status } chapters isFavourite format episodes nextAiringEpisode{episode}meanScore isFavourite format startDate{year month day} title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}}}"""
}
suspend fun recommendations(): ArrayList<Media> {
@ -383,7 +474,7 @@ class AnilistQueries {
}
private fun recommendationPlannedQuery(type: String): String {
return """ MediaListCollection(userId: ${Anilist.userid}, type: $type, status: PLANNING , sort: MEDIA_POPULARITY_DESC ) { lists { entries { media { id mediaListEntry { progress private score(format:POINT_100) status } idMal type isAdult popularity status(version: 2) chapters episodes nextAiringEpisode {episode} meanScore isFavourite format bannerImage coverImage{large} title { english romaji userPreferred } } } } }"""
return """ MediaListCollection(userId: ${Anilist.userid}, type: $type, status: PLANNING${if (type == "ANIME") ", sort: MEDIA_POPULARITY_DESC" else ""} ) { lists { entries { media { id mediaListEntry { progress private score(format:POINT_100) status } idMal type isAdult popularity status(version: 2) chapters episodes nextAiringEpisode {episode} meanScore isFavourite format bannerImage coverImage{large} title { english romaji userPreferred } } } } }"""
}
suspend fun initHomePage(): Map<String, ArrayList<Media>> {
@ -423,7 +514,8 @@ class AnilistQueries {
}, recommendationPlannedQueryManga: ${recommendationPlannedQuery("MANGA")}"""
query += """}""".trimEnd(',')
val response = executeQuery<Query.HomePageMedia>(query)
val response = executeQuery<Query.HomePageMedia>(query, show = true)
Logger.log(response.toString())
val returnMap = mutableMapOf<String, ArrayList<Media>>()
fun current(type: String) {
val subMap = mutableMapOf<Int, Media>()
@ -446,10 +538,18 @@ class AnilistQueries {
subMap[m.id] = m
}
}
val set = PrefManager.getCustomVal<Set<Int>>("continue_${type.uppercase()}", setOf())
.toMutableSet()
if (set.isNotEmpty()) {
set.reversed().forEach {
if (type != "Anime") {
returnArray.addAll(subMap.values)
returnMap["current$type"] = returnArray
return
}
val list = PrefManager.getNullableCustomVal(
"continueAnimeList",
listOf<Int>(),
List::class.java
) as List<Int>
if (list.isNotEmpty()) {
list.reversed().forEach {
if (subMap.containsKey(it)) returnArray.add(subMap[it]!!)
}
for (i in subMap) {
@ -472,9 +572,13 @@ class AnilistQueries {
subMap[m.id] = m
}
}
val set = PrefManager.getCustomVal<Set<Int>>("continue_$type", setOf()).toMutableSet()
if (set.isNotEmpty()) {
set.reversed().forEach {
val list = PrefManager.getNullableCustomVal(
"continueAnimeList",
listOf<Int>(),
List::class.java
) as List<Int>
if (list.isNotEmpty()) {
list.reversed().forEach {
if (subMap.containsKey(it)) returnArray.add(subMap[it]!!)
}
for (i in subMap) {
@ -558,12 +662,11 @@ class AnilistQueries {
private suspend fun bannerImage(type: String): String? {
//var image = loadData<BannerImage>("banner_$type")
val image: BannerImage? = BannerImage(
PrefManager.getCustomVal("banner_${type}_url", null),
val image = BannerImage(
PrefManager.getCustomVal("banner_${type}_url", ""),
PrefManager.getCustomVal("banner_${type}_time", 0L)
)
if (image == null || image.checkTime()) {
if (image.url.isNullOrEmpty() || image.checkTime()) {
val response =
executeQuery<Query.MediaListCollection>("""{ MediaListCollection(userId: ${Anilist.userid}, type: $type, chunk:1,perChunk:25, sort: [SCORE_DESC,UPDATED_TIME_DESC]) { lists { entries{ media { id bannerImage } } } } } """)
val random = response?.data?.mediaListCollection?.lists?.mapNotNull {
@ -592,7 +695,7 @@ class AnilistQueries {
sortOrder: String? = null
): MutableMap<String, ArrayList<Media>> {
val response =
executeQuery<Query.MediaListCollection>("""{ MediaListCollection(userId: $userId, type: ${if (anime) "ANIME" else "MANGA"}) { lists { name isCustomList entries { status progress private score(format:POINT_100) updatedAt media { id idMal isAdult type status chapters episodes nextAiringEpisode {episode} bannerImage meanScore isFavourite format coverImage{large} startDate{year month day} title {english romaji userPreferred } } } } user { id mediaListOptions { rowOrder animeList { sectionOrder } mangaList { sectionOrder } } } } }""")
executeQuery<Query.MediaListCollection>("""{ MediaListCollection(userId: $userId, type: ${if (anime) "ANIME" else "MANGA"}) { lists { name isCustomList entries { status progress private score(format:POINT_100) updatedAt media { id idMal isAdult type status chapters episodes nextAiringEpisode {episode} bannerImage genres meanScore isFavourite format coverImage{large} startDate{year month day} title {english romaji userPreferred } } } } user { id mediaListOptions { rowOrder animeList { sectionOrder } mangaList { sectionOrder } } } } }""")
val sorted = mutableMapOf<String, ArrayList<Media>>()
val unsorted = mutableMapOf<String, ArrayList<Media>>()
val all = arrayListOf<Media>()
@ -620,7 +723,7 @@ class AnilistQueries {
if (!sorted.containsKey(it.key)) sorted[it.key] = it.value
}
sorted["Favourites"] = favMedia(anime)
sorted["Favourites"] = favMedia(anime, userId)
sorted["Favourites"]?.sortWith(compareBy { it.userFavOrder })
//favMedia doesn't fill userProgress, so we need to fill it manually by searching :(
sorted["Favourites"]?.forEach { fav ->
@ -661,8 +764,8 @@ class AnilistQueries {
PrefManager.getVal<Set<String>>(PrefName.TagsListNonAdult).toMutableList()
var tags = if (adultTags.isEmpty() || nonAdultTags.isEmpty()) null else
mapOf(
true to adultTags,
false to nonAdultTags
true to adultTags.sortedBy { it },
false to nonAdultTags.sortedBy { it }
)
if (genres.isNullOrEmpty()) {
@ -698,7 +801,7 @@ class AnilistQueries {
}
}
return if (!genres.isNullOrEmpty() && tags != null) {
Anilist.genres = genres
Anilist.genres = genres?.sortedBy { it }?.toMutableList() as ArrayList<String>
Anilist.tags = tags
true
} else false
@ -1225,4 +1328,116 @@ Page(page:$page,perPage:50) {
return author
}
suspend fun toggleFollow(id: Int): Query.ToggleFollow? {
return executeQuery<Query.ToggleFollow>(
"""mutation{ToggleFollow(userId:$id){id, isFollowing, isFollower}}"""
)
}
suspend fun toggleLike(id: Int, type: String): ToggleLike? {
return executeQuery<ToggleLike>(
"""mutation Like{ToggleLikeV2(id:$id,type:$type){__typename}}"""
)
}
suspend fun getUserProfile(id: Int): Query.UserProfileResponse? {
return executeQuery<Query.UserProfileResponse>(
"""{followerPage:Page{followers(userId:$id){id}pageInfo{total}}followingPage:Page{following(userId:$id){id}pageInfo{total}}user:User(id:$id){id name about(asHtml:true)avatar{medium large}bannerImage isFollowing isFollower isBlocked favourites{anime{nodes{id coverImage{extraLarge large medium color}}}manga{nodes{id coverImage{extraLarge large medium color}}}characters{nodes{id name{first middle last full native alternative userPreferred}image{large medium}isFavourite}}staff{nodes{id name{first middle last full native alternative userPreferred}image{large medium}isFavourite}}studios{nodes{id name isFavourite}}}statistics{anime{count meanScore standardDeviation minutesWatched episodesWatched chaptersRead volumesRead}manga{count meanScore standardDeviation minutesWatched episodesWatched chaptersRead volumesRead}}siteUrl}}""",
force = true
)
}
suspend fun getUserProfile(username: String): Query.UserProfileResponse? {
val id = getUserId(username) ?: return null
return getUserProfile(id)
}
suspend fun getUserId(username: String): Int? {
return executeQuery<Query.User>(
"""{User(name:"$username"){id}}""",
force = true
)?.data?.user?.id
}
suspend fun getUserStatistics(id: Int, sort: String = "ID"): Query.StatisticsResponse? {
return executeQuery<Query.StatisticsResponse>(
"""{User(id:$id){id name mediaListOptions{scoreFormat}statistics{anime{...UserStatistics}manga{...UserStatistics}}}}fragment UserStatistics on UserStatistics{count meanScore standardDeviation minutesWatched episodesWatched chaptersRead volumesRead formats(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds format}statuses(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds status}scores(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds score}lengths(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds length}releaseYears(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds releaseYear}startYears(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds startYear}genres(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds genre}tags(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds tag{id name}}countries(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds country}voiceActors(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds voiceActor{id name{first middle last full native alternative userPreferred}}characterIds}staff(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds staff{id name{first middle last full native alternative userPreferred}}}studios(sort:$sort){count meanScore minutesWatched chaptersRead mediaIds studio{id name isAnimationStudio}}}""",
force = true,
show = true
)
}
private fun userFavMediaQuery(anime: Boolean, page: Int, id: Int): String {
return """User(id:${id}){id favourites{${if (anime) "anime" else "manga"}(page:$page){pageInfo{hasNextPage}edges{favouriteOrder node{id idMal isAdult mediaListEntry{ progress private score(format:POINT_100) status } chapters isFavourite format episodes nextAiringEpisode{episode}meanScore isFavourite format startDate{year month day} title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}}}"""
}
suspend fun userFollowing(id: Int): Query.Following? {
return executeQuery<Query.Following>(
"""{Page {following(userId:${id},sort:[USERNAME]){id name avatar{large medium}bannerImage}}}""",
force = true
)
}
suspend fun userFollowers(id: Int): Query.Follower? {
return executeQuery<Query.Follower>(
"""{Page {followers(userId:${id},sort:[USERNAME]){id name avatar{large medium}bannerImage}}}""",
force = true
)
}
suspend fun initProfilePage(id: Int): Query.ProfilePageMedia? {
return executeQuery<Query.ProfilePageMedia>(
"""{
favoriteAnime:${userFavMediaQuery(true, 1, id)}
favoriteManga:${userFavMediaQuery(false, 1, id)}
animeMediaList:${bannerImageQuery("ANIME", id)}
mangaMediaList:${bannerImageQuery("MANGA", id)}
}""".trimIndent(), force = true
)
}
private fun bannerImageQuery(type: String, id: Int?): String {
return """MediaListCollection(userId: ${id}, type: $type, chunk:1,perChunk:25, sort: [SCORE_DESC,UPDATED_TIME_DESC]) { lists { entries{ media { id bannerImage } } } }"""
}
suspend fun getNotifications(id: Int, page: Int = 1, resetNotification: Boolean = true): NotificationResponse? {
val reset = if (resetNotification) "true" else "false"
val res = executeQuery<NotificationResponse>(
"""{User(id:$id){unreadNotificationCount}Page(page:$page,perPage:$ITEMS_PER_PAGE){pageInfo{currentPage,hasNextPage}notifications(resetNotificationCount:$reset){__typename...on AiringNotification{id,type,animeId,episode,contexts,createdAt,media{id,title{romaji,english,native,userPreferred}bannerImage,coverImage{medium,large}},}...on FollowingNotification{id,userId,type,context,createdAt,user{id,name,bannerImage,avatar{medium,large,}}}...on ActivityMessageNotification{id,userId,type,activityId,context,createdAt,message{id}user{id,name,bannerImage,avatar{medium,large,}}}...on ActivityMentionNotification{id,userId,type,activityId,context,createdAt,activity{__typename}user{id,name,bannerImage,avatar{medium,large,}}}...on ActivityReplyNotification{id,userId,type,activityId,context,createdAt,activity{__typename}user{id,name,bannerImage,avatar{medium,large,}}}...on ActivityReplySubscribedNotification{id,userId,type,activityId,context,createdAt,activity{__typename}user{id,name,bannerImage,avatar{medium,large,}}}...on ActivityLikeNotification{id,userId,type,activityId,context,createdAt,activity{__typename}user{id,name,bannerImage,avatar{medium,large,}}}...on ActivityReplyLikeNotification{id,userId,type,activityId,context,createdAt,activity{__typename}user{id,name,bannerImage,avatar{medium,large,}}}...on ThreadCommentMentionNotification{id,userId,type,commentId,context,createdAt,thread{id}comment{id}user{id,name,bannerImage,avatar{medium,large,}}}...on ThreadCommentReplyNotification{id,userId,type,commentId,context,createdAt,thread{id}comment{id}user{id,name,bannerImage,avatar{medium,large,}}}...on ThreadCommentSubscribedNotification{id,userId,type,commentId,context,createdAt,thread{id}comment{id}user{id,name,bannerImage,avatar{medium,large,}}}...on ThreadCommentLikeNotification{id,userId,type,commentId,context,createdAt,thread{id}comment{id}user{id,name,bannerImage,avatar{medium,large,}}}...on ThreadLikeNotification{id,userId,type,threadId,context,createdAt,thread{id}comment{id}user{id,name,bannerImage,avatar{medium,large,}}}...on RelatedMediaAdditionNotification{id,type,context,createdAt,media{id,title{romaji,english,native,userPreferred}bannerImage,coverImage{medium,large}}}...on MediaDataChangeNotification{id,type,mediaId,context,reason,createdAt,media{id,title{romaji,english,native,userPreferred}bannerImage,coverImage{medium,large}}}...on MediaMergeNotification{id,type,mediaId,deletedMediaTitles,context,reason,createdAt,media{id,title{romaji,english,native,userPreferred}bannerImage,coverImage{medium,large}}}...on MediaDeletionNotification{id,type,deletedMediaTitle,context,reason,createdAt,}}}}""",
force = true
)
if (res != null && resetNotification) {
val commentNotifications = PrefManager.getVal(PrefName.UnreadCommentNotifications, 0)
res.data.user.unreadNotificationCount += commentNotifications
PrefManager.setVal(PrefName.UnreadCommentNotifications, 0)
Anilist.unreadNotificationCount = 0
}
return res
}
suspend fun getFeed(userId: Int?, global: Boolean = false, page: Int = 1, activityId: Int? = null): FeedResponse? {
val filter = if (activityId != null) "id:$activityId,"
else if (userId != null) "userId:$userId,"
else if (global) "isFollowing:false,hasRepliesOrTypeText:true,"
else "isFollowing:true,type_not:MESSAGE,"
return executeQuery<FeedResponse>(
"""{Page(page:$page,perPage:$ITEMS_PER_PAGE){activities(${filter}sort:ID_DESC){__typename ... on TextActivity{id userId type replyCount text(asHtml:true)siteUrl isLocked isSubscribed likeCount isLiked isPinned createdAt user{id name bannerImage avatar{medium large}}replies{id userId activityId text(asHtml:true)likeCount isLiked createdAt user{id name bannerImage avatar{medium large}}likes{id name bannerImage avatar{medium large}}}likes{id name bannerImage avatar{medium large}}}... on ListActivity{id userId type replyCount status progress siteUrl isLocked isSubscribed likeCount isLiked isPinned createdAt user{id name bannerImage avatar{medium large}}media{id title{english romaji native userPreferred}bannerImage coverImage{medium large}}replies{id userId activityId text(asHtml:true)likeCount isLiked createdAt user{id name bannerImage avatar{medium large}}likes{id name bannerImage avatar{medium large}}}likes{id name bannerImage avatar{medium large}}}... on MessageActivity{id recipientId messengerId type replyCount likeCount message(asHtml:true)isLocked isSubscribed isLiked isPrivate siteUrl createdAt recipient{id name bannerImage avatar{medium large}}messenger{id name bannerImage avatar{medium large}}replies{id userId activityId text(asHtml:true)likeCount isLiked createdAt user{id name bannerImage avatar{medium large}}likes{id name bannerImage avatar{medium large}}}likes{id name bannerImage avatar{medium large}}}}}}""",
force = true
)
}
suspend fun isUserFav(favType: AnilistMutations.FavType, id: Int): Boolean { //anilist isFavourite is broken, so we need to check it manually
val res = getUserProfile(Anilist.userid?: return false)
return when (favType) {
AnilistMutations.FavType.ANIME -> res?.data?.user?.favourites?.anime?.nodes?.any { it.id == id } ?: false
AnilistMutations.FavType.MANGA -> res?.data?.user?.favourites?.manga?.nodes?.any { it.id == id } ?: false
AnilistMutations.FavType.CHARACTER -> res?.data?.user?.favourites?.characters?.nodes?.any { it.id == id } ?: false
AnilistMutations.FavType.STAFF -> res?.data?.user?.favourites?.staff?.nodes?.any { it.id == id } ?: false
AnilistMutations.FavType.STUDIO -> res?.data?.user?.favourites?.studios?.nodes?.any { it.id == id } ?: false
}
}
companion object {
const val ITEMS_PER_PAGE = 25
}
}

View file

@ -15,6 +15,7 @@ import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString
import ani.dantotsu.tryWithSuspend
import ani.dantotsu.util.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -99,6 +100,7 @@ class AnilistHomeViewModel : ViewModel() {
suspend fun initHomePage() {
val res = Anilist.query.initHomePage()
Logger.log("AnilistHomeViewModel : res=$res")
res["currentAnime"]?.let { animeContinue.postValue(it) }
res["favoriteAnime"]?.let { animeFav.postValue(it) }
res["plannedAnime"]?.let { animePlanned.postValue(it) }
@ -332,3 +334,63 @@ class GenresViewModel : ViewModel() {
}
}
}
class ProfileViewModel : ViewModel() {
private val mangaFav: MutableLiveData<ArrayList<Media>> =
MutableLiveData<ArrayList<Media>>(null)
fun getMangaFav(): LiveData<ArrayList<Media>> = mangaFav
private val animeFav: MutableLiveData<ArrayList<Media>> =
MutableLiveData<ArrayList<Media>>(null)
fun getAnimeFav(): LiveData<ArrayList<Media>> = animeFav
private val listImages: MutableLiveData<ArrayList<String?>> =
MutableLiveData<ArrayList<String?>>(arrayListOf())
fun getListImages(): LiveData<ArrayList<String?>> = listImages
suspend fun setData(id: Int) {
val res = Anilist.query.initProfilePage(id)
val mangaList = res?.data?.favoriteManga?.favourites?.manga?.edges?.mapNotNull {
it.node?.let { i ->
Media(i).apply { isFav = true }
}
}
mangaFav.postValue(ArrayList(mangaList ?: arrayListOf()))
val animeList = res?.data?.favoriteAnime?.favourites?.anime?.edges?.mapNotNull {
it.node?.let { i ->
Media(i).apply { isFav = true }
}
}
animeFav.postValue(ArrayList(animeList ?: arrayListOf()))
val bannerImages = arrayListOf<String?>(null, null)
val animeRandom = res?.data?.animeMediaList?.lists?.mapNotNull {
it.entries?.mapNotNull { entry ->
val imageUrl = entry.media?.bannerImage
if (imageUrl != null && imageUrl != "null") imageUrl
else null
}
}?.flatten()?.randomOrNull()
bannerImages[0] = animeRandom
val mangaRandom = res?.data?.mangaMediaList?.lists?.mapNotNull {
it.entries?.mapNotNull { entry ->
val imageUrl = entry.media?.bannerImage
if (imageUrl != null && imageUrl != "null") imageUrl
else null
}
}?.flatten()?.randomOrNull()
bannerImages[1] = mangaRandom
listImages.postValue(bannerImages)
}
fun refresh() {
mangaFav.postValue(mangaFav.value)
animeFav.postValue(animeFav.value)
listImages.postValue(listImages.value)
}
}

View file

@ -4,7 +4,7 @@ import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import ani.dantotsu.logError
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.startMainActivity
@ -16,7 +16,6 @@ class Login : AppCompatActivity() {
ThemeManager(this).applyTheme()
val data: Uri? = intent?.data
logger(data.toString())
try {
Anilist.token =
Regex("""(?<=access_token=).+(?=&token_type)""").find(data.toString())!!.value

View file

@ -11,14 +11,15 @@ import ani.dantotsu.themes.ThemeManager
class UrlMedia : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ThemeManager(this).applyTheme()
val data: Uri? = intent?.data
val type = data?.pathSegments?.getOrNull(0)
if (type != "user") {
var id: Int? = intent?.extras?.getInt("media", 0) ?: 0
var isMAL = false
var continueMedia = true
if (id == 0) {
continueMedia = false
val data: Uri? = intent?.data
isMAL = data?.host != "anilist.co"
id = data?.pathSegments?.getOrNull(1)?.toIntOrNull()
} else loadMedia = id
@ -26,5 +27,9 @@ class UrlMedia : Activity() {
this,
bundleOf("mediaId" to id, "mal" to isMAL, "continue" to continueMedia)
)
} else {
val username = data.pathSegments?.getOrNull(1)
startMainActivity(this, bundleOf("username" to username))
}
}
}

View file

@ -46,7 +46,7 @@ data class Character(
// Notes for site moderators
@SerialName("modNotes") var modNotes: String?,
)
) : java.io.Serializable
@Serializable
data class CharacterConnection(
@ -56,7 +56,7 @@ data class CharacterConnection(
// The pagination information
// @SerialName("pageInfo") var pageInfo: PageInfo?,
)
) : java.io.Serializable
@Serializable
data class CharacterEdge(
@ -82,7 +82,7 @@ data class CharacterEdge(
// The order the character should be displayed from the users favourites
@SerialName("favouriteOrder") var favouriteOrder: Int?,
)
) : java.io.Serializable
@Serializable
data class CharacterName(
@ -109,7 +109,7 @@ data class CharacterName(
// The currently authenticated users preferred name language. Default romaji for non-authenticated
@SerialName("userPreferred") var userPreferred: String?,
)
) : java.io.Serializable
@Serializable
data class CharacterImage(
@ -118,4 +118,4 @@ data class CharacterImage(
// The character's image of media at medium size
@SerialName("medium") var medium: String?,
)
) : java.io.Serializable

View file

@ -139,41 +139,550 @@ class Query {
)
}
@Serializable
data class ProfilePageMedia(
@SerialName("data")
val data: Data?
) {
@Serializable
data class Data(
@SerialName("favoriteAnime") val favoriteAnime: ani.dantotsu.connections.anilist.api.User?,
@SerialName("favoriteManga") val favoriteManga: ani.dantotsu.connections.anilist.api.User?,
@SerialName("animeMediaList") val animeMediaList: ani.dantotsu.connections.anilist.api.MediaListCollection?,
@SerialName("mangaMediaList") val mangaMediaList: ani.dantotsu.connections.anilist.api.MediaListCollection?
)
}
@Serializable
data class ToggleFollow(
@SerialName("data")
val data: Data?
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("ToggleFollow")
val toggleFollow: FollowData
) : java.io.Serializable
}
@Serializable
data class GenreCollection(
@SerialName("data")
val data: Data
) {
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("GenreCollection")
val genreCollection: List<String>?
)
) : java.io.Serializable
}
@Serializable
data class MediaTagCollection(
@SerialName("data")
val data: Data
) {
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("MediaTagCollection")
val mediaTagCollection: List<MediaTag>?
)
) : java.io.Serializable
}
@Serializable
data class User(
@SerialName("data")
val data: Data
) {
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("User")
val user: ani.dantotsu.connections.anilist.api.User?
)
) : java.io.Serializable
}
@Serializable
data class UserProfileResponse(
@SerialName("data")
val data: Data
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("followerPage")
val followerPage: UserProfilePage?,
@SerialName("followingPage")
val followingPage: UserProfilePage?,
@SerialName("user")
val user: UserProfile?
) : java.io.Serializable
}
@Serializable
data class UserProfilePage(
@SerialName("pageInfo")
val pageInfo: PageInfo,
) : java.io.Serializable
@Serializable
data class Following(
@SerialName("data")
val data: Data
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("Page")
val page: FollowingPage?
) : java.io.Serializable
}
@Serializable
data class Follower(
@SerialName("data")
val data: Data
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("Page")
val page: FollowerPage?
) : java.io.Serializable
}
@Serializable
data class FollowerPage(
@SerialName("followers")
val followers: List<ani.dantotsu.connections.anilist.api.User>?
) : java.io.Serializable
@Serializable
data class FollowingPage(
@SerialName("following")
val following: List<ani.dantotsu.connections.anilist.api.User>?
) : java.io.Serializable
@Serializable
data class UserProfile(
@SerialName("id")
val id: Int,
@SerialName("name")
val name: String,
@SerialName("about")
val about: String?,
@SerialName("avatar")
val avatar: UserAvatar?,
@SerialName("bannerImage")
val bannerImage: String?,
@SerialName("isFollowing")
var isFollowing: Boolean,
@SerialName("isFollower")
val isFollower: Boolean,
@SerialName("isBlocked")
val isBlocked: Boolean,
@SerialName("favourites")
val favourites: UserFavourites?,
@SerialName("statistics")
val statistics: NNUserStatisticTypes,
@SerialName("siteUrl")
val siteUrl: String,
): java.io.Serializable
@Serializable
data class NNUserStatisticTypes(
@SerialName("anime") var anime: NNUserStatistics,
@SerialName("manga") var manga: NNUserStatistics
): java.io.Serializable
@Serializable
data class NNUserStatistics(
@SerialName("count") var count: Int,
@SerialName("meanScore") var meanScore: Float,
@SerialName("standardDeviation") var standardDeviation: Float,
@SerialName("minutesWatched") var minutesWatched: Int,
@SerialName("episodesWatched") var episodesWatched: Int,
@SerialName("chaptersRead") var chaptersRead: Int,
@SerialName("volumesRead") var volumesRead: Int,
): java.io.Serializable
@Serializable
data class UserFavourites(
@SerialName("anime")
val anime: UserMediaFavouritesCollection,
@SerialName("manga")
val manga: UserMediaFavouritesCollection,
@SerialName("characters")
val characters: UserCharacterFavouritesCollection,
@SerialName("staff")
val staff: UserStaffFavouritesCollection,
@SerialName("studios")
val studios: UserStudioFavouritesCollection,
): java.io.Serializable
@Serializable
data class UserMediaFavouritesCollection(
@SerialName("nodes")
val nodes: List<UserMediaImageFavorite>,
): java.io.Serializable
@Serializable
data class UserMediaImageFavorite(
@SerialName("id")
val id: Int,
@SerialName("coverImage")
val coverImage: MediaCoverImage
): java.io.Serializable
@Serializable
data class UserCharacterFavouritesCollection(
@SerialName("nodes")
val nodes: List<UserCharacterImageFavorite>,
): java.io.Serializable
@Serializable
data class UserCharacterImageFavorite(
@SerialName("id")
val id: Int,
@SerialName("name")
val name: CharacterName,
@SerialName("image")
val image: CharacterImage,
@SerialName("isFavourite")
val isFavourite: Boolean
): java.io.Serializable
@Serializable
data class UserStaffFavouritesCollection(
@SerialName("nodes")
val nodes: List<UserCharacterImageFavorite>, //downstream it's the same as character
): java.io.Serializable
@Serializable
data class UserStudioFavouritesCollection(
@SerialName("nodes")
val nodes: List<UserStudioFavorite>,
): java.io.Serializable
@Serializable
data class UserStudioFavorite(
@SerialName("id")
val id: Int,
@SerialName("name")
val name: String,
): java.io.Serializable
//----------------------------------------
// Statistics
@Serializable
data class StatisticsResponse(
@SerialName("data")
val data: Data
): java.io.Serializable {
@Serializable
data class Data(
@SerialName("User")
val user: StatisticsUser?
): java.io.Serializable
}
@Serializable
data class StatisticsUser(
@SerialName("id")
val id: Int,
@SerialName("name")
val name: String,
@SerialName("mediaListOptions")
val mediaListOptions: MediaListOptions,
@SerialName("statistics")
val statistics: StatisticsTypes
) : java.io.Serializable
@Serializable
data class StatisticsTypes(
@SerialName("anime")
val anime: Statistics,
@SerialName("manga")
val manga: Statistics
) : java.io.Serializable
@Serializable
data class Statistics(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("standardDeviation")
val standardDeviation: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("episodesWatched")
val episodesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("volumesRead")
val volumesRead: Int,
@SerialName("formats")
val formats: List<StatisticsFormat>,
@SerialName("statuses")
val statuses: List<StatisticsStatus>,
@SerialName("scores")
val scores: List<StatisticsScore>,
@SerialName("lengths")
val lengths: List<StatisticsLength>,
@SerialName("releaseYears")
val releaseYears: List<StatisticsReleaseYear>,
@SerialName("startYears")
val startYears: List<StatisticsStartYear>,
@SerialName("genres")
val genres: List<StatisticsGenre>,
@SerialName("tags")
val tags: List<StatisticsTag>,
@SerialName("countries")
val countries: List<StatisticsCountry>,
@SerialName("voiceActors")
val voiceActors: List<StatisticsVoiceActor>,
@SerialName("staff")
val staff: List<StatisticsStaff>,
@SerialName("studios")
val studios: List<StatisticsStudio>
) : java.io.Serializable
@Serializable
data class StatisticsFormat(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("format")
val format: String
) : java.io.Serializable
@Serializable
data class StatisticsStatus(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("status")
val status: String
) : java.io.Serializable
@Serializable
data class StatisticsScore(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("score")
val score: Int
) : java.io.Serializable
@Serializable
data class StatisticsLength(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("length")
val length: String? //can be null for manga
) : java.io.Serializable
@Serializable
data class StatisticsReleaseYear(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("releaseYear")
val releaseYear: Int
) : java.io.Serializable
@Serializable
data class StatisticsStartYear(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("startYear")
val startYear: Int
) : java.io.Serializable
@Serializable
data class StatisticsGenre(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("genre")
val genre: String
) : java.io.Serializable
@Serializable
data class StatisticsTag(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("tag")
val tag: Tag
) : java.io.Serializable
@Serializable
data class Tag(
@SerialName("id")
val id: Int,
@SerialName("name")
val name: String
) : java.io.Serializable
@Serializable
data class StatisticsCountry(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("country")
val country: String
) : java.io.Serializable
@Serializable
data class StatisticsVoiceActor(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("voiceActor")
val voiceActor: VoiceActor,
@SerialName("characterIds")
val characterIds: List<Int>
) : java.io.Serializable
@Serializable
data class VoiceActor(
@SerialName("id")
val id: Int,
@SerialName("name")
val name: StaffName
) : java.io.Serializable
@Serializable
data class StaffName(
@SerialName("first")
val first: String?,
@SerialName("middle")
val middle: String?,
@SerialName("last")
val last: String?,
@SerialName("full")
val full: String?,
@SerialName("native")
val native: String?,
@SerialName("alternative")
val alternative: List<String>?,
@SerialName("userPreferred")
val userPreferred: String?
) : java.io.Serializable
@Serializable
data class StatisticsStaff(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("staff")
val staff: VoiceActor
) : java.io.Serializable
@Serializable
data class StatisticsStudio(
@SerialName("count")
val count: Int,
@SerialName("meanScore")
val meanScore: Float,
@SerialName("minutesWatched")
val minutesWatched: Int,
@SerialName("chaptersRead")
val chaptersRead: Int,
@SerialName("mediaIds")
val mediaIds: List<Int>,
@SerialName("studio")
val studio: StatStudio
) : java.io.Serializable
@Serializable
data class StatStudio(
@SerialName("id")
val id: Int,
@SerialName("name")
val name: String,
@SerialName("isAnimationStudio")
val isAnimationStudio: Boolean
) : java.io.Serializable
}
//data class WhaData(
@ -203,7 +712,7 @@ class Query {
// // Activity reply query
// val ActivityReply: ActivityReply?,
// // Comment query
// // CommentNotificationWorker query
// val ThreadComment: List<ThreadComment>?,
// // Notification query

View file

@ -0,0 +1,114 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class FeedResponse(
@SerialName("data")
val data: Data
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("Page")
val page: ActivityPage
) : java.io.Serializable
}
@Serializable
data class ActivityPage(
@SerialName("activities")
val activities: List<Activity>
) : java.io.Serializable
@Serializable
data class Activity(
@SerialName("__typename")
val typename: String,
@SerialName("id")
val id: Int,
@SerialName("recipientId")
val recipientId: Int?,
@SerialName("messengerId")
val messengerId: Int?,
@SerialName("userId")
val userId: Int?,
@SerialName("type")
val type: String,
@SerialName("replyCount")
val replyCount: Int,
@SerialName("status")
val status: String?,
@SerialName("progress")
val progress: String?,
@SerialName("text")
val text: String?,
@SerialName("message")
val message: String?,
@SerialName("siteUrl")
val siteUrl: String?,
@SerialName("isLocked")
val isLocked: Boolean,
@SerialName("isSubscribed")
val isSubscribed: Boolean,
@SerialName("likeCount")
var likeCount: Int?,
@SerialName("isLiked")
var isLiked: Boolean?,
@SerialName("isPinned")
val isPinned: Boolean?,
@SerialName("isPrivate")
val isPrivate: Boolean?,
@SerialName("createdAt")
val createdAt: Int,
@SerialName("user")
val user: User?,
@SerialName("recipient")
val recipient: User?,
@SerialName("messenger")
val messenger: User?,
@SerialName("media")
val media: Media?,
@SerialName("replies")
val replies: List<ActivityReply>?,
@SerialName("likes")
val likes: List<User>?,
) : java.io.Serializable
@Serializable
data class ActivityReply(
@SerialName("id")
val id: Int,
@SerialName("userId")
val userId: Int,
@SerialName("text")
val text: String,
@SerialName("likeCount")
val likeCount: Int,
@SerialName("isLiked")
val isLiked: Boolean,
@SerialName("createdAt")
val createdAt: Int,
@SerialName("user")
val user: User,
@SerialName("likes")
val likes: List<User>?,
) : java.io.Serializable
@Serializable
data class ToggleLike(
@SerialName("data")
val data: Data
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("ToggleLikeV2")
val toggleLike: LikeData
) : java.io.Serializable
}
@Serializable
data class LikeData(
@SerialName("__typename")
val typename: String
) : java.io.Serializable

View file

@ -251,7 +251,7 @@ data class MediaCoverImage(
// Average #hex color of cover image
@SerialName("color") var color: String?,
)
) : java.io.Serializable
@Serializable
data class MediaList(
@ -490,7 +490,7 @@ data class MediaExternalLink(
// isDisabled: Boolean
@SerialName("notes") var notes: String?,
)
) : java.io.Serializable
@Serializable
enum class ExternalLinkType {
@ -512,7 +512,13 @@ data class MediaListCollection(
// If there is another chunk
@SerialName("hasNextChunk") var hasNextChunk: Boolean?,
)
) : java.io.Serializable
@Serializable
data class FollowData(
@SerialName("id") var id: Int,
@SerialName("isFollowing") var isFollowing: Boolean,
) : java.io.Serializable
@Serializable
data class MediaListGroup(
@ -526,4 +532,4 @@ data class MediaListGroup(
@SerialName("isSplitCompletedList") var isSplitCompletedList: Boolean?,
@SerialName("status") var status: MediaListStatus?,
)
) : java.io.Serializable

View file

@ -0,0 +1,122 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
enum class NotificationType(val value: String) {
ACTIVITY_MESSAGE("ACTIVITY_MESSAGE"),
ACTIVITY_REPLY("ACTIVITY_REPLY"),
FOLLOWING("FOLLOWING"),
ACTIVITY_MENTION("ACTIVITY_MENTION"),
THREAD_COMMENT_MENTION("THREAD_COMMENT_MENTION"),
THREAD_SUBSCRIBED("THREAD_SUBSCRIBED"),
THREAD_COMMENT_REPLY("THREAD_COMMENT_REPLY"),
AIRING("AIRING"),
ACTIVITY_LIKE("ACTIVITY_LIKE"),
ACTIVITY_REPLY_LIKE("ACTIVITY_REPLY_LIKE"),
THREAD_LIKE("THREAD_LIKE"),
THREAD_COMMENT_LIKE("THREAD_COMMENT_LIKE"),
ACTIVITY_REPLY_SUBSCRIBED("ACTIVITY_REPLY_SUBSCRIBED"),
RELATED_MEDIA_ADDITION("RELATED_MEDIA_ADDITION"),
MEDIA_DATA_CHANGE("MEDIA_DATA_CHANGE"),
MEDIA_MERGE("MEDIA_MERGE"),
MEDIA_DELETION("MEDIA_DELETION"),
//custom
COMMENT_REPLY("COMMENT_REPLY"),
}
@Serializable
data class NotificationResponse(
@SerialName("data")
val data: Data,
) : java.io.Serializable {
@Serializable
data class Data(
@SerialName("User")
val user: NotificationUser,
@SerialName("Page")
val page: NotificationPage,
) : java.io.Serializable
}
@Serializable
data class NotificationUser(
@SerialName("unreadNotificationCount")
var unreadNotificationCount: Int,
) : java.io.Serializable
@Serializable
data class NotificationPage(
@SerialName("pageInfo")
val pageInfo: PageInfo,
@SerialName("notifications")
val notifications: List<Notification>,
) : java.io.Serializable
@Serializable
data class Notification(
@SerialName("__typename")
val typename: String,
@SerialName("id")
val id: Int,
@SerialName("userId")
val userId: Int? = null,
@SerialName("CommentId")
val commentId: Int?,
@SerialName("type")
val notificationType: String,
@SerialName("activityId")
val activityId: Int? = null,
@SerialName("animeId")
val mediaId: Int? = null,
@SerialName("episode")
val episode: Int? = null,
@SerialName("contexts")
val contexts: List<String>? = null,
@SerialName("context")
val context: String? = null,
@SerialName("reason")
val reason: String? = null,
@SerialName("deletedMediaTitle")
val deletedMediaTitle: String? = null,
@SerialName("deletedMediaTitles")
val deletedMediaTitles: List<String>? = null,
@SerialName("createdAt")
val createdAt: Int,
@SerialName("media")
val media: ani.dantotsu.connections.anilist.api.Media? = null,
@SerialName("user")
val user: ani.dantotsu.connections.anilist.api.User? = null,
@SerialName("message")
val message: MessageActivity? = null,
@SerialName("activity")
val activity: ActivityUnion? = null,
@SerialName("Thread")
val thread: Thread? = null,
@SerialName("comment")
val comment: ThreadComment? = null,
) : java.io.Serializable
@Serializable
data class MessageActivity(
@SerialName("id")
val id: Int?,
) : java.io.Serializable
@Serializable
data class ActivityUnion(
@SerialName("id")
val id: Int?,
) : java.io.Serializable
@Serializable
data class Thread(
@SerialName("id")
val id: Int?,
) : java.io.Serializable
@Serializable
data class ThreadComment(
@SerialName("id")
val id: Int?,
) : java.io.Serializable

View file

@ -15,7 +15,7 @@ data class Staff(
@SerialName("languageV2") var languageV2: String?,
// The staff images
// @SerialName("image") var image: StaffImage?,
@SerialName("image") var image: StaffImage?,
// A general description of the staff member
@SerialName("description") var description: String?,
@ -93,7 +93,14 @@ data class StaffConnection(
// The pagination information
// @SerialName("pageInfo") var pageInfo: PageInfo?,
)
@Serializable
data class StaffImage(
// The character's image of media at its largest size
@SerialName("large") var large: String?,
// The character's image of media at medium size
@SerialName("medium") var medium: String?,
) : java.io.Serializable
@Serializable
data class StaffEdge(
var role: String?,

View file

@ -46,7 +46,7 @@ data class User(
@SerialName("statistics") var statistics: UserStatisticTypes?,
// The number of unread notifications the user has
// @SerialName("unreadNotificationCount") var unreadNotificationCount: Int?,
@SerialName("unreadNotificationCount") var unreadNotificationCount: Int?,
// The url for the user page on the AniList website
// @SerialName("siteUrl") var siteUrl: String?,
@ -111,7 +111,7 @@ data class UserAvatar(
// The avatar of user at medium size
@SerialName("medium") var medium: String?,
)
): java.io.Serializable
@Serializable
data class UserStatisticTypes(
@ -164,7 +164,7 @@ data class Favourites(
@Serializable
data class MediaListOptions(
// The score format the user is using for media lists
// @SerialName("scoreFormat") var scoreFormat: ScoreFormat?,
@SerialName("scoreFormat") var scoreFormat: String?,
// The default order list rows should be displayed in
@SerialName("rowOrder") var rowOrder: String?,

View file

@ -0,0 +1,554 @@
package ani.dantotsu.connections.comments
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString
import ani.dantotsu.toast
import com.lagradost.nicehttp.NiceResponse
import com.lagradost.nicehttp.Requests
import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okio.IOException
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
object CommentsAPI {
val address: String = "https://1224665.xyz:443"
var authToken: String? = null
var userId: String? = null
var isBanned: Boolean = false
var isAdmin: Boolean = false
var isMod: Boolean = false
var totalVotes: Int = 0
suspend fun getCommentsForId(id: Int, page: Int = 1, tag: Int?, sort: String?): CommentResponse? {
var url = "$address/comments/$id/$page"
val request = requestBuilder()
tag?.let {
url += "?tag=$it"
}
sort?.let {
url += if (tag != null) "&sort=$it" else "?sort=$it"
}
val json = try {
request.get(url)
} catch (e: IOException) {
snackString("Failed to fetch comments")
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<CommentResponse>(json.text)
} catch (e: Exception) {
return null
}
return parsed
}
suspend fun getRepliesFromId(id: Int, page: Int = 1): CommentResponse? {
val url = "$address/comments/parent/$id/$page"
val request = requestBuilder()
val json = try {
request.get(url)
} catch (e: IOException) {
snackString("Failed to fetch comments")
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<CommentResponse>(json.text)
} catch (e: Exception) {
return null
}
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 {
val url = "$address/comments/vote/$commentId/$voteType"
val request = requestBuilder()
val json = try {
request.post(url)
} catch (e: IOException) {
snackString("Failed to vote")
return false
}
val res = json.code == 200
if (!res) {
errorReason(json.code, json.text)
}
return res
}
suspend fun comment(mediaId: Int, parentCommentId: Int?, content: String, tag: Int?): Comment? {
val url = "$address/comments"
val body = FormBody.Builder()
.add("user_id", userId ?: return null)
.add("media_id", mediaId.toString())
.add("content", content)
if (tag != null) {
body.add("tag", tag.toString())
}
parentCommentId?.let {
body.add("parent_comment_id", it.toString())
}
val request = requestBuilder()
val json = try {
request.post(url, requestBody = body.build())
} catch (e: IOException) {
snackString("Failed to comment")
return null
}
val res = json.code == 200
if (!res) {
errorReason(json.code, json.text)
return null
}
val parsed = try {
Json.decodeFromString<ReturnedComment>(json.text)
} catch (e: Exception) {
snackString("Failed to parse comment")
return null
}
return Comment(
parsed.id,
parsed.userId,
parsed.mediaId,
parsed.parentCommentId,
parsed.content,
parsed.timestamp,
parsed.deleted,
parsed.tag,
0,
0,
null,
Anilist.username ?: "",
Anilist.avatar,
totalVotes = totalVotes
)
}
suspend fun deleteComment(commentId: Int): Boolean {
val url = "$address/comments/$commentId"
val request = requestBuilder()
val json = try {
request.delete(url)
} catch (e: IOException) {
snackString("Failed to delete comment")
return false
}
val res = json.code == 200
if (!res) {
errorReason(json.code, json.text)
}
return res
}
suspend fun editComment(commentId: Int, content: String): Boolean {
val url = "$address/comments/$commentId"
val body = FormBody.Builder()
.add("content", content)
.build()
val request = requestBuilder()
val json = try {
request.put(url, requestBody = body)
} catch (e: IOException) {
snackString("Failed to edit comment")
return false
}
val res = json.code == 200
if (!res) {
errorReason(json.code, json.text)
}
return res
}
suspend fun banUser(userId: String): Boolean {
val url = "$address/ban/$userId"
val request = requestBuilder()
val json = try {
request.post(url)
} catch (e: IOException) {
snackString("Failed to ban user")
return false
}
val res = json.code == 200
if (!res) {
errorReason(json.code, json.text)
}
return res
}
suspend fun reportComment(
commentId: Int,
username: String,
mediaTitle: String,
reportedId: String
): Boolean {
val url = "$address/report/$commentId"
val body = FormBody.Builder()
.add("username", username)
.add("mediaName", mediaTitle)
.add("reporter", Anilist.username ?: "unknown")
.add("reportedId", reportedId)
.build()
val request = requestBuilder()
val json = try {
request.post(url, requestBody = body)
} catch (e: IOException) {
snackString("Failed to report comment")
return false
}
val res = json.code == 200
if (!res) {
errorReason(json.code, json.text)
}
return res
}
suspend fun getNotifications(client: OkHttpClient): NotificationResponse? {
val url = "$address/notification/reply"
val request = requestBuilder(client)
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
}
private suspend fun getUserDetails(client: OkHttpClient? = null): User? {
val url = "$address/user"
val request = if (client != null) requestBuilder(client) else requestBuilder()
val json = try {
request.get(url)
} catch (e: IOException) {
return null
}
if (json.code == 200) {
val parsed = try {
Json.decodeFromString<UserResponse>(json.text)
} catch (e: Exception) {
e.printStackTrace()
return null
}
isBanned = parsed.user.isBanned ?: false
isAdmin = parsed.user.isAdmin ?: false
isMod = parsed.user.isMod ?: false
totalVotes = parsed.user.totalVotes
return parsed.user
}
return null
}
suspend fun fetchAuthToken(client: OkHttpClient? = null) {
if (authToken != null) return
val MAX_RETRIES = 5
val tokenLifetime: Long = 1000 * 60 * 60 * 24 * 6 // 6 days
val tokenExpiry = PrefManager.getVal<Long>(PrefName.CommentTokenExpiry)
if (tokenExpiry < System.currentTimeMillis() + tokenLifetime) {
val commentResponse =
PrefManager.getNullableVal<AuthResponse>(PrefName.CommentAuthResponse, null)
if (commentResponse != null) {
authToken = commentResponse.authToken
userId = commentResponse.user.id
isBanned = commentResponse.user.isBanned ?: false
isAdmin = commentResponse.user.isAdmin ?: false
isMod = commentResponse.user.isMod ?: false
totalVotes = commentResponse.user.totalVotes
if (getUserDetails(client) != null) return
}
}
val url = "$address/authenticate"
val token = PrefManager.getVal(PrefName.AnilistToken, null as String?) ?: return
repeat(MAX_RETRIES) {
try {
val json = authRequest(token, url, client)
if (json.code == 200) {
if (!json.text.startsWith("{")) throw IOException("Invalid response")
val parsed = try {
Json.decodeFromString<AuthResponse>(json.text)
} catch (e: Exception) {
snackString("Failed to login to comments API: ${e.printStackTrace()}")
return
}
PrefManager.setVal(PrefName.CommentAuthResponse, parsed)
PrefManager.setVal(
PrefName.CommentTokenExpiry,
System.currentTimeMillis() + tokenLifetime
)
authToken = parsed.authToken
userId = parsed.user.id
isBanned = parsed.user.isBanned ?: false
isAdmin = parsed.user.isAdmin ?: false
isMod = parsed.user.isMod ?: false
totalVotes = parsed.user.totalVotes
return
} else if (json.code != 429) {
errorReason(json.code, json.text)
return
}
} catch (e: IOException) {
snackString("Failed to login to comments API")
return
}
kotlinx.coroutines.delay(60000)
}
snackString("Failed to login after multiple attempts")
}
private suspend fun authRequest(
token: String,
url: String,
client: OkHttpClient? = null
): NiceResponse {
val body: FormBody = FormBody.Builder()
.add("token", token)
.build()
val request = if (client != null) requestBuilder(client) else requestBuilder()
return request.post(url, requestBody = body)
}
private fun headerBuilder(): Map<String, String> {
val map = mutableMapOf(
"appauth" to "6*45Qp%W2RS@t38jkXoSKY588Ynj%n"
)
if (authToken != null) {
map["Authorization"] = authToken!!
}
return map
}
private fun requestBuilder(client: OkHttpClient = Injekt.get<NetworkHelper>().client): Requests {
return Requests(
client,
headerBuilder()
)
}
private fun errorReason(code: Int, reason: String? = null) {
val error = when (code) {
429 -> "Rate limited. :("
else -> "Failed to connect"
}
val parsed = try {
Json.decodeFromString<ErrorResponse>(reason!!)
} catch (e: Exception) {
null
}
val message = parsed?.message ?: reason ?: error
val fullMessage = if(code == 500) message else "$code: $message"
toast(fullMessage)
}
}
@Serializable
data class ErrorResponse(
@SerialName("message")
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,
@SerialName("type")
val type: Int? = null,
@SerialName("content")
val content: String? = null,
@SerialName("notification_id")
val notificationId: Int
)
@Serializable
data class AuthResponse(
@SerialName("authToken")
val authToken: String,
@SerialName("user")
val user: User
) : java.io.Serializable {
companion object {
private const val serialVersionUID: Long = 1
}
}
@Serializable
data class UserResponse(
@SerialName("user")
val user: User
)
@Serializable
data class User(
@SerialName("user_id")
val id: String,
@SerialName("username")
val username: String,
@SerialName("profile_picture_url")
val profilePictureUrl: String? = null,
@SerialName("is_banned")
@Serializable(with = NumericBooleanSerializer::class)
val isBanned: Boolean? = null,
@SerialName("is_mod")
@Serializable(with = NumericBooleanSerializer::class)
val isAdmin: Boolean? = null,
@SerialName("is_admin")
@Serializable(with = NumericBooleanSerializer::class)
val isMod: Boolean? = null,
@SerialName("total_votes")
val totalVotes: Int,
@SerialName("warnings")
val warnings: Int
) : java.io.Serializable {
companion object {
private const val serialVersionUID: Long = 1
}
}
@Serializable
data class CommentResponse(
@SerialName("comments")
val comments: List<Comment>,
@SerialName("totalPages")
val totalPages: Int
)
@Serializable
data class Comment(
@SerialName("comment_id")
val commentId: Int,
@SerialName("user_id")
val userId: String,
@SerialName("media_id")
val mediaId: Int,
@SerialName("parent_comment_id")
val parentCommentId: Int?,
@SerialName("content")
var content: String,
@SerialName("timestamp")
var timestamp: String,
@SerialName("deleted")
@Serializable(with = NumericBooleanSerializer::class)
val deleted: Boolean?,
@SerialName("tag")
val tag: Int?,
@SerialName("upvotes")
var upvotes: Int,
@SerialName("downvotes")
var downvotes: Int,
@SerialName("user_vote_type")
var userVoteType: Int?,
@SerialName("username")
val username: String,
@SerialName("profile_picture_url")
val profilePictureUrl: String?,
@SerialName("is_mod")
@Serializable(with = NumericBooleanSerializer::class)
val isMod: Boolean? = null,
@SerialName("is_admin")
@Serializable(with = NumericBooleanSerializer::class)
val isAdmin: Boolean? = null,
@SerialName("reply_count")
val replyCount: Int? = null,
@SerialName("total_votes")
val totalVotes: Int
)
@Serializable
data class ReturnedComment(
@SerialName("id")
var id: Int,
@SerialName("comment_id")
var commentId: Int?,
@SerialName("user_id")
val userId: String,
@SerialName("media_id")
val mediaId: Int,
@SerialName("parent_comment_id")
val parentCommentId: Int? = null,
@SerialName("content")
val content: String,
@SerialName("timestamp")
val timestamp: String,
@SerialName("deleted")
@Serializable(with = NumericBooleanSerializer::class)
val deleted: Boolean?,
@SerialName("tag")
val tag: Int? = null,
)
object NumericBooleanSerializer : KSerializer<Boolean> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("NumericBoolean", PrimitiveKind.INT)
override fun serialize(encoder: Encoder, value: Boolean) {
encoder.encodeInt(if (value) 1 else 0)
}
override fun deserialize(decoder: Decoder): Boolean {
return decoder.decodeInt() != 0
}
}

View file

@ -1,17 +1,18 @@
package ani.dantotsu.connections.crashlytics
import android.content.Context
import ani.dantotsu.util.Logger
class CrashlyticsStub : CrashlyticsInterface {
override fun initialize(context: Context) {
//no-op
}
override fun logException(e: Throwable) {
//no-op
Logger.log(e)
}
override fun log(message: String) {
//no-op
Logger.log(message)
}
override fun setUserId(id: String) {

View file

@ -70,19 +70,5 @@ object Discord {
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 = application_Id
smallImage = RPC.Link(
"Dantotsu",
small_Image
)
buttons.add(RPC.Link("Stream on Dantotsu", "https://github.com/rebelonion/Dantotsu/"))
}
}
}*/
"mp:external/GJEe4hKzr8w56IW6ZKQz43HFVEo8pOtA_C-dJiWwxKo/https/cdn.discordapp.com/app-icons/1163925779692912771/f6b42d41dfdf0b56fcc79d4a12d2ac66.png"
}

View file

@ -15,7 +15,6 @@ import android.os.Environment
import android.os.IBinder
import android.os.PowerManager
import android.provider.MediaStore
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@ -26,6 +25,7 @@ import ani.dantotsu.connections.discord.serializers.User
import ani.dantotsu.isOnline
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.util.Logger
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
@ -274,7 +274,7 @@ class DiscordService : Service() {
return
}
}
t.message?.let { Log.d("WebSocket", "onFailure() $it") }
t.message?.let { Logger.log("onFailure() $it") }
log("WebSocket: Error, onFailure() reason: ${t.message}")
client = OkHttpClient()
client.newWebSocket(
@ -289,7 +289,7 @@ class DiscordService : Service() {
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
super.onClosing(webSocket, code, reason)
Log.d("WebSocket", "onClosing() $code $reason")
Logger.log("onClosing() $code $reason")
if (::heartbeatThread.isInitialized && !heartbeatThread.isInterrupted) {
heartbeatThread.interrupt()
}
@ -297,7 +297,7 @@ class DiscordService : Service() {
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
super.onClosed(webSocket, code, reason)
Log.d("WebSocket", "onClosed() $code $reason")
Logger.log("onClosed() $code $reason")
if (code >= 4000) {
log("WebSocket: Error, code: $code reason: $reason")
client = OkHttpClient()
@ -382,52 +382,7 @@ class DiscordService : Service() {
}
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")
}
//Logger.log(string)
}
fun resume() {

View file

@ -2,6 +2,8 @@ package ani.dantotsu.connections.discord
import ani.dantotsu.connections.discord.serializers.Activity
import ani.dantotsu.connections.discord.serializers.Presence
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ -81,7 +83,7 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) {
),
afk = true,
since = data.startTimestamp,
status = data.status
status = PrefManager.getVal(PrefName.DiscordStatus)
)
))
}

View file

@ -27,7 +27,7 @@ import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.video.ExoplayerDownloadService
import ani.dantotsu.download.video.Helper
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import ani.dantotsu.media.Media
import ani.dantotsu.media.SubtitleDownloader
import ani.dantotsu.media.anime.AnimeWatchFragment
@ -54,6 +54,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import tachiyomi.core.util.lang.launchIO
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
@ -249,7 +250,7 @@ class AnimeDownloaderService : Service() {
hasDownloadStarted(downloadManager, task, 30000) // 30 seconds timeout
if (!downloadStarted) {
logger("Download failed to start")
Logger.log("Download failed to start")
builder.setContentText("${task.title} - ${task.episode} Download failed to start")
notificationManager.notify(NOTIFICATION_ID, builder.build())
snackString("${task.title} - ${task.episode} Download failed to start")
@ -263,11 +264,11 @@ class AnimeDownloaderService : Service() {
val download = downloadManager.downloadIndex.getDownload(task.video.file.url)
if (download != null) {
if (download.state == androidx.media3.exoplayer.offline.Download.STATE_FAILED) {
logger("Download failed")
Logger.log("Download failed")
builder.setContentText("${task.title} - ${task.episode} Download failed")
notificationManager.notify(NOTIFICATION_ID, builder.build())
snackString("${task.title} - ${task.episode} Download failed")
logger("Download failed: ${download.failureReason}")
Logger.log("Download failed: ${download.failureReason}")
downloadsManager.removeDownload(
DownloadedType(
task.title,
@ -289,7 +290,7 @@ class AnimeDownloaderService : Service() {
break
}
if (download.state == androidx.media3.exoplayer.offline.Download.STATE_COMPLETED) {
logger("Download completed")
Logger.log("Download completed")
builder.setContentText("${task.title} - ${task.episode} Download completed")
notificationManager.notify(NOTIFICATION_ID, builder.build())
snackString("${task.title} - ${task.episode} Download completed")
@ -309,7 +310,7 @@ class AnimeDownloaderService : Service() {
break
}
if (download.state == androidx.media3.exoplayer.offline.Download.STATE_STOPPED) {
logger("Download stopped")
Logger.log("Download stopped")
builder.setContentText("${task.title} - ${task.episode} Download stopped")
notificationManager.notify(NOTIFICATION_ID, builder.build())
snackString("${task.title} - ${task.episode} Download stopped")
@ -328,7 +329,7 @@ class AnimeDownloaderService : Service() {
}
} catch (e: Exception) {
if (e.message?.contains("Coroutine was cancelled") == false) { //wut
logger("Exception while downloading file: ${e.message}")
Logger.log("Exception while downloading file: ${e.message}")
snackString("Exception while downloading file: ${e.message}")
e.printStackTrace()
Injekt.get<CrashlyticsInterface>().logException(e)
@ -355,15 +356,13 @@ class AnimeDownloaderService : Service() {
return false
}
@OptIn(DelicateCoroutinesApi::class)
private fun saveMediaInfo(task: AnimeDownloadTask) {
GlobalScope.launch(Dispatchers.IO) {
launchIO {
val directory = File(
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"${DownloadsManager.animeLocation}/${task.title}"
)
val episodeDirectory = File(directory, task.episode)
if (!directory.exists()) directory.mkdirs()
if (!episodeDirectory.exists()) episodeDirectory.mkdirs()
val file = File(directory, "media.json")

View file

@ -33,7 +33,7 @@ import ani.dantotsu.currContext
import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.initActivity
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsActivity
import ani.dantotsu.navBarHeight
@ -318,8 +318,8 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
val mediaJson = media.readText()
gson.fromJson(mediaJson, Media::class.java)
} catch (e: Exception) {
logger("Error loading media.json: ${e.message}")
logger(e.printStackTrace())
Logger.log("Error loading media.json: ${e.message}")
Logger.log(e)
Injekt.get<CrashlyticsInterface>().logException(e)
null
}
@ -374,8 +374,8 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener {
bannerUri
)
} catch (e: Exception) {
logger("Error loading media.json: ${e.message}")
logger(e.printStackTrace())
Logger.log("Error loading media.json: ${e.message}")
Logger.log(e)
Injekt.get<CrashlyticsInterface>().logException(e)
return OfflineAnimeModel(
"unknown",

View file

@ -21,7 +21,7 @@ import ani.dantotsu.R
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import ani.dantotsu.media.Media
import ani.dantotsu.media.manga.ImageData
import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_FAILED
@ -37,6 +37,7 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SChapterImpl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
@ -47,6 +48,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import tachiyomi.core.util.lang.launchIO
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
@ -251,7 +253,7 @@ class MangaDownloaderService : Service() {
snackString("${task.title} - ${task.chapter} Download finished")
}
} catch (e: Exception) {
logger("Exception while downloading file: ${e.message}")
Logger.log("Exception while downloading file: ${e.message}")
snackString("Exception while downloading file: ${e.message}")
Injekt.get<CrashlyticsInterface>().logException(e)
broadcastDownloadFailed(task.chapter)
@ -287,8 +289,9 @@ class MangaDownloaderService : Service() {
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun saveMediaInfo(task: DownloadTask) {
GlobalScope.launch(Dispatchers.IO) {
launchIO {
val directory = File(
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Manga/${task.title}"

View file

@ -30,7 +30,7 @@ import ani.dantotsu.currContext
import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.initActivity
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsActivity
import ani.dantotsu.navBarHeight
@ -308,8 +308,8 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
val mediaJson = media.readText()
gson.fromJson(mediaJson, Media::class.java)
} catch (e: Exception) {
logger("Error loading media.json: ${e.message}")
logger(e.printStackTrace())
Logger.log("Error loading media.json: ${e.message}")
Logger.log(e)
Injekt.get<CrashlyticsInterface>().logException(e)
null
}
@ -358,8 +358,8 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener {
bannerUri
)
} catch (e: Exception) {
logger("Error loading media.json: ${e.message}")
logger(e.printStackTrace())
Logger.log("Error loading media.json: ${e.message}")
Logger.log(e)
Injekt.get<CrashlyticsInterface>().logException(e)
return OfflineMangaModel(
"unknown",

View file

@ -20,7 +20,7 @@ import ani.dantotsu.R
import ani.dantotsu.connections.crashlytics.CrashlyticsInterface
import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import ani.dantotsu.media.Media
import ani.dantotsu.media.novel.NovelReadFragment
import ani.dantotsu.snackString
@ -31,6 +31,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SChapterImpl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
@ -42,6 +43,7 @@ import kotlinx.coroutines.withContext
import okhttp3.Request
import okio.buffer
import okio.sink
import tachiyomi.core.util.lang.launchIO
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
@ -186,15 +188,15 @@ class NovelDownloaderService : Service() {
val contentType = response.header("Content-Type")
val contentDisposition = response.header("Content-Disposition")
logger("Content-Type: $contentType")
logger("Content-Disposition: $contentDisposition")
Logger.log("Content-Type: $contentType")
Logger.log("Content-Disposition: $contentDisposition")
// Return true if the Content-Type or Content-Disposition indicates an EPUB file
contentType == "application/epub+zip" ||
(contentDisposition?.contains(".epub") == true)
}
} catch (e: Exception) {
logger("Error checking file type: ${e.message}")
Logger.log("Error checking file type: ${e.message}")
false
}
}
@ -225,12 +227,12 @@ class NovelDownloaderService : Service() {
if (!isEpubFile(task.downloadLink)) {
if (isAlreadyDownloaded(task.originalLink)) {
logger("Already downloaded")
Logger.log("Already downloaded")
broadcastDownloadFinished(task.originalLink)
snackString("Already downloaded")
return@withContext
}
logger("Download link is not an .epub file")
Logger.log("Download link is not an .epub file")
broadcastDownloadFailed(task.originalLink)
snackString("Download link is not an .epub file")
return@withContext
@ -301,7 +303,7 @@ class NovelDownloaderService : Service() {
withContext(Dispatchers.Main) {
val progress =
(downloadedBytes * 100 / totalBytes).toInt()
logger("Download progress: $progress")
Logger.log("Download progress: $progress")
broadcastDownloadProgress(task.originalLink, progress)
}
lastBroadcastUpdate = downloadedBytes
@ -316,7 +318,7 @@ class NovelDownloaderService : Service() {
}
}
} catch (e: Exception) {
logger("Exception while downloading .epub inside request: ${e.message}")
Logger.log("Exception while downloading .epub inside request: ${e.message}")
throw e
}
}
@ -340,15 +342,16 @@ class NovelDownloaderService : Service() {
snackString("${task.title} - ${task.chapter} Download finished")
}
} catch (e: Exception) {
logger("Exception while downloading .epub: ${e.message}")
Logger.log("Exception while downloading .epub: ${e.message}")
snackString("Exception while downloading .epub: ${e.message}")
Injekt.get<CrashlyticsInterface>().logException(e)
broadcastDownloadFailed(task.originalLink)
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun saveMediaInfo(task: DownloadTask) {
GlobalScope.launch(Dispatchers.IO) {
launchIO {
val directory = File(
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"Dantotsu/Novel/${task.title}"

View file

@ -43,6 +43,7 @@ import ani.dantotsu.parsers.SubtitleType
import ani.dantotsu.parsers.Video
import ani.dantotsu.parsers.VideoType
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.util.Logger
import eu.kanade.tachiyomi.network.NetworkHelper
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -157,15 +158,15 @@ object Helper {
finalException: Exception?
) {
if (download.state == Download.STATE_COMPLETED) {
Log.e("Downloader", "Download Completed")
Logger.log("Download Completed")
} else if (download.state == Download.STATE_FAILED) {
Log.e("Downloader", "Download Failed")
Logger.log("Download Failed")
} else if (download.state == Download.STATE_STOPPED) {
Log.e("Downloader", "Download Stopped")
Logger.log("Download Stopped")
} else if (download.state == Download.STATE_QUEUED) {
Log.e("Downloader", "Download Queued")
Logger.log("Download Queued")
} else if (download.state == Download.STATE_DOWNLOADING) {
Log.e("Downloader", "Download Downloading")
Logger.log("Download Downloading")
}
}
}

View file

@ -283,7 +283,6 @@ class AnimeFragment : Fragment() {
binding.root.requestApplyInsets()
binding.root.requestLayout()
}
super.onResume()
}
}

View file

@ -26,6 +26,7 @@ import ani.dantotsu.media.CalendarActivity
import ani.dantotsu.media.GenreActivity
import ani.dantotsu.media.MediaAdaptor
import ani.dantotsu.media.SearchActivity
import ani.dantotsu.profile.ProfileActivity
import ani.dantotsu.px
import ani.dantotsu.setSafeOnClickListener
import ani.dantotsu.setSlideIn
@ -94,6 +95,17 @@ class AnimePageAdapter : RecyclerView.Adapter<AnimePageAdapter.AnimePageViewHold
SettingsDialogFragment.newInstance(SettingsDialogFragment.Companion.PageType.ANIME)
dialogFragment.show((it.context as AppCompatActivity).supportFragmentManager, "dialog")
}
binding.animeUserAvatar.setOnLongClickListener { view ->
ContextCompat.startActivity(
view.context,
Intent(view.context, ProfileActivity::class.java)
.putExtra("userId", Anilist.userid),null
)
false
}
binding.animeNotificationCount.visibility = if (Anilist.unreadNotificationCount > 0) View.VISIBLE else View.GONE
binding.animeNotificationCount.text = Anilist.unreadNotificationCount.toString()
listOf(
binding.animePreviousSeason,

View file

@ -21,6 +21,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import ani.dantotsu.R
import ani.dantotsu.Refresh
import ani.dantotsu.blurImage
import ani.dantotsu.bottomBar
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.anilist.AnilistHomeViewModel
@ -32,6 +33,7 @@ import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaAdaptor
import ani.dantotsu.media.user.ListActivity
import ani.dantotsu.navBarHeight
import ani.dantotsu.profile.ProfileActivity
import ani.dantotsu.setSafeOnClickListener
import ani.dantotsu.setSlideIn
import ani.dantotsu.setSlideUp
@ -77,8 +79,10 @@ class HomeFragment : Fragment() {
binding.homeUserChaptersRead.text = Anilist.chapterRead.toString()
binding.homeUserAvatar.loadImage(Anilist.avatar)
if (!(PrefManager.getVal(PrefName.BannerAnimations) as Boolean)) binding.homeUserBg.pause()
binding.homeUserBg.loadImage(Anilist.bg)
blurImage(binding.homeUserBg, Anilist.bg)
binding.homeUserDataProgressBar.visibility = View.GONE
binding.homeNotificationCount.visibility = if (Anilist.unreadNotificationCount > 0) View.VISIBLE else View.GONE
binding.homeNotificationCount.text = Anilist.unreadNotificationCount.toString()
binding.homeAnimeList.setOnClickListener {
ContextCompat.startActivity(
@ -118,6 +122,13 @@ class HomeFragment : Fragment() {
"dialog"
)
}
binding.homeUserAvatarContainer.setOnLongClickListener {
ContextCompat.startActivity(
requireContext(), Intent(requireContext(), ProfileActivity::class.java)
.putExtra("userId", Anilist.userid),null
)
false
}
binding.homeContainer.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = navBarHeight
@ -127,6 +138,8 @@ class HomeFragment : Fragment() {
var reached = false
val duration = ((PrefManager.getVal(PrefName.AnimationSpeed) as Float) * 200).toLong()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.homeScroll.setOnScrollChangeListener { _, _, _, _, _ ->
if (!binding.homeScroll.canScrollVertically(1)) {
reached = true
@ -141,6 +154,7 @@ class HomeFragment : Fragment() {
}
}
}
}
var height = statusBarHeight
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val displayCutout = activity?.window?.decorView?.rootWindowInsets?.displayCutout
@ -305,6 +319,7 @@ class HomeFragment : Fragment() {
}
}
val array = arrayOf(
"AnimeContinue",
"AnimeFav",
@ -357,9 +372,12 @@ class HomeFragment : Fragment() {
}
}
}
override fun onResume() {
if (!model.loaded) Refresh.activity[1]!!.postValue(true)
if (_binding != null) {
binding.homeNotificationCount.visibility = if (Anilist.unreadNotificationCount > 0) View.VISIBLE else View.GONE
binding.homeNotificationCount.text = Anilist.unreadNotificationCount.toString()
}
super.onResume()
}
}

View file

@ -17,6 +17,7 @@ import ani.dantotsu.openLinkInBrowser
import ani.dantotsu.settings.saving.internal.PreferenceKeystore
import ani.dantotsu.settings.saving.internal.PreferencePackager
import ani.dantotsu.toast
import ani.dantotsu.util.Logger
import com.google.android.material.textfield.TextInputEditText
class LoginFragment : Fragment() {
@ -50,7 +51,7 @@ class LoginFragment : Fragment() {
DocumentFile.fromSingleUri(requireActivity(), uri)?.name ?: "settings"
//.sani is encrypted, .ani is not
if (name.endsWith(".sani")) {
passwordAlertDialog() { password ->
passwordAlertDialog { password ->
if (password != null) {
val salt = jsonString.copyOfRange(0, 16)
val encrypted = jsonString.copyOfRange(16, jsonString.size)
@ -78,7 +79,7 @@ class LoginFragment : Fragment() {
toast("Invalid file type")
}
} catch (e: Exception) {
e.printStackTrace()
Logger.log(e)
toast("Error importing settings")
}
}

View file

@ -25,6 +25,7 @@ import ani.dantotsu.loadImage
import ani.dantotsu.media.GenreActivity
import ani.dantotsu.media.MediaAdaptor
import ani.dantotsu.media.SearchActivity
import ani.dantotsu.profile.ProfileActivity
import ani.dantotsu.px
import ani.dantotsu.setSafeOnClickListener
import ani.dantotsu.setSlideIn
@ -74,7 +75,8 @@ class MangaPageAdapter : RecyclerView.Adapter<MangaPageAdapter.MangaPageViewHold
}
updateAvatar()
binding.mangaNotificationCount.visibility = if (Anilist.unreadNotificationCount > 0) View.VISIBLE else View.GONE
binding.mangaNotificationCount.text = Anilist.unreadNotificationCount.toString()
binding.mangaSearchBar.hint = "MANGA"
binding.mangaSearchBarText.setOnClickListener {
ContextCompat.startActivity(
@ -89,6 +91,14 @@ class MangaPageAdapter : RecyclerView.Adapter<MangaPageAdapter.MangaPageViewHold
SettingsDialogFragment.newInstance(SettingsDialogFragment.Companion.PageType.MANGA)
dialogFragment.show((it.context as AppCompatActivity).supportFragmentManager, "dialog")
}
binding.mangaUserAvatar.setOnLongClickListener { view ->
ContextCompat.startActivity(
view.context,
Intent(view.context, ProfileActivity::class.java)
.putExtra("userId", Anilist.userid),null
)
false
}
binding.mangaSearchBar.setEndIconOnClickListener {
binding.mangaSearchBarText.performClick()
@ -145,8 +155,7 @@ class MangaPageAdapter : RecyclerView.Adapter<MangaPageAdapter.MangaPageViewHold
binding.mangaTrendingViewPager.setPageTransformer(MediaPageTransformer())
trendHandler = Handler(Looper.getMainLooper())
trendRun = Runnable {
binding.mangaTrendingViewPager.currentItem =
binding.mangaTrendingViewPager.currentItem + 1
binding.mangaTrendingViewPager.currentItem += 1
}
binding.mangaTrendingViewPager.registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {

View file

@ -3,7 +3,9 @@ package ani.dantotsu.media
import java.io.Serializable
data class Author(
val id: String,
val name: String,
val id: Int,
val name: String?,
val image: String?,
val role: String?,
var yearMedia: MutableMap<String, ArrayList<Media>>? = null
) : Serializable

View file

@ -0,0 +1,60 @@
package ani.dantotsu.media
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.util.Pair
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView
import ani.dantotsu.databinding.ItemCharacterBinding
import ani.dantotsu.loadImage
import ani.dantotsu.setAnimation
import java.io.Serializable
class AuthorAdapter(
private val authorList: ArrayList<Author>
) : RecyclerView.Adapter<AuthorAdapter.AuthorViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AuthorViewHolder {
val binding =
ItemCharacterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AuthorViewHolder(binding)
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder:AuthorViewHolder, position: Int) {
val binding = holder.binding
setAnimation(binding.root.context, holder.binding.root)
val author = authorList[position]
binding.itemCompactRelation.text = author.role
binding.itemCompactImage.loadImage(author.image)
binding.itemCompactTitle.text = author.name
}
override fun getItemCount(): Int = authorList.size
inner class AuthorViewHolder(val binding: ItemCharacterBinding) :
RecyclerView.ViewHolder(binding.root) {
init {
itemView.setOnClickListener {
val author = authorList[bindingAdapterPosition]
ContextCompat.startActivity(
itemView.context,
Intent(
itemView.context,
AuthorActivity::class.java
).putExtra("author", author as Serializable),
ActivityOptionsCompat.makeSceneTransitionAnimation(
itemView.context as Activity,
Pair.create(
binding.itemCompactImage,
ViewCompat.getTransitionName(binding.itemCompactImage)!!
),
).toBundle()
)
}
}
}
}

View file

@ -80,14 +80,13 @@ class CalendarActivity : AppCompatActivity() {
)
binding.settingsContainer.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = statusBarHeight
bottomMargin = navBarHeight
}
}
setContentView(binding.root)
binding.listTitle.setText(R.string.release_calendar)
binding.listSort.visibility = View.GONE
binding.random.visibility = View.GONE
binding.listTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
this@CalendarActivity.selectedTabIdx = tab?.position ?: 1

View file

@ -9,7 +9,7 @@ data class Character(
val image: String?,
val banner: String?,
val role: String,
var isFav: Boolean,
var description: String? = null,
var age: String? = null,
var gender: String? = null,

View file

@ -1,5 +1,6 @@
package ani.dantotsu.media
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
@ -15,20 +16,25 @@ import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import ani.dantotsu.R
import ani.dantotsu.Refresh
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.anilist.AnilistMutations
import ani.dantotsu.databinding.ActivityCharacterBinding
import ani.dantotsu.initActivity
import ani.dantotsu.loadImage
import ani.dantotsu.navBarHeight
import ani.dantotsu.openLinkInBrowser
import ani.dantotsu.others.ImageViewDialog
import ani.dantotsu.others.getSerialized
import ani.dantotsu.px
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString
import ani.dantotsu.statusBarHeight
import ani.dantotsu.themes.ThemeManager
import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.abs
class CharacterDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListener {
@ -48,7 +54,7 @@ class CharacterDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChang
initActivity(this)
screenWidth = resources.displayMetrics.run { widthPixels / density }
if (PrefManager.getVal(PrefName.ImmersiveMode)) this.window.statusBarColor =
ContextCompat.getColor(this, R.color.status)
ContextCompat.getColor(this, R.color.transparent)
val banner =
if (PrefManager.getVal(PrefName.BannerAnimations)) binding.characterBanner else binding.characterBannerNoKen
@ -75,7 +81,39 @@ class CharacterDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChang
character.image
)
}
val link = "https://anilist.co/character/${character.id}"
binding.characterShare.setOnClickListener {
val i = Intent(Intent.ACTION_SEND)
i.type = "text/plain"
i.putExtra(Intent.EXTRA_TEXT, link)
startActivity(Intent.createChooser(i, character.name))
}
binding.characterShare.setOnLongClickListener {
openLinkInBrowser(link)
true
}
lifecycleScope.launch {
withContext(Dispatchers.IO) {
character.isFav = Anilist.query.isUserFav(AnilistMutations.FavType.CHARACTER, character.id)
}
withContext(Dispatchers.Main) {
binding.characterFav.setImageResource(
if (character.isFav) R.drawable.ic_round_favorite_24 else R.drawable.ic_round_favorite_border_24
)
}
}
binding.characterFav.setOnClickListener {
lifecycleScope.launch {
if (Anilist.mutation.toggleFav(AnilistMutations.FavType.CHARACTER, character.id)) {
character.isFav = !character.isFav
binding.characterFav.setImageResource(
if (character.isFav) R.drawable.ic_round_favorite_24 else R.drawable.ic_round_favorite_border_24
)
} else {
snackString("Failed to toggle favorite")
}
}
}
model.getCharacter().observe(this) {
if (it != null && !loaded) {
character = it
@ -139,13 +177,11 @@ class CharacterDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChang
isCollapsed = true
if (immersiveMode) this.window.statusBarColor =
ContextCompat.getColor(this, R.color.nav_bg)
binding.characterAppBar.setBackgroundResource(R.color.nav_bg)
}
if (percentage <= percent && isCollapsed) {
isCollapsed = false
if (immersiveMode) this.window.statusBarColor =
ContextCompat.getColor(this, R.color.status)
binding.characterAppBar.setBackgroundResource(R.color.bg)
ContextCompat.getColor(this, R.color.transparent)
}
}
}

View file

@ -35,7 +35,7 @@ class CharacterDetailsAdapter(private val character: Character, private val acti
binding.characterDesc.isTextSelectable
val markWon = Markwon.builder(activity).usePlugin(SoftBreakAddsNewLinePlugin.create())
.usePlugin(SpoilerPlugin()).build()
markWon.setMarkdown(binding.characterDesc, desc)
markWon.setMarkdown(binding.characterDesc, desc.replace("~!", "||").replace("!~", "||"))
}

View file

@ -67,11 +67,12 @@ class GenreActivity : AppCompatActivity() {
private fun loadLocalGenres(): ArrayList<String>? {
val genres = PrefManager.getVal<Set<String>>(PrefName.GenresList)
.toMutableList() as ArrayList<String>?
return if (genres.isNullOrEmpty()) {
.toMutableList()
return if (genres.isEmpty()) {
null
} else {
genres
//sort alphabetically
genres.sort().let { genres as ArrayList<String> }
}
}
}

View file

@ -58,6 +58,7 @@ data class Media(
var endDate: FuzzyDate? = null,
var characters: ArrayList<Character>? = null,
var staff: ArrayList<Author>? = null,
var prequel: Media? = null,
var sequel: Media? = null,
var relations: ArrayList<Media>? = null,
@ -108,6 +109,7 @@ data class Media(
this.userScore = mediaList.score?.toInt() ?: 0
this.userStatus = mediaList.status?.toString()
this.userUpdatedAt = mediaList.updatedAt?.toLong()
this.genres = mediaList.media?.genres?.toMutableList() as? ArrayList<String>? ?: arrayListOf()
}
constructor(mediaEdge: MediaEdge) : this(mediaEdge.node!!) {

View file

@ -43,6 +43,7 @@ class MediaAdaptor(
private val activity: FragmentActivity,
private val matchParent: Boolean = false,
private val viewPager: ViewPager2? = null,
private val fav: Boolean = false,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
@ -128,6 +129,7 @@ class MediaAdaptor(
)
b.itemCompactTotal.text = " | ${media.manga.totalChapters ?: "~"}"
}
b.itemCompactProgressContainer.visibility = if (fav) View.GONE else View.VISIBLE
}
}
@ -137,7 +139,7 @@ class MediaAdaptor(
val media = mediaList?.get(position)
if (media != null) {
b.itemCompactImage.loadImage(media.cover)
b.itemCompactBanner.loadImage(media.banner ?: media.cover)
blurImage(b.itemCompactBanner, media.banner ?: media.cover)
b.itemCompactOngoing.visibility =
if (media.status == currActivity()!!.getString(R.string.status_releasing)) View.VISIBLE else View.GONE
b.itemCompactTitle.text = media.userPreferredName
@ -185,15 +187,7 @@ class MediaAdaptor(
AccelerateDecelerateInterpolator()
)
)
val banner =
if (bannerAnimations) b.itemCompactBanner else b.itemCompactBannerNoKen
val context = b.itemCompactBanner.context
if (!(context as Activity).isDestroyed)
Glide.with(context as Context)
.load(GlideUrl(media.banner ?: media.cover))
.diskCacheStrategy(DiskCacheStrategy.ALL).override(400)
.apply(RequestOptions.bitmapTransform(BlurTransformation(2, 3)))
.into(banner)
blurImage(b.itemCompactBanner, media.banner ?: media.cover)
b.itemCompactOngoing.visibility =
if (media.status == currActivity()!!.getString(R.string.status_releasing)) View.VISIBLE else View.GONE
b.itemCompactTitle.text = media.userPreferredName
@ -242,15 +236,7 @@ class MediaAdaptor(
AccelerateDecelerateInterpolator()
)
)
val banner =
if (bannerAnimations) b.itemCompactBanner else b.itemCompactBannerNoKen
val context = b.itemCompactBanner.context
if (!(context as Activity).isDestroyed)
Glide.with(context as Context)
.load(GlideUrl(media.banner ?: media.cover))
.diskCacheStrategy(DiskCacheStrategy.ALL).override(400)
.apply(RequestOptions.bitmapTransform(BlurTransformation(2, 3)))
.into(banner)
blurImage(b.itemCompactBanner, media.banner ?: media.cover)
b.itemCompactOngoing.visibility =
if (media.status == currActivity()!!.getString(R.string.status_releasing)) View.VISIBLE else View.GONE
b.itemCompactTitle.text = media.userPreferredName

View file

@ -2,7 +2,9 @@ package ani.dantotsu.media
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.util.TypedValue
@ -10,7 +12,9 @@ import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@ -18,6 +22,7 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.text.bold
import androidx.core.text.color
import androidx.core.view.marginBottom
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
@ -25,21 +30,23 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import ani.dantotsu.CustomBottomNavBar
import ani.dantotsu.GesturesListener
import ani.dantotsu.R
import ani.dantotsu.Refresh
import ani.dantotsu.ZoomOutPageTransformer
import ani.dantotsu.blurImage
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.copyToClipboard
import ani.dantotsu.databinding.ActivityMediaBinding
import ani.dantotsu.initActivity
import ani.dantotsu.loadImage
import ani.dantotsu.media.anime.AnimeWatchFragment
import ani.dantotsu.media.comments.CommentsFragment
import ani.dantotsu.media.manga.MangaReadFragment
import ani.dantotsu.media.novel.NovelReadFragment
import ani.dantotsu.navBarHeight
import ani.dantotsu.openLinkInBrowser
import ani.dantotsu.others.AndroidBug5497Workaround
import ani.dantotsu.others.ImageViewDialog
import ani.dantotsu.others.getSerialized
import ani.dantotsu.settings.saving.PrefManager
@ -49,20 +56,21 @@ import ani.dantotsu.statusBarHeight
import ani.dantotsu.themes.ThemeManager
import com.flaviofaria.kenburnsview.RandomTransitionGenerator
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.navigation.NavigationBarView
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
class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListener {
private lateinit var binding: ActivityMediaBinding
lateinit var binding: ActivityMediaBinding
private val scope = lifecycleScope
private val model: MediaDetailsViewModel by viewModels()
private lateinit var tabLayout: NavigationBarView
lateinit var tabLayout: TripleNavAdapter
var selected = 0
var anime = true
private var adult = false
@ -71,6 +79,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()
@ -84,20 +101,31 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
setContentView(binding.root)
screenWidth = resources.displayMetrics.widthPixels.toFloat()
val isVertical = resources.configuration.orientation
//Ui init
initActivity(this)
binding.mediaViewPager.updateLayoutParams<ViewGroup.MarginLayoutParams> { bottomMargin += navBarHeight }
val oldMargin = binding.mediaViewPager.marginBottom
AndroidBug5497Workaround.assistActivity(this) {
if (it) {
binding.mediaViewPager.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = 0
}
binding.mediaTabContainer.visibility = View.GONE
} else {
binding.mediaViewPager.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = oldMargin
}
binding.mediaTabContainer.visibility = View.VISIBLE
}
}
binding.mediaBanner.updateLayoutParams { height += statusBarHeight }
binding.mediaBannerNoKen.updateLayoutParams { height += statusBarHeight }
binding.mediaClose.updateLayoutParams<ViewGroup.MarginLayoutParams> { topMargin += statusBarHeight }
binding.incognito.updateLayoutParams<ViewGroup.MarginLayoutParams> { topMargin += statusBarHeight }
binding.mediaCollapsing.minimumHeight = statusBarHeight
if (binding.mediaTab is CustomBottomNavBar) binding.mediaTab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = navBarHeight
}
binding.mediaTitle.isSelected = true
mMaxScrollSize = binding.mediaAppBar.totalScrollRange
@ -119,7 +147,7 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
val banner =
if (bannerAnimations) binding.mediaBanner else binding.mediaBannerNoKen
val viewPager = binding.mediaViewPager
tabLayout = binding.mediaTab as NavigationBarView
//tabLayout = binding.mediaTab as AnimatedBottomBar
viewPager.isUserInputEnabled = false
viewPager.setPageTransformer(ZoomOutPageTransformer())
@ -135,7 +163,8 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
media.cover
)
}
banner.loadImage(media.banner ?: media.cover, 400)
blurImage(banner, media.banner ?: media.cover)
val gestureDetector = GestureDetector(this, object : GesturesListener() {
override fun onDoubleClick(event: MotionEvent) {
if (!(PrefManager.getVal(PrefName.BannerAnimations) as Boolean))
@ -313,49 +342,54 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
progress()
}
}
tabLayout = TripleNavAdapter(
binding.mediaTab1,
binding.mediaTab2,
binding.mediaTab3,
media.anime != null,
media.format ?: "",
isVertical == 1
)
adult = media.isAdult
tabLayout.menu.clear()
if (media.anime != null) {
viewPager.adapter =
ViewPagerAdapter(supportFragmentManager, lifecycle, SupportedMedia.ANIME)
tabLayout.inflateMenu(R.menu.anime_menu_detail)
ViewPagerAdapter(supportFragmentManager, lifecycle, SupportedMedia.ANIME, media, intent.getIntExtra("commentId", -1))
} else if (media.manga != null) {
viewPager.adapter = ViewPagerAdapter(
supportFragmentManager,
lifecycle,
if (media.format == "NOVEL") SupportedMedia.NOVEL else SupportedMedia.MANGA
if (media.format == "NOVEL") SupportedMedia.NOVEL else SupportedMedia.MANGA,
media,
intent.getIntExtra("commentId", -1)
)
if (media.format == "NOVEL") {
tabLayout.inflateMenu(R.menu.novel_menu_detail)
} else {
tabLayout.inflateMenu(R.menu.manga_menu_detail)
}
anime = false
}
selected = media.selected!!.window
binding.mediaTitle.translationX = -screenWidth
tabLayout.visibility = View.VISIBLE
tabLayout.setOnItemSelectedListener { item ->
selectFromID(item.itemId)
tabLayout.selectionListener = { selected, newId ->
binding.commentInputLayout.visibility = if (selected == 2) View.VISIBLE else View.GONE
this.selected = selected
selectFromID(newId)
viewPager.setCurrentItem(selected, false)
val sel = model.loadSelected(media, isDownload)
sel.window = selected
model.saveSelected(media.id, sel)
true
}
tabLayout.selectedItemId = idFromSelect()
tabLayout.selectTab(selected)
selectFromID(tabLayout.selected)
viewPager.setCurrentItem(selected, false)
if (model.continueMedia == null && media.cameFromContinue) {
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) {
@ -368,7 +402,6 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
}
}
private fun selectFromID(id: Int) {
when (id) {
R.id.info -> {
@ -378,6 +411,10 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
R.id.watch, R.id.read -> {
selected = 1
}
R.id.comment -> {
selected = 2
}
}
}
@ -385,17 +422,19 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
if (anime) when (selected) {
0 -> return R.id.info
1 -> return R.id.watch
2 -> return R.id.comment
}
else when (selected) {
0 -> return R.id.info
1 -> return R.id.read
2 -> return R.id.comment
}
return R.id.info
}
override fun onResume() {
if (this::tabLayout.isInitialized) {
tabLayout.selectedItemId = idFromSelect()
tabLayout.selectTab(selected)
}
super.onResume()
}
@ -408,19 +447,30 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
private class ViewPagerAdapter(
fragmentManager: FragmentManager,
lifecycle: Lifecycle,
private val media: SupportedMedia
private val mediaType: SupportedMedia,
private val media: Media,
private val commentId: Int
) :
FragmentStateAdapter(fragmentManager, lifecycle) {
override fun getItemCount(): Int = 2
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment = when (position) {
0 -> MediaInfoFragment()
1 -> when (media) {
1 -> when (mediaType) {
SupportedMedia.ANIME -> AnimeWatchFragment()
SupportedMedia.MANGA -> MangaReadFragment()
SupportedMedia.NOVEL -> NovelReadFragment()
}
2 -> {
val fragment = CommentsFragment()
val bundle = Bundle()
bundle.putInt("mediaId", media.id)
bundle.putString("mediaName", media.mainName())
if (commentId != -1) bundle.putInt("commentId", commentId)
fragment.arguments = bundle
fragment
}
else -> MediaInfoFragment()
}
@ -484,6 +534,7 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
private val c1: Int,
private val c2: Int,
var clicked: Boolean,
needsInitialClick: Boolean = false,
callback: suspend (Boolean) -> (Unit)
) {
private var disabled = false
@ -492,6 +543,11 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
init {
enabled(true)
if (needsInitialClick) {
scope.launch {
clicked()
}
}
image.setOnClickListener {
if (pressable && !disabled) {
pressable = false
@ -547,4 +603,3 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
var mediaSingleton: Media? = null
}
}

View file

@ -9,7 +9,7 @@ import androidx.lifecycle.ViewModel
import ani.dantotsu.R
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.currContext
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import ani.dantotsu.media.anime.Episode
import ani.dantotsu.media.anime.SelectorDialogFragment
import ani.dantotsu.media.manga.MangaChapter
@ -223,7 +223,7 @@ class MediaDetailsViewModel : ViewModel() {
}
fun setEpisode(ep: Episode?, who: String) {
logger("set episode ${ep?.number} - $who", false)
Logger.log("set episode ${ep?.number} - $who")
episode.postValue(ep)
MainScope().launch(Dispatchers.Main) {
episode.value = null
@ -270,7 +270,7 @@ class MediaDetailsViewModel : ViewModel() {
mangaChapters
suspend fun loadMangaChapters(media: Media, i: Int, invalidate: Boolean = false) {
logger("Loading Manga Chapters : $mangaLoaded")
Logger.log("Loading Manga Chapters : $mangaLoaded")
if (!mangaLoaded.containsKey(i) || invalidate) tryWithSuspend {
mangaLoaded[i] =
mangaReadSources?.loadChaptersFromMedia(i, media) ?: return@tryWithSuspend

View file

@ -3,6 +3,7 @@ package ani.dantotsu.media
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.CountDownTimer
@ -73,6 +74,8 @@ class MediaInfoFragment : Fragment() {
model.getMedia().observe(viewLifecycleOwner) { media ->
if (media != null && !loaded) {
loaded = true
binding.mediaInfoProgressBar.visibility = View.GONE
binding.mediaInfoContainer.visibility = View.VISIBLE
binding.mediaInfoName.text = "\t\t\t" + (media.name ?: media.nameRomaji)
@ -408,23 +411,6 @@ class MediaInfoFragment : Fragment() {
parent.addView(bind.root)
}
if (!media.characters.isNullOrEmpty() && !offline) {
val bind = ItemTitleRecyclerBinding.inflate(
LayoutInflater.from(context),
parent,
false
)
bind.itemTitle.setText(R.string.characters)
bind.itemRecycler.adapter =
CharacterAdapter(media.characters!!)
bind.itemRecycler.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.HORIZONTAL,
false
)
parent.addView(bind.root)
}
if (!media.relations.isNullOrEmpty() && !offline) {
if (media.sequel != null || media.prequel != null) {
val bind = ItemQuelsBinding.inflate(
@ -487,7 +473,38 @@ class MediaInfoFragment : Fragment() {
)
parent.addView(bindi.root)
}
if (!media.characters.isNullOrEmpty() && !offline) {
val bind = ItemTitleRecyclerBinding.inflate(
LayoutInflater.from(context),
parent,
false
)
bind.itemTitle.setText(R.string.characters)
bind.itemRecycler.adapter =
CharacterAdapter(media.characters!!)
bind.itemRecycler.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.HORIZONTAL,
false
)
parent.addView(bind.root)
}
if (!media.staff.isNullOrEmpty() && !offline) {
val bind = ItemTitleRecyclerBinding.inflate(
LayoutInflater.from(context),
parent,
false
)
bind.itemTitle.setText(R.string.staff)
bind.itemRecycler.adapter =
AuthorAdapter(media.staff!!)
bind.itemRecycler.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.HORIZONTAL,
false
)
parent.addView(bind.root)
}
if (!media.recommendations.isNullOrEmpty() && !offline) {
val bind = ItemTitleRecyclerBinding.inflate(
LayoutInflater.from(context),

View file

@ -254,20 +254,28 @@ class MediaListDialogFragment : BottomSheetDialogFragment() {
}
binding.mediaListDelete.setOnClickListener {
val id = media!!.userListId
if (id != null) {
var id = media!!.userListId
scope.launch {
withContext(Dispatchers.IO) {
Anilist.mutation.deleteList(id)
if (id != null) {
Anilist.mutation.deleteList(id!!)
MAL.query.deleteList(media?.anime != null, media?.idMAL)
} else {
val profile = Anilist.query.userMediaDetails(media!!)
profile.userListId?.let { listId ->
id = listId
Anilist.mutation.deleteList(listId)
MAL.query.deleteList(media?.anime != null, media?.idMAL)
}
}
}
}
if (id != null) {
Refresh.all()
snackString(getString(R.string.deleted_from_list))
dismissAllowingStateLoss()
}
} else {
snackString(getString(R.string.no_list_id))
Refresh.all()
}
}
}

View file

@ -58,6 +58,40 @@ class MediaListDialogSmallFragment : BottomSheetDialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.mediaListContainer.updateLayoutParams<ViewGroup.MarginLayoutParams> { bottomMargin += navBarHeight }
val scope = viewLifecycleOwner.lifecycleScope
binding.mediaListDelete.setOnClickListener {
var id = media.userListId
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) {
if (id != null) {
try {
Anilist.mutation.deleteList(id!!)
MAL.query.deleteList(media.anime != null, media.idMAL)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
snackString("Failed to delete because of... ${e.message}")
}
return@withContext
}
} else {
val profile = Anilist.query.userMediaDetails(media)
profile.userListId?.let { listId ->
id = listId
Anilist.mutation.deleteList(listId)
MAL.query.deleteList(media.anime != null, media.idMAL)
}
}
}
withContext(Dispatchers.Main) {
if (id != null) {
Refresh.all()
snackString(getString(R.string.deleted_from_list))
dismissAllowingStateLoss()
} else {
snackString(getString(R.string.no_list_id))
}
}
}
}
binding.mediaListProgressBar.visibility = View.GONE
binding.mediaListLayout.visibility = View.VISIBLE

View file

@ -199,7 +199,9 @@ class SearchActivity : AppCompatActivity() {
var state: Parcelable? = null
override fun onPause() {
if (this::headerAdaptor.isInitialized) {
headerAdaptor.addHistory()
}
super.onPause()
state = binding.searchRecyclerView.layoutManager?.onSaveInstanceState()
}

View file

@ -1,6 +1,7 @@
package ani.dantotsu.media
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.drawable.Drawable
import android.text.Editable
import android.text.TextWatcher
@ -14,6 +15,7 @@ import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat.startActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.HORIZONTAL
@ -23,6 +25,7 @@ import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.databinding.ItemChipBinding
import ani.dantotsu.databinding.ItemSearchHeaderBinding
import ani.dantotsu.openLinkInBrowser
import ani.dantotsu.others.imagesearch.ImageSearchActivity
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import com.google.android.material.checkbox.MaterialCheckBox.*
@ -92,13 +95,16 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri
binding.searchChipRecycler.adapter = SearchChipAdapter(activity).also {
activity.updateChips = { it.update() }
}
binding.searchChipRecycler.layoutManager =
LinearLayoutManager(binding.root.context, HORIZONTAL, false)
binding.searchFilter.setOnClickListener {
SearchFilterBottomDialog.newInstance().show(activity.supportFragmentManager, "dialog")
}
binding.searchByImage.setOnClickListener {
activity.startActivity(Intent(activity, ImageSearchActivity::class.java))
}
fun searchTitle() {
activity.result.apply {
search =
@ -208,13 +214,16 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri
binding.searchHistoryList.startAnimation(fadeInAnimation())
binding.searchResultLayout.visibility = View.GONE
binding.searchHistoryList.visibility = View.VISIBLE
binding.searchByImage.visibility = View.VISIBLE
} else {
if (binding.searchResultLayout.visibility != View.VISIBLE) {
binding.searchResultLayout.startAnimation(fadeInAnimation())
binding.searchHistoryList.startAnimation(fadeOutAnimation())
}
binding.searchResultLayout.visibility = View.VISIBLE
binding.searchHistoryList.visibility = View.GONE
binding.searchByImage.visibility = View.GONE
}
}

View file

@ -0,0 +1,136 @@
package ani.dantotsu.media
import android.graphics.Color
import android.view.ViewGroup
import androidx.core.view.updateLayoutParams
import ani.dantotsu.R
import ani.dantotsu.navBarHeight
import nl.joery.animatedbottombar.AnimatedBottomBar
class TripleNavAdapter(
private val nav1: AnimatedBottomBar,
private val nav2: AnimatedBottomBar,
private val nav3: AnimatedBottomBar,
anime: Boolean,
format: String,
private val isScreenVertical: Boolean = false
) {
var selected: Int = 0
var selectionListener: ((Int, Int) -> Unit)? = null
init {
nav1.tabs.clear()
nav2.tabs.clear()
nav3.tabs.clear()
val infoTab = nav1.createTab(R.drawable.ic_round_info_24, R.string.info, R.id.info)
val watchTab = if (anime) {
nav2.createTab(R.drawable.ic_round_movie_filter_24, R.string.watch, R.id.watch)
} else if (format == "NOVEL") {
nav2.createTab(R.drawable.ic_round_book_24, R.string.read, R.id.read)
} else {
nav2.createTab(R.drawable.ic_round_import_contacts_24, R.string.read, R.id.read)
}
val commentTab = nav3.createTab(R.drawable.ic_round_comment_24, R.string.comments, R.id.comment)
nav1.addTab(infoTab)
nav1.visibility = ViewGroup.VISIBLE
if (isScreenVertical) {
nav2.visibility = ViewGroup.GONE
nav3.visibility = ViewGroup.GONE
nav1.addTab(watchTab)
nav1.addTab(commentTab)
nav1.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = navBarHeight
}
nav2.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = navBarHeight
}
nav3.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = navBarHeight
}
} else {
nav1.indicatorColor = Color.TRANSPARENT
nav2.indicatorColor = Color.TRANSPARENT
nav3.indicatorColor = Color.TRANSPARENT
nav2.visibility = ViewGroup.VISIBLE
nav3.visibility = ViewGroup.VISIBLE
nav2.addTab(watchTab)
nav3.addTab(commentTab)
nav2.setOnTabSelectListener(object : AnimatedBottomBar.OnTabSelectListener {
override fun onTabSelected(
lastIndex: Int,
lastTab: AnimatedBottomBar.Tab?,
newIndex: Int,
newTab: AnimatedBottomBar.Tab
) {
selected = 1
deselectOthers(selected)
selectionListener?.invoke(selected, newTab.id)
}
})
nav3.setOnTabSelectListener(object : AnimatedBottomBar.OnTabSelectListener {
override fun onTabSelected(
lastIndex: Int,
lastTab: AnimatedBottomBar.Tab?,
newIndex: Int,
newTab: AnimatedBottomBar.Tab
) {
selected = 2
deselectOthers(selected)
selectionListener?.invoke(selected, newTab.id)
}
})
}
nav1.setOnTabSelectListener(object : AnimatedBottomBar.OnTabSelectListener {
override fun onTabSelected(
lastIndex: Int,
lastTab: AnimatedBottomBar.Tab?,
newIndex: Int,
newTab: AnimatedBottomBar.Tab
) {
if (!isScreenVertical) {
selected = 0
deselectOthers(selected)
} else selected = newIndex
selectionListener?.invoke(selected, newTab.id)
}
})
}
private fun deselectOthers(selected: Int) {
if (selected == 0) {
nav2.clearSelection()
nav3.clearSelection()
}
if (selected == 1) {
nav1.clearSelection()
nav3.clearSelection()
}
if (selected == 2) {
nav1.clearSelection()
nav2.clearSelection()
}
}
fun selectTab(tab: Int) {
selected = tab
if (!isScreenVertical) {
when (tab) {
0 -> nav1.selectTabAt(0)
1 -> nav2.selectTabAt(0)
2 -> nav3.selectTabAt(0)
}
deselectOthers(selected)
} else {
nav1.selectTabAt(selected)
}
}
fun setVisibility(visibility: Int) {
if (isScreenVertical) {
nav1.visibility = visibility
return
}
nav1.visibility = visibility
nav2.visibility = visibility
nav3.visibility = visibility
}
}

View file

@ -7,7 +7,7 @@ import java.util.regex.Pattern
class AnimeNameAdapter {
companion object {
const val episodeRegex =
"(episode|ep|e)[\\s:.\\-]*([\\d]+\\.?[\\d]*)[\\s:.\\-]*\\(?\\s*(sub|subbed|dub|dubbed)*\\s*\\)?\\s*"
"(episode|episodio|ep|e)[\\s:.\\-]*([\\d]+\\.?[\\d]*)[\\s:.\\-]*\\(?\\s*(sub|subbed|dub|dubbed)*\\s*\\)?\\s*"
const val failedEpisodeNumberRegex =
"(?<!part\\s)\\b(\\d+)\\b"
const val seasonRegex = "(season|s)[\\s:.\\-]*(\\d+)[\\s:.\\-]*"

View file

@ -5,6 +5,7 @@ import android.content.Intent
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import ani.dantotsu.settings.FAQActivity
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ImageButton
@ -28,10 +29,9 @@ import ani.dantotsu.parsers.DynamicAnimeParser
import ani.dantotsu.parsers.WatchSources
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.subcriptions.Notifications.Companion.openSettings
import ani.dantotsu.subcriptions.Subscription.Companion.getChannelId
import com.google.android.material.chip.Chip
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_SUBSCRIPTION_CHECK
import eu.kanade.tachiyomi.util.system.WebViewUtil
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
@ -59,15 +59,21 @@ class AnimeWatchAdapter(
val binding = holder.binding
_binding = binding
binding.faqbutton.setOnClickListener {
startActivity(
fragment.requireContext(),
Intent(fragment.requireContext(), FAQActivity::class.java),
null
)
}
//Youtube
if (media.anime!!.youtube != null && PrefManager.getVal(PrefName.ShowYtButton)) {
if (media.anime?.youtube != null && PrefManager.getVal(PrefName.ShowYtButton)) {
binding.animeSourceYT.visibility = View.VISIBLE
binding.animeSourceYT.setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(media.anime.youtube))
fragment.requireContext().startActivity(intent)
}
}
binding.animeSourceDubbed.isChecked = media.selected!!.preferDub
binding.animeSourceDubbedText.text =
if (media.selected!!.preferDub) currActivity()!!.getString(R.string.dubbed) else currActivity()!!.getString(
@ -179,7 +185,8 @@ class AnimeWatchAdapter(
R.drawable.ic_round_notifications_none_24,
R.color.bg_opp,
R.color.violet_400,
fragment.subscribed
fragment.subscribed,
true
) {
fragment.onNotificationPressed(it, binding.animeSource.text.toString())
}
@ -187,7 +194,7 @@ class AnimeWatchAdapter(
subscribeButton(false)
binding.animeSourceSubscribe.setOnLongClickListener {
openSettings(fragment.requireContext(), getChannelId(true, media.id))
openSettings(fragment.requireContext(), CHANNEL_SUBSCRIPTION_CHECK)
}
//Nested Button
@ -421,13 +428,17 @@ class AnimeWatchAdapter(
}
binding.animeSourceProgressBar.visibility = View.GONE
if (media.anime.episodes!!.isNotEmpty())
if (media.anime.episodes!!.isNotEmpty()) {
binding.animeSourceNotFound.visibility = View.GONE
else
binding.faqbutton.visibility = View.GONE}
else {
binding.animeSourceNotFound.visibility = View.VISIBLE
binding.faqbutton.visibility = View.VISIBLE
}
} else {
binding.animeSourceContinue.visibility = View.GONE
binding.animeSourceNotFound.visibility = View.GONE
binding.faqbutton.visibility = View.GONE
clearChips()
binding.animeSourceProgressBar.visibility = View.VISIBLE
}

View file

@ -43,13 +43,9 @@ import ani.dantotsu.parsers.HAnimeSources
import ani.dantotsu.settings.extensionprefs.AnimeSourcePreferencesFragment
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.subcriptions.Notifications
import ani.dantotsu.subcriptions.Notifications.Group.ANIME_GROUP
import ani.dantotsu.subcriptions.Subscription.Companion.getChannelId
import ani.dantotsu.subcriptions.SubscriptionHelper
import ani.dantotsu.subcriptions.SubscriptionHelper.Companion.saveSubscription
import ani.dantotsu.notifications.subscription.SubscriptionHelper
import ani.dantotsu.notifications.subscription.SubscriptionHelper.Companion.saveSubscription
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.navigationrail.NavigationRailView
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
import kotlinx.coroutines.Dispatchers
@ -333,16 +329,7 @@ class AnimeWatchFragment : Fragment() {
var subscribed = false
fun onNotificationPressed(subscribed: Boolean, source: String) {
this.subscribed = subscribed
saveSubscription(requireContext(), media, subscribed)
if (!subscribed)
Notifications.deleteChannel(requireContext(), getChannelId(true, media.id))
else
Notifications.createChannel(
requireContext(),
ANIME_GROUP,
getChannelId(true, media.id),
media.userPreferredName
)
saveSubscription(media, subscribed)
snackString(
if (subscribed) getString(R.string.subscribed_notification, source)
else getString(R.string.unsubscribed_notification)
@ -358,11 +345,9 @@ class AnimeWatchFragment : Fragment() {
activity.findViewById<ViewPager2>(R.id.mediaViewPager).visibility = visibility
activity.findViewById<CardView>(R.id.mediaCover).visibility = visibility
activity.findViewById<CardView>(R.id.mediaClose).visibility = visibility
try {
activity.findViewById<CustomBottomNavBar>(R.id.mediaTab).visibility = visibility
} catch (e: ClassCastException) {
activity.findViewById<NavigationRailView>(R.id.mediaTab).visibility = visibility
}
activity.tabLayout.setVisibility(visibility)
activity.findViewById<FrameLayout>(R.id.fragmentExtensionsContainer).visibility =
if (show) View.GONE else View.VISIBLE
}
@ -561,6 +546,8 @@ class AnimeWatchFragment : Fragment() {
super.onResume()
binding.mediaInfoProgressBar.visibility = progress
binding.animeSourceRecycler.layoutManager?.onRestoreInstanceState(state)
requireActivity().setNavigationTheme()
}
override fun onPause() {

View file

@ -20,6 +20,7 @@ import android.media.AudioManager.*
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.CountDownTimer
import android.os.Handler
import android.os.Looper
import android.provider.Settings.System
@ -39,6 +40,7 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.res.ResourcesCompat
import androidx.core.math.MathUtils.clamp
import androidx.core.view.isVisible
import androidx.core.view.WindowCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
@ -61,6 +63,7 @@ import androidx.media3.exoplayer.util.EventLogger
import androidx.media3.session.MediaSession
import androidx.media3.ui.*
import androidx.media3.ui.CaptionStyleCompat.*
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.mediarouter.app.MediaRouteButton
import ani.dantotsu.*
import ani.dantotsu.R
@ -85,6 +88,7 @@ import ani.dantotsu.settings.PlayerSettingsActivity
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.themes.ThemeManager
import ani.dantotsu.util.Logger
import com.bumptech.glide.Glide
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext
@ -113,6 +117,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
private val resumePosition = "resumePosition"
private val playerFullscreen = "playerFullscreen"
private val playerOnPlay = "playerOnPlay"
private var disappeared: Boolean = false
private var functionstarted: Boolean = false
private lateinit var exoPlayer: ExoPlayer
private var castPlayer: CastPlayer? = null
@ -311,7 +317,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
2 -> ResourcesCompat.getFont(this, R.font.poppins)
3 -> ResourcesCompat.getFont(this, R.font.poppins_thin)
4 -> ResourcesCompat.getFont(this, R.font.century_gothic_regular)
5 -> ResourcesCompat.getFont(this, R.font.century_gothic_bold)
5 -> ResourcesCompat.getFont(this, R.font.levenim_mt_bold)
6 -> ResourcesCompat.getFont(this, R.font.blocky)
else -> ResourcesCompat.getFont(this, R.font.poppins_semi_bold)
}
playerView.subtitleView?.setStyle(
@ -391,13 +398,20 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
object : OrientationEventListener(this, SensorManager.SENSOR_DELAY_UI) {
override fun onOrientationChanged(orientation: Int) {
if (orientation in 45..135) {
if (rotation != ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) exoRotate.visibility =
View.VISIBLE
if (rotation != ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) {
exoRotate.visibility = View.VISIBLE
}
rotation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
} else if (orientation in 225..315) {
if (rotation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) exoRotate.visibility =
View.VISIBLE
if (rotation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) {
exoRotate.visibility = View.VISIBLE
}
rotation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else if (orientation in 315..360 || orientation in 0..45) {
if (rotation != ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) {
exoRotate.visibility = View.VISIBLE
}
rotation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
}
@ -409,7 +423,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
requestedOrientation = rotation
it.visibility = View.GONE
}
}
}
setupSubFormatting(playerView)
@ -963,7 +977,11 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
episodeTitle.setSelection(currentEpisodeIndex)
episodeTitle.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
if (position != currentEpisodeIndex) change(position)
if (position != currentEpisodeIndex) {
disappeared = false
functionstarted = false
change(position)
}
}
override fun onNothingSelected(parent: AdapterView<*>) {}
@ -975,6 +993,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
if (isInitialized) {
nextEpisode { i ->
updateAniProgress()
disappeared = false
functionstarted = false
change(currentEpisodeIndex + i)
}
}
@ -983,6 +1003,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
exoPrev = playerView.findViewById(R.id.exo_prev_ep)
exoPrev.setOnClickListener {
if (currentEpisodeIndex > 0) {
disappeared = false
change(currentEpisodeIndex - 1)
} else
snackString(getString(R.string.first_episode))
@ -1243,10 +1264,10 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
media.anime!!.selectedEpisode!!
)
val list = PrefManager.getVal<Set<Int>>(PrefName.ContinuedAnime).toMutableList()
val list = (PrefManager.getNullableCustomVal("continueAnimeList", listOf<Int>(), List::class.java) as List<Int>).toMutableList()
if (list.contains(media.id)) list.remove(media.id)
list.add(media.id)
PrefManager.setVal(PrefName.ContinuedAnime, list.toList())
PrefManager.setCustomVal("continueAnimeList", list)
lifecycleScope.launch(Dispatchers.IO) {
extractor?.onVideoStopped(video)
@ -1258,7 +1279,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
subtitle = intent.getSerialized("subtitle")
?: when (val subLang: String? =
PrefManager.getCustomVal("subLang_${media.id}", null as String?)) {
PrefManager.getNullableCustomVal("subLang_${media.id}", null, String::class.java)) {
null -> {
when (episode.selectedSubtitle) {
null -> null
@ -1370,8 +1391,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
mediaItem = if (downloadedMediaItem == null) {
val builder = MediaItem.Builder().setUri(video!!.file.url).setMimeType(mimeType)
logger("url: ${video!!.file.url}")
logger("mimeType: $mimeType")
Logger.log("url: ${video!!.file.url}")
Logger.log("mimeType: $mimeType")
if (sub != null) {
val listofnotnullsubs = listOfNotNull(sub)
@ -1447,10 +1468,26 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
private fun buildExoplayer() {
//Player
val DEFAULT_MIN_BUFFER_MS = 600000
val DEFAULT_MAX_BUFFER_MS = 600000
val BUFFER_FOR_PLAYBACK_MS = 2500
val BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 5000
val loadControl = DefaultLoadControl.Builder()
.setBackBuffer(1000 * 60 * 2, true)
.setBufferDurationsMs(
DEFAULT_MIN_BUFFER_MS,
DEFAULT_MAX_BUFFER_MS,
BUFFER_FOR_PLAYBACK_MS,
BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
hideSystemBars()
exoPlayer = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(cacheFactory))
.setTrackSelector(trackSelector)
.setLoadControl(loadControl)
.build().apply {
playWhenReady = true
this.playbackParameters = this@ExoplayerView.playbackParameters
@ -1509,6 +1546,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
private fun releasePlayer() {
isPlayerPlaying = exoPlayer.playWhenReady
playbackPosition = exoPlayer.currentPosition
disappeared = false
functionstarted = false
exoPlayer.release()
VideoCache.release()
mediaSession?.release()
@ -1682,7 +1721,49 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
val new = currentTimeStamp
timeStampText.text = if (new != null) {
fun disappearSkip() {
functionstarted = true
skipTimeButton.visibility = View.VISIBLE
exoSkip.visibility = View.GONE
skipTimeText.text = new.skipType.getType()
skipTimeButton.setOnClickListener {
exoPlayer.seekTo((new.interval.endTime * 1000).toLong())
}
var timer: CountDownTimer? = null
fun cancelTimer() {
timer?.cancel()
timer = null
return
}
if (timer == null) {
timer = object : CountDownTimer(5000, 1000) {
override fun onTick(millisUntilFinished: Long) {
if (new == null){
skipTimeButton.visibility = View.GONE
exoSkip.visibility = View.VISIBLE
disappeared = false
functionstarted = false
cancelTimer()
}
}
override fun onFinish() {
skipTimeButton.visibility = View.GONE
exoSkip.visibility = View.VISIBLE
disappeared = true
functionstarted = false
cancelTimer()
}
}
timer?.start()
}
}
if (PrefManager.getVal(PrefName.ShowTimeStampButton)) {
if (!functionstarted && !disappeared && PrefManager.getVal<Boolean>(PrefName.AutoHideTimeStamps)) {
disappearSkip()
} else if (!PrefManager.getVal<Boolean>(PrefName.AutoHideTimeStamps)){
skipTimeButton.visibility = View.VISIBLE
exoSkip.visibility = View.GONE
skipTimeText.text = new.skipType.getType()
@ -1690,18 +1771,19 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
exoPlayer.seekTo((new.interval.endTime * 1000).toLong())
}
}
if (PrefManager.getVal(PrefName.AutoSkipOPED) && (new.skipType == "op" || new.skipType == "ed") && !skippedTimeStamps.contains(
new
)
}
if (PrefManager.getVal(PrefName.AutoSkipOPED) && (new.skipType == "op" || new.skipType == "ed")
&& !skippedTimeStamps.contains(new)
) {
exoPlayer.seekTo((new.interval.endTime * 1000).toLong())
skippedTimeStamps.add(new)
}
new.skipType.getType()
} else {
disappeared = false
functionstarted = false
skipTimeButton.visibility = View.GONE
if (PrefManager.getVal<Int>(PrefName.SkipTime) > 0) exoSkip.visibility =
View.VISIBLE
exoSkip.isVisible = PrefManager.getVal<Int>(PrefName.SkipTime) > 0
""
}
}
@ -1772,20 +1854,26 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
private fun updateAniProgress() {
val incognito: Boolean = PrefManager.getVal(PrefName.Incognito)
if (!incognito && exoPlayer.currentPosition / episodeLength > PrefManager.getVal<Float>(
val episodeEnd = exoPlayer.currentPosition / episodeLength > PrefManager.getVal<Float>(
PrefName.WatchPercentage
) && Anilist.userid != null
)
val episode0 = currentEpisodeIndex == 0 && PrefManager.getVal(PrefName.ChapterZeroPlayer)
if (!incognito && (episodeEnd || episode0) && Anilist.userid != null
)
if (PrefManager.getCustomVal(
"${media.id}_save_progress",
true
) && (if (media.isAdult) PrefManager.getVal(PrefName.UpdateForHPlayer) else true)
) {
if (episode0) {
updateProgress(media, "0")
} else {
media.anime!!.selectedEpisode?.apply {
updateProgress(media, this)
}
}
}
}
private fun nextEpisode(toast: Boolean = true, runnable: ((Int) -> Unit)) {
var isFiller = true
@ -1822,6 +1910,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
if (isInitialized) {
updateAniProgress()
disappeared = false
functionstarted = false
releasePlayer()
}

View file

@ -181,11 +181,23 @@ class SelectorDialogFragment : BottomSheetDialogFragment() {
model.loadEpisodeVideos(ep, media!!.selected!!.sourceIndex)
withContext(Dispatchers.Main) {
binding.selectorProgressBar.visibility = View.GONE
if (adapter.itemCount == 0) {
snackString(getString(R.string.stream_selection_empty))
tryWith {
dismiss()
}
}
}
}
} else {
media!!.anime?.episodes?.set(media!!.anime?.selectedEpisode!!, ep)
adapter.addAll(ep.extractors)
if (ep.extractors?.size == 0) {
snackString(getString(R.string.stream_selection_empty))
tryWith {
dismiss()
}
}
if (model.watchSources!!.isDownloadedSource(media?.selected!!.sourceIndex)) {
adapter.performClick(0)
}

View file

@ -68,7 +68,7 @@ class SubtitleDialogFragment : BottomSheetDialogFragment() {
binding.subtitleTitle.setText(R.string.none)
model.getMedia().observe(viewLifecycleOwner) { media ->
val mediaID: Int = media.id
val selSubs = PrefManager.getCustomVal<String?>("subLang_${mediaID}", null)
val selSubs = PrefManager.getNullableCustomVal("subLang_${mediaID}", null, String::class.java)
if (episode.selectedSubtitle != null && selSubs != "None") {
binding.root.setCardBackgroundColor(TRANSPARENT)
}
@ -108,7 +108,7 @@ class SubtitleDialogFragment : BottomSheetDialogFragment() {
model.getMedia().observe(viewLifecycleOwner) { media ->
val mediaID: Int = media.id
val selSubs: String? =
PrefManager.getCustomVal<String?>("subLang_${mediaID}", null)
PrefManager.getNullableCustomVal("subLang_${mediaID}", null, String::class.java)
if (episode.selectedSubtitle != position - 1 && selSubs != subtitles[position - 1].language) {
binding.root.setCardBackgroundColor(TRANSPARENT)
}

View file

@ -0,0 +1,394 @@
package ani.dantotsu.media.comments
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.view.View
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import ani.dantotsu.R
import ani.dantotsu.connections.comments.Comment
import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.copyToClipboard
import ani.dantotsu.databinding.ItemCommentsBinding
import ani.dantotsu.loadImage
import ani.dantotsu.others.ImageViewDialog
import ani.dantotsu.profile.ProfileActivity
import ani.dantotsu.setAnimation
import ani.dantotsu.snackString
import ani.dantotsu.util.ColorEditor.Companion.adjustColorForContrast
import ani.dantotsu.util.ColorEditor.Companion.getContrastRatio
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Section
import com.xwray.groupie.viewbinding.BindableItem
import io.noties.markwon.Markwon
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.TimeZone
import kotlin.math.abs
import kotlin.math.sqrt
class CommentItem(val comment: Comment,
private val markwon: Markwon,
val parentSection: Section,
private val commentsFragment: CommentsFragment,
private val backgroundColor: Int,
val commentDepth: Int
) : BindableItem<ItemCommentsBinding>() {
lateinit var binding: ItemCommentsBinding
val adapter = GroupieAdapter()
private var subCommentIds: MutableList<Int> = mutableListOf()
val repliesSection = Section()
private var isEditing = false
var isReplying = false
private var repliesVisible = false
var MAX_DEPTH = 3
init {
adapter.add(repliesSection)
}
@SuppressLint("SetTextI18n")
override fun bind(viewBinding: ItemCommentsBinding, position: Int) {
binding = viewBinding
setAnimation(binding.root.context, binding.root)
viewBinding.commentRepliesList.layoutManager = LinearLayoutManager(commentsFragment.activity)
viewBinding.commentRepliesList.adapter = adapter
val isUserComment = CommentsAPI.userId == comment.userId
val levelColor = getAvatarColor(comment.totalVotes, backgroundColor)
markwon.setMarkdown(viewBinding.commentText, comment.content)
viewBinding.commentDelete.visibility = if (isUserComment || CommentsAPI.isAdmin || CommentsAPI.isMod) View.VISIBLE else View.GONE
viewBinding.commentBanUser.visibility = if ((CommentsAPI.isAdmin || CommentsAPI.isMod) && !isUserComment) View.VISIBLE else View.GONE
viewBinding.commentReport.visibility = if (!isUserComment) View.VISIBLE else View.GONE
viewBinding.commentEdit.visibility = if (isUserComment) View.VISIBLE else View.GONE
if (comment.tag == null) {
viewBinding.commentUserTagLayout.visibility = View.GONE
} else {
viewBinding.commentUserTagLayout.visibility = View.VISIBLE
viewBinding.commentUserTag.text = comment.tag.toString()
}
replying(isReplying) //sets default text
editing(isEditing)
if ((comment.replyCount ?: 0) > 0) {
viewBinding.commentTotalReplies.visibility = View.VISIBLE
viewBinding.commentRepliesDivider.visibility = View.VISIBLE
viewBinding.commentTotalReplies.text = if(repliesVisible) "Hide Replies" else
"View ${comment.replyCount} repl${if (comment.replyCount == 1) "y" else "ies"}"
} else {
viewBinding.commentTotalReplies.visibility = View.GONE
viewBinding.commentRepliesDivider.visibility = View.GONE
}
viewBinding.commentReply.visibility = View.VISIBLE
viewBinding.commentTotalReplies.setOnClickListener {
if (repliesVisible) {
repliesSection.clear()
removeSubCommentIds()
viewBinding.commentTotalReplies.text = "View ${comment.replyCount} repl${if (comment.replyCount == 1) "y" else "ies"}"
repliesVisible = false
} else {
viewBinding.commentTotalReplies.text = "Hide Replies"
repliesSection.clear()
commentsFragment.viewReplyCallback(this)
repliesVisible = true
}
}
viewBinding.commentUserName.setOnClickListener {
ContextCompat.startActivity(
commentsFragment.activity, Intent(commentsFragment.activity, ProfileActivity::class.java)
.putExtra("userId", comment.userId.toInt())
.putExtra("userLVL","[${levelColor.second}]"), null
)
}
viewBinding.commentUserAvatar.setOnClickListener {
ContextCompat.startActivity(
commentsFragment.activity, Intent(commentsFragment.activity, ProfileActivity::class.java)
.putExtra("userId", comment.userId.toInt())
.putExtra("userLVL","[${levelColor.second}]"), null
)
}
viewBinding.commentText.setOnLongClickListener {
copyToClipboard(comment.content)
true
}
viewBinding.commentEdit.setOnClickListener {
editing(!isEditing)
commentsFragment.editCallback(this)
}
viewBinding.commentReply.setOnClickListener {
replying(!isReplying)
commentsFragment.replyTo(this, comment.username)
commentsFragment.replyCallback(this)
}
viewBinding.modBadge.visibility = if (comment.isMod == true) View.VISIBLE else View.GONE
viewBinding.adminBadge.visibility = if (comment.isAdmin == true) View.VISIBLE else View.GONE
viewBinding.commentDelete.setOnClickListener {
dialogBuilder("Delete Comment", "Are you sure you want to delete this comment?") {
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
val success = CommentsAPI.deleteComment(comment.commentId)
if (success) {
snackString("Comment Deleted")
parentSection.remove(this@CommentItem)
}
}
}
}
viewBinding.commentBanUser.setOnClickListener {
dialogBuilder("Ban User", "Are you sure you want to ban this user?") {
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
val success = CommentsAPI.banUser(comment.userId)
if (success) {
snackString("User Banned")
}
}
}
}
viewBinding.commentReport.setOnClickListener {
dialogBuilder("Report Comment", "Only report comments that violate the rules. Are you sure you want to report this comment?") {
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
val success = CommentsAPI.reportComment(comment.commentId, comment.username, commentsFragment.mediaName, comment.userId)
if (success) {
snackString("Comment Reported")
}
}
}
}
//fill the icon if the user has liked the comment
setVoteButtons(viewBinding)
viewBinding.commentUpVote.setOnClickListener {
val voteType = if (comment.userVoteType == 1) 0 else 1
val previousVoteType = comment.userVoteType
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
val success = CommentsAPI.vote(comment.commentId, voteType)
if (success) {
comment.userVoteType = voteType
if (previousVoteType == -1) {
comment.downvotes -= 1
}
comment.upvotes += if (voteType == 1) 1 else -1
notifyChanged()
}
}
}
viewBinding.commentDownVote.setOnClickListener {
val voteType = if (comment.userVoteType == -1) 0 else -1
val previousVoteType = comment.userVoteType
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
val success = CommentsAPI.vote(comment.commentId, voteType)
if (success) {
comment.userVoteType = voteType
if (previousVoteType == 1) {
comment.upvotes -= 1
}
comment.downvotes += if (voteType == -1) 1 else -1
notifyChanged()
}
}
}
viewBinding.commentTotalVotes.text = (comment.upvotes - comment.downvotes).toString()
viewBinding.commentUserAvatar.setOnLongClickListener {
ImageViewDialog.newInstance(
commentsFragment.activity,
"${comment.username}'s [Cover]",
comment.profilePictureUrl
)
}
comment.profilePictureUrl?.let { viewBinding.commentUserAvatar.loadImage(it) }
viewBinding.commentUserName.text = comment.username
viewBinding.commentUserLevel.text = "[${levelColor.second}]"
viewBinding.commentUserLevel.setTextColor(levelColor.first)
viewBinding.commentUserTime.text = formatTimestamp(comment.timestamp)
}
override fun getLayout(): Int {
return R.layout.item_comments
}
fun containsGif(): Boolean {
return comment.content.contains(".gif")
}
override fun initializeViewBinding(view: View): ItemCommentsBinding {
return ItemCommentsBinding.bind(view)
}
fun replying(isReplying: Boolean) {
binding.commentReply.text = if (isReplying) commentsFragment.activity.getString(R.string.cancel) else "Reply"
this.isReplying = isReplying
}
fun editing(isEditing: Boolean) {
binding.commentEdit.text = if (isEditing) commentsFragment.activity.getString(R.string.cancel) else commentsFragment.activity.getString(R.string.edit)
this.isEditing = isEditing
}
fun registerSubComment(id: Int) {
subCommentIds.add(id)
}
private fun removeSubCommentIds(){
subCommentIds.forEach { id ->
val parentComments = parentSection.groups as? List<CommentItem> ?: emptyList()
val commentToRemove = parentComments.find { it.comment.commentId == id }
commentToRemove?.let {
it.removeSubCommentIds()
parentSection.remove(it)
}
}
subCommentIds.clear()
}
private fun setVoteButtons(viewBinding: ItemCommentsBinding) {
when (comment.userVoteType) {
1 -> {
viewBinding.commentUpVote.setImageResource(R.drawable.ic_round_upvote_active_24)
viewBinding.commentUpVote.alpha = 1f
viewBinding.commentDownVote.setImageResource(R.drawable.ic_round_upvote_inactive_24)
}
-1 -> {
viewBinding.commentUpVote.setImageResource(R.drawable.ic_round_upvote_inactive_24)
viewBinding.commentDownVote.setImageResource(R.drawable.ic_round_upvote_active_24)
viewBinding.commentDownVote.alpha = 1f
}
else -> {
viewBinding.commentUpVote.setImageResource(R.drawable.ic_round_upvote_inactive_24)
viewBinding.commentDownVote.setImageResource(R.drawable.ic_round_upvote_inactive_24)
}
}
}
private fun formatTimestamp(timestamp: String): String {
return try {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
val parsedDate = dateFormat.parse(timestamp)
val currentDate = Date()
val diff = currentDate.time - (parsedDate?.time ?: 0)
val days = diff / (24 * 60 * 60 * 1000)
val hours = diff / (60 * 60 * 1000) % 24
val minutes = diff / (60 * 1000) % 60
return when {
days > 0 -> "${days}d"
hours > 0 -> "${hours}h"
minutes > 0 -> "${minutes}m"
else -> "now"
}
} catch (e: Exception) {
"now"
}
}
companion object {
fun timestampToMillis(timestamp: String): Long {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
val parsedDate = dateFormat.parse(timestamp)
return parsedDate?.time ?: 0
}
}
private fun getAvatarColor(voteCount: Int, backgroundColor: Int): Pair<Int, Int> {
val level = if (voteCount < 0) 0 else sqrt(abs(voteCount.toDouble()) / 0.8).toInt()
val colorString = if (level > usernameColors.size - 1) usernameColors[usernameColors.size - 1] else usernameColors[level]
var color = Color.parseColor(colorString)
val ratio = getContrastRatio(color, backgroundColor)
if (ratio < 4.5) {
color = adjustColorForContrast(color, backgroundColor)
}
return Pair(color, level)
}
/**
* Builds the dialog for yes/no confirmation
* no doesn't do anything, yes calls the callback
* @param title the title of the dialog
* @param message the message of the dialog
* @param callback the callback to call when the user clicks yes
*/
private fun dialogBuilder(title: String, message: String, callback: () -> Unit) {
val alertDialog = android.app.AlertDialog.Builder(commentsFragment.activity, R.style.MyPopup)
.setTitle(title)
.setMessage(message)
.setPositiveButton("Yes") { dialog, _ ->
callback()
dialog.dismiss()
}
.setNegativeButton("No") { dialog, _ ->
dialog.dismiss()
}
val dialog = alertDialog.show()
dialog?.window?.setDimAmount(0.8f)
}
private val usernameColors: Array<String> = arrayOf(
"#9932cc",
"#a020f0",
"#8b008b",
"#7b68ee",
"#da70d6",
"#dda0dd",
"#ffe4b5",
"#f0e68c",
"#ffb6c1",
"#fa8072",
"#b03060",
"#ff1493",
"#ff00ff",
"#ff69b4",
"#dc143c",
"#8b0000",
"#ff0000",
"#a0522d",
"#f4a460",
"#b8860b",
"#ffa500",
"#d2691e",
"#ff6347",
"#808000",
"#ffd700",
"#ffff54",
"#8fbc8f",
"#3cb371",
"#008000",
"#00fa9a",
"#98fb98",
"#00ff00",
"#adff2f",
"#32cd32",
"#556b2f",
"#9acd32",
"#7fffd4",
"#2f4f4f",
"#5f9ea0",
"#87ceeb",
"#00bfff",
"#00ffff",
"#1e90ff",
"#4682b4",
"#0000ff",
"#0000cd",
"#00008b",
"#191970",
"#ffffff",
)
}

View file

@ -0,0 +1,706 @@
package ani.dantotsu.media.comments
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context.INPUT_METHOD_SERVICE
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import androidx.appcompat.widget.PopupMenu
import androidx.core.animation.doOnEnd
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import ani.dantotsu.R
import ani.dantotsu.buildMarkwon
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.comments.Comment
import ani.dantotsu.connections.comments.CommentResponse
import ani.dantotsu.connections.comments.CommentsAPI
import ani.dantotsu.databinding.FragmentCommentsBinding
import ani.dantotsu.loadImage
import ani.dantotsu.media.MediaDetailsActivity
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString
import ani.dantotsu.toast
import ani.dantotsu.util.Logger
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Section
import io.noties.markwon.editor.MarkwonEditor
import io.noties.markwon.editor.MarkwonEditorTextWatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
@SuppressLint("ClickableViewAccessibility")
class CommentsFragment : Fragment() {
lateinit var binding: FragmentCommentsBinding
lateinit var activity: MediaDetailsActivity
private var interactionState = InteractionState.NONE
private var commentWithInteraction: CommentItem? = null
private val section = Section()
private val adapter = GroupieAdapter()
private var tag: Int? = null
private var filterTag: Int? = null
private var mediaId: Int = -1
var mediaName: String = ""
private var backgroundColor: Int = 0
var pagesLoaded = 1
var totalPages = 1
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentCommentsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
activity = requireActivity() as MediaDetailsActivity
//get the media id from the intent
val mediaId = arguments?.getInt("mediaId") ?: -1
mediaName = arguments?.getString("mediaName") ?: "unknown"
if (mediaId == -1) {
snackString("Invalid Media ID")
return
}
this.mediaId = mediaId
backgroundColor = (binding.root.background as? ColorDrawable)?.color ?: 0
val markwon = buildMarkwon(activity, fragment = this@CommentsFragment)
activity.binding.commentUserAvatar.loadImage(Anilist.avatar)
val markwonEditor = MarkwonEditor.create(markwon)
activity.binding.commentInput.addTextChangedListener(
MarkwonEditorTextWatcher.withProcess(
markwonEditor
)
)
binding.commentsRefresh.setOnRefreshListener {
lifecycleScope.launch {
loadAndDisplayComments()
binding.commentsRefresh.isRefreshing = false
}
activity.binding.commentReplyToContainer.visibility = View.GONE
}
binding.commentsList.adapter = adapter
binding.commentsList.layoutManager = LinearLayoutManager(activity)
if (CommentsAPI.authToken != null) {
lifecycleScope.launch {
val commentId = arguments?.getInt("commentId")
if (commentId != null && commentId > 0) {
loadSingleComment(commentId)
} else {
loadAndDisplayComments()
}
}
} else {
toast("Not logged in")
activity.binding.commentMessageContainer.visibility = View.GONE
}
binding.commentSort.setOnClickListener { sortView ->
fun sortComments(sortOrder: String) {
val groups = section.groups
when (sortOrder) {
"newest" -> groups.sortByDescending { CommentItem.timestampToMillis((it as CommentItem).comment.timestamp) }
"oldest" -> groups.sortBy { CommentItem.timestampToMillis((it as CommentItem).comment.timestamp) }
"highest_rated" -> groups.sortByDescending { (it as CommentItem).comment.upvotes - it.comment.downvotes }
"lowest_rated" -> groups.sortBy { (it as CommentItem).comment.upvotes - it.comment.downvotes }
}
section.update(groups)
}
val popup = PopupMenu(activity, sortView)
popup.setOnMenuItemClickListener { item ->
val sortOrder = when (item.itemId) {
R.id.comment_sort_newest -> "newest"
R.id.comment_sort_oldest -> "oldest"
R.id.comment_sort_highest_rated -> "highest_rated"
R.id.comment_sort_lowest_rated -> "lowest_rated"
else -> return@setOnMenuItemClickListener false
}
PrefManager.setVal(PrefName.CommentSortOrder, sortOrder)
if (totalPages > pagesLoaded) {
lifecycleScope.launch {
loadAndDisplayComments()
activity.binding.commentReplyToContainer.visibility = View.GONE
}
} else {
sortComments(sortOrder)
}
binding.commentsList.scrollToPosition(0)
true
}
popup.inflate(R.menu.comments_sort_menu)
popup.show()
}
binding.commentFilter.setOnClickListener {
val alertDialog = AlertDialog.Builder(activity, R.style.MyPopup)
.setTitle("Enter a chapter/episode number tag")
.setView(R.layout.dialog_edittext)
.setPositiveButton("OK") { dialog, _ ->
val editText =
(dialog as AlertDialog).findViewById<EditText>(R.id.dialogEditText)
val text = editText?.text.toString()
filterTag = text.toIntOrNull()
lifecycleScope.launch {
loadAndDisplayComments()
}
dialog.dismiss()
}
.setNeutralButton("Clear") { dialog, _ ->
filterTag = null
lifecycleScope.launch {
loadAndDisplayComments()
}
dialog.dismiss()
}
.setNegativeButton("Cancel") { dialog, _ ->
filterTag = null
dialog.dismiss()
}
val dialog = alertDialog.show()
dialog?.window?.setDimAmount(0.8f)
}
var isFetching = false
binding.commentsList.setOnTouchListener(
object : View.OnTouchListener {
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
if (event?.action == MotionEvent.ACTION_UP) {
if (!binding.commentsList.canScrollVertically(1) && !isFetching &&
(binding.commentsList.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() == (binding.commentsList.adapter!!.itemCount - 1)
) {
if (pagesLoaded < totalPages && totalPages > 1) {
binding.commentBottomRefresh.visibility = View.VISIBLE
loadMoreComments()
lifecycleScope.launch {
kotlinx.coroutines.delay(1000)
withContext(Dispatchers.Main) {
binding.commentBottomRefresh.visibility = View.GONE
}
}
} else {
//snackString("No more comments") fix spam?
Logger.log("No more comments")
}
}
}
return false
}
private fun loadMoreComments() {
isFetching = true
lifecycleScope.launch {
val comments = fetchComments()
comments?.comments?.forEach { comment ->
updateUIWithComment(comment)
}
totalPages = comments?.totalPages ?: 1
pagesLoaded++
isFetching = false
}
}
private suspend fun fetchComments(): CommentResponse? {
return withContext(Dispatchers.IO) {
CommentsAPI.getCommentsForId(
mediaId,
pagesLoaded + 1,
filterTag,
PrefManager.getVal(PrefName.CommentSortOrder, "newest")
)
}
}
//adds additional comments to the section
private suspend fun updateUIWithComment(comment: Comment) {
withContext(Dispatchers.Main) {
section.add(
CommentItem(
comment,
buildMarkwon(activity, fragment = this@CommentsFragment),
section,
this@CommentsFragment,
backgroundColor,
0
)
)
}
}
})
activity.binding.commentInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
override fun afterTextChanged(s: android.text.Editable?) {
if ((activity.binding.commentInput.text.length) > 300) {
activity.binding.commentInput.text.delete(
300,
activity.binding.commentInput.text.length
)
snackString("Comment cannot be longer than 300 characters")
}
}
})
activity.binding.commentInput.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
val targetWidth = activity.binding.commentInputLayout.width -
activity.binding.commentLabel.width -
activity.binding.commentSend.width -
activity.binding.commentUserAvatar.width - 12 + 16
val anim = ValueAnimator.ofInt(activity.binding.commentInput.width, targetWidth)
anim.addUpdateListener { valueAnimator ->
val layoutParams = activity.binding.commentInput.layoutParams
layoutParams.width = valueAnimator.animatedValue as Int
activity.binding.commentInput.layoutParams = layoutParams
}
anim.duration = 300
anim.start()
anim.doOnEnd {
activity.binding.commentLabel.visibility = View.VISIBLE
activity.binding.commentSend.visibility = View.VISIBLE
activity.binding.commentLabel.animate().translationX(0f).setDuration(300)
.start()
activity.binding.commentSend.animate().translationX(0f).setDuration(300).start()
}
}
activity.binding.commentLabel.setOnClickListener {
//alert dialog to enter a number, with a cancel and ok button
val alertDialog = android.app.AlertDialog.Builder(activity, R.style.MyPopup)
.setTitle("Enter a chapter/episode number tag")
.setView(R.layout.dialog_edittext)
.setPositiveButton("OK") { dialog, _ ->
val editText =
(dialog as AlertDialog).findViewById<EditText>(R.id.dialogEditText)
val text = editText?.text.toString()
tag = text.toIntOrNull()
if (tag == null) {
activity.binding.commentLabel.background = ResourcesCompat.getDrawable(
resources,
R.drawable.ic_label_off_24,
null
)
} else {
activity.binding.commentLabel.background = ResourcesCompat.getDrawable(
resources,
R.drawable.ic_label_24,
null
)
}
dialog.dismiss()
}
.setNeutralButton("Clear") { dialog, _ ->
tag = null
activity.binding.commentLabel.background = ResourcesCompat.getDrawable(
resources,
R.drawable.ic_label_off_24,
null
)
dialog.dismiss()
}
.setNegativeButton("Cancel") { dialog, _ ->
tag = null
activity.binding.commentLabel.background = ResourcesCompat.getDrawable(
resources,
R.drawable.ic_label_off_24,
null
)
dialog.dismiss()
}
val dialog = alertDialog.show()
dialog?.window?.setDimAmount(0.8f)
}
}
activity.binding.commentSend.setOnClickListener {
if (CommentsAPI.isBanned) {
snackString("You are banned from commenting :(")
return@setOnClickListener
}
if (PrefManager.getVal(PrefName.FirstComment)) {
showCommentRulesDialog()
} else {
processComment()
}
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onStart() {
super.onStart()
}
@SuppressLint("NotifyDataSetChanged")
override fun onResume() {
super.onResume()
tag = null
section.groups.forEach {
if (it is CommentItem && it.containsGif()) {
it.notifyChanged()
}
}
}
enum class InteractionState {
NONE, EDIT, REPLY
}
/**
* Loads and displays the comments
* Called when the activity is created
* Or when the user refreshes the comments
*/
private suspend fun loadAndDisplayComments() {
binding.commentsProgressBar.visibility = View.VISIBLE
binding.commentsList.visibility = View.GONE
adapter.clear()
section.clear()
val comments = withContext(Dispatchers.IO) {
CommentsAPI.getCommentsForId(
mediaId,
tag = filterTag,
sort = PrefManager.getVal(PrefName.CommentSortOrder, "newest")
)
}
val sortedComments = sortComments(comments?.comments)
sortedComments.forEach {
withContext(Dispatchers.Main) {
section.add(
CommentItem(
it,
buildMarkwon(activity, fragment = this@CommentsFragment),
section,
this@CommentsFragment,
backgroundColor,
0
)
)
}
}
totalPages = comments?.totalPages ?: 1
binding.commentsProgressBar.visibility = View.GONE
binding.commentsList.visibility = View.VISIBLE
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(activity, fragment = this@CommentsFragment),
section,
this@CommentsFragment,
backgroundColor,
0
)
)
}
}
binding.commentsProgressBar.visibility = View.GONE
binding.commentsList.visibility = View.VISIBLE
adapter.add(section)
}
private fun sortComments(comments: List<Comment>?): List<Comment> {
if (comments == null) return emptyList()
return when (PrefManager.getVal(PrefName.CommentSortOrder, "newest")) {
"newest" -> comments.sortedByDescending { CommentItem.timestampToMillis(it.timestamp) }
"oldest" -> comments.sortedBy { CommentItem.timestampToMillis(it.timestamp) }
"highest_rated" -> comments.sortedByDescending { it.upvotes - it.downvotes }
"lowest_rated" -> comments.sortedBy { it.upvotes - it.downvotes }
else -> comments
}
}
/**
* Resets the old state of the comment input
* @return the old state
*/
private fun resetOldState(): InteractionState {
val oldState = interactionState
interactionState = InteractionState.NONE
return when (oldState) {
InteractionState.EDIT -> {
activity.binding.commentReplyToContainer.visibility = View.GONE
activity.binding.commentInput.setText("")
val imm = activity.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(activity.binding.commentInput.windowToken, 0)
commentWithInteraction?.editing(false)
InteractionState.EDIT
}
InteractionState.REPLY -> {
activity.binding.commentReplyToContainer.visibility = View.GONE
activity.binding.commentInput.setText("")
val imm = activity.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(activity.binding.commentInput.windowToken, 0)
commentWithInteraction?.replying(false)
InteractionState.REPLY
}
else -> {
InteractionState.NONE
}
}
}
/**
* Callback from the comment item to edit the comment
* Called every time the edit button is clicked
* @param comment the comment to edit
*/
fun editCallback(comment: CommentItem) {
if (resetOldState() == InteractionState.EDIT) return
commentWithInteraction = comment
activity.binding.commentInput.setText(comment.comment.content)
activity.binding.commentInput.requestFocus()
activity.binding.commentInput.setSelection(activity.binding.commentInput.text.length)
val imm = activity.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(activity.binding.commentInput, InputMethodManager.SHOW_IMPLICIT)
interactionState = InteractionState.EDIT
}
/**
* Callback from the comment item to reply to the comment
* Called every time the reply button is clicked
* @param comment the comment to reply to
*/
fun replyCallback(comment: CommentItem) {
if (resetOldState() == InteractionState.REPLY) return
commentWithInteraction = comment
activity.binding.commentReplyToContainer.visibility = View.VISIBLE
activity.binding.commentInput.requestFocus()
activity.binding.commentInput.setSelection(activity.binding.commentInput.text.length)
val imm = activity.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(activity.binding.commentInput, InputMethodManager.SHOW_IMPLICIT)
interactionState = InteractionState.REPLY
}
@SuppressLint("SetTextI18n")
fun replyTo(comment: CommentItem, username: String) {
if (comment.isReplying) {
activity.binding.commentReplyToContainer.visibility = View.VISIBLE
activity.binding.commentReplyTo.text = "Replying to $username"
activity.binding.commentReplyToCancel.setOnClickListener {
comment.replying(false)
replyCallback(comment)
activity.binding.commentReplyToContainer.visibility = View.GONE
}
} else {
activity.binding.commentReplyToContainer.visibility = View.GONE
}
}
/**
* Callback from the comment item to view the replies to the comment
* @param comment the comment to view the replies of
*/
fun viewReplyCallback(comment: CommentItem) {
lifecycleScope.launch {
val replies = withContext(Dispatchers.IO) {
CommentsAPI.getRepliesFromId(comment.comment.commentId)
}
replies?.comments?.forEach {
val depth =
if (comment.commentDepth + 1 > comment.MAX_DEPTH) comment.commentDepth else comment.commentDepth + 1
val section =
if (comment.commentDepth + 1 > comment.MAX_DEPTH) comment.parentSection else comment.repliesSection
if (depth >= comment.MAX_DEPTH) comment.registerSubComment(it.commentId)
val newCommentItem = CommentItem(
it,
buildMarkwon(activity, fragment = this@CommentsFragment),
section,
this@CommentsFragment,
backgroundColor,
depth
)
section.add(newCommentItem)
}
}
}
/**
* Shows the comment rules dialog
* Called when the user tries to comment for the first time
*/
private fun showCommentRulesDialog() {
val alertDialog = android.app.AlertDialog.Builder(activity, R.style.MyPopup)
.setTitle("Commenting Rules")
.setMessage(
"I WILL BAN YOU WITHOUT HESITATION\n" +
"1. No racism\n" +
"2. No hate speech\n" +
"3. No spam\n" +
"4. No NSFW content\n" +
"6. ENGLISH ONLY\n" +
"7. No self promotion\n" +
"8. No impersonation\n" +
"9. No harassment\n" +
"10. No illegal content\n" +
"11. Anything you know you shouldn't comment\n"
)
.setPositiveButton("I Understand") { dialog, _ ->
dialog.dismiss()
PrefManager.setVal(PrefName.FirstComment, false)
processComment()
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
val dialog = alertDialog.show()
dialog?.window?.setDimAmount(0.8f)
}
private fun processComment() {
val commentText = activity.binding.commentInput.text.toString()
if (commentText.isEmpty()) {
snackString("Comment cannot be empty")
return
}
activity.binding.commentInput.text.clear()
lifecycleScope.launch {
if (interactionState == InteractionState.EDIT) {
handleEditComment(commentText)
} else {
handleNewComment(commentText)
tag = null
activity.binding.commentLabel.background = ResourcesCompat.getDrawable(
resources,
R.drawable.ic_label_off_24,
null
)
}
resetOldState()
}
}
private suspend fun handleEditComment(commentText: String) {
val success = withContext(Dispatchers.IO) {
CommentsAPI.editComment(
commentWithInteraction?.comment?.commentId ?: return@withContext false, commentText
)
}
if (success) {
updateCommentInSection(commentText)
}
}
private fun updateCommentInSection(commentText: String) {
val groups = section.groups
groups.forEach { item ->
if (item is CommentItem && item.comment.commentId == commentWithInteraction?.comment?.commentId) {
updateCommentItem(item, commentText)
snackString("Comment edited")
}
}
}
private fun updateCommentItem(item: CommentItem, commentText: String) {
item.comment.content = commentText
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
item.comment.timestamp = dateFormat.format(System.currentTimeMillis())
item.notifyChanged()
}
/**
* Handles the new user-added comment
* @param commentText the text of the comment
*/
private suspend fun handleNewComment(commentText: String) {
val success = withContext(Dispatchers.IO) {
CommentsAPI.comment(
mediaId,
if (interactionState == InteractionState.REPLY) commentWithInteraction?.comment?.commentId else null,
commentText,
tag
)
}
success?.let {
if (interactionState == InteractionState.REPLY) {
if (commentWithInteraction == null) return@let
val section =
if (commentWithInteraction!!.commentDepth + 1 > commentWithInteraction!!.MAX_DEPTH) commentWithInteraction?.parentSection else commentWithInteraction?.repliesSection
val depth =
if (commentWithInteraction!!.commentDepth + 1 > commentWithInteraction!!.MAX_DEPTH) commentWithInteraction!!.commentDepth else commentWithInteraction!!.commentDepth + 1
if (depth >= commentWithInteraction!!.MAX_DEPTH) commentWithInteraction!!.registerSubComment(
it.commentId
)
section?.add(
if (commentWithInteraction!!.commentDepth + 1 > commentWithInteraction!!.MAX_DEPTH) 0 else section.itemCount,
CommentItem(
it,
buildMarkwon(activity, fragment = this@CommentsFragment),
section,
this@CommentsFragment,
backgroundColor,
depth
)
)
} else {
section.add(
0,
CommentItem(
it,
buildMarkwon(activity, fragment = this@CommentsFragment),
section,
this@CommentsFragment,
backgroundColor,
0
)
)
}
}
}
}

View file

@ -10,7 +10,7 @@ import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.LruCache
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import ani.dantotsu.snackString
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
@ -32,8 +32,8 @@ data class ImageData(
try {
// Fetch the image
val response = httpSource.getImage(page)
logger("Response: ${response.code}")
logger("Response: ${response.message}")
Logger.log("Response: ${response.code}")
Logger.log("Response: ${response.message}")
// Convert the Response to an InputStream
val inputStream = response.body.byteStream()
@ -47,7 +47,7 @@ data class ImageData(
return@withContext bitmap
} catch (e: Exception) {
// Handle any exceptions
logger("An error occurred: ${e.message}")
Logger.log("An error occurred: ${e.message}")
snackString("An error occurred: ${e.message}")
return@withContext null
}

View file

@ -13,6 +13,7 @@ data class MangaChapter(
var description: String? = null,
var sChapter: SChapter,
val scanlator: String? = null,
val date: Long? = null,
var progress: String? = ""
) : Serializable {
constructor(chapter: MangaChapter) : this(
@ -21,7 +22,8 @@ data class MangaChapter(
chapter.title,
chapter.description,
chapter.sChapter,
chapter.scanlator
chapter.scanlator,
chapter.date
)
private val images = mutableListOf<MangaImage>()

View file

@ -1,5 +1,6 @@
package ani.dantotsu.media.manga
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.util.TypedValue
import android.view.LayoutInflater
@ -16,6 +17,9 @@ import ani.dantotsu.databinding.ItemChapterListBinding
import ani.dantotsu.databinding.ItemEpisodeCompactBinding
import ani.dantotsu.media.Media
import ani.dantotsu.setAnimation
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -258,6 +262,7 @@ class MangaChapterAdapter(
}
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is ChapterCompactViewHolder -> {
@ -290,6 +295,23 @@ class MangaChapterAdapter(
holder.bind(ep.number, ep.progress)
setAnimation(fragment.requireContext(), holder.binding.root)
binding.itemChapterNumber.text = ep.number
if (ep.date != null) {
binding.itemChapterDateLayout.visibility = View.VISIBLE
binding.itemChapterDate.text = formatDate(ep.date)
}
if (ep.scanlator != null) {
binding.itemChapterDateLayout.visibility = View.VISIBLE
binding.itemChapterScan.text = ep.scanlator.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(
Locale.ROOT
) else it.toString()
}
}
if (formatDate(ep.date) == "" || ep.scanlator == null) {
binding.itemChapterDateDivider.visibility = View.GONE
} else binding.itemChapterDateDivider.visibility = View.VISIBLE
if (ep.progress.isNullOrEmpty()) {
binding.itemChapterTitle.visibility = View.GONE
} else binding.itemChapterTitle.visibility = View.VISIBLE
@ -322,6 +344,33 @@ class MangaChapterAdapter(
fun updateType(t: Int) {
type = t
}
private fun formatDate(timestamp: Long?): String {
timestamp ?: return "" // Return empty string if timestamp is null
val targetDate = Date(timestamp)
if (targetDate < Date(946684800000L)) { // January 1, 2000 (who want dates before that?)
return ""
}
val currentDate = Date()
val difference = currentDate.time - targetDate.time
return when (val daysDifference = difference / (1000 * 60 * 60 * 24)) {
0L -> {
val hoursDifference = difference / (1000 * 60 * 60)
val minutesDifference = (difference / (1000 * 60)) % 60
when {
hoursDifference > 0 -> "$hoursDifference hour${if (hoursDifference > 1) "s" else ""} ago"
minutesDifference > 0 -> "$minutesDifference minute${if (minutesDifference > 1) "s" else ""} ago"
else -> "Just now"
}
}
1L -> "1 day ago"
in 2..6 -> "$daysDifference days ago"
else -> SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(targetDate)
}
}
}

View file

@ -12,6 +12,7 @@ import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.NumberPicker
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import ani.dantotsu.*
@ -27,11 +28,11 @@ import ani.dantotsu.others.webview.CookieCatcher
import ani.dantotsu.parsers.DynamicMangaParser
import ani.dantotsu.parsers.MangaReadSources
import ani.dantotsu.parsers.MangaSources
import ani.dantotsu.settings.FAQActivity
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.subcriptions.Notifications.Companion.openSettings
import ani.dantotsu.subcriptions.Subscription.Companion.getChannelId
import com.google.android.material.chip.Chip
import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_SUBSCRIPTION_CHECK
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.WebViewUtil
import kotlinx.coroutines.MainScope
@ -63,6 +64,12 @@ class MangaReadAdapter(
_binding = binding
binding.sourceTitle.setText(R.string.chaps)
//Fuck u launch
binding.faqbutton.setOnClickListener {
val intent = Intent(fragment.requireContext(), FAQActivity::class.java)
startActivity(fragment.requireContext(), intent, null)
}
//Wrong Title
binding.animeSourceSearch.setOnClickListener {
SourceSearchDialogFragment().show(
@ -142,7 +149,8 @@ class MangaReadAdapter(
R.drawable.ic_round_notifications_none_24,
R.color.bg_opp,
R.color.violet_400,
fragment.subscribed
fragment.subscribed,
true
) {
fragment.onNotificationPressed(it, binding.animeSource.text.toString())
}
@ -150,7 +158,7 @@ class MangaReadAdapter(
subscribeButton(false)
binding.animeSourceSubscribe.setOnLongClickListener {
openSettings(fragment.requireContext(), getChannelId(true, media.id))
openSettings(fragment.requireContext(), CHANNEL_SUBSCRIPTION_CHECK)
}
binding.animeNestedButton.setOnClickListener {
@ -245,17 +253,41 @@ class MangaReadAdapter(
if (options.count() > 1) View.VISIBLE else View.GONE
dialogBinding.scanlatorNo.text = "${options.count()}"
dialogBinding.animeScanlatorTop.setOnClickListener {
val dialogView2 =
LayoutInflater.from(currContext()).inflate(R.layout.custom_dialog_layout, null)
val checkboxContainer =
dialogView2.findViewById<LinearLayout>(R.id.checkboxContainer)
val dialogView2 = LayoutInflater.from(currContext()).inflate(R.layout.custom_dialog_layout, null)
val checkboxContainer = dialogView2.findViewById<LinearLayout>(R.id.checkboxContainer)
val tickAllButton = dialogView2.findViewById<ImageButton>(R.id.toggleButton)
// Function to get the right image resource for the toggle button
fun getToggleImageResource(container: ViewGroup): Int {
var allChecked = true
var allUnchecked = true
for (i in 0 until container.childCount) {
val checkBox = container.getChildAt(i) as CheckBox
if (!checkBox.isChecked) {
allChecked = false
} else {
allUnchecked = false
}
}
return when {
allChecked -> R.drawable.untick_all_boxes
allUnchecked -> R.drawable.tick_all_boxes
else -> R.drawable.invert_all_boxes
}
}
// Dynamically add checkboxes
options.forEach { option ->
val checkBox = CheckBox(currContext()).apply {
text = option
setOnCheckedChangeListener { _, _ ->
// Update image resource when you change a checkbox
tickAllButton.setImageResource(getToggleImageResource(checkboxContainer))
}
//set checked if it's already selected
}
// Set checked if its already selected
if (media.selected!!.scanlators != null) {
checkBox.isChecked = media.selected!!.scanlators?.contains(option) != true
scanlatorSelectionListener?.onScanlatorsSelected()
@ -269,7 +301,6 @@ class MangaReadAdapter(
val dialog = AlertDialog.Builder(currContext(), R.style.MyPopup)
.setView(dialogView2)
.setPositiveButton("OK") { _, _ ->
//add unchecked to hidden
hiddenScanlators.clear()
for (i in 0 until checkboxContainer.childCount) {
val checkBox = checkboxContainer.getChildAt(i) as CheckBox
@ -283,6 +314,21 @@ class MangaReadAdapter(
.setNegativeButton("Cancel", null)
.show()
dialog.window?.setDimAmount(0.8f)
// Standard image resource
tickAllButton.setImageResource(getToggleImageResource(checkboxContainer))
// Listens to ticked checkboxes and changes image resource accordingly
tickAllButton.setOnClickListener {
// Toggle checkboxes
for (i in 0 until checkboxContainer.childCount) {
val checkBox = checkboxContainer.getChildAt(i) as CheckBox
checkBox.isChecked = !checkBox.isChecked
}
// Update image resource
tickAllButton.setImageResource(getToggleImageResource(checkboxContainer))
}
}
nestedDialog = AlertDialog.Builder(fragment.requireContext(), R.style.MyPopup)
@ -391,7 +437,7 @@ class MangaReadAdapter(
if (media.manga?.chapters != null) {
val chapters = media.manga.chapters!!.keys.toTypedArray()
val anilistEp = (media.userProgress ?: 0).plus(1)
val appEp = PrefManager.getCustomVal<String?>("${media.id}_current_chp", null)
val appEp = PrefManager.getNullableCustomVal("${media.id}_current_chp", null, String::class.java)
?.toIntOrNull() ?: 1
var continueEp = (if (anilistEp > appEp) anilistEp else appEp).toString()
val filteredChapters = chapters.filter { chapterKey ->
@ -435,13 +481,17 @@ class MangaReadAdapter(
binding.animeSourceContinue.visibility = View.GONE
}
binding.animeSourceProgressBar.visibility = View.GONE
if (media.manga.chapters!!.isNotEmpty())
if (media.manga.chapters!!.isNotEmpty()) {
binding.animeSourceNotFound.visibility = View.GONE
else
binding.faqbutton.visibility = View.GONE
} else {
binding.animeSourceNotFound.visibility = View.VISIBLE
binding.faqbutton.visibility = View.VISIBLE
}
} else {
binding.animeSourceContinue.visibility = View.GONE
binding.animeSourceNotFound.visibility = View.GONE
binding.faqbutton.visibility = View.GONE
clearChips()
binding.animeSourceProgressBar.visibility = View.VISIBLE
}

View file

@ -46,13 +46,9 @@ import ani.dantotsu.parsers.MangaSources
import ani.dantotsu.settings.extensionprefs.MangaSourcePreferencesFragment
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.subcriptions.Notifications
import ani.dantotsu.subcriptions.Notifications.Group.MANGA_GROUP
import ani.dantotsu.subcriptions.Subscription.Companion.getChannelId
import ani.dantotsu.subcriptions.SubscriptionHelper
import ani.dantotsu.subcriptions.SubscriptionHelper.Companion.saveSubscription
import ani.dantotsu.notifications.subscription.SubscriptionHelper
import ani.dantotsu.notifications.subscription.SubscriptionHelper.Companion.saveSubscription
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.navigationrail.NavigationRailView
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
import eu.kanade.tachiyomi.source.ConfigurableSource
import kotlinx.coroutines.CoroutineScope
@ -347,16 +343,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
var subscribed = false
fun onNotificationPressed(subscribed: Boolean, source: String) {
this.subscribed = subscribed
saveSubscription(requireContext(), media, subscribed)
if (!subscribed)
Notifications.deleteChannel(requireContext(), getChannelId(true, media.id))
else
Notifications.createChannel(
requireContext(),
MANGA_GROUP,
getChannelId(true, media.id),
media.userPreferredName
)
saveSubscription(media, subscribed)
snackString(
if (subscribed) getString(R.string.subscribed_notification, source)
else getString(R.string.unsubscribed_notification)
@ -372,11 +359,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
activity.findViewById<ViewPager2>(R.id.mediaViewPager).visibility = visibility
activity.findViewById<CardView>(R.id.mediaCover).visibility = visibility
activity.findViewById<CardView>(R.id.mediaClose).visibility = visibility
try {
activity.findViewById<CustomBottomNavBar>(R.id.mediaTab).visibility = visibility
} catch (e: ClassCastException) {
activity.findViewById<NavigationRailView>(R.id.mediaTab).visibility = visibility
}
activity.tabLayout.setVisibility(visibility)
activity.findViewById<FrameLayout>(R.id.fragmentExtensionsContainer).visibility =
if (show) View.GONE else View.VISIBLE
}
@ -605,6 +588,8 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
super.onResume()
binding.mediaInfoProgressBar.visibility = progress
binding.animeSourceRecycler.layoutManager?.onRestoreInstanceState(state)
requireActivity().setNavigationTheme()
}
override fun onPause() {

View file

@ -88,6 +88,7 @@ class MangaReaderActivity : AppCompatActivity() {
private var isContVisible = false
private var showProgressDialog = true
private var hidescrollbar = false
private var maxChapterPage = 0L
private var currentChapterPage = 0L
@ -121,8 +122,11 @@ class MangaReaderActivity : AppCompatActivity() {
}
}
private fun hideBars() {
if (!PrefManager.getVal<Boolean>(PrefName.ShowSystemBars)) hideSystemBars()
private fun hideSystemBars() {
if (PrefManager.getVal<Boolean>(PrefName.ShowSystemBars))
showSystemBarsRetractView()
else
hideSystemBarsExtendView()
}
override fun onDestroy() {
@ -147,12 +151,26 @@ class MangaReaderActivity : AppCompatActivity() {
defaultSettings = loadReaderSettings("reader_settings") ?: defaultSettings
onBackPressedDispatcher.addCallback(this) {
val chapter = (MangaNameAdapter.findChapterNumber(media.manga!!.selectedChapter!!)
?.minus(1L) ?: 0).toString()
if (chapter == "0.0" && PrefManager.getVal(PrefName.ChapterZeroReader)
// Not asking individually or incognito
&& !showProgressDialog && !PrefManager.getVal<Boolean>(PrefName.Incognito)
// Not ...opted out ...already? Somehow?
&& PrefManager.getCustomVal("${media.id}_save_progress", true)
// Allowing Doujin updates or not one
&& if (media.isAdult) PrefManager.getVal(PrefName.UpdateForHReader) else true
) {
updateProgress(media, chapter)
finish()
} else {
progress { finish() }
}
}
controllerDuration = (PrefManager.getVal<Float>(PrefName.AnimationSpeed) * 200).toLong()
hideBars()
hideSystemBars()
var pageSliderTimer = Timer()
fun pageSliderHide() {
@ -285,17 +303,27 @@ class MangaReaderActivity : AppCompatActivity() {
binding.mangaReaderNextChapter.performClick()
}
binding.mangaReaderNextChapter.setOnClickListener {
if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) {
if (currentChapterIndex > 0) change(currentChapterIndex - 1)
else snackString(getString(R.string.first_chapter))
} else {
if (chaptersArr.size > currentChapterIndex + 1) progress { change(currentChapterIndex + 1) }
else snackString(getString(R.string.next_chapter_not_found))
}
}
//Prev Chapter
binding.mangaReaderPrevChap.setOnClickListener {
binding.mangaReaderPreviousChapter.performClick()
}
binding.mangaReaderPreviousChapter.setOnClickListener {
if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) {
if (chaptersArr.size > currentChapterIndex + 1) progress { change(currentChapterIndex + 1) }
else snackString(getString(R.string.next_chapter_not_found))
} else {
if (currentChapterIndex > 0) change(currentChapterIndex - 1)
else snackString(getString(R.string.first_chapter))
}
}
model.getMangaChapter().observe(this) { chap ->
if (chap != null) {
@ -305,10 +333,17 @@ class MangaReaderActivity : AppCompatActivity() {
PrefManager.setCustomVal("${media.id}_current_chp", chap.number)
currentChapterIndex = chaptersArr.indexOf(chap.number)
binding.mangaReaderChapterSelect.setSelection(currentChapterIndex)
if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) {
binding.mangaReaderNextChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: ""
binding.mangaReaderPrevChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: ""
} else {
binding.mangaReaderNextChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: ""
binding.mangaReaderPrevChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: ""
}
applySettings()
val context = this
val offline: Boolean = PrefManager.getVal(PrefName.OfflineMode)
@ -377,7 +412,7 @@ class MangaReaderActivity : AppCompatActivity() {
fun applySettings() {
saveReaderSettings("${media.id}_current_settings", defaultSettings)
hideBars()
hideSystemBars()
//true colors
SubsamplingScaleImageView.setPreferredBitmapConfig(
@ -422,6 +457,10 @@ class MangaReaderActivity : AppCompatActivity() {
if ((defaultSettings.direction == TOP_TO_BOTTOM || defaultSettings.direction == BOTTOM_TO_TOP)) {
binding.mangaReaderSwipy.vertical = true
if (defaultSettings.direction == TOP_TO_BOTTOM) {
binding.mangaReaderNextChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: ""
binding.mangaReaderPrevChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: ""
binding.BottomSwipeText.text = chaptersTitleArr.getOrNull(currentChapterIndex + 1)
?: getString(R.string.no_chapter)
binding.TopSwipeText.text = chaptersTitleArr.getOrNull(currentChapterIndex - 1)
@ -433,6 +472,10 @@ class MangaReaderActivity : AppCompatActivity() {
binding.mangaReaderNextChapter.performClick()
}
} else {
binding.mangaReaderNextChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: ""
binding.mangaReaderPrevChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: ""
binding.BottomSwipeText.text = chaptersTitleArr.getOrNull(currentChapterIndex - 1)
?: getString(R.string.no_chapter)
binding.TopSwipeText.text = chaptersTitleArr.getOrNull(currentChapterIndex + 1)
@ -459,34 +502,36 @@ class MangaReaderActivity : AppCompatActivity() {
} else {
binding.mangaReaderSwipy.vertical = false
if (defaultSettings.direction == RIGHT_TO_LEFT) {
binding.mangaReaderNextChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: ""
binding.mangaReaderPrevChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: ""
binding.LeftSwipeText.text = chaptersTitleArr.getOrNull(currentChapterIndex + 1)
?: getString(R.string.no_chapter)
binding.RightSwipeText.text = chaptersTitleArr.getOrNull(currentChapterIndex - 1)
?: getString(R.string.no_chapter)
binding.mangaReaderSwipy.onLeftSwiped = {
binding.mangaReaderNextChapter.performClick()
}
binding.mangaReaderSwipy.onRightSwiped = {
binding.mangaReaderPreviousChapter.performClick()
}
} else {
binding.mangaReaderNextChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: ""
binding.mangaReaderPrevChap.text =
chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: ""
binding.LeftSwipeText.text = chaptersTitleArr.getOrNull(currentChapterIndex - 1)
?: getString(R.string.no_chapter)
binding.RightSwipeText.text = chaptersTitleArr.getOrNull(currentChapterIndex + 1)
?: getString(R.string.no_chapter)
}
binding.mangaReaderSwipy.onLeftSwiped = {
binding.mangaReaderPreviousChapter.performClick()
}
binding.mangaReaderSwipy.onRightSwiped = {
binding.mangaReaderNextChapter.performClick()
}
}
binding.mangaReaderSwipy.leftBeingSwiped = { value ->
binding.LeftSwipeContainer.apply {
alpha = value
translationX = -width.dp * (1 - min(value, 1f))
}
}
binding.mangaReaderSwipy.onRightSwiped = {
binding.mangaReaderNextChapter.performClick()
}
binding.mangaReaderSwipy.rightBeingSwiped = { value ->
binding.RightSwipeContainer.apply {
alpha = value
@ -710,7 +755,7 @@ class MangaReaderActivity : AppCompatActivity() {
val screenWidth = Resources.getSystem().displayMetrics.widthPixels
//if in the 1st 1/5th of the screen width, left and lower than 1/5th of the screen height, left
if (screenWidth / 5 in x + 1..<y) {
pressLocation = if (defaultSettings.direction == RIGHT_TO_LEFT) {
pressLocation = if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) {
pressPos.RIGHT
} else {
pressPos.LEFT
@ -718,7 +763,7 @@ class MangaReaderActivity : AppCompatActivity() {
}
//if in the last 1/5th of the screen width, right and lower than 1/5th of the screen height, right
else if (x > screenWidth - screenWidth / 5 && y > screenWidth / 5) {
pressLocation = if (defaultSettings.direction == RIGHT_TO_LEFT) {
pressLocation = if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) {
pressPos.LEFT
} else {
pressPos.RIGHT
@ -751,9 +796,41 @@ class MangaReaderActivity : AppCompatActivity() {
}
if (!PrefManager.getVal<Boolean>(PrefName.ShowSystemBars)) {
hideBars()
hideSystemBars()
checkNotch()
}
// Hide the scrollbar completely
if (defaultSettings.hideScrollBar) {
binding.mangaReaderSliderContainer.visibility = View.GONE
} else {
if (defaultSettings.horizontalScrollBar) {
binding.mangaReaderSliderContainer.updateLayoutParams {
height = ViewGroup.LayoutParams.WRAP_CONTENT
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
binding.mangaReaderSlider.apply {
updateLayoutParams<ViewGroup.MarginLayoutParams> {
width = ViewGroup.LayoutParams.MATCH_PARENT
}
rotation = 0f
}
} else {
binding.mangaReaderSliderContainer.updateLayoutParams {
height = ViewGroup.LayoutParams.MATCH_PARENT
width = 48f.px
}
binding.mangaReaderSlider.apply {
updateLayoutParams {
width = binding.mangaReaderSliderContainer.height - 16f.px
}
rotation = 90f
}
}
binding.mangaReaderSliderContainer.visibility = View.VISIBLE
}
//horizontal scrollbar
if (defaultSettings.horizontalScrollBar) {
binding.mangaReaderSliderContainer.updateLayoutParams {
@ -877,7 +954,7 @@ class MangaReaderActivity : AppCompatActivity() {
dialog.dismiss()
runnable.run()
}
.setOnCancelListener { hideBars() }
.setOnCancelListener { hideSystemBars() }
.create()
.show()
} else {

View file

@ -127,6 +127,12 @@ class ReaderSettingsDialogFragment : BottomSheetDialogFragment() {
activity.applySettings()
}
binding.readerHideScrollBar.isChecked = settings.hideScrollBar
binding.readerHideScrollBar.setOnCheckedChangeListener { _, isChecked ->
settings.hideScrollBar = isChecked
activity.applySettings()
}
binding.readerHidePageNumbers.isChecked = settings.hidePageNumbers
binding.readerHidePageNumbers.setOnCheckedChangeListener { _, isChecked ->
settings.hidePageNumbers = isChecked

View file

@ -31,6 +31,7 @@ import ani.dantotsu.media.MediaDetailsViewModel
import ani.dantotsu.media.novel.novelreader.NovelReaderActivity
import ani.dantotsu.navBarHeight
import ani.dantotsu.parsers.ShowResponse
import ani.dantotsu.util.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -59,7 +60,7 @@ class NovelReadFragment : Fragment(),
var loaded = false
override fun downloadTrigger(novelDownloadPackage: NovelDownloadPackage) {
Log.e("downloadTrigger", novelDownloadPackage.link)
Logger.log("novel link: ${novelDownloadPackage.link}")
val downloadTask = NovelDownloaderService.DownloadTask(
title = media.mainName(),
chapter = novelDownloadPackage.novelName,

View file

@ -5,12 +5,14 @@ import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import ani.dantotsu.R
import ani.dantotsu.databinding.ItemNovelResponseBinding
import ani.dantotsu.parsers.ShowResponse
import ani.dantotsu.setAnimation
import ani.dantotsu.snackString
import ani.dantotsu.util.Logger
import com.bumptech.glide.Glide
import com.bumptech.glide.load.model.GlideUrl
@ -58,11 +60,11 @@ class NovelResponseAdapter(
}
if (binding.itemEpisodeFiller.text.contains("Downloading")) {
binding.itemEpisodeFiller.setTextColor(
fragment.requireContext().getColor(android.R.color.holo_blue_light)
ContextCompat.getColor(fragment.requireContext(), android.R.color.holo_blue_light)
)
} else if (binding.itemEpisodeFiller.text.contains("Downloaded")) {
binding.itemEpisodeFiller.setTextColor(
fragment.requireContext().getColor(android.R.color.holo_green_light)
ContextCompat.getColor(fragment.requireContext(), android.R.color.holo_green_light)
)
} else {
binding.itemEpisodeFiller.setTextColor(color)
@ -181,7 +183,7 @@ class NovelResponseAdapter(
if (position != -1) {
list[position].extra?.remove("0")
list[position].extra?.set("0", "Downloading: $progress%")
Log.d("NovelResponseAdapter", "updateDownloadProgress: $progress, position: $position")
Logger.log( "updateDownloadProgress: $progress, position: $position")
notifyItemChanged(position)
}
}

View file

@ -291,7 +291,7 @@ class NovelReaderActivity : AppCompatActivity(), EbookReaderEventListener {
applySettings()
}
val cfi = PrefManager.getCustomVal("${sanitizedBookId}_progress", null as String?)
val cfi = PrefManager.getNullableCustomVal("${sanitizedBookId}_progress", null, String::class.java)
cfi?.let { binding.bookReader.goto(it) }
binding.progress.visibility = View.GONE

View file

@ -78,7 +78,6 @@ class ListActivity : AppCompatActivity() {
)
binding.settingsContainer.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = statusBarHeight
bottomMargin = navBarHeight
}
}
setContentView(binding.root)
@ -171,6 +170,21 @@ class ListActivity : AppCompatActivity() {
popup.show()
}
binding.filter.setOnClickListener {
val genres = PrefManager.getVal<Set<String>>(PrefName.GenresList).toMutableSet().sorted()
val popup = PopupMenu(this, it)
popup.menu.add("All")
genres.forEach { genre ->
popup.menu.add(genre)
}
popup.setOnMenuItemClickListener { menuItem ->
val selectedGenre = menuItem.title.toString()
model.filterLists(selectedGenre)
true
}
popup.show()
}
binding.random.setOnClickListener {
//get the current tab
val currentTab =

View file

@ -13,10 +13,29 @@ class ListViewModel : ViewModel() {
var grid = MutableLiveData(PrefManager.getVal<Boolean>(PrefName.ListGrid))
private val lists = MutableLiveData<MutableMap<String, ArrayList<Media>>>()
private val unfilteredLists = MutableLiveData<MutableMap<String, ArrayList<Media>>>()
fun getLists(): LiveData<MutableMap<String, ArrayList<Media>>> = lists
suspend fun loadLists(anime: Boolean, userId: Int, sortOrder: String? = null) {
tryWithSuspend {
lists.postValue(Anilist.query.getMediaLists(anime, userId, sortOrder))
val res = Anilist.query.getMediaLists(anime, userId, sortOrder)
lists.postValue(res)
unfilteredLists.postValue(res)
}
}
fun filterLists(genre: String) {
if (genre == "All") {
lists.postValue(unfilteredLists.value)
return
}
val currentLists = unfilteredLists.value ?: return
val filteredLists = currentLists.mapValues { entry ->
entry.value.filter { media ->
genre in media.genres
} as ArrayList<Media>
}.toMutableMap()
lists.postValue(filteredLists)
}
}

View file

@ -0,0 +1,88 @@
package ani.dantotsu.notifications
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import ani.dantotsu.notifications.anilist.AnilistNotificationReceiver
import ani.dantotsu.notifications.comment.CommentNotificationReceiver
import ani.dantotsu.notifications.TaskScheduler.TaskType
import ani.dantotsu.notifications.subscription.SubscriptionNotificationReceiver
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import java.util.concurrent.TimeUnit
class AlarmManagerScheduler(private val context: Context) : TaskScheduler {
override fun scheduleRepeatingTask(taskType: TaskType, interval: Long) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = when (taskType) {
TaskType.COMMENT_NOTIFICATION -> Intent(
context,
CommentNotificationReceiver::class.java
)
TaskType.ANILIST_NOTIFICATION -> Intent(
context,
AnilistNotificationReceiver::class.java
)
TaskType.SUBSCRIPTION_NOTIFICATION -> Intent(
context,
SubscriptionNotificationReceiver::class.java
)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
taskType.ordinal,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val triggerAtMillis = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(interval)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
} else {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
}
} catch (e: SecurityException) {
PrefManager.setVal(PrefName.UseAlarmManager, false)
TaskScheduler.create(context, true).cancelAllTasks()
TaskScheduler.create(context, false).scheduleAllTasks(context)
}
}
override fun cancelTask(taskType: TaskType) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = when (taskType) {
TaskType.COMMENT_NOTIFICATION -> Intent(
context,
CommentNotificationReceiver::class.java
)
TaskType.ANILIST_NOTIFICATION -> Intent(
context,
AnilistNotificationReceiver::class.java
)
TaskType.SUBSCRIPTION_NOTIFICATION -> Intent(
context,
SubscriptionNotificationReceiver::class.java
)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
taskType.ordinal,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
}
}

View file

@ -0,0 +1,60 @@
package ani.dantotsu.notifications
import android.app.AlarmManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import ani.dantotsu.notifications.anilist.AnilistNotificationWorker
import ani.dantotsu.notifications.comment.CommentNotificationWorker
import ani.dantotsu.notifications.TaskScheduler.TaskType
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.util.Logger
class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
val scheduler = AlarmManagerScheduler(context)
PrefManager.init(context)
Logger.init(context)
Logger.log("Starting Dantotsu Subscription Service on Boot")
if (PrefManager.getVal(PrefName.UseAlarmManager)) {
val commentInterval =
CommentNotificationWorker.checkIntervals[PrefManager.getVal(PrefName.CommentNotificationInterval)]
val anilistInterval =
AnilistNotificationWorker.checkIntervals[PrefManager.getVal(PrefName.AnilistNotificationInterval)]
scheduler.scheduleRepeatingTask(
TaskType.COMMENT_NOTIFICATION,
commentInterval
)
scheduler.scheduleRepeatingTask(
TaskType.ANILIST_NOTIFICATION,
anilistInterval
)
}
}
}
}
class AlarmPermissionStateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED) {
PrefManager.init(context)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val canScheduleExactAlarms = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
alarmManager.canScheduleExactAlarms()
} else {
true
}
if (canScheduleExactAlarms) {
TaskScheduler.create(context, false).cancelAllTasks()
TaskScheduler.create(context, true).scheduleAllTasks(context)
} else {
TaskScheduler.create(context, true).cancelAllTasks()
TaskScheduler.create(context, false).scheduleAllTasks(context)
}
PrefManager.setVal(PrefName.UseAlarmManager, canScheduleExactAlarms)
}
}
}

View file

@ -1,4 +1,4 @@
package ani.dantotsu.subcriptions
package ani.dantotsu.notifications
import android.app.NotificationManager
import android.content.BroadcastReceiver
@ -9,7 +9,7 @@ import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
class NotificationClickReceiver : BroadcastReceiver() {
class IncognitoNotificationClickReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
PrefManager.setVal(PrefName.Incognito, false)

View file

@ -0,0 +1,52 @@
package ani.dantotsu.notifications
import android.content.Context
import ani.dantotsu.notifications.anilist.AnilistNotificationWorker
import ani.dantotsu.notifications.comment.CommentNotificationWorker
import ani.dantotsu.notifications.subscription.SubscriptionNotificationWorker
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
interface TaskScheduler {
fun scheduleRepeatingTask(taskType: TaskType, interval: Long)
fun cancelTask(taskType: TaskType)
fun cancelAllTasks() {
for (taskType in TaskType.entries) {
cancelTask(taskType)
}
}
fun scheduleAllTasks(context: Context) {
for (taskType in TaskType.entries) {
val interval = when (taskType) {
TaskType.COMMENT_NOTIFICATION -> CommentNotificationWorker.checkIntervals[PrefManager.getVal(
PrefName.CommentNotificationInterval)]
TaskType.ANILIST_NOTIFICATION -> AnilistNotificationWorker.checkIntervals[PrefManager.getVal(
PrefName.AnilistNotificationInterval)]
TaskType.SUBSCRIPTION_NOTIFICATION -> SubscriptionNotificationWorker.checkIntervals[PrefManager.getVal(
PrefName.SubscriptionNotificationInterval)]
}
scheduleRepeatingTask(taskType, interval)
}
}
companion object {
fun create(context: Context, useAlarmManager: Boolean): TaskScheduler {
return if (useAlarmManager) {
AlarmManagerScheduler(context)
} else {
WorkManagerScheduler(context)
}
}
}
enum class TaskType {
COMMENT_NOTIFICATION,
ANILIST_NOTIFICATION,
SUBSCRIPTION_NOTIFICATION
}
}
interface Task {
suspend fun execute(context: Context): Boolean
}

View file

@ -0,0 +1,89 @@
package ani.dantotsu.notifications
import android.content.Context
import androidx.work.Constraints
import androidx.work.PeriodicWorkRequest
import ani.dantotsu.notifications.TaskScheduler.TaskType
import ani.dantotsu.notifications.anilist.AnilistNotificationWorker
import ani.dantotsu.notifications.comment.CommentNotificationWorker
import ani.dantotsu.notifications.subscription.SubscriptionNotificationWorker
class WorkManagerScheduler(private val context: Context) : TaskScheduler {
override fun scheduleRepeatingTask(taskType: TaskType, interval: Long) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(androidx.work.NetworkType.CONNECTED)
.build()
when (taskType) {
TaskType.COMMENT_NOTIFICATION -> {
val recurringWork = PeriodicWorkRequest.Builder(
CommentNotificationWorker::class.java,
interval,
java.util.concurrent.TimeUnit.MINUTES,
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS,
java.util.concurrent.TimeUnit.MINUTES
)
.setConstraints(constraints)
.build()
androidx.work.WorkManager.getInstance(context).enqueueUniquePeriodicWork(
CommentNotificationWorker.WORK_NAME,
androidx.work.ExistingPeriodicWorkPolicy.UPDATE,
recurringWork
)
}
TaskType.ANILIST_NOTIFICATION -> {
val recurringWork = PeriodicWorkRequest.Builder(
AnilistNotificationWorker::class.java,
interval,
java.util.concurrent.TimeUnit.MINUTES,
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS,
java.util.concurrent.TimeUnit.MINUTES
)
.setConstraints(constraints)
.build()
androidx.work.WorkManager.getInstance(context).enqueueUniquePeriodicWork(
AnilistNotificationWorker.WORK_NAME,
androidx.work.ExistingPeriodicWorkPolicy.UPDATE,
recurringWork
)
}
TaskType.SUBSCRIPTION_NOTIFICATION -> {
val recurringWork = PeriodicWorkRequest.Builder(
SubscriptionNotificationWorker::class.java,
interval,
java.util.concurrent.TimeUnit.MINUTES,
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS,
java.util.concurrent.TimeUnit.MINUTES
)
.setConstraints(constraints)
.build()
androidx.work.WorkManager.getInstance(context).enqueueUniquePeriodicWork(
SubscriptionNotificationWorker.WORK_NAME,
androidx.work.ExistingPeriodicWorkPolicy.UPDATE,
recurringWork
)
}
}
}
override fun cancelTask(taskType: TaskType) {
when (taskType) {
TaskType.COMMENT_NOTIFICATION -> {
androidx.work.WorkManager.getInstance(context)
.cancelUniqueWork(CommentNotificationWorker.WORK_NAME)
}
TaskType.ANILIST_NOTIFICATION -> {
androidx.work.WorkManager.getInstance(context)
.cancelUniqueWork(AnilistNotificationWorker.WORK_NAME)
}
TaskType.SUBSCRIPTION_NOTIFICATION -> {
androidx.work.WorkManager.getInstance(context)
.cancelUniqueWork(SubscriptionNotificationWorker.WORK_NAME)
}
}
}
}

View file

@ -0,0 +1,26 @@
package ani.dantotsu.notifications.anilist
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import ani.dantotsu.notifications.AlarmManagerScheduler
import ani.dantotsu.notifications.TaskScheduler
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.util.Logger
import kotlinx.coroutines.runBlocking
class AnilistNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
Logger.log("AnilistNotificationReceiver: onReceive")
runBlocking {
AnilistNotificationTask().execute(context)
}
val anilistInterval =
AnilistNotificationWorker.checkIntervals[PrefManager.getVal(PrefName.AnilistNotificationInterval)]
AlarmManagerScheduler(context).scheduleRepeatingTask(
TaskScheduler.TaskType.ANILIST_NOTIFICATION,
anilistInterval
)
}
}

View file

@ -0,0 +1,109 @@
package ani.dantotsu.notifications.anilist
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 ani.dantotsu.MainActivity
import ani.dantotsu.R
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.notifications.Task
import ani.dantotsu.profile.activity.ActivityItemBuilder
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.util.Logger
import eu.kanade.tachiyomi.data.notification.Notifications
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AnilistNotificationTask : Task {
override suspend fun execute(context: Context): Boolean {
try {
withContext(Dispatchers.IO) {
PrefManager.init(context) //make sure prefs are initialized
val userId = PrefManager.getVal<String>(PrefName.AnilistUserId)
if (userId.isNotEmpty()) {
Anilist.getSavedToken()
val res = Anilist.query.getNotifications(
userId.toInt(),
resetNotification = false
)
val unreadNotificationCount = res?.data?.user?.unreadNotificationCount ?: 0
if (unreadNotificationCount > 0) {
val unreadNotifications =
res?.data?.page?.notifications?.sortedBy { it.id }
?.takeLast(unreadNotificationCount)
val lastId = PrefManager.getVal<Int>(PrefName.LastAnilistNotificationId)
val newNotifications = unreadNotifications?.filter { it.id > lastId }
val filteredTypes =
PrefManager.getVal<Set<String>>(PrefName.AnilistFilteredTypes)
newNotifications?.forEach {
if (!filteredTypes.contains(it.notificationType)) {
val content = ActivityItemBuilder.getContent(it)
val notification = createNotification(context, content, it.id)
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
NotificationManagerCompat.from(context)
.notify(
Notifications.CHANNEL_ANILIST,
System.currentTimeMillis().toInt(),
notification
)
}
}
}
if (newNotifications?.isNotEmpty() == true) {
PrefManager.setVal(
PrefName.LastAnilistNotificationId,
newNotifications.last().id
)
}
}
}
}
return true
} catch (e: Exception) {
Logger.log("AnilistNotificationTask: ${e.message}")
Logger.log(e)
return false
}
}
private fun createNotification(
context: Context,
content: String,
notificationId: Int? = null
): android.app.Notification {
val title = "New Anilist Notification"
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("FRAGMENT_TO_LOAD", "NOTIFICATIONS")
if (notificationId != null) {
Logger.log("notificationId: $notificationId")
putExtra("activityId", notificationId)
}
}
val pendingIntent = PendingIntent.getActivity(
context,
notificationId ?: 0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(context, Notifications.CHANNEL_ANILIST)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(title)
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
}
}

View file

@ -0,0 +1,25 @@
package ani.dantotsu.notifications.anilist
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import ani.dantotsu.util.Logger
class AnilistNotificationWorker(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
Logger.log("AnilistNotificationWorker: doWork")
return if (AnilistNotificationTask().execute(applicationContext)) {
Result.success()
} else {
Logger.log("AnilistNotificationWorker: doWork failed")
Result.retry()
}
}
companion object {
val checkIntervals = arrayOf(0L, 30, 60, 120, 240, 360, 720, 1440)
const val WORK_NAME = "ani.dantotsu.notifications.anilist.AnilistNotificationWorker"
}
}

View file

@ -0,0 +1,16 @@
package ani.dantotsu.notifications.comment
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import ani.dantotsu.util.Logger
import kotlinx.coroutines.runBlocking
class CommentNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
Logger.log("CommentNotificationReceiver: onReceive")
runBlocking {
CommentNotificationTask().execute(context)
}
}
}

View file

@ -0,0 +1,316 @@
package ani.dantotsu.notifications.comment
import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
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.comments.CommentsAPI
import ani.dantotsu.notifications.Task
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.util.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
class CommentNotificationTask : Task {
override suspend fun execute(context: Context): Boolean {
try {
withContext(Dispatchers.IO) {
PrefManager.init(context) //make sure prefs are initialized
val client = OkHttpClient()
CommentsAPI.fetchAuthToken(client)
val notificationResponse = CommentsAPI.getNotifications(client)
var notifications = notificationResponse?.notifications?.toMutableList()
//if we have at least one reply notification, we need to fetch the media titles
var names = emptyMap<Int, MediaNameFetch.Companion.ReturnedData>()
if (notifications?.any { it.type == 1 || it.type == null } == true) {
val mediaIds =
notifications.filter { it.type == 1 || it.type == null }.map { it.mediaId }
names = MediaNameFetch.fetchMediaTitles(mediaIds)
}
val recentGlobal = PrefManager.getVal<Int>(
PrefName.RecentGlobalNotification
)
notifications =
notifications?.filter { it.type != 3 || it.notificationId > recentGlobal }
?.toMutableList()
val newRecentGlobal =
notifications?.filter { it.type == 3 }?.maxOfOrNull { it.notificationId }
if (newRecentGlobal != null) {
PrefManager.setVal(PrefName.RecentGlobalNotification, newRecentGlobal)
}
if (notifications.isNullOrEmpty()) return@withContext
PrefManager.setVal(
PrefName.UnreadCommentNotifications,
PrefManager.getVal<Int>(PrefName.UnreadCommentNotifications) + (notifications.size
?: 0)
)
notifications.forEach {
val type: CommentNotificationWorker.NotificationType = when (it.type) {
1 -> CommentNotificationWorker.NotificationType.COMMENT_REPLY
2 -> CommentNotificationWorker.NotificationType.COMMENT_WARNING
3 -> CommentNotificationWorker.NotificationType.APP_GLOBAL
420 -> CommentNotificationWorker.NotificationType.NO_NOTIFICATION
else -> CommentNotificationWorker.NotificationType.UNKNOWN
}
val notification = when (type) {
CommentNotificationWorker.NotificationType.COMMENT_WARNING -> {
val title = "You received a warning"
val message = it.content ?: "Be more thoughtful with your comments"
val commentStore = CommentStore(
title,
message,
it.mediaId,
it.commentId
)
addNotificationToStore(commentStore)
createNotification(
context,
CommentNotificationWorker.NotificationType.COMMENT_WARNING,
message,
title,
it.mediaId,
it.commentId,
"",
""
)
}
CommentNotificationWorker.NotificationType.COMMENT_REPLY -> {
val title = "New Comment Reply"
val mediaName = names[it.mediaId]?.title ?: "Unknown"
val message = "${it.username} replied to your comment in $mediaName"
val commentStore = CommentStore(
title,
message,
it.mediaId,
it.commentId
)
addNotificationToStore(commentStore)
createNotification(
context,
CommentNotificationWorker.NotificationType.COMMENT_REPLY,
message,
title,
it.mediaId,
it.commentId,
names[it.mediaId]?.color ?: "#222222",
names[it.mediaId]?.coverImage ?: ""
)
}
CommentNotificationWorker.NotificationType.APP_GLOBAL -> {
val title = "Update from Dantotsu"
val message = it.content ?: "New feature available"
val commentStore = CommentStore(
title,
message,
null,
null
)
addNotificationToStore(commentStore)
createNotification(
context,
CommentNotificationWorker.NotificationType.APP_GLOBAL,
message,
title,
0,
0,
"",
""
)
}
CommentNotificationWorker.NotificationType.NO_NOTIFICATION -> {
PrefManager.removeCustomVal("genre_thumb")
PrefManager.removeCustomVal("banner_ANIME_time")
PrefManager.removeCustomVal("banner_MANGA_time")
PrefManager.setVal(PrefName.ImageUrl, it.content ?: "")
null
}
CommentNotificationWorker.NotificationType.UNKNOWN -> {
null
}
}
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
if (notification != null) {
NotificationManagerCompat.from(context)
.notify(
type.id,
System.currentTimeMillis().toInt(),
notification
)
}
}
}
}
return true
} catch (e: Exception) {
Logger.log("CommentNotificationTask: ${e.message}")
Logger.log(e)
return false
}
}
private fun addNotificationToStore(notification: CommentStore) {
val notificationStore = PrefManager.getNullableVal<List<CommentStore>>(
PrefName.CommentNotificationStore,
null
) ?: listOf()
val newStore = notificationStore.toMutableList()
if (newStore.size > 10) {
newStore.remove(newStore.minByOrNull { it.time })
}
if (newStore.any { it.content == notification.content }) {
return
}
newStore.add(notification)
PrefManager.setVal(PrefName.CommentNotificationStore, newStore)
}
private fun createNotification(
context: Context,
notificationType: CommentNotificationWorker.NotificationType,
message: String,
title: String,
mediaId: Int,
commentId: Int,
color: String,
imageUrl: String
): android.app.Notification? {
Logger.log(
"Creating notification of type $notificationType" +
", message: $message, title: $title, mediaId: $mediaId, commentId: $commentId"
)
val notification = when (notificationType) {
CommentNotificationWorker.NotificationType.COMMENT_WARNING -> {
val intent = Intent(context, MainActivity::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(
context,
commentId,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, notificationType.id)
.setContentTitle(title)
.setContentText(message)
.setSmallIcon(R.drawable.notification_icon)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
builder.build()
}
CommentNotificationWorker.NotificationType.COMMENT_REPLY -> {
val intent = Intent(context, MainActivity::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(
context,
commentId,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, notificationType.id)
.setContentTitle(title)
.setContentText(message)
.setSmallIcon(R.drawable.notification_icon)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
if (imageUrl.isNotEmpty()) {
val bitmap = getBitmapFromUrl(imageUrl)
if (bitmap != null) {
builder.setLargeIcon(bitmap)
}
}
if (color.isNotEmpty()) {
builder.color = Color.parseColor(color)
}
builder.build()
}
CommentNotificationWorker.NotificationType.APP_GLOBAL -> {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, notificationType.id)
.setContentTitle(title)
.setContentText(message)
.setSmallIcon(R.drawable.notification_icon)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
builder.build()
}
else -> {
null
}
}
return notification
}
private fun getBitmapFromVectorDrawable(context: Context, drawableId: Int): Bitmap? {
val drawable = ContextCompat.getDrawable(context, drawableId) ?: return null
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight, Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
private fun getBitmapFromUrl(url: String): Bitmap? {
return try {
val inputStream = java.net.URL(url).openStream()
BitmapFactory.decodeStream(inputStream)
} catch (e: Exception) {
null
}
}
}

View file

@ -0,0 +1,34 @@
package ani.dantotsu.notifications.comment
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import ani.dantotsu.util.Logger
import eu.kanade.tachiyomi.data.notification.Notifications
class CommentNotificationWorker(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
Logger.log("CommentNotificationWorker: doWork")
return if (CommentNotificationTask().execute(applicationContext)) {
Result.success()
} else {
Logger.log("CommentNotificationWorker: doWork failed")
Result.retry()
}
}
enum class NotificationType(val id: String) {
COMMENT_REPLY(Notifications.CHANNEL_COMMENTS),
COMMENT_WARNING(Notifications.CHANNEL_COMMENT_WARING),
APP_GLOBAL(Notifications.CHANNEL_APP_GLOBAL),
NO_NOTIFICATION("no_notification"),
UNKNOWN("unknown")
}
companion object {
val checkIntervals = arrayOf(0L, 480, 720, 1440)
const val WORK_NAME = "ani.dantotsu.notifications.comment.CommentNotificationWorker"
}
}

View file

@ -0,0 +1,20 @@
package ani.dantotsu.notifications.comment
import kotlinx.serialization.Serializable
@Suppress("INAPPROPRIATE_CONST_NAME")
@Serializable
data class CommentStore(
val title: String,
val content: String,
val mediaId: Int? = null,
val commentId: Int? = null,
val time: Long = System.currentTimeMillis(),
) : java.io.Serializable {
companion object {
@Suppress("INAPPROPRIATE_CONST_NAME")
private const val serialVersionUID = 1L
}
}

View file

@ -0,0 +1,82 @@
package ani.dantotsu.notifications.comment
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) {
coverImage {
medium
color
}
id
title {
romaji
}
}
""".trimIndent()
}
query += "}"
return query
}
suspend fun fetchMediaTitles(ids: List<Int>): Map<Int, ReturnedData> {
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, ReturnedData>()
mediaResponse.data.forEach { (_, mediaItem) ->
mediaMap[mediaItem.id] = ReturnedData(
mediaItem.title.romaji,
mediaItem.coverImage.medium,
mediaItem.coverImage.color
)
}
mediaMap
}
} catch (e: Exception) {
val errorMap = mutableMapOf<Int, ReturnedData>()
ids.forEach { errorMap[it] = ReturnedData("Unknown", "", "#222222") }
errorMap
}
}
private fun parseMediaResponseWithGson(response: String): MediaResponse {
val gson = Gson()
val type = object : TypeToken<MediaResponse>() {}.type
return gson.fromJson(response, type)
}
data class ReturnedData(val title: String, val coverImage: String, val color: String)
data class MediaResponse(val data: Map<String, MediaItem>)
data class MediaItem(
val coverImage: MediaCoverImage,
val id: Int,
val title: MediaTitle
)
data class MediaTitle(val romaji: String)
data class MediaCoverImage(val medium: String, val color: String)
}
}

View file

@ -1,6 +1,5 @@
package ani.dantotsu.subcriptions
package ani.dantotsu.notifications.subscription
import android.content.Context
import ani.dantotsu.R
import ani.dantotsu.currContext
import ani.dantotsu.media.Media
@ -17,15 +16,13 @@ import ani.dantotsu.parsers.MangaSources
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.tryWithSuspend
import ani.dantotsu.util.Logger
import kotlinx.coroutines.withTimeoutOrNull
class SubscriptionHelper {
companion object {
private fun loadSelected(
context: Context,
mediaId: Int,
isAdult: Boolean,
isAnime: Boolean
mediaId: Int
): Selected {
val data =
PrefManager.getNullableCustomVal("${mediaId}-select", null, Selected::class.java)
@ -37,26 +34,25 @@ class SubscriptionHelper {
return data
}
private fun saveSelected(context: Context, mediaId: Int, data: Selected) {
private fun saveSelected( mediaId: Int, data: Selected) {
PrefManager.setCustomVal("${mediaId}-select", data)
}
fun getAnimeParser(context: Context, isAdult: Boolean, id: Int): AnimeParser {
val sources = if (isAdult) HAnimeSources else AnimeSources
val selected = loadSelected(context, id, isAdult, true)
fun getAnimeParser(id: Int): AnimeParser {
val sources = AnimeSources
Logger.log("getAnimeParser size: ${sources.list.size}")
val selected = loadSelected(id)
val parser = sources[selected.sourceIndex]
parser.selectDub = selected.preferDub
return parser
}
suspend fun getEpisode(
context: Context,
parser: AnimeParser,
id: Int,
isAdult: Boolean
id: Int
): Episode? {
val selected = loadSelected(context, id, isAdult, true)
val selected = loadSelected(id)
val ep = withTimeoutOrNull(10 * 1000) {
tryWithSuspend {
val show = parser.loadSavedShowResponse(id) ?: throw Exception(
@ -76,23 +72,21 @@ class SubscriptionHelper {
return ep?.apply {
selected.latest = number.toFloat()
saveSelected(context, id, selected)
saveSelected(id, selected)
}
}
fun getMangaParser(context: Context, isAdult: Boolean, id: Int): MangaParser {
val sources = if (isAdult) HMangaSources else MangaSources
val selected = loadSelected(context, id, isAdult, false)
fun getMangaParser(id: Int): MangaParser {
val sources = MangaSources
val selected = loadSelected(id)
return sources[selected.sourceIndex]
}
suspend fun getChapter(
context: Context,
parser: MangaParser,
id: Int,
isAdult: Boolean
id: Int
): MangaChapter? {
val selected = loadSelected(context, id, isAdult, true)
val selected = loadSelected(id)
val chp = withTimeoutOrNull(10 * 1000) {
tryWithSuspend {
val show = parser.loadSavedShowResponse(id) ?: throw Exception(
@ -112,7 +106,7 @@ class SubscriptionHelper {
return chp?.apply {
selected.latest = MangaNameAdapter.findChapterNumber(number) ?: 0f
saveSelected(context, id, selected)
saveSelected(id, selected)
}
}
@ -124,21 +118,21 @@ class SubscriptionHelper {
val image: String?
) : java.io.Serializable
private const val subscriptions = "subscriptions"
private const val SUBSCRIPTIONS = "subscriptions"
@Suppress("UNCHECKED_CAST")
fun getSubscriptions(): Map<Int, SubscribeMedia> =
(PrefManager.getNullableCustomVal(
subscriptions,
SUBSCRIPTIONS,
null,
Map::class.java
) as? Map<Int, SubscribeMedia>)
?: mapOf<Int, SubscribeMedia>().also { PrefManager.setCustomVal(subscriptions, it) }
?: mapOf<Int, SubscribeMedia>().also { PrefManager.setCustomVal(SUBSCRIPTIONS, it) }
@Suppress("UNCHECKED_CAST")
fun saveSubscription(context: Context, media: Media, subscribed: Boolean) {
fun saveSubscription(media: Media, subscribed: Boolean) {
val data = PrefManager.getNullableCustomVal(
subscriptions,
SUBSCRIPTIONS,
null,
Map::class.java
) as? MutableMap<Int, SubscribeMedia>
@ -157,7 +151,7 @@ class SubscriptionHelper {
} else {
data.remove(media.id)
}
PrefManager.setCustomVal(subscriptions, data)
PrefManager.setCustomVal(SUBSCRIPTIONS, data)
}
}
}

View file

@ -0,0 +1,26 @@
package ani.dantotsu.notifications.subscription
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import ani.dantotsu.notifications.AlarmManagerScheduler
import ani.dantotsu.notifications.TaskScheduler
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.util.Logger
import kotlinx.coroutines.runBlocking
class SubscriptionNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
Logger.log("SubscriptionNotificationReceiver: onReceive")
runBlocking {
SubscriptionNotificationTask().execute(context)
}
val subscriptionInterval =
SubscriptionNotificationWorker.checkIntervals[PrefManager.getVal(PrefName.SubscriptionNotificationInterval)]
AlarmManagerScheduler(context).scheduleRepeatingTask(
TaskScheduler.TaskType.SUBSCRIPTION_NOTIFICATION,
subscriptionInterval
)
}
}

View file

@ -0,0 +1,222 @@
package ani.dantotsu.notifications.subscription
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import ani.dantotsu.App
import ani.dantotsu.FileUrl
import ani.dantotsu.R
import ani.dantotsu.connections.anilist.UrlMedia
import ani.dantotsu.hasNotificationPermission
import ani.dantotsu.notifications.Task
import ani.dantotsu.parsers.AnimeSources
import ani.dantotsu.parsers.Episode
import ani.dantotsu.parsers.MangaChapter
import ani.dantotsu.parsers.MangaSources
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.util.Logger
import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_SUBSCRIPTION_CHECK
import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_SUBSCRIPTION_CHECK_PROGRESS
import eu.kanade.tachiyomi.data.notification.Notifications.ID_SUBSCRIPTION_CHECK_PROGRESS
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SubscriptionNotificationTask : Task {
private var currentlyPerforming = false
@SuppressLint("MissingPermission")
override suspend fun execute(context: Context): Boolean {
if (!currentlyPerforming) {
try {
withContext(Dispatchers.IO) {
PrefManager.init(context)
currentlyPerforming = true
App.context = context
Logger.log("SubscriptionNotificationTask: execute")
var timeout = 15_000L
do {
delay(1000)
timeout -= 1000
} while (timeout > 0 && !AnimeSources.isInitialized && !MangaSources.isInitialized)
Logger.log("SubscriptionNotificationTask: timeout: $timeout")
if (timeout <= 0) {
currentlyPerforming = false
return@withContext
}
val subscriptions = SubscriptionHelper.getSubscriptions()
var i = 0
val index = subscriptions.map { i++; it.key to i }.toMap()
val notificationManager = NotificationManagerCompat.from(context)
val progressEnabled: Boolean =
PrefManager.getVal(PrefName.SubscriptionCheckingNotifications)
val progressNotification = if (progressEnabled) getProgressNotification(
context,
subscriptions.size
) else null
if (progressNotification != null && hasNotificationPermission(context)) {
notificationManager.notify(
ID_SUBSCRIPTION_CHECK_PROGRESS,
progressNotification.build()
)
//Seems like if the parent coroutine scope gets cancelled, the notification stays
//So adding this as a safeguard? dk if this will be useful
CoroutineScope(Dispatchers.Main).launch {
delay(5 * subscriptions.size * 1000L)
notificationManager.cancel(ID_SUBSCRIPTION_CHECK_PROGRESS)
}
}
fun progress(progress: Int, parser: String, media: String) {
if (progressNotification != null && hasNotificationPermission(context))
notificationManager.notify(
ID_SUBSCRIPTION_CHECK_PROGRESS,
progressNotification
.setProgress(subscriptions.size, progress, false)
.setContentText("$media on $parser")
.build()
)
}
subscriptions.toList().map {
val media = it.second
val text = if (media.isAnime) {
val parser =
SubscriptionHelper.getAnimeParser(media.id)
progress(index[it.first]!!, parser.name, media.name)
val ep: Episode? =
SubscriptionHelper.getEpisode(
parser,
media.id
)
if (ep != null) context.getString(R.string.episode) + "${ep.number}${
if (ep.title != null) " : ${ep.title}" else ""
}${
if (ep.isFiller) " [Filler]" else ""
} " + context.getString(R.string.just_released) to ep.thumbnail
else null
} else {
val parser =
SubscriptionHelper.getMangaParser(media.id)
progress(index[it.first]!!, parser.name, media.name)
val ep: MangaChapter? =
SubscriptionHelper.getChapter(
parser,
media.id
)
if (ep != null) ep.number + " " + context.getString(R.string.just_released) to null
else null
} ?: return@map
val notification = createNotification(
context.applicationContext,
media,
text.first,
text.second
)
if (hasNotificationPermission(context)) {
NotificationManagerCompat.from(context)
.notify(
CHANNEL_SUBSCRIPTION_CHECK,
System.currentTimeMillis().toInt(),
notification
)
}
}
if (progressNotification != null) notificationManager.cancel(
ID_SUBSCRIPTION_CHECK_PROGRESS
)
currentlyPerforming = false
}
return true
} catch (e: Exception) {
Logger.log("SubscriptionNotificationTask: ${e.message}")
Logger.log(e)
return false
}
} else {
return false
}
}
@SuppressLint("MissingPermission")
private fun createNotification(
context: Context,
media: SubscriptionHelper.Companion.SubscribeMedia,
text: String,
thumbnail: FileUrl?
): android.app.Notification {
val pendingIntent = getIntent(context, media.id)
val icon =
if (media.isAnime) R.drawable.ic_round_movie_filter_24 else R.drawable.ic_round_menu_book_24
val builder = NotificationCompat.Builder(context, CHANNEL_SUBSCRIPTION_CHECK)
.setSmallIcon(icon)
.setContentTitle(media.name)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
if (thumbnail != null) {
val bitmap = getBitmapFromUrl(thumbnail.url)
if (bitmap != null) {
builder.setLargeIcon(bitmap)
}
}
return builder.build()
}
private fun getProgressNotification(
context: Context,
size: Int
): NotificationCompat.Builder {
return NotificationCompat.Builder(context, CHANNEL_SUBSCRIPTION_CHECK_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(context.getString(R.string.checking_subscriptions_title))
.setProgress(size, 0, false)
.setOngoing(true)
.setAutoCancel(false)
}
private fun getBitmapFromUrl(url: String): Bitmap? {
return try {
val inputStream = java.net.URL(url).openStream()
BitmapFactory.decodeStream(inputStream)
} catch (e: Exception) {
null
}
}
private fun getIntent(context: Context, mediaId: Int): PendingIntent {
val notifyIntent = Intent(context, UrlMedia::class.java)
.putExtra("media", mediaId)
.setAction(mediaId.toString())
.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
return PendingIntent.getActivity(
context, mediaId, notifyIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT
} else {
PendingIntent.FLAG_ONE_SHOT
}
)
}
}

View file

@ -0,0 +1,27 @@
package ani.dantotsu.notifications.subscription
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import ani.dantotsu.notifications.anilist.AnilistNotificationTask
import ani.dantotsu.util.Logger
class SubscriptionNotificationWorker(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
Logger.log("SubscriptionNotificationWorker: doWork")
return if (AnilistNotificationTask().execute(applicationContext)) {
Result.success()
} else {
Logger.log("SubscriptionNotificationWorker: doWork failed")
Result.retry()
}
}
companion object {
val checkIntervals = arrayOf(0L, 480, 720, 1440)
const val WORK_NAME =
"ani.dantotsu.notifications.subscription.SubscriptionNotificationWorker"
}
}

View file

@ -0,0 +1,53 @@
package ani.dantotsu.others
import android.app.Activity
import android.graphics.Rect
import android.view.View
import android.widget.FrameLayout
class AndroidBug5497Workaround private constructor(activity: Activity, private val callback: (Boolean) -> Unit) {
private val mChildOfContent: View
private var usableHeightPrevious = 0
private val frameLayoutParams: FrameLayout.LayoutParams
init {
val content = activity.findViewById(android.R.id.content) as FrameLayout
mChildOfContent = content.getChildAt(0)
mChildOfContent.viewTreeObserver.addOnGlobalLayoutListener { possiblyResizeChildOfContent() }
frameLayoutParams = mChildOfContent.layoutParams as FrameLayout.LayoutParams
}
private fun possiblyResizeChildOfContent() {
val usableHeightNow = computeUsableHeight()
if (usableHeightNow != usableHeightPrevious) {
val usableHeightSansKeyboard = mChildOfContent.rootView.height
val heightDifference = usableHeightSansKeyboard - usableHeightNow
if (heightDifference > usableHeightSansKeyboard / 4) {
// keyboard probably just became visible
callback.invoke(true)
frameLayoutParams.height = usableHeightSansKeyboard - heightDifference
} else {
// keyboard probably just became hidden
callback.invoke(false)
frameLayoutParams.height = usableHeightSansKeyboard
}
mChildOfContent.requestLayout()
usableHeightPrevious = usableHeightNow
}
}
private fun computeUsableHeight(): Int {
val r = Rect()
mChildOfContent.getWindowVisibleDisplayFrame(r)
return r.bottom
}
companion object {
// For more information, see https://issuetracker.google.com/issues/36911528
// To use this class, simply invoke assistActivity() on an Activity that already has its content view set.
fun assistActivity(activity: Activity, callback: (Boolean) -> Unit) {
AndroidBug5497Workaround(activity, callback)
}
}
}

View file

@ -1,6 +1,6 @@
package ani.dantotsu.others
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import java.util.regex.Pattern
import kotlin.math.pow
@ -64,7 +64,7 @@ class JsUnpacker(packedJS: String?) {
return decoded.toString()
}
} catch (e: Exception) {
logger(e)
Logger.log(e)
}
return null
}

View file

@ -2,7 +2,7 @@ package ani.dantotsu.others
import ani.dantotsu.FileUrl
import ani.dantotsu.client
import ani.dantotsu.logger
import ani.dantotsu.util.Logger
import ani.dantotsu.media.Media
import ani.dantotsu.media.anime.Episode
import ani.dantotsu.tryWithSuspend
@ -41,8 +41,7 @@ object Kitsu {
}
suspend fun getKitsuEpisodesDetails(media: Media): Map<String, Episode>? {
val print = false
logger("Kitsu : title=${media.mainName()}", print)
Logger.log("Kitsu : title=${media.mainName()}")
val query =
"""
query {
@ -70,7 +69,7 @@ query {
val result = getKitsuData(query) ?: return null
logger("Kitsu : result=$result", print)
//Logger.log("Kitsu : result=$result")
media.idKitsu = result.data?.lookupMapping?.id
val a = (result.data?.lookupMapping?.episodes?.nodes ?: return null).mapNotNull { ep ->
val num = ep?.number?.toString() ?: return@mapNotNull null
@ -81,7 +80,7 @@ query {
thumb = FileUrl[ep.thumbnail?.original?.url],
)
}.toMap()
logger("Kitsu : a=$a", print)
//Logger.log("Kitsu : a=$a")
return a
}

View file

@ -44,7 +44,7 @@ class SpoilerPlugin : AbstractMarkwonPlugin() {
}
companion object {
private val RE = Pattern.compile("~!.+?!~")
private val RE = Pattern.compile("\\|\\|.+?\\|\\|")
private fun applySpoilerSpans(spannable: Spannable) {
val text = spannable.toString()
val matcher = RE.matcher(text)

View file

@ -11,7 +11,7 @@ class CloudFlare(override val location: FileUrl) : WebViewBottomDialog() {
override var title = "Cloudflare Bypass"
override val webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
val cookie = cookies.getCookie(url.toString())
val cookie = cookies?.getCookie(url.toString())
if (cookie?.contains(cfTag) == true) {
val clearance = cookie.substringAfter("$cfTag=").substringBefore(";")
privateCallback.invoke(mapOf(cfTag to clearance))

View file

@ -34,8 +34,8 @@ class CookieCatcher : AppCompatActivity() {
val webView = findViewById<WebView>(R.id.discordWebview)
val cookies: CookieManager = Injekt.get<NetworkHelper>().cookieJar.manager
cookies.setAcceptThirdPartyCookies(webView, true)
val cookies: CookieManager? = Injekt.get<NetworkHelper>().cookieJar.manager
cookies?.setAcceptThirdPartyCookies(webView, true)
webView.apply {
settings.javaScriptEnabled = true

Some files were not shown because too many files have changed in this diff Show more