diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 21e1f14b..a1277018 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -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: diff --git a/.gitignore b/.gitignore index c81d22db..6cc9cdea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ local.properties # Log/OS Files *.log +# Secrets +apikey.properties + # Android Studio generated files and folders captures/ .externalNativeBuild/ diff --git a/app/build.gradle b/app/build.gradle index 89eafa1b..8886aa90 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8d6bcddf..7f171c72 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 { + (...); +} +-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 diff --git a/app/src/google/java/ani/dantotsu/others/AppUpdater.kt b/app/src/google/java/ani/dantotsu/others/AppUpdater.kt index 153cf970..c368ff68 100644 --- a/app/src/google/java/ani/dantotsu/others/AppUpdater.kt +++ b/app/src/google/java/ani/dantotsu/others/AppUpdater.kt @@ -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,9 +46,10 @@ object AppUpdater { .parsed().map { Mapper.json.decodeFromJsonElement(it) } - val r = res.filter { it.prerelease }.filter { !it.tagName.contains("fdroid") }.maxByOrNull { - it.timeStamp() - } ?: throw Exception("No Pre Release Found") + 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", "") (r.body ?: "") to v.ifEmpty { throw Exception("Weird Version : ${r.tagName}") } } else { @@ -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) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 57e6b8c2..cf650323 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,7 +16,7 @@ - + @@ -69,7 +69,7 @@ android:name="android.appwidget.provider" android:resource="@xml/currently_airing_widget_info" /> - + + + + + + + @@ -117,6 +136,8 @@ android:name=".media.CalendarActivity" android:parentActivityName=".MainActivity" /> + + android:theme="@style/Theme.Dantotsu.NeverCutout" + android:windowSoftInputMode="adjustResize|stateHidden"/> + + + + + + + + + + + + + + + + + + + + + - + + + + + + - + + + (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) } } diff --git a/app/src/main/java/ani/dantotsu/Functions.kt b/app/src/main/java/ani/dantotsu/Functions.kt index f8756e39..f51b28ff 100644 --- a/app/src/main/java/ani/dantotsu/Functions.kt +++ b/app/src/main/java/ani/dantotsu/Functions.kt @@ -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,47 +219,73 @@ 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 - if (this.resources.configuration.orientation != Configuration.ORIENTATION_PORTRAIT) { - val behavior = BottomSheetBehavior.from(requireView().parent as View) - behavior.state = BottomSheetBehavior.STATE_EXPANDED + 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 + } + val typedValue = TypedValue() + val theme = requireContext().theme + theme.resolveAttribute( + com.google.android.material.R.attr.colorSurface, + typedValue, + true + ) + window.navigationBarColor = typedValue.data } - val typedValue = TypedValue() - val theme = requireContext().theme - theme.resolveAttribute( - com.google.android.material.R.attr.colorSurface, - typedValue, - true - ) - window.navigationBarColor = typedValue.data } override fun show(manager: FragmentManager, tag: String?) { @@ -202,21 +299,35 @@ fun isOnline(context: Context): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager return tryWith { - val cap = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) - return@tryWith if (cap != null) { - when { - cap.hasTransport(TRANSPORT_BLUETOOTH) || - cap.hasTransport(TRANSPORT_CELLULAR) || - cap.hasTransport(TRANSPORT_ETHERNET) || - cap.hasTransport(TRANSPORT_LOWPAN) || - cap.hasTransport(TRANSPORT_USB) || - cap.hasTransport(TRANSPORT_VPN) || - cap.hasTransport(TRANSPORT_WIFI) || - cap.hasTransport(TRANSPORT_WIFI_AWARE) -> true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val cap = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + return@tryWith if (cap != null) { + when { + cap.hasTransport(TRANSPORT_BLUETOOTH) || + cap.hasTransport(TRANSPORT_CELLULAR) || + cap.hasTransport(TRANSPORT_ETHERNET) || + cap.hasTransport(TRANSPORT_LOWPAN) || + cap.hasTransport(TRANSPORT_USB) || + cap.hasTransport(TRANSPORT_VPN) || + cap.hasTransport(TRANSPORT_WIFI) || + cap.hasTransport(TRANSPORT_WIFI_AWARE) -> true - else -> false - } - } else false + 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(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 (toast) snackString(activity.getString(R.string.copied_text, string)) + 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 { 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().logException(e) } + return null } open class NoPaddingArrayAdapter(context: Context, layoutId: Int, items: List) : @@ -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(PrefName.BlurRadius).toInt() + val sampling = PrefManager.getVal(PrefName.BlurSampling).toInt() + if (PrefManager.getVal(PrefName.BlurBanners)) { + val context = imageView.context + if (!(context as Activity).isDestroyed) { + val url = PrefManager.getVal(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 { + override fun onResourceReady( + resource: Any, + model: Any, + target: Target, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + if (resource is GifDrawable) { + resource.start() + } + return false + } + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + Logger.log("Image failed to load: $model") + Logger.log(e as Exception) + return false + } + }) + } + + override fun load(drawable: AsyncDrawable): RequestBuilder { + 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 +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/MainActivity.kt b/app/src/main/java/ani/dantotsu/MainActivity.kt index 8f5dd79f..23d8e508 100644 --- a/app/src/main/java/ani/dantotsu/MainActivity.kt +++ b/app/src/main/java/ani/dantotsu/MainActivity.kt @@ -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(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() { + 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 { 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,12 +364,14 @@ class MainActivity : AppCompatActivity() { mainViewPager.setCurrentItem(newIndex, false) } }) - navbar.selectTabAt(selectedOption) - mainViewPager.post { - mainViewPager.setCurrentItem( - selectedOption, - false - ) + if (mainViewPager.getCurrentItem() != selectedOption) { + navbar.selectTabAt(selectedOption) + mainViewPager.post { + mainViewPager.setCurrentItem( + selectedOption, + 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(R.id.userAgentTextBox)?.hint = "Password" + val subtitleTextView = dialogView.findViewById(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(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) : diff --git a/app/src/main/java/ani/dantotsu/Network.kt b/app/src/main/java/ani/dantotsu/Network.kt index dd58849a..aa71cb48 100644 --- a/app/src/main/java/ani/dantotsu/Network.kt +++ b/app/src/main/java/ani/dantotsu/Network.kt @@ -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 tryWith(post: Boolean = false, snackbar: Boolean = true, call: () -> T): T? { @@ -134,7 +136,7 @@ suspend fun tryWithSuspend( * A url, which can also have headers * **/ data class FileUrl( - val url: String, + var url: String, val headers: Map = mapOf() ) : Serializable { companion object { diff --git a/app/src/main/java/ani/dantotsu/connections/UpdateProgress.kt b/app/src/main/java/ani/dantotsu/connections/UpdateProgress.kt index acfd0cdb..40fbd116 100644 --- a/app/src/main/java/ani/dantotsu/connections/UpdateProgress.kt +++ b/app/src/main/java/ani/dantotsu/connections/UpdateProgress.kt @@ -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, diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt b/app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt index 85979714..804343f6 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt @@ -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? = null var tags: Map>? = 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 } } } diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt index 686381f1..c2b8eb24 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt @@ -13,6 +13,23 @@ class AnilistMutations { executeQuery(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(query) + return result?.get("errors") == null && result != null + } + + enum class FavType { + ANIME, MANGA, CHARACTER, STAFF, STUDIO + } + suspend fun editList( mediaID: Int, progress: Int? = null, diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt index 92cfc52d..c9ff694d 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt @@ -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(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, 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, 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 { val returnArray = arrayListOf() val map = mutableMapOf() @@ -299,9 +382,17 @@ class AnilistQueries { } } } - val set = PrefManager.getCustomVal>("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(), + List::class.java + ) as List + 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 { + suspend fun favMedia(anime: Boolean, id: Int? = Anilist.userid): ArrayList { var hasNextPage = true var page = 0 suspend fun getNextPage(page: Int): List { - val response = executeQuery("""{${favMediaQuery(anime, page)}}""") + val response = executeQuery("""{${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 { @@ -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> { @@ -423,7 +514,8 @@ class AnilistQueries { }, recommendationPlannedQueryManga: ${recommendationPlannedQuery("MANGA")}""" query += """}""".trimEnd(',') - val response = executeQuery(query) + val response = executeQuery(query, show = true) + Logger.log(response.toString()) val returnMap = mutableMapOf>() fun current(type: String) { val subMap = mutableMapOf() @@ -446,10 +538,18 @@ class AnilistQueries { subMap[m.id] = m } } - val set = PrefManager.getCustomVal>("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(), + List::class.java + ) as List + 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>("continue_$type", setOf()).toMutableSet() - if (set.isNotEmpty()) { - set.reversed().forEach { + val list = PrefManager.getNullableCustomVal( + "continueAnimeList", + listOf(), + List::class.java + ) as List + 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("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("""{ 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> { val response = - executeQuery("""{ 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("""{ 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>() val unsorted = mutableMapOf>() val all = arrayListOf() @@ -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>(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 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( + """mutation{ToggleFollow(userId:$id){id, isFollowing, isFollower}}""" + ) + } + + suspend fun toggleLike(id: Int, type: String): ToggleLike? { + return executeQuery( + """mutation Like{ToggleLikeV2(id:$id,type:$type){__typename}}""" + ) + } + + suspend fun getUserProfile(id: Int): Query.UserProfileResponse? { + return executeQuery( + """{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( + """{User(name:"$username"){id}}""", + force = true + )?.data?.user?.id + } + + suspend fun getUserStatistics(id: Int, sort: String = "ID"): Query.StatisticsResponse? { + return executeQuery( + """{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( + """{Page {following(userId:${id},sort:[USERNAME]){id name avatar{large medium}bannerImage}}}""", + force = true + ) + } + + suspend fun userFollowers(id: Int): Query.Follower? { + return executeQuery( + """{Page {followers(userId:${id},sort:[USERNAME]){id name avatar{large medium}bannerImage}}}""", + force = true + ) + } + + suspend fun initProfilePage(id: Int): Query.ProfilePageMedia? { + return executeQuery( + """{ + 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( + """{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( + """{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 + } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt index 95d84ec9..5eb28a79 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt @@ -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) } @@ -331,4 +333,64 @@ class GenresViewModel : ViewModel() { } } } +} + +class ProfileViewModel : ViewModel() { + + private val mangaFav: MutableLiveData> = + MutableLiveData>(null) + + fun getMangaFav(): LiveData> = mangaFav + + private val animeFav: MutableLiveData> = + MutableLiveData>(null) + + fun getAnimeFav(): LiveData> = animeFav + + private val listImages: MutableLiveData> = + MutableLiveData>(arrayListOf()) + + fun getListImages(): LiveData> = 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(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) + } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/Login.kt b/app/src/main/java/ani/dantotsu/connections/anilist/Login.kt index 7512fc5d..0ec87efb 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/Login.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/Login.kt @@ -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 diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/UrlMedia.kt b/app/src/main/java/ani/dantotsu/connections/anilist/UrlMedia.kt index 98d63d27..318bbc29 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/UrlMedia.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/UrlMedia.kt @@ -11,20 +11,25 @@ import ani.dantotsu.themes.ThemeManager class UrlMedia : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - ThemeManager(this).applyTheme() - 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 - startMainActivity( - this, - bundleOf("mediaId" to id, "mal" to isMAL, "continue" to continueMedia) - ) + 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 + isMAL = data?.host != "anilist.co" + id = data?.pathSegments?.getOrNull(1)?.toIntOrNull() + } else loadMedia = id + startMainActivity( + this, + bundleOf("mediaId" to id, "mal" to isMAL, "continue" to continueMedia) + ) + } else { + val username = data.pathSegments?.getOrNull(1) + startMainActivity(this, bundleOf("username" to username)) + } } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/api/Character.kt b/app/src/main/java/ani/dantotsu/connections/anilist/api/Character.kt index 766df516..e0539085 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/api/Character.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/api/Character.kt @@ -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?, -) \ No newline at end of file +) : java.io.Serializable \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/api/Data.kt b/app/src/main/java/ani/dantotsu/connections/anilist/api/Data.kt index 8e53de02..58445406 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/api/Data.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/api/Data.kt @@ -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? - ) + ) : java.io.Serializable } @Serializable data class MediaTagCollection( @SerialName("data") val data: Data - ) { + ) : java.io.Serializable { @Serializable data class Data( @SerialName("MediaTagCollection") val mediaTagCollection: List? - ) + ) : 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? + ) : java.io.Serializable + + @Serializable + data class FollowingPage( + @SerialName("following") + val following: List? + ) : 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, + ): 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, + ): 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, //downstream it's the same as character + ): java.io.Serializable + + @Serializable + data class UserStudioFavouritesCollection( + @SerialName("nodes") + val nodes: List, + ): 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, + @SerialName("statuses") + val statuses: List, + @SerialName("scores") + val scores: List, + @SerialName("lengths") + val lengths: List, + @SerialName("releaseYears") + val releaseYears: List, + @SerialName("startYears") + val startYears: List, + @SerialName("genres") + val genres: List, + @SerialName("tags") + val tags: List, + @SerialName("countries") + val countries: List, + @SerialName("voiceActors") + val voiceActors: List, + @SerialName("staff") + val staff: List, + @SerialName("studios") + val studios: List + ) : 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, + @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, + @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, + @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, + @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, + @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, + @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, + @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, + @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, + @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, + @SerialName("voiceActor") + val voiceActor: VoiceActor, + @SerialName("characterIds") + val characterIds: List + ) : 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?, + @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, + @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, + @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?, // // Notification query diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/api/Feed.kt b/app/src/main/java/ani/dantotsu/connections/anilist/api/Feed.kt new file mode 100644 index 00000000..5f0af956 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/connections/anilist/api/Feed.kt @@ -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 +) : 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?, + @SerialName("likes") + val likes: List?, +) : 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?, +) : 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 \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/api/Media.kt b/app/src/main/java/ani/dantotsu/connections/anilist/api/Media.kt index 491f4df4..da2a989e 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/api/Media.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/api/Media.kt @@ -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?, -) \ No newline at end of file +) : java.io.Serializable \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/api/Notification.kt b/app/src/main/java/ani/dantotsu/connections/anilist/api/Notification.kt new file mode 100644 index 00000000..b7d1cf0c --- /dev/null +++ b/app/src/main/java/ani/dantotsu/connections/anilist/api/Notification.kt @@ -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, +) : 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? = null, + @SerialName("context") + val context: String? = null, + @SerialName("reason") + val reason: String? = null, + @SerialName("deletedMediaTitle") + val deletedMediaTitle: String? = null, + @SerialName("deletedMediaTitles") + val deletedMediaTitles: List? = 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 diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/api/Staff.kt b/app/src/main/java/ani/dantotsu/connections/anilist/api/Staff.kt index b4742e5b..7b56f693 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/api/Staff.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/api/Staff.kt @@ -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?, diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/api/User.kt b/app/src/main/java/ani/dantotsu/connections/anilist/api/User.kt index dddef0d5..b1ec8862 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/api/User.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/api/User.kt @@ -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?, diff --git a/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt b/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt new file mode 100644 index 00000000..defb5bad --- /dev/null +++ b/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt @@ -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(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(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(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(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(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(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(PrefName.CommentTokenExpiry) + if (tokenExpiry < System.currentTimeMillis() + tokenLifetime) { + val commentResponse = + PrefManager.getNullableVal(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(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 { + 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().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(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 +) + +@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, + @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 { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/crashlytics/CrashlyticsStub.kt b/app/src/main/java/ani/dantotsu/connections/crashlytics/CrashlyticsStub.kt index 524d42bf..6c4e988a 100644 --- a/app/src/main/java/ani/dantotsu/connections/crashlytics/CrashlyticsStub.kt +++ b/app/src/main/java/ani/dantotsu/connections/crashlytics/CrashlyticsStub.kt @@ -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) { diff --git a/app/src/main/java/ani/dantotsu/connections/discord/Discord.kt b/app/src/main/java/ani/dantotsu/connections/discord/Discord.kt index c151850d..ea34f1bf 100644 --- a/app/src/main/java/ani/dantotsu/connections/discord/Discord.kt +++ b/app/src/main/java/ani/dantotsu/connections/discord/Discord.kt @@ -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" } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/discord/DiscordService.kt b/app/src/main/java/ani/dantotsu/connections/discord/DiscordService.kt index 761dbeac..0a29ab60 100644 --- a/app/src/main/java/ani/dantotsu/connections/discord/DiscordService.kt +++ b/app/src/main/java/ani/dantotsu/connections/discord/DiscordService.kt @@ -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() { diff --git a/app/src/main/java/ani/dantotsu/connections/discord/RPC.kt b/app/src/main/java/ani/dantotsu/connections/discord/RPC.kt index 822218c5..8a4f314b 100644 --- a/app/src/main/java/ani/dantotsu/connections/discord/RPC.kt +++ b/app/src/main/java/ani/dantotsu/connections/discord/RPC.kt @@ -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) ) )) } diff --git a/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt index dd0198ec..22b20dcb 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt @@ -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().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") diff --git a/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt b/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt index 4c91a43b..4ff5497c 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt @@ -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().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().logException(e) return OfflineAnimeModel( "unknown", diff --git a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt index de91960c..188c9dc3 100644 --- a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt @@ -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().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}" diff --git a/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt b/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt index 7bd43c9a..0c97a8df 100644 --- a/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt +++ b/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt @@ -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().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().logException(e) return OfflineMangaModel( "unknown", diff --git a/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt index f6a7e2ed..0c3575a3 100644 --- a/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt @@ -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().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}" diff --git a/app/src/main/java/ani/dantotsu/download/video/Helper.kt b/app/src/main/java/ani/dantotsu/download/video/Helper.kt index 7f92eda6..4e82e76c 100644 --- a/app/src/main/java/ani/dantotsu/download/video/Helper.kt +++ b/app/src/main/java/ani/dantotsu/download/video/Helper.kt @@ -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") } } } diff --git a/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt b/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt index f3baeb3f..2e110541 100644 --- a/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt +++ b/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt @@ -283,7 +283,6 @@ class AnimeFragment : Fragment() { binding.root.requestApplyInsets() binding.root.requestLayout() } - super.onResume() } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt b/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt index 295b49b2..7665ace2 100644 --- a/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt +++ b/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt @@ -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 + 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, diff --git a/app/src/main/java/ani/dantotsu/home/HomeFragment.kt b/app/src/main/java/ani/dantotsu/home/HomeFragment.kt index dc03d34b..fae3fedf 100644 --- a/app/src/main/java/ani/dantotsu/home/HomeFragment.kt +++ b/app/src/main/java/ani/dantotsu/home/HomeFragment.kt @@ -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 { bottomMargin = navBarHeight @@ -127,17 +138,20 @@ class HomeFragment : Fragment() { var reached = false val duration = ((PrefManager.getVal(PrefName.AnimationSpeed) as Float) * 200).toLong() - binding.homeScroll.setOnScrollChangeListener { _, _, _, _, _ -> - if (!binding.homeScroll.canScrollVertically(1)) { - reached = true - bottomBar.animate().translationZ(0f).setDuration(duration).start() - ObjectAnimator.ofFloat(bottomBar, "elevation", 4f, 0f).setDuration(duration) - .start() - } else { - if (reached) { - bottomBar.animate().translationZ(12f).setDuration(duration).start() - ObjectAnimator.ofFloat(bottomBar, "elevation", 0f, 4f).setDuration(duration) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + binding.homeScroll.setOnScrollChangeListener { _, _, _, _, _ -> + if (!binding.homeScroll.canScrollVertically(1)) { + reached = true + bottomBar.animate().translationZ(0f).setDuration(duration).start() + ObjectAnimator.ofFloat(bottomBar, "elevation", 4f, 0f).setDuration(duration) .start() + } else { + if (reached) { + bottomBar.animate().translationZ(12f).setDuration(duration).start() + ObjectAnimator.ofFloat(bottomBar, "elevation", 0f, 4f).setDuration(duration) + .start() + } } } } @@ -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() } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/home/LoginFragment.kt b/app/src/main/java/ani/dantotsu/home/LoginFragment.kt index 8efa8ac3..5f89464e 100644 --- a/app/src/main/java/ani/dantotsu/home/LoginFragment.kt +++ b/app/src/main/java/ani/dantotsu/home/LoginFragment.kt @@ -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") } } diff --git a/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt b/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt index 6cedbb80..f4b34f7d 100644 --- a/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt +++ b/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt @@ -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 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 + 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>? = null ) : Serializable diff --git a/app/src/main/java/ani/dantotsu/media/AuthorAdapter.kt b/app/src/main/java/ani/dantotsu/media/AuthorAdapter.kt new file mode 100644 index 00000000..35195960 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/media/AuthorAdapter.kt @@ -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 +) : RecyclerView.Adapter() { + 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() + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/CalendarActivity.kt b/app/src/main/java/ani/dantotsu/media/CalendarActivity.kt index e6bc8b3b..bbe66477 100644 --- a/app/src/main/java/ani/dantotsu/media/CalendarActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/CalendarActivity.kt @@ -80,14 +80,13 @@ class CalendarActivity : AppCompatActivity() { ) binding.settingsContainer.updateLayoutParams { 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 diff --git a/app/src/main/java/ani/dantotsu/media/Character.kt b/app/src/main/java/ani/dantotsu/media/Character.kt index 1c23ff50..e774ed30 100644 --- a/app/src/main/java/ani/dantotsu/media/Character.kt +++ b/app/src/main/java/ani/dantotsu/media/Character.kt @@ -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, diff --git a/app/src/main/java/ani/dantotsu/media/CharacterDetailsActivity.kt b/app/src/main/java/ani/dantotsu/media/CharacterDetailsActivity.kt index 6c987096..4778f708 100644 --- a/app/src/main/java/ani/dantotsu/media/CharacterDetailsActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/CharacterDetailsActivity.kt @@ -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) } } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/CharacterDetailsAdapter.kt b/app/src/main/java/ani/dantotsu/media/CharacterDetailsAdapter.kt index 15781d8b..2e419492 100644 --- a/app/src/main/java/ani/dantotsu/media/CharacterDetailsAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/CharacterDetailsAdapter.kt @@ -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("!~", "||")) } diff --git a/app/src/main/java/ani/dantotsu/media/GenreActivity.kt b/app/src/main/java/ani/dantotsu/media/GenreActivity.kt index 0c49ec11..5d89e42f 100644 --- a/app/src/main/java/ani/dantotsu/media/GenreActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/GenreActivity.kt @@ -67,11 +67,12 @@ class GenreActivity : AppCompatActivity() { private fun loadLocalGenres(): ArrayList? { val genres = PrefManager.getVal>(PrefName.GenresList) - .toMutableList() as ArrayList? - return if (genres.isNullOrEmpty()) { + .toMutableList() + return if (genres.isEmpty()) { null } else { - genres + //sort alphabetically + genres.sort().let { genres as ArrayList } } } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/Media.kt b/app/src/main/java/ani/dantotsu/media/Media.kt index ebc4d4f7..d26a5048 100644 --- a/app/src/main/java/ani/dantotsu/media/Media.kt +++ b/app/src/main/java/ani/dantotsu/media/Media.kt @@ -58,6 +58,7 @@ data class Media( var endDate: FuzzyDate? = null, var characters: ArrayList? = null, + var staff: ArrayList? = null, var prequel: Media? = null, var sequel: Media? = null, var relations: ArrayList? = 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? ?: arrayListOf() } constructor(mediaEdge: MediaEdge) : this(mediaEdge.node!!) { diff --git a/app/src/main/java/ani/dantotsu/media/MediaAdaptor.kt b/app/src/main/java/ani/dantotsu/media/MediaAdaptor.kt index c17a5ec1..8c415e2d 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaAdaptor.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaAdaptor.kt @@ -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() { 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 diff --git a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt index 77224111..9a528b80 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt @@ -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 { bottomMargin += navBarHeight } + val oldMargin = binding.mediaViewPager.marginBottom + AndroidBug5497Workaround.assistActivity(this) { + if (it) { + binding.mediaViewPager.updateLayoutParams { + bottomMargin = 0 + } + binding.mediaTabContainer.visibility = View.GONE + } else { + binding.mediaViewPager.updateLayoutParams { + bottomMargin = oldMargin + } + binding.mediaTabContainer.visibility = View.VISIBLE + } + } binding.mediaBanner.updateLayoutParams { height += statusBarHeight } binding.mediaBannerNoKen.updateLayoutParams { height += statusBarHeight } binding.mediaClose.updateLayoutParams { topMargin += statusBarHeight } binding.incognito.updateLayoutParams { topMargin += statusBarHeight } binding.mediaCollapsing.minimumHeight = statusBarHeight - if (binding.mediaTab is CustomBottomNavBar) binding.mediaTab.updateLayoutParams { - 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 @@ -546,5 +602,4 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi companion object { var mediaSingleton: Media? = null } -} - +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/MediaDetailsViewModel.kt b/app/src/main/java/ani/dantotsu/media/MediaDetailsViewModel.kt index 8f10863f..650e722d 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaDetailsViewModel.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaDetailsViewModel.kt @@ -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 diff --git a/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt b/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt index e3d4a363..06fb3aa9 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt @@ -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), diff --git a/app/src/main/java/ani/dantotsu/media/MediaListDialogFragment.kt b/app/src/main/java/ani/dantotsu/media/MediaListDialogFragment.kt index fe5c0c93..0430709b 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaListDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaListDialogFragment.kt @@ -254,20 +254,28 @@ class MediaListDialogFragment : BottomSheetDialogFragment() { } binding.mediaListDelete.setOnClickListener { - val id = media!!.userListId - if (id != null) { - scope.launch { - withContext(Dispatchers.IO) { - Anilist.mutation.deleteList(id) + var id = media!!.userListId + scope.launch { + withContext(Dispatchers.IO) { + 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) + } } - Refresh.all() - snackString(getString(R.string.deleted_from_list)) - dismissAllowingStateLoss() } + } + if (id != null) { + Refresh.all() + snackString(getString(R.string.deleted_from_list)) + dismissAllowingStateLoss() } else { snackString(getString(R.string.no_list_id)) - Refresh.all() } } } diff --git a/app/src/main/java/ani/dantotsu/media/MediaListDialogSmallFragment.kt b/app/src/main/java/ani/dantotsu/media/MediaListDialogSmallFragment.kt index cb7390ce..982eca8e 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaListDialogSmallFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaListDialogSmallFragment.kt @@ -58,6 +58,40 @@ class MediaListDialogSmallFragment : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.mediaListContainer.updateLayoutParams { 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 diff --git a/app/src/main/java/ani/dantotsu/media/SearchActivity.kt b/app/src/main/java/ani/dantotsu/media/SearchActivity.kt index 2962a527..33403915 100644 --- a/app/src/main/java/ani/dantotsu/media/SearchActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/SearchActivity.kt @@ -199,7 +199,9 @@ class SearchActivity : AppCompatActivity() { var state: Parcelable? = null override fun onPause() { - headerAdaptor.addHistory() + if (this::headerAdaptor.isInitialized) { + headerAdaptor.addHistory() + } super.onPause() state = binding.searchRecyclerView.layoutManager?.onSaveInstanceState() } diff --git a/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt b/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt index 9327fc77..2bfec418 100644 --- a/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt @@ -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 } } diff --git a/app/src/main/java/ani/dantotsu/media/TripleNavAdapter.kt b/app/src/main/java/ani/dantotsu/media/TripleNavAdapter.kt new file mode 100644 index 00000000..b76a0dff --- /dev/null +++ b/app/src/main/java/ani/dantotsu/media/TripleNavAdapter.kt @@ -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 { + bottomMargin = navBarHeight + } + nav2.updateLayoutParams { + bottomMargin = navBarHeight + } + nav3.updateLayoutParams { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/anime/AnimeNameAdapter.kt b/app/src/main/java/ani/dantotsu/media/anime/AnimeNameAdapter.kt index a30da85f..ad8c378e 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/AnimeNameAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/AnimeNameAdapter.kt @@ -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 = "(?(R.id.mediaViewPager).visibility = visibility activity.findViewById(R.id.mediaCover).visibility = visibility activity.findViewById(R.id.mediaClose).visibility = visibility - try { - activity.findViewById(R.id.mediaTab).visibility = visibility - } catch (e: ClassCastException) { - activity.findViewById(R.id.mediaTab).visibility = visibility - } + + activity.tabLayout.setVisibility(visibility) + activity.findViewById(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() { diff --git a/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt b/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt index b8e78c61..7ae06a1a 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt @@ -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( @@ -386,30 +393,37 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL }, AUDIO_CONTENT_TYPE_MOVIE, AUDIOFOCUS_GAIN) if (System.getInt(contentResolver, System.ACCELEROMETER_ROTATION, 0) != 1) { - if (PrefManager.getVal(PrefName.RotationPlayer)) { - orientationListener = - 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 - rotation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE - } else if (orientation in 225..315) { - if (rotation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) exoRotate.visibility = - View.VISIBLE - rotation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } + if (PrefManager.getVal(PrefName.RotationPlayer)) { + orientationListener = + 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 } + rotation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + } else if (orientation in 225..315) { + 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 } - orientationListener?.enable() + } } + orientationListener?.enable() + } - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - exoRotate.setOnClickListener { - requestedOrientation = rotation - it.visibility = View.GONE - } - } + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + exoRotate.setOnClickListener { + 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>(PrefName.ContinuedAnime).toMutableList() + val list = (PrefManager.getNullableCustomVal("continueAnimeList", listOf(), List::class.java) as List).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,26 +1721,69 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL val new = currentTimeStamp timeStampText.text = if (new != null) { - if (PrefManager.getVal(PrefName.ShowTimeStampButton)) { + 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.AutoSkipOPED) && (new.skipType == "op" || new.skipType == "ed") && !skippedTimeStamps.contains( - new - ) + if (PrefManager.getVal(PrefName.ShowTimeStampButton)) { + + if (!functionstarted && !disappeared && PrefManager.getVal(PrefName.AutoHideTimeStamps)) { + disappearSkip() + } else if (!PrefManager.getVal(PrefName.AutoHideTimeStamps)){ + skipTimeButton.visibility = View.VISIBLE + exoSkip.visibility = View.GONE + skipTimeText.text = new.skipType.getType() + skipTimeButton.setOnClickListener { + exoPlayer.seekTo((new.interval.endTime * 1000).toLong()) + } + } + } + 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(PrefName.SkipTime) > 0) exoSkip.visibility = - View.VISIBLE + exoSkip.isVisible = PrefManager.getVal(PrefName.SkipTime) > 0 "" } } @@ -1772,17 +1854,23 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL private fun updateAniProgress() { val incognito: Boolean = PrefManager.getVal(PrefName.Incognito) - if (!incognito && exoPlayer.currentPosition / episodeLength > PrefManager.getVal( - PrefName.WatchPercentage - ) && Anilist.userid != null + val episodeEnd = exoPlayer.currentPosition / episodeLength > PrefManager.getVal( + PrefName.WatchPercentage + ) + 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) ) { - media.anime!!.selectedEpisode?.apply { - updateProgress(media, this) + if (episode0) { + updateProgress(media, "0") + } else { + media.anime!!.selectedEpisode?.apply { + updateProgress(media, this) + } } } } @@ -1822,6 +1910,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL if (isInitialized) { updateAniProgress() + disappeared = false + functionstarted = false releasePlayer() } @@ -2031,4 +2121,4 @@ class CustomCastButton : MediaRouteButton { true } } -} \ No newline at end of file +} diff --git a/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt b/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt index e016dc29..f83b22ad 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt @@ -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) } diff --git a/app/src/main/java/ani/dantotsu/media/anime/SubtitleDialogFragment.kt b/app/src/main/java/ani/dantotsu/media/anime/SubtitleDialogFragment.kt index 3fae8c79..8b172fe1 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/SubtitleDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/SubtitleDialogFragment.kt @@ -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("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("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) } diff --git a/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt b/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt new file mode 100644 index 00000000..b78d4dce --- /dev/null +++ b/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt @@ -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() { + lateinit var binding: ItemCommentsBinding + val adapter = GroupieAdapter() + private var subCommentIds: MutableList = 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 ?: 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 { + 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 = 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", + ) + +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt b/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt new file mode 100644 index 00000000..e0fe6203 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt @@ -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(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(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?): List { + 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 + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaCache.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaCache.kt index 50eec776..ebf1c57f 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaCache.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaCache.kt @@ -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 } diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaChapter.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaChapter.kt index 6af71788..d8944b66 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaChapter.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaChapter.kt @@ -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() diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaChapterAdapter.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaChapterAdapter.kt index 6cad3b90..d19aa011 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaChapterAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaChapterAdapter.kt @@ -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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaReadAdapter.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaReadAdapter.kt index e990f94e..9f8ca4da 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaReadAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaReadAdapter.kt @@ -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(R.id.checkboxContainer) + val dialogView2 = LayoutInflater.from(currContext()).inflate(R.layout.custom_dialog_layout, null) + val checkboxContainer = dialogView2.findViewById(R.id.checkboxContainer) + val tickAllButton = dialogView2.findViewById(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("${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 } diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt index e21d7fc7..621e07fb 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt @@ -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(R.id.mediaViewPager).visibility = visibility activity.findViewById(R.id.mediaCover).visibility = visibility activity.findViewById(R.id.mediaClose).visibility = visibility - try { - activity.findViewById(R.id.mediaTab).visibility = visibility - } catch (e: ClassCastException) { - activity.findViewById(R.id.mediaTab).visibility = visibility - } + activity.tabLayout.setVisibility(visibility) activity.findViewById(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() { diff --git a/app/src/main/java/ani/dantotsu/media/manga/mangareader/MangaReaderActivity.kt b/app/src/main/java/ani/dantotsu/media/manga/mangareader/MangaReaderActivity.kt index ef8725e1..157c6146 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/mangareader/MangaReaderActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/mangareader/MangaReaderActivity.kt @@ -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(PrefName.ShowSystemBars)) hideSystemBars() + private fun hideSystemBars() { + if (PrefManager.getVal(PrefName.ShowSystemBars)) + showSystemBarsRetractView() + else + hideSystemBarsExtendView() } override fun onDestroy() { @@ -147,12 +151,26 @@ class MangaReaderActivity : AppCompatActivity() { defaultSettings = loadReaderSettings("reader_settings") ?: defaultSettings onBackPressedDispatcher.addCallback(this) { - progress { finish() } + 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(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(PrefName.AnimationSpeed) * 200).toLong() - hideBars() + hideSystemBars() var pageSliderTimer = Timer() fun pageSliderHide() { @@ -285,16 +303,26 @@ class MangaReaderActivity : AppCompatActivity() { binding.mangaReaderNextChapter.performClick() } binding.mangaReaderNextChapter.setOnClickListener { - if (chaptersArr.size > currentChapterIndex + 1) progress { change(currentChapterIndex + 1) } - else snackString(getString(R.string.next_chapter_not_found)) + 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 (currentChapterIndex > 0) change(currentChapterIndex - 1) - else snackString(getString(R.string.first_chapter)) + 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 -> @@ -305,10 +333,17 @@ class MangaReaderActivity : AppCompatActivity() { PrefManager.setCustomVal("${media.id}_current_chp", chap.number) currentChapterIndex = chaptersArr.indexOf(chap.number) binding.mangaReaderChapterSelect.setSelection(currentChapterIndex) - binding.mangaReaderNextChap.text = - chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: "" - binding.mangaReaderPrevChap.text = - chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: "" + 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,27 +502,26 @@ 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.onLeftSwiped = { + binding.mangaReaderPreviousChapter.performClick() } binding.mangaReaderSwipy.leftBeingSwiped = { value -> binding.LeftSwipeContainer.apply { @@ -487,6 +529,9 @@ class MangaReaderActivity : AppCompatActivity() { 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.. 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(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 { + 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 { diff --git a/app/src/main/java/ani/dantotsu/media/manga/mangareader/ReaderSettingsDialogFragment.kt b/app/src/main/java/ani/dantotsu/media/manga/mangareader/ReaderSettingsDialogFragment.kt index c9c42aab..de69d399 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/mangareader/ReaderSettingsDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/mangareader/ReaderSettingsDialogFragment.kt @@ -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 diff --git a/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt b/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt index 369f446e..859dfdac 100644 --- a/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt @@ -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, diff --git a/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt b/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt index 0ab80e14..8da7c35b 100644 --- a/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt @@ -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) } } diff --git a/app/src/main/java/ani/dantotsu/media/novel/novelreader/NovelReaderActivity.kt b/app/src/main/java/ani/dantotsu/media/novel/novelreader/NovelReaderActivity.kt index b77cdc77..13712c21 100644 --- a/app/src/main/java/ani/dantotsu/media/novel/novelreader/NovelReaderActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/novel/novelreader/NovelReaderActivity.kt @@ -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 diff --git a/app/src/main/java/ani/dantotsu/media/user/ListActivity.kt b/app/src/main/java/ani/dantotsu/media/user/ListActivity.kt index 7db5f057..7ce2f136 100644 --- a/app/src/main/java/ani/dantotsu/media/user/ListActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/user/ListActivity.kt @@ -78,7 +78,6 @@ class ListActivity : AppCompatActivity() { ) binding.settingsContainer.updateLayoutParams { topMargin = statusBarHeight - bottomMargin = navBarHeight } } setContentView(binding.root) @@ -171,6 +170,21 @@ class ListActivity : AppCompatActivity() { popup.show() } + binding.filter.setOnClickListener { + val genres = PrefManager.getVal>(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 = diff --git a/app/src/main/java/ani/dantotsu/media/user/ListViewModel.kt b/app/src/main/java/ani/dantotsu/media/user/ListViewModel.kt index 06fd29a9..d415b432 100644 --- a/app/src/main/java/ani/dantotsu/media/user/ListViewModel.kt +++ b/app/src/main/java/ani/dantotsu/media/user/ListViewModel.kt @@ -13,10 +13,29 @@ class ListViewModel : ViewModel() { var grid = MutableLiveData(PrefManager.getVal(PrefName.ListGrid)) private val lists = MutableLiveData>>() + private val unfilteredLists = MutableLiveData>>() fun getLists(): LiveData>> = 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 + }.toMutableMap() + + lists.postValue(filteredLists) + } + } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/AlarmManagerScheduler.kt b/app/src/main/java/ani/dantotsu/notifications/AlarmManagerScheduler.kt new file mode 100644 index 00000000..9c89a582 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/AlarmManagerScheduler.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/BootCompletedReceiver.kt b/app/src/main/java/ani/dantotsu/notifications/BootCompletedReceiver.kt new file mode 100644 index 00000000..8cfc0a1c --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/BootCompletedReceiver.kt @@ -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) + } + } +} diff --git a/app/src/main/java/ani/dantotsu/subcriptions/NotificationClickReceiver.kt b/app/src/main/java/ani/dantotsu/notifications/IncognitoNotificationClickReceiver.kt similarity index 85% rename from app/src/main/java/ani/dantotsu/subcriptions/NotificationClickReceiver.kt rename to app/src/main/java/ani/dantotsu/notifications/IncognitoNotificationClickReceiver.kt index 410d9b23..814441e7 100644 --- a/app/src/main/java/ani/dantotsu/subcriptions/NotificationClickReceiver.kt +++ b/app/src/main/java/ani/dantotsu/notifications/IncognitoNotificationClickReceiver.kt @@ -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) diff --git a/app/src/main/java/ani/dantotsu/notifications/TaskScheduler.kt b/app/src/main/java/ani/dantotsu/notifications/TaskScheduler.kt new file mode 100644 index 00000000..fa0304ed --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/TaskScheduler.kt @@ -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 +} diff --git a/app/src/main/java/ani/dantotsu/notifications/WorkManagerScheduler.kt b/app/src/main/java/ani/dantotsu/notifications/WorkManagerScheduler.kt new file mode 100644 index 00000000..0a26213e --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/WorkManagerScheduler.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/anilist/AnilistNotificationReceiver.kt b/app/src/main/java/ani/dantotsu/notifications/anilist/AnilistNotificationReceiver.kt new file mode 100644 index 00000000..fc3f1793 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/anilist/AnilistNotificationReceiver.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/anilist/AnilistNotificationTask.kt b/app/src/main/java/ani/dantotsu/notifications/anilist/AnilistNotificationTask.kt new file mode 100644 index 00000000..56be25ad --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/anilist/AnilistNotificationTask.kt @@ -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(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(PrefName.LastAnilistNotificationId) + val newNotifications = unreadNotifications?.filter { it.id > lastId } + val filteredTypes = + PrefManager.getVal>(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() + } + +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/anilist/AnilistNotificationWorker.kt b/app/src/main/java/ani/dantotsu/notifications/anilist/AnilistNotificationWorker.kt new file mode 100644 index 00000000..f3506fc8 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/anilist/AnilistNotificationWorker.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationReceiver.kt b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationReceiver.kt new file mode 100644 index 00000000..c084bba2 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationReceiver.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt new file mode 100644 index 00000000..ec6b2361 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt @@ -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() + 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( + 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(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>( + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationWorker.kt b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationWorker.kt new file mode 100644 index 00000000..47d61fd5 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationWorker.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/comment/CommentStore.kt b/app/src/main/java/ani/dantotsu/notifications/comment/CommentStore.kt new file mode 100644 index 00000000..bc6ab98b --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/comment/CommentStore.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/comment/MediaNameFetch.kt b/app/src/main/java/ani/dantotsu/notifications/comment/MediaNameFetch.kt new file mode 100644 index 00000000..15e0e7e6 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/comment/MediaNameFetch.kt @@ -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): 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): Map { + return try { + val url = "https://graphql.anilist.co/" + val data = mapOf( + "query" to queryBuilder(ids), + ) + withContext(Dispatchers.IO) { + val response = client.post( + url, + headers = mapOf( + "Content-Type" to "application/json", + "Accept" to "application/json" + ), + data = data + ) + val mediaResponse = parseMediaResponseWithGson(response.text) + val mediaMap = mutableMapOf() + mediaResponse.data.forEach { (_, mediaItem) -> + mediaMap[mediaItem.id] = ReturnedData( + mediaItem.title.romaji, + mediaItem.coverImage.medium, + mediaItem.coverImage.color + ) + } + mediaMap + } + } catch (e: Exception) { + val errorMap = mutableMapOf() + ids.forEach { errorMap[it] = ReturnedData("Unknown", "", "#222222") } + errorMap + } + } + + private fun parseMediaResponseWithGson(response: String): MediaResponse { + val gson = Gson() + val type = object : TypeToken() {}.type + return gson.fromJson(response, type) + } + data class ReturnedData(val title: String, val coverImage: String, val color: String) + + data class MediaResponse(val data: Map) + 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) + + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/subcriptions/SubscriptionHelper.kt b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionHelper.kt similarity index 75% rename from app/src/main/java/ani/dantotsu/subcriptions/SubscriptionHelper.kt rename to app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionHelper.kt index 1929ce3c..9c8498a4 100644 --- a/app/src/main/java/ani/dantotsu/subcriptions/SubscriptionHelper.kt +++ b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionHelper.kt @@ -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 = (PrefManager.getNullableCustomVal( - subscriptions, + SUBSCRIPTIONS, null, Map::class.java ) as? Map) - ?: mapOf().also { PrefManager.setCustomVal(subscriptions, it) } + ?: mapOf().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 @@ -157,7 +151,7 @@ class SubscriptionHelper { } else { data.remove(media.id) } - PrefManager.setCustomVal(subscriptions, data) + PrefManager.setCustomVal(SUBSCRIPTIONS, data) } } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionNotificationReceiver.kt b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionNotificationReceiver.kt new file mode 100644 index 00000000..72afe484 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionNotificationReceiver.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionNotificationTask.kt b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionNotificationTask.kt new file mode 100644 index 00000000..ecbf6bd2 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionNotificationTask.kt @@ -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 + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionNotificationWorker.kt b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionNotificationWorker.kt new file mode 100644 index 00000000..22086b3b --- /dev/null +++ b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionNotificationWorker.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/others/AndroidBug5497Workaround.kt b/app/src/main/java/ani/dantotsu/others/AndroidBug5497Workaround.kt new file mode 100644 index 00000000..46e8a297 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/others/AndroidBug5497Workaround.kt @@ -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) + } + } +} + diff --git a/app/src/main/java/ani/dantotsu/others/JsUnpacker.kt b/app/src/main/java/ani/dantotsu/others/JsUnpacker.kt index 35ebe7b5..ecb5f0ce 100644 --- a/app/src/main/java/ani/dantotsu/others/JsUnpacker.kt +++ b/app/src/main/java/ani/dantotsu/others/JsUnpacker.kt @@ -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 } diff --git a/app/src/main/java/ani/dantotsu/others/Kitsu.kt b/app/src/main/java/ani/dantotsu/others/Kitsu.kt index 374f0dd0..f548fc31 100644 --- a/app/src/main/java/ani/dantotsu/others/Kitsu.kt +++ b/app/src/main/java/ani/dantotsu/others/Kitsu.kt @@ -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? { - 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 } diff --git a/app/src/main/java/ani/dantotsu/others/SpoilerPlugin.kt b/app/src/main/java/ani/dantotsu/others/SpoilerPlugin.kt index fd746664..79b6138a 100644 --- a/app/src/main/java/ani/dantotsu/others/SpoilerPlugin.kt +++ b/app/src/main/java/ani/dantotsu/others/SpoilerPlugin.kt @@ -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) diff --git a/app/src/main/java/ani/dantotsu/others/webview/CloudFlare.kt b/app/src/main/java/ani/dantotsu/others/webview/CloudFlare.kt index 11fd7cdb..c7866978 100644 --- a/app/src/main/java/ani/dantotsu/others/webview/CloudFlare.kt +++ b/app/src/main/java/ani/dantotsu/others/webview/CloudFlare.kt @@ -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)) diff --git a/app/src/main/java/ani/dantotsu/others/webview/CookieCatcher.kt b/app/src/main/java/ani/dantotsu/others/webview/CookieCatcher.kt index 22f11f3f..73ae553a 100644 --- a/app/src/main/java/ani/dantotsu/others/webview/CookieCatcher.kt +++ b/app/src/main/java/ani/dantotsu/others/webview/CookieCatcher.kt @@ -34,8 +34,8 @@ class CookieCatcher : AppCompatActivity() { val webView = findViewById(R.id.discordWebview) - val cookies: CookieManager = Injekt.get().cookieJar.manager - cookies.setAcceptThirdPartyCookies(webView, true) + val cookies: CookieManager? = Injekt.get().cookieJar.manager + cookies?.setAcceptThirdPartyCookies(webView, true) webView.apply { settings.javaScriptEnabled = true diff --git a/app/src/main/java/ani/dantotsu/others/webview/WebViewBottomDialog.kt b/app/src/main/java/ani/dantotsu/others/webview/WebViewBottomDialog.kt index 489c68fc..219c5ff0 100644 --- a/app/src/main/java/ani/dantotsu/others/webview/WebViewBottomDialog.kt +++ b/app/src/main/java/ani/dantotsu/others/webview/WebViewBottomDialog.kt @@ -33,7 +33,7 @@ abstract class WebViewBottomDialog : BottomSheetDialogFragment() { dismiss() } - val cookies: CookieManager = Injekt.get().cookieJar.manager + val cookies: CookieManager? = Injekt.get().cookieJar.manager //CookieManager.getInstance() override fun onCreateView( @@ -52,7 +52,7 @@ abstract class WebViewBottomDialog : BottomSheetDialogFragment() { javaScriptEnabled = true userAgentString = defaultHeaders["User-Agent"] } - cookies.setAcceptThirdPartyCookies(binding.webView, true) + cookies?.setAcceptThirdPartyCookies(binding.webView, true) binding.webView.webViewClient = webViewClient binding.webView.loadUrl(location.url, location.headers) this.dismiss() diff --git a/app/src/main/java/ani/dantotsu/parsers/AnimeSources.kt b/app/src/main/java/ani/dantotsu/parsers/AnimeSources.kt index 9806767a..119f4d2f 100644 --- a/app/src/main/java/ani/dantotsu/parsers/AnimeSources.kt +++ b/app/src/main/java/ani/dantotsu/parsers/AnimeSources.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.first object AnimeSources : WatchSources() { override var list: List> = emptyList() var pinnedAnimeSources: List = emptyList() + var isInitialized = false suspend fun init(fromExtensions: StateFlow>) { pinnedAnimeSources = @@ -23,6 +24,7 @@ object AnimeSources : WatchSources() { { OfflineAnimeParser() }, "Downloaded" ) + isInitialized = true // Update as StateFlow emits new values fromExtensions.collect { extensions -> diff --git a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt index 761bd949..5ef0adf1 100644 --- a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt +++ b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt @@ -11,7 +11,7 @@ import android.os.Environment import android.provider.MediaStore import ani.dantotsu.FileUrl import ani.dantotsu.currContext -import ani.dantotsu.logger +import ani.dantotsu.util.Logger import ani.dantotsu.media.anime.AnimeNameAdapter import ani.dantotsu.media.manga.ImageData import ani.dantotsu.media.manga.MangaCache @@ -82,6 +82,9 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { } private fun getDub(): Boolean { + if (sourceLanguage >= extension.sources.size) { + sourceLanguage = extension.sources.size - 1 + } val configurableSource = extension.sources[sourceLanguage] as? ConfigurableAnimeSource ?: return false currContext()?.let { context -> @@ -103,6 +106,9 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { } fun setDub(setDub: Boolean) { + if (sourceLanguage >= extension.sources.size) { + sourceLanguage = extension.sources.size - 1 + } val configurableSource = extension.sources[sourceLanguage] as? ConfigurableAnimeSource ?: return val type = when (setDub) { @@ -129,7 +135,7 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { val configurableSource = extension.sources[sourceLanguage] as? ConfigurableAnimeSource ?: return false currContext()?.let { context -> - logger("isDubAvailableSeparately: ${configurableSource.getPreferenceKey()}") + Logger.log("isDubAvailableSeparately: ${configurableSource.getPreferenceKey()}") val sharedPreferences = context.getSharedPreferences( configurableSource.getPreferenceKey(), @@ -205,7 +211,7 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { } return sortedEpisodes.map { SEpisodeToEpisode(it) } } catch (e: Exception) { - logger("Exception: $e") + Logger.log("Exception: $e") } return emptyList() } @@ -240,7 +246,7 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { val videos = source.getVideoList(sEpisode) videos.map { VideoToVideoServer(it) } } catch (e: Exception) { - logger("Exception occurred: ${e.message}") + Logger.log("Exception occurred: ${e.message}") emptyList() } } @@ -260,16 +266,16 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { ?: return emptyList()) return try { val res = source.fetchSearchAnime(1, query, source.getFilterList()).awaitSingle() - logger("query: $query") + Logger.log("query: $query") convertAnimesPageToShowResponse(res) } catch (e: CloudflareBypassException) { - logger("Exception in search: $e") + Logger.log("Exception in search: $e") withContext(Dispatchers.Main) { snackString("Failed to bypass Cloudflare") } emptyList() } catch (e: Exception) { - logger("General exception in search: $e") + Logger.log("General exception in search: $e") emptyList() } } @@ -358,12 +364,12 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { val res = source.getChapterList(sManga) val reversedRes = res.reversed() val chapterList = reversedRes.map { SChapterToMangaChapter(it) } - logger("chapterList size: ${chapterList.size}") - logger("chapterList: ${chapterList[1].title}") - logger("chapterList: ${chapterList[1].description}") + Logger.log("chapterList size: ${chapterList.size}") + Logger.log("chapterList: ${chapterList[1].title}") + Logger.log("chapterList: ${chapterList[1].description}") chapterList } catch (e: Exception) { - logger("loadChapters Exception: $e") + Logger.log("loadChapters Exception: $e") emptyList() } } @@ -379,7 +385,7 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { var imageDataList: List = listOf() val ret = coroutineScope { try { - logger("source.name " + source.name) + Logger.log("source.name " + source.name) val res = source.getPageList(sChapter) val reIndexedPages = res.mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) } @@ -388,7 +394,7 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { async(Dispatchers.IO) { mangaCache.put(page.imageUrl ?: "", ImageData(page, source)) imageDataList += ImageData(page, source) - logger("put page: ${page.imageUrl}") + Logger.log("put page: ${page.imageUrl}") pageToMangaImage(page) } } @@ -396,7 +402,7 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { deferreds.awaitAll() } catch (e: Exception) { - logger("loadImages Exception: $e") + Logger.log("loadImages Exception: $e") snackString("Failed to load images: $e") emptyList() } @@ -414,7 +420,7 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { return coroutineScope { try { - logger("source.name " + source.name) + Logger.log("source.name " + source.name) val res = source.getPageList(sChapter) val reIndexedPages = res.mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) } @@ -430,7 +436,7 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { deferreds.awaitAll() } catch (e: Exception) { - logger("loadImages Exception: $e") + Logger.log("loadImages Exception: $e") snackString("Failed to load images: $e") emptyList() } @@ -446,8 +452,8 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { 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() @@ -467,7 +473,7 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { return@withContext bitmap } catch (e: Exception) { // Handle any exceptions - logger("An error occurred: ${e.message}") + Logger.log("An error occurred: ${e.message}") return@withContext null } } @@ -500,7 +506,7 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { inputStream.close() } catch (e: Exception) { // Handle any exceptions - logger("An error occurred: ${e.message}") + Logger.log("An error occurred: ${e.message}") } } } @@ -547,7 +553,7 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { } } catch (e: Exception) { // Handle exception here - logger("Exception while saving image: ${e.message}") + Logger.log("Exception while saving image: ${e.message}") } } @@ -562,16 +568,16 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { return try { val res = source.fetchSearchManga(1, query, source.getFilterList()).awaitSingle() - logger("res observable: $res") + Logger.log("res observable: $res") convertMangasPageToShowResponse(res) } catch (e: CloudflareBypassException) { - logger("Exception in search: $e") + Logger.log("Exception in search: $e") withContext(Dispatchers.Main) { snackString("Failed to bypass Cloudflare") } emptyList() } catch (e: Exception) { - logger("General exception in search: $e") + Logger.log("General exception in search: $e") emptyList() } } @@ -633,7 +639,8 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { sChapter.name, null, sChapter.scanlator, - sChapter + sChapter, + sChapter.date_upload ) } @@ -713,7 +720,7 @@ class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() { // If the format is still undetermined, log an error if (format == null) { - logger("Unknown video format: $videoUrl") + Logger.log("Unknown video format: $videoUrl") format = VideoType.CONTAINER } val headersMap: Map = @@ -745,7 +752,7 @@ class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() { private fun headRequest(fileName: String, networkHelper: NetworkHelper): VideoType? { return try { - logger("attempting head request for $fileName") + Logger.log("attempting head request for $fileName") val request = Request.Builder() .url(fileName) .head() @@ -770,13 +777,13 @@ class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() { else -> null } } else { - logger("failed head request for $fileName") + Logger.log("failed head request for $fileName") null } } } catch (e: Exception) { - logger("Exception in headRequest: $e") + Logger.log("Exception in headRequest: $e") null } diff --git a/app/src/main/java/ani/dantotsu/parsers/BaseParser.kt b/app/src/main/java/ani/dantotsu/parsers/BaseParser.kt index e61c6890..014be93d 100644 --- a/app/src/main/java/ani/dantotsu/parsers/BaseParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/BaseParser.kt @@ -3,9 +3,9 @@ package ani.dantotsu.parsers import ani.dantotsu.FileUrl import ani.dantotsu.R import ani.dantotsu.currContext -import ani.dantotsu.logger import ani.dantotsu.media.Media import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.source.model.SManga import me.xdrop.fuzzywuzzy.FuzzySearch @@ -59,11 +59,11 @@ abstract class BaseParser { saveShowResponse(mediaObj.id, response, true) } else { setUserText("Searching : ${mediaObj.mainName()}") - logger("Searching : ${mediaObj.mainName()}") + Logger.log("Searching : ${mediaObj.mainName()}") val results = search(mediaObj.mainName()) //log all results results.forEach { - logger("Result: ${it.name}") + Logger.log("Result: ${it.name}") } val sortedResults = if (results.isNotEmpty()) { results.sortedByDescending { @@ -83,7 +83,7 @@ abstract class BaseParser { ) < 100 ) { setUserText("Searching : ${mediaObj.nameRomaji}") - logger("Searching : ${mediaObj.nameRomaji}") + Logger.log("Searching : ${mediaObj.nameRomaji}") val romajiResults = search(mediaObj.nameRomaji) val sortedRomajiResults = if (romajiResults.isNotEmpty()) { romajiResults.sortedByDescending { @@ -96,10 +96,10 @@ abstract class BaseParser { emptyList() } val closestRomaji = sortedRomajiResults.firstOrNull() - logger("Closest match from RomajiResults: ${closestRomaji?.name ?: "None"}") + Logger.log("Closest match from RomajiResults: ${closestRomaji?.name ?: "None"}") response = if (response == null) { - logger("No exact match found in results. Using closest match from RomajiResults.") + Logger.log("No exact match found in results. Using closest match from RomajiResults.") closestRomaji } else { val romajiRatio = FuzzySearch.ratio( @@ -110,14 +110,14 @@ abstract class BaseParser { response.name.lowercase(), mediaObj.mainName().lowercase() ) - logger("Fuzzy ratio for closest match in results: $mainNameRatio for ${response.name.lowercase()}") - logger("Fuzzy ratio for closest match in RomajiResults: $romajiRatio for ${closestRomaji?.name?.lowercase() ?: "None"}") + Logger.log("Fuzzy ratio for closest match in results: $mainNameRatio for ${response.name.lowercase()}") + Logger.log("Fuzzy ratio for closest match in RomajiResults: $romajiRatio for ${closestRomaji?.name?.lowercase() ?: "None"}") if (romajiRatio > mainNameRatio) { - logger("RomajiResults has a closer match. Replacing response.") + Logger.log("RomajiResults has a closer match. Replacing response.") closestRomaji } else { - logger("Results has a closer or equal match. Keeping existing response.") + Logger.log("Results has a closer or equal match. Keeping existing response.") response } } @@ -216,8 +216,7 @@ data class ShowResponse( otherNames: List = listOf(), total: Int? = null, extra: MutableMap? = null - ) - : this(name, link, FileUrl(coverUrl), otherNames, total, extra) + ) : this(name, link, FileUrl(coverUrl), otherNames, total, extra) constructor( name: String, @@ -225,8 +224,7 @@ data class ShowResponse( coverUrl: String, otherNames: List = listOf(), total: Int? = null - ) - : this(name, link, FileUrl(coverUrl), otherNames, total) + ) : this(name, link, FileUrl(coverUrl), otherNames, total) constructor(name: String, link: String, coverUrl: String, otherNames: List = listOf()) : this(name, link, FileUrl(coverUrl), otherNames) @@ -239,6 +237,10 @@ data class ShowResponse( constructor(name: String, link: String, coverUrl: String, sManga: SManga) : this(name, link, FileUrl(coverUrl), sManga = sManga) + + companion object { + private const val serialVersionUID = 1L + } } diff --git a/app/src/main/java/ani/dantotsu/parsers/BaseSources.kt b/app/src/main/java/ani/dantotsu/parsers/BaseSources.kt index 5aa6ffc2..475d681f 100644 --- a/app/src/main/java/ani/dantotsu/parsers/BaseSources.kt +++ b/app/src/main/java/ani/dantotsu/parsers/BaseSources.kt @@ -1,7 +1,7 @@ package ani.dantotsu.parsers import ani.dantotsu.Lazier -import ani.dantotsu.logger +import ani.dantotsu.util.Logger import ani.dantotsu.media.Media import ani.dantotsu.media.anime.Episode import ani.dantotsu.media.manga.MangaChapter @@ -96,7 +96,7 @@ abstract class MangaReadSources : BaseSources() { } //must be downloaded if (show.sManga == null) { - logger("sManga is null") + Logger.log("sManga is null") } if (parser is OfflineMangaParser && show.sManga == null) { tryWithSuspend(true) { @@ -106,11 +106,11 @@ abstract class MangaReadSources : BaseSources() { } } } else { - logger("Parser is not an instance of OfflineMangaParser") + Logger.log("Parser is not an instance of OfflineMangaParser") } - logger("map size ${map.size}") + Logger.log("map size ${map.size}") return map } } diff --git a/app/src/main/java/ani/dantotsu/parsers/MangaParser.kt b/app/src/main/java/ani/dantotsu/parsers/MangaParser.kt index 8948d495..cd757307 100644 --- a/app/src/main/java/ani/dantotsu/parsers/MangaParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/MangaParser.kt @@ -81,6 +81,7 @@ data class MangaChapter( val description: String? = null, val scanlator: String? = null, val sChapter: SChapter, + val date: Long? = null, ) data class MangaImage( diff --git a/app/src/main/java/ani/dantotsu/parsers/MangaSources.kt b/app/src/main/java/ani/dantotsu/parsers/MangaSources.kt index 34c3e0a0..c610654f 100644 --- a/app/src/main/java/ani/dantotsu/parsers/MangaSources.kt +++ b/app/src/main/java/ani/dantotsu/parsers/MangaSources.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.first object MangaSources : MangaReadSources() { override var list: List> = emptyList() var pinnedMangaSources: List = emptyList() + var isInitialized = false suspend fun init(fromExtensions: StateFlow>) { pinnedMangaSources = @@ -23,6 +24,7 @@ object MangaSources : MangaReadSources() { { OfflineMangaParser() }, "Downloaded" ) + isInitialized = true // Update as StateFlow emits new values fromExtensions.collect { extensions -> diff --git a/app/src/main/java/ani/dantotsu/parsers/NovelSources.kt b/app/src/main/java/ani/dantotsu/parsers/NovelSources.kt index 8fb66650..9420feb8 100644 --- a/app/src/main/java/ani/dantotsu/parsers/NovelSources.kt +++ b/app/src/main/java/ani/dantotsu/parsers/NovelSources.kt @@ -6,6 +6,7 @@ import ani.dantotsu.parsers.novel.DynamicNovelParser import ani.dantotsu.parsers.novel.NovelExtension import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.util.Logger import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first @@ -47,8 +48,8 @@ object NovelSources : NovelReadSources() { } private fun createParsersFromExtensions(extensions: List): List> { - Log.d("NovelSources", "createParsersFromExtensions") - Log.d("NovelSources", extensions.toString()) + Logger.log("createParsersFromExtensions") + Logger.log(extensions.toString()) return extensions.map { extension -> val name = extension.name Lazier({ DynamicNovelParser(extension) }, name) diff --git a/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt b/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt index deffb420..25099d78 100644 --- a/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt @@ -3,7 +3,7 @@ package ani.dantotsu.parsers import android.os.Environment import ani.dantotsu.currContext import ani.dantotsu.download.DownloadsManager -import ani.dantotsu.logger +import ani.dantotsu.util.Logger import ani.dantotsu.media.manga.MangaNameAdapter import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga @@ -68,7 +68,7 @@ class OfflineMangaParser : MangaParser() { matchResult?.groups?.get(1)?.value?.toIntOrNull() ?: Int.MAX_VALUE } for (image in images) { - logger("imageNumber: ${image.url.url}") + Logger.log("imageNumber: ${image.url.url}") } return images } diff --git a/app/src/main/java/ani/dantotsu/parsers/StringMatcher.kt b/app/src/main/java/ani/dantotsu/parsers/StringMatcher.kt index 191fecff..46c09781 100644 --- a/app/src/main/java/ani/dantotsu/parsers/StringMatcher.kt +++ b/app/src/main/java/ani/dantotsu/parsers/StringMatcher.kt @@ -1,6 +1,6 @@ package ani.dantotsu.parsers -import ani.dantotsu.logger +import ani.dantotsu.util.Logger class StringMatcher { companion object { @@ -54,10 +54,10 @@ class StringMatcher { val closestShowAndIndex = closestShow(target, shows) val closestIndex = closestShowAndIndex.second if (closestIndex == -1) { - logger("No closest show found for $target") + Logger.log("No closest show found for $target") return shows // Return original list if no closest show found } - logger("Closest show found for $target is ${closestShowAndIndex.first.name}") + Logger.log("Closest show found for $target is ${closestShowAndIndex.first.name}") return listOf(shows[closestIndex]) + shows.subList(0, closestIndex) + shows.subList( closestIndex + 1, shows.size diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionGithubApi.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionGithubApi.kt index 4381ee27..9867658e 100644 --- a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionGithubApi.kt +++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionGithubApi.kt @@ -2,7 +2,7 @@ package ani.dantotsu.parsers.novel import android.content.Context -import ani.dantotsu.logger +import ani.dantotsu.util.Logger import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier @@ -14,9 +14,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import logcat.LogPriority import tachiyomi.core.util.lang.withIOContext -import tachiyomi.core.util.system.logcat import uy.kohesive.injekt.injectLazy import java.util.Date import kotlin.time.Duration.Companion.days @@ -41,20 +39,20 @@ class NovelExtensionGithubApi { .newCall(GET("${REPO_URL_PREFIX}index.min.json")) .awaitSuccess() } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" } + Logger.log("Failed to get extensions from GitHub") requiresFallbackSource = true null } } val response = githubResponse ?: run { - logger("using fallback source") + Logger.log("using fallback source") networkService.client .newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")) .awaitSuccess() } - logger("response: $response") + Logger.log("response: $response") val extensions = with(json) { response @@ -67,7 +65,7 @@ class NovelExtensionGithubApi { /*if (extensions.size < 10) { //TODO: uncomment when more extensions are added throw Exception() }*/ - logger("extensions: $extensions") + Logger.log("extensions: $extensions") extensions } } diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstallReceiver.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstallReceiver.kt index 7835cb1d..056118a5 100644 --- a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstallReceiver.kt +++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstallReceiver.kt @@ -3,6 +3,7 @@ package ani.dantotsu.parsers.novel import android.os.FileObserver import android.util.Log import ani.dantotsu.parsers.novel.FileObserver.fileObserver +import ani.dantotsu.util.Logger import java.io.File @@ -22,24 +23,24 @@ class NovelExtensionFileObserver(private val listener: Listener, private val pat override fun onEvent(event: Int, file: String?) { - Log.e("NovelExtensionFileObserver", "Event: $event") + Logger.log("Event: $event") if (file == null) return val fullPath = File(path, file) when (event) { CREATE -> { - Log.e("NovelExtensionFileObserver", "File created: $fullPath") + Logger.log("File created: $fullPath") listener.onExtensionFileCreated(fullPath) } DELETE -> { - Log.e("NovelExtensionFileObserver", "File deleted: $fullPath") + Logger.log("File deleted: $fullPath") listener.onExtensionFileDeleted(fullPath) } MODIFY -> { - Log.e("NovelExtensionFileObserver", "File modified: $fullPath") + Logger.log("File modified: $fullPath") listener.onExtensionFileModified(fullPath) } } diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstaller.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstaller.kt index 8bbf0561..14d7b75e 100644 --- a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstaller.kt +++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionInstaller.kt @@ -15,13 +15,12 @@ import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import ani.dantotsu.snackString +import ani.dantotsu.util.Logger import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.extension.InstallStep import eu.kanade.tachiyomi.util.storage.getUriCompat -import logcat.LogPriority import rx.Observable import rx.android.schedulers.AndroidSchedulers -import tachiyomi.core.util.system.logcat import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -77,12 +76,12 @@ internal class NovelExtensionInstaller(private val context: Context) { val fileToDelete = File("$sourcePath/${url.toUri().lastPathSegment}") if (fileToDelete.exists()) { if (fileToDelete.delete()) { - Log.i("Install APK", "APK file deleted successfully.") + Logger.log("APK file deleted successfully.") } else { - Log.e("Install APK", "Failed to delete APK file.") + Logger.log("Failed to delete APK file.") } } else { - Log.e("Install APK", "APK file not found.") + Logger.log("APK file not found.") } // Register the receiver after removing (and unregistering) the previous download @@ -161,7 +160,7 @@ internal class NovelExtensionInstaller(private val context: Context) { // Check if source path is obtained correctly if (sourcePath == null) { - Log.e("Install APK", "Source APK path not found.") + Logger.log("Source APK path not found.") downloadsRelay.call(downloadId to InstallStep.Error) return InstallStep.Error } @@ -172,14 +171,14 @@ internal class NovelExtensionInstaller(private val context: Context) { destinationDir.mkdirs() } if (destinationDir?.setWritable(true) == false) { - Log.e("Install APK", "Failed to set destinationDir to writable.") + Logger.log("Failed to set destinationDir to writable.") downloadsRelay.call(downloadId to InstallStep.Error) return InstallStep.Error } // Copy the file to the new location copyFileToInternalStorage(sourcePath, destinationPath) - Log.i("Install APK", "APK moved to $destinationPath") + Logger.log("APK moved to $destinationPath") downloadsRelay.call(downloadId to InstallStep.Installed) return InstallStep.Installed } @@ -198,9 +197,9 @@ internal class NovelExtensionInstaller(private val context: Context) { val fileToDelete = File(apkPath) //give write permission to the file if (fileToDelete.exists() && !fileToDelete.canWrite()) { - Log.i("Uninstall APK", "File is not writable. Giving write permission.") + Logger.log("File is not writable. Giving write permission.") val a = fileToDelete.setWritable(true) - Log.i("Uninstall APK", "Success: $a") + Logger.log("Success: $a") } //set the directory to writable val destinationDir = File(apkPath).parentFile @@ -208,27 +207,27 @@ internal class NovelExtensionInstaller(private val context: Context) { destinationDir.mkdirs() } val s = destinationDir?.setWritable(true) - Log.i("Uninstall APK", "Success destinationDir: $s") + Logger.log("Success destinationDir: $s") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try { Files.delete(fileToDelete.toPath()) } catch (e: Exception) { - Log.e("Uninstall APK", "Failed to delete APK file.") - Log.e("Uninstall APK", e.toString()) + Logger.log("Failed to delete APK file.") + Logger.log(e) snackString("Failed to delete APK file.") } } else { if (fileToDelete.exists()) { if (fileToDelete.delete()) { - Log.i("Uninstall APK", "APK file deleted successfully.") + Logger.log("APK file deleted successfully.") snackString("APK file deleted successfully.") } else { - Log.e("Uninstall APK", "Failed to delete APK file.") + Logger.log("Failed to delete APK file.") snackString("Failed to delete APK file.") } } else { - Log.e("Uninstall APK", "APK file not found.") + Logger.log("APK file not found.") snackString("APK file not found.") } } @@ -242,9 +241,9 @@ internal class NovelExtensionInstaller(private val context: Context) { //delete the file if it already exists if (destination.exists()) { if (destination.delete()) { - Log.i("File Copy", "File deleted successfully.") + Logger.log("File deleted successfully.") } else { - Log.e("File Copy", "Failed to delete file.") + Logger.log("Failed to delete file.") } } @@ -262,7 +261,7 @@ internal class NovelExtensionInstaller(private val context: Context) { outputChannel?.close() } - Log.i("File Copy", "File copied to internal storage.") + Logger.log("File copied to internal storage.") } private fun getRealPathFromURI(context: Context, contentUri: Uri): String? { @@ -350,7 +349,7 @@ internal class NovelExtensionInstaller(private val context: Context) { // Set next installation step if (uri == null) { - logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" } + Logger.log("Couldn't locate downloaded APK") downloadsRelay.call(id to InstallStep.Error) return } @@ -371,7 +370,7 @@ internal class NovelExtensionInstaller(private val context: Context) { val uri = Uri.parse(localUri) val path = uri.path val pkgName = path?.substring(path.lastIndexOf('/') + 1)?.removeSuffix(".apk") - Log.i("Install APK", "Package name: $pkgName") + Logger.log("Package name: $pkgName") return pkgName ?: "" } } diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionLoader.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionLoader.kt index 94574e20..78535115 100644 --- a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionLoader.kt +++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionLoader.kt @@ -7,7 +7,7 @@ import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.os.Build import android.util.Log import ani.dantotsu.connections.crashlytics.CrashlyticsInterface -import ani.dantotsu.logger +import ani.dantotsu.util.Logger import ani.dantotsu.parsers.NovelInterface import ani.dantotsu.snackString import dalvik.system.PathClassLoader @@ -26,21 +26,20 @@ internal object NovelExtensionLoader { val installDir = context.getExternalFilesDir(null)?.absolutePath + "/extensions/novel/" val results = mutableListOf() //the number of files - Log.e("NovelExtensionLoader", "Loading extensions from $installDir") - Log.e( - "NovelExtensionLoader", + Logger.log("Loading extensions from $installDir") + Logger.log( "Loading extensions from ${File(installDir).listFiles()?.size}" ) File(installDir).setWritable(false) File(installDir).listFiles()?.forEach { //set the file to read only it.setWritable(false) - Log.e("NovelExtensionLoader", "Loading extension ${it.name}") + Logger.log("Loading extension ${it.name}") val extension = loadExtension(context, it) if (extension is NovelLoadResult.Success) { results.add(extension) } else { - logger("Failed to load extension ${it.name}") + Logger.log("Failed to load extension ${it.name}") } } return results @@ -62,7 +61,7 @@ internal object NovelExtensionLoader { context.packageManager.getPackageArchiveInfo(path, 0) } catch (error: Exception) { // Unlikely, but the package may have been uninstalled at this point - logger("Failed to load extension $pkgName") + Logger.log("Failed to load extension $pkgName") return NovelLoadResult.Error(Exception("Failed to load extension")) } return loadExtension(context, File(path)) @@ -88,8 +87,8 @@ internal object NovelExtensionLoader { val signatureHash = getSignatureHash(packageInfo) if ((signatureHash == null) || !signatureHash.contains(officialSignature)) { - logger("Package ${packageInfo.packageName} isn't signed") - logger("signatureHash: $signatureHash") + Logger.log("Package ${packageInfo.packageName} isn't signed") + Logger.log("signatureHash: $signatureHash") snackString("Package ${packageInfo.packageName} isn't signed") //return NovelLoadResult.Error(Exception("Extension not signed")) } @@ -128,12 +127,12 @@ internal object NovelExtensionLoader { private fun loadSources(context: Context, file: File, className: String): List { return try { - Log.e("NovelExtensionLoader", "isFileWritable: ${file.canWrite()}") + Logger.log("isFileWritable: ${file.canWrite()}") if (file.canWrite()) { val a = file.setWritable(false) - Log.e("NovelExtensionLoader", "success: $a") + Logger.log("success: $a") } - Log.e("NovelExtensionLoader", "isFileWritable: ${file.canWrite()}") + Logger.log("isFileWritable: ${file.canWrite()}") val classLoader = PathClassLoader(file.absolutePath, null, context.classLoader) val className = "some.random.novelextensions.${className.lowercase(Locale.getDefault())}.$className" diff --git a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt index c7a9723c..d52edd30 100644 --- a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt +++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt @@ -2,7 +2,7 @@ package ani.dantotsu.parsers.novel import android.content.Context import android.graphics.drawable.Drawable -import ani.dantotsu.logger +import ani.dantotsu.util.Logger import ani.dantotsu.snackString import eu.kanade.tachiyomi.extension.InstallStep import kotlinx.coroutines.flow.MutableStateFlow @@ -70,7 +70,7 @@ class NovelExtensionManager(private val context: Context) { val extensions: List = try { api.findExtensions() } catch (e: Exception) { - logger("Error finding extensions: ${e.message}") + Logger.log("Error finding extensions: ${e.message}") withUIContext { snackString("Failed to get Novel extensions list") } emptyList() } diff --git a/app/src/main/java/ani/dantotsu/profile/ChartBuilder.kt b/app/src/main/java/ani/dantotsu/profile/ChartBuilder.kt new file mode 100644 index 00000000..1fcce464 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/ChartBuilder.kt @@ -0,0 +1,426 @@ +package ani.dantotsu.profile + +import android.content.Context +import android.graphics.Color +import android.util.TypedValue +import ani.dantotsu.util.ColorEditor +import com.github.aachartmodel.aainfographics.aachartcreator.AAChartModel +import com.github.aachartmodel.aainfographics.aachartcreator.AAChartStackingType +import com.github.aachartmodel.aainfographics.aachartcreator.AAChartType +import com.github.aachartmodel.aainfographics.aachartcreator.AAChartZoomType +import com.github.aachartmodel.aainfographics.aachartcreator.AADataElement +import com.github.aachartmodel.aainfographics.aachartcreator.AAOptions +import com.github.aachartmodel.aainfographics.aachartcreator.AASeriesElement +import com.github.aachartmodel.aainfographics.aachartcreator.aa_toAAOptions +import com.github.aachartmodel.aainfographics.aaoptionsmodel.AADataLabels +import com.github.aachartmodel.aainfographics.aaoptionsmodel.AAItemStyle +import com.github.aachartmodel.aainfographics.aaoptionsmodel.AAScrollablePlotArea +import com.github.aachartmodel.aainfographics.aaoptionsmodel.AAStyle +import com.github.aachartmodel.aainfographics.aaoptionsmodel.AAYAxis +import com.github.aachartmodel.aainfographics.aatools.AAColor + +class ChartBuilder { + companion object { + enum class ChartType { + OneDimensional, TwoDimensional + } + + enum class StatType { + COUNT, TIME, AVG_SCORE + } + + enum class MediaType { + ANIME, MANGA + } + + data class ChartPacket( + val username: String, + val names: List, + var statData: List + ) + + fun buildChart( + context: Context, + passedChartType: ChartType, + passedAaChartType: AAChartType, + statType: StatType, + mediaType: MediaType, + chartPackets: List, + xAxisName: String, + xAxisTickInterval: Int? = null, + polar: Boolean = false, + passedCategories: List? = null, + scrollPos: Float? = null, + normalize: Boolean = false + ): AAOptions { + val typedValue = TypedValue() + context.theme.resolveAttribute( + com.google.android.material.R.attr.colorPrimary, + typedValue, + true + ) + val primaryColor = typedValue.data + var chartType = passedChartType + var aaChartType = passedAaChartType + var categories = passedCategories + if (chartType == ChartType.OneDimensional && chartPackets.size != 1) { + //need to convert to 2D + chartType = ChartType.TwoDimensional + aaChartType = AAChartType.Column + categories = chartPackets[0].names.map { it.toString() } + } + if (normalize && chartPackets.size > 1) { + chartPackets.forEach { + it.statData = normalizeData(it.statData) + } + } + + val namesMax = chartPackets.maxOf { it.names.size } + val palette = ColorEditor.generateColorPalette(primaryColor, namesMax) + val aaChartModel = when (chartType) { + ChartType.OneDimensional -> { + val chart = AAChartModel() + .chartType(aaChartType) + .subtitle( + getTypeName( + statType, + mediaType + ) + if (normalize && chartPackets.size > 1) " (Normalized)" else "" + ) + .zoomType(AAChartZoomType.None) + .dataLabelsEnabled(true) + val elements: MutableList = mutableListOf() + chartPackets.forEachIndexed { index, chartPacket -> + val element = AASeriesElement() + .name(chartPacket.username) + .data( + get1DElements( + chartPacket.names, + chartPacket.statData, + palette + ) + ) + if (index == 0) { + element.color(primaryColor) + } else { + element.color(ColorEditor.oppositeColor(primaryColor)) + } + elements.add(element) + + } + chart.series(elements.toTypedArray()) + xAxisTickInterval?.let { chart.xAxisTickInterval(it) } + categories?.let { chart.categories(it.toTypedArray()) } + chart + } + + ChartType.TwoDimensional -> { + val hexColorsArray: Array = + palette.map { String.format("#%06X", 0xFFFFFF and it) }.toTypedArray() + val chart = AAChartModel() + .chartType(aaChartType) + .subtitle( + getTypeName( + statType, + mediaType + ) + if (normalize && chartPackets.size > 1) " (Normalized)" else "" + ) + .zoomType(AAChartZoomType.None) + .dataLabelsEnabled(false) + .yAxisTitle( + getTypeName( + statType, + mediaType + ) + if (normalize && chartPackets.size > 1) " (Normalized)" else "" + ) + if (chartPackets.size == 1) { + chart.colorsTheme(hexColorsArray) + } + + val elements: MutableList = mutableListOf() + chartPackets.forEachIndexed { index, chartPacket -> + val element = get2DElements( + chartPacket.names, + chartPacket.statData, + chartPackets.size == 1 + ) + element.name(chartPacket.username) + + if (index == 0) { + element.color( + AAColor.rgbaColor( + Color.red(primaryColor), + Color.green(primaryColor), + Color.blue(primaryColor), + 0.9f + ) + ) + + } else { + element.color( + AAColor.rgbaColor( + Color.red( + ColorEditor.oppositeColor( + primaryColor + ) + ), + Color.green(ColorEditor.oppositeColor(primaryColor)), + Color.blue(ColorEditor.oppositeColor(primaryColor)), + 0.9f + ) + ) + } + if (chartPackets.size == 1) { + element.fillColor( + AAColor.rgbaColor( + Color.red(primaryColor), + Color.green(primaryColor), + Color.blue(primaryColor), + 0.9f + ) + ) + } + elements.add(element) + } + chart.series(elements.toTypedArray()) + + xAxisTickInterval?.let { chart.xAxisTickInterval(it) } + categories?.let { chart.categories(it.toTypedArray()) } + + chart + } + } + val aaOptions = aaChartModel.aa_toAAOptions() + aaOptions.chart?.polar = polar + aaOptions.tooltip?.apply { + headerFormat + formatter( + getToolTipFunction( + chartType, + xAxisName, + getTypeName(statType, mediaType), + chartPackets.size + ) + ) + if (chartPackets.size > 1) { + useHTML(true) + } + } + aaOptions.legend?.apply { + enabled(true) + .labelFormat = "{name}" + } + aaOptions.plotOptions?.series?.connectNulls(false) + aaOptions.plotOptions?.series?.stacking(AAChartStackingType.False) + aaOptions.chart?.panning = true + + scrollPos?.let { + aaOptions.chart?.scrollablePlotArea(AAScrollablePlotArea().scrollPositionX(scrollPos)) + aaOptions.chart?.scrollablePlotArea?.minWidth((context.resources.displayMetrics.widthPixels.toFloat() / context.resources.displayMetrics.density) * (namesMax.toFloat() / 18.0f)) + } + val allStatData = chartPackets.flatMap { it.statData } + val min = (allStatData.minOfOrNull { it.toDouble() } ?: 0.0) - 1.0 + val coercedMin = min.coerceAtLeast(0.0) + val max = allStatData.maxOfOrNull { it.toDouble() } ?: 0.0 + + val aaYaxis = AAYAxis().min(coercedMin).max(max) + val tickInterval = when (max) { + in 0.0..10.0 -> 1.0 + in 10.0..30.0 -> 5.0 + in 30.0..100.0 -> 10.0 + in 100.0..1000.0 -> 100.0 + in 1000.0..10000.0 -> 1000.0 + else -> 10000.0 + } + aaYaxis.tickInterval(tickInterval) + aaOptions.yAxis(aaYaxis) + + setColors(aaOptions, context, primaryColor) + + return aaOptions + } + + private fun get2DElements( + names: List, + statData: List, + colorByPoint: Boolean + ): AASeriesElement { + val statValues = mutableListOf>() + for (i in statData.indices) { + statValues.add(arrayOf(names[i], statData[i], statData[i])) + } + return AASeriesElement() + .data(statValues.toTypedArray()) + .dataLabels( + AADataLabels() + .enabled(false) + ) + .colorByPoint(colorByPoint) + } + + private fun get1DElements( + names: List, + statData: List, + colors: List + ): Array { + val statDataElements = mutableListOf() + for (i in statData.indices) { + val element = AADataElement() + .y(statData[i]) + .color( + AAColor.rgbaColor( + Color.red(colors[i]), + Color.green(colors[i]), + Color.blue(colors[i]), + 0.9f + ) + ) + if (names[i] is Number) { + element.x(names[i] as Number) + element.dataLabels( + AADataLabels() + .enabled(false) + .format("{point.y}") + .backgroundColor(AAColor.rgbaColor(255, 255, 255, 0.0f)) + ) + } else { + element.x(i) + element.name(names[i] as String) + } + statDataElements.add(element) + } + return statDataElements.toTypedArray() + } + + private fun getTypeName(statType: StatType, mediaType: MediaType): String { + return when (statType) { + StatType.COUNT -> "Count" + StatType.TIME -> if (mediaType == MediaType.ANIME) "Hours Watched" else "Chapters Read" + StatType.AVG_SCORE -> "Mean Score" + } + } + + private fun normalizeData(data: List): List { + if (data.isEmpty()) { + return data + } + val max = data.maxOf { it.toDouble() } + return data.map { (it.toDouble() / max) * 100 } + } + + private fun setColors(aaOptions: AAOptions, context: Context, primaryColor: Int) { + val backgroundColor = TypedValue() + context.theme.resolveAttribute( + com.google.android.material.R.attr.colorSurfaceVariant, + backgroundColor, + true + ) + val backgroundStyle = AAStyle().color( + AAColor.rgbaColor( + Color.red(backgroundColor.data), + Color.green(backgroundColor.data), + Color.blue(backgroundColor.data), + 1f + ) + ) + val colorOnBackground = TypedValue() + context.theme.resolveAttribute( + com.google.android.material.R.attr.colorOnSurface, + colorOnBackground, + true + ) + val onBackgroundStyle = AAStyle().color( + AAColor.rgbaColor( + Color.red(colorOnBackground.data), + Color.green(colorOnBackground.data), + Color.blue(colorOnBackground.data), + 1.0f + ) + ) + + + aaOptions.chart?.backgroundColor(backgroundStyle.color) + aaOptions.tooltip?.backgroundColor( + AAColor.rgbaColor( + Color.red(backgroundColor.data), + Color.green(backgroundColor.data), + Color.blue(backgroundColor.data), + 1.0f + ) + ) + aaOptions.title?.style(onBackgroundStyle) + aaOptions.subtitle?.style(onBackgroundStyle) + aaOptions.tooltip?.style(onBackgroundStyle) + aaOptions.credits?.style(onBackgroundStyle) + aaOptions.xAxis?.labels?.style(onBackgroundStyle) + aaOptions.yAxis?.labels?.style(onBackgroundStyle) + aaOptions.plotOptions?.series?.dataLabels?.style(onBackgroundStyle) + aaOptions.plotOptions?.series?.dataLabels?.backgroundColor(backgroundStyle.color) + aaOptions.legend?.itemStyle(AAItemStyle().color(onBackgroundStyle.color)) + + aaOptions.touchEventEnabled(true) + } + + private fun getToolTipFunction( + chartType: ChartType, + type: String, + typeName: String, + chartSize: Int + ): String { + return when (chartType) { + ChartType.OneDimensional -> { + """ + function () { + return this.point.name + + ':
' + + ' ' + + this.y + + ', ' + + (this.percentage).toFixed(2) + + '%' + } + """.trimIndent() + } + + ChartType.TwoDimensional -> { + if (chartSize == 1) { + """ + function () { + return '$type: ' + + this.x + + '
' + + ' $typeName ' + + this.y + } + """.trimIndent() + } else { + """ +function() { + let wholeContentStr = '◉${type}: ' + this.x + '
'; + if (this.points) { + let length = this.points.length; + for (let i = 0; i < length; i++) { + let thisPoint = this.points[i]; + let yValue = thisPoint.y; + if (yValue != 0) { + let spanStyleStartStr = '◉ '; + let spanStyleEndStr = '
'; + wholeContentStr += spanStyleStartStr + thisPoint.series.name + ': ' + yValue + spanStyleEndStr; + + } + } + } else { + let spanStyleStartStr = '◉ '; + let spanStyleEndStr = '
'; + wholeContentStr += spanStyleStartStr + this.point.series.name + ': ' + this.point.y + spanStyleEndStr; + } + return wholeContentStr; +} + """.trimIndent() + } + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/ChartItem.kt b/app/src/main/java/ani/dantotsu/profile/ChartItem.kt new file mode 100644 index 00000000..98b2889e --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/ChartItem.kt @@ -0,0 +1,84 @@ +package ani.dantotsu.profile + +import android.content.Intent +import android.view.View +import ani.dantotsu.R +import ani.dantotsu.databinding.ItemChartBinding +import com.github.aachartmodel.aainfographics.aachartcreator.AAChartView +import com.github.aachartmodel.aainfographics.aachartcreator.AAMoveOverEventMessageModel +import com.github.aachartmodel.aainfographics.aachartcreator.AAOptions +import com.xwray.groupie.OnItemClickListener +import com.xwray.groupie.OnItemLongClickListener +import com.xwray.groupie.viewbinding.BindableItem +import com.xwray.groupie.viewbinding.GroupieViewHolder + +class ChartItem( + private val title: String, + private val aaOptions: AAOptions, + private val activity: ProfileActivity): BindableItem() { + private lateinit var binding: ItemChartBinding + override fun bind(viewBinding: ItemChartBinding, position: Int) { + binding = viewBinding + binding.typeText.text = title + binding.root.visibility = View.INVISIBLE + binding.chartView.clipToPadding = true + val callback: AAChartView.AAChartViewCallBack = object : AAChartView.AAChartViewCallBack { + + override fun chartViewDidFinishLoad(aaChartView: AAChartView) { + binding.root.visibility = View.VISIBLE + } + + override fun chartViewMoveOverEventMessage( + aaChartView: AAChartView, + messageModel: AAMoveOverEventMessageModel + ) { + } + } + binding.chartView.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + binding.chartView.callBack = callback + binding.chartView.reload() + binding.chartView.aa_drawChartWithChartOptions(aaOptions) + binding.openButton.setOnClickListener { + SingleStatActivity.chartOptions = aaOptions + activity.startActivity( + Intent(activity, SingleStatActivity::class.java) + ) + } + } + + override fun getLayout(): Int { + return R.layout.item_chart + } + + override fun initializeViewBinding(view: View): ItemChartBinding { + return ItemChartBinding.bind(view) + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.setIsRecyclable(false) + super.bind(viewHolder, position) + } + + override fun bind( + viewHolder: GroupieViewHolder, + position: Int, + payloads: MutableList + ) { + viewHolder.setIsRecyclable(false) + super.bind(viewHolder, position, payloads) + } + + override fun bind( + viewHolder: GroupieViewHolder, + position: Int, + payloads: MutableList, + onItemClickListener: OnItemClickListener?, + onItemLongClickListener: OnItemLongClickListener? + ) { + viewHolder.setIsRecyclable(false) + super.bind(viewHolder, position, payloads, onItemClickListener, onItemLongClickListener) + } + override fun getViewType(): Int { + return 0 + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/FollowActivity.kt b/app/src/main/java/ani/dantotsu/profile/FollowActivity.kt new file mode 100644 index 00000000..70368ea3 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/FollowActivity.kt @@ -0,0 +1,130 @@ +package ani.dantotsu.profile + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.ImageButton +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.User +import ani.dantotsu.databinding.ActivityFollowBinding +import ani.dantotsu.initActivity +import ani.dantotsu.navBarHeight +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.statusBarHeight +import ani.dantotsu.themes.ThemeManager +import com.xwray.groupie.GroupieAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +class FollowActivity : AppCompatActivity(){ + private lateinit var binding: ActivityFollowBinding + val adapter = GroupieAdapter() + var users: List? = null + private lateinit var selected: ImageButton + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + binding = ActivityFollowBinding.inflate(layoutInflater) + binding.listToolbar.updateLayoutParams { topMargin = statusBarHeight } + binding.listFrameLayout.updateLayoutParams { bottomMargin = navBarHeight } + setContentView(binding.root) + val layoutType = PrefManager.getVal(PrefName.FollowerLayout) + selected = getSelected(layoutType) + binding.followerGrid.alpha = 0.33f + binding.followerList.alpha = 0.33f + selected(selected) + binding.listRecyclerView.layoutManager = LinearLayoutManager( + this, + LinearLayoutManager.VERTICAL, + false + ) + binding.listRecyclerView.adapter = adapter + binding.listProgressBar.visibility = View.VISIBLE + binding.listBack.setOnClickListener { finish() } + + val title = intent.getStringExtra("title") + val userID= intent.getIntExtra("userId", 0) + binding.listTitle.text = title + + lifecycleScope.launch(Dispatchers.IO) { + val respond = when (title) { + "Following" -> Anilist.query.userFollowing(userID)?.data?.page?.following + "Followers" -> Anilist.query.userFollowers(userID)?.data?.page?.followers + else -> null + } + users = respond + withContext(Dispatchers.Main) { + fillList() + binding.listProgressBar.visibility = View.GONE + } + } + binding.followerList.setOnClickListener { + selected(it as ImageButton) + PrefManager.setVal(PrefName.FollowerLayout, 0) + fillList() + } + binding.followerGrid.setOnClickListener { + selected(it as ImageButton) + PrefManager.setVal(PrefName.FollowerLayout, 1) + fillList() + } + binding.followSwipeRefresh.setOnRefreshListener { + binding.followSwipeRefresh.isRefreshing = false + } + } + + private fun fillList() { + adapter.clear() + binding.listRecyclerView.layoutManager = when (getLayoutType(selected)) { + 0 -> LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) + 1 -> GridLayoutManager(this, 3, GridLayoutManager.VERTICAL, false) + else -> LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) + } + users?.forEach { user -> + if (getLayoutType(selected) == 0) { + adapter.add(FollowerItem(user.id, user.name ?: "Unknown", user.avatar?.medium, user.bannerImage ?: user.avatar?.medium ) { onUserClick(it) }) + } else { + adapter.add(GridFollowerItem(user.id, user.name ?: "Unknown", user.avatar?.medium) { onUserClick(it) }) + } + } + } + + fun selected(it: ImageButton) { + selected.alpha = 0.33f + selected = it + selected.alpha = 1f + } + + private fun getSelected(pos: Int): ImageButton { + return when (pos) { + 0 -> binding.followerList + 1 -> binding.followerGrid + else -> binding.followerList + } + } + + private fun getLayoutType(it: ImageButton): Int { + return when (it) { + binding.followerList -> 0 + binding.followerGrid -> 1 + else -> 0 + } + } + + private fun onUserClick(id: Int) { + val intent = Intent(this, ProfileActivity::class.java) + intent.putExtra("userId", id) + startActivity(intent) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/FollowerItem.kt b/app/src/main/java/ani/dantotsu/profile/FollowerItem.kt new file mode 100644 index 00000000..6312e3c5 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/FollowerItem.kt @@ -0,0 +1,35 @@ +package ani.dantotsu.profile + + +import android.view.View +import ani.dantotsu.R +import ani.dantotsu.blurImage +import ani.dantotsu.databinding.ItemFollowerBinding +import ani.dantotsu.loadImage +import com.xwray.groupie.viewbinding.BindableItem + +class FollowerItem( + private val id: Int, + private val name: String, + private val avatar: String?, + private val banner: String?, + val clickCallback: (Int) -> Unit +): BindableItem() { + private lateinit var binding: ItemFollowerBinding + + override fun bind(viewBinding: ItemFollowerBinding, position: Int) { + binding = viewBinding + binding.profileUserName.text = name + avatar?.let { binding.profileUserAvatar.loadImage(it) } + blurImage(binding.profileBannerImage, banner ?: avatar) + binding.root.setOnClickListener { clickCallback(id) } + } + + override fun getLayout(): Int { + return R.layout.item_follower + } + + override fun initializeViewBinding(view: View): ItemFollowerBinding { + return ItemFollowerBinding.bind(view) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/GridFollowerItem.kt b/app/src/main/java/ani/dantotsu/profile/GridFollowerItem.kt new file mode 100644 index 00000000..f6a61fa3 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/GridFollowerItem.kt @@ -0,0 +1,31 @@ +package ani.dantotsu.profile + +import android.view.View +import ani.dantotsu.R +import ani.dantotsu.databinding.ItemFollowerGridBinding +import ani.dantotsu.loadImage +import com.xwray.groupie.viewbinding.BindableItem + +class GridFollowerItem ( + private val id: Int, + private val name: String, + private val avatar: String?, + val clickCallback: (Int) -> Unit +): BindableItem() { + private lateinit var binding: ItemFollowerGridBinding + + override fun bind(viewBinding: ItemFollowerGridBinding, position: Int) { + binding = viewBinding + binding.profileUserName.text = name + avatar?.let { binding.profileUserAvatar.loadImage(it) } + binding.root.setOnClickListener { clickCallback(id) } + } + + override fun getLayout(): Int { + return R.layout.item_follower_grid + } + + override fun initializeViewBinding(view: View): ItemFollowerGridBinding { + return ItemFollowerGridBinding.bind(view) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/ProfileActivity.kt b/app/src/main/java/ani/dantotsu/profile/ProfileActivity.kt new file mode 100644 index 00000000..78867e37 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/ProfileActivity.kt @@ -0,0 +1,304 @@ +package ani.dantotsu.profile + +import android.animation.ObjectAnimator +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.viewpager2.adapter.FragmentStateAdapter +import ani.dantotsu.R +import ani.dantotsu.blurImage +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.Query +import ani.dantotsu.databinding.ActivityProfileBinding +import ani.dantotsu.initActivity +import ani.dantotsu.loadImage +import ani.dantotsu.media.user.ListActivity +import ani.dantotsu.navBarHeight +import ani.dantotsu.openLinkInBrowser +import ani.dantotsu.others.ImageViewDialog +import ani.dantotsu.profile.activity.FeedFragment +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 ani.dantotsu.toast +import com.google.android.material.appbar.AppBarLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import nl.joery.animatedbottombar.AnimatedBottomBar +import kotlin.math.abs + + +class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListener { + lateinit var binding: ActivityProfileBinding + private var selected: Int = 0 + private lateinit var navBar: AnimatedBottomBar + + @SuppressLint("SetTextI18n") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + binding = ActivityProfileBinding.inflate(layoutInflater) + setContentView(binding.root) + screenWidth = resources.displayMetrics.widthPixels.toFloat() + navBar = binding.profileNavBar + navBar.updateLayoutParams { bottomMargin = navBarHeight } + + val feedTab = navBar.createTab(R.drawable.ic_round_filter_24, "Feed") + val profileTab = navBar.createTab(R.drawable.ic_round_person_24, "Profile") + val statsTab = navBar.createTab(R.drawable.ic_stats_24, "Stats") + navBar.addTab(profileTab) + navBar.addTab(feedTab) + navBar.addTab(statsTab) + navBar.visibility = View.GONE + binding.profileViewPager.isUserInputEnabled = false + + lifecycleScope.launch(Dispatchers.IO) { + val userid = intent.getIntExtra("userId", -1) + val username = intent.getStringExtra("username") ?: "" + val respond = + if (userid != -1) Anilist.query.getUserProfile(userid) else + Anilist.query.getUserProfile(username) + val user = respond?.data?.user + if (user == null) { + toast("User not found") + finish() + return@launch + } + val following = respond.data.followingPage?.pageInfo?.total ?: 0 + val followers = respond.data.followerPage?.pageInfo?.total ?: 0 + withContext(Dispatchers.Main) { + binding.profileViewPager.updateLayoutParams { + bottomMargin = navBarHeight + } + binding.profileViewPager.adapter = + ViewPagerAdapter(supportFragmentManager, lifecycle, user) + binding.profileViewPager.setOffscreenPageLimit(3) + binding.profileViewPager.setCurrentItem(selected, false) + navBar.visibility = View.VISIBLE + navBar.selectTabAt(selected) + navBar.setOnTabSelectListener(object : AnimatedBottomBar.OnTabSelectListener { + override fun onTabSelected( + lastIndex: Int, + lastTab: AnimatedBottomBar.Tab?, + newIndex: Int, + newTab: AnimatedBottomBar.Tab + ) { + selected = newIndex + binding.profileViewPager.setCurrentItem(selected, true) + } + }) + val userLevel = intent.getStringExtra("userLVL") ?: "" + binding.followButton.visibility = + if (user.id == Anilist.userid || Anilist.userid == null) View.GONE else View.VISIBLE + binding.followButton.text = + if (user.isFollowing) "Unfollow" else if (user.isFollower) "Follows you" else "Follow" + if (user.isFollowing && user.isFollower) binding.followButton.text = "Mutual" + binding.followButton.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + val res = Anilist.query.toggleFollow(user.id) + if (res?.data?.toggleFollow != null) { + withContext(Dispatchers.Main) { + snackString("Success") + user.isFollowing = res.data.toggleFollow.isFollowing + binding.followButton.text = + if (user.isFollowing) "Unfollow" else if (user.isFollower) "Follows you" else "Follow" + if (user.isFollowing && user.isFollower) binding.followButton.text = + "Mutual" + } + } + } + } + binding.profileProgressBar.visibility = View.GONE + binding.profileAppBar.visibility = View.VISIBLE + binding.profileMenuButton.setOnClickListener { + val popup = PopupMenu(this@ProfileActivity, binding.profileMenuButton) + popup.menuInflater.inflate(R.menu.menu_profile, popup.menu) + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_view_following -> { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, FollowActivity::class.java) + .putExtra("title", "Following") + .putExtra("userId", user.id), + null + ) + true + } + + R.id.action_view_followers -> { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, FollowActivity::class.java) + .putExtra("title", "Followers") + .putExtra("userId", user.id), + null + ) + true + } + + R.id.action_view_on_anilist -> { + openLinkInBrowser("https://anilist.co/user/${user.name}") + true + } + + else -> false + } + } + popup.show() + } + + binding.profileUserAvatar.loadImage(user.avatar?.medium) + binding.profileUserAvatar.setOnLongClickListener { + ImageViewDialog.newInstance( + this@ProfileActivity, + "${user.name}'s [Avatar]", + user.avatar?.medium + ) + } + + binding.profileUserName.text = "${user.name} $userLevel" + if (!(PrefManager.getVal(PrefName.BannerAnimations) as Boolean)) binding.profileBannerImage.pause() + blurImage(binding.profileBannerImage, user.bannerImage ?: user.avatar?.medium) + binding.profileBannerImage.updateLayoutParams { height += statusBarHeight } + binding.profileBannerGradient.updateLayoutParams { height += statusBarHeight } + binding.profileMenuButton.updateLayoutParams { topMargin += statusBarHeight } + binding.profileButtonContainer.updateLayoutParams { topMargin += statusBarHeight } + binding.profileBannerImage.setOnLongClickListener { + ImageViewDialog.newInstance( + this@ProfileActivity, + user.name + " [Banner]", + user.bannerImage + ) + } + + mMaxScrollSize = binding.profileAppBar.totalScrollRange + binding.profileAppBar.addOnOffsetChangedListener(this@ProfileActivity) + + + binding.profileFollowerCount.text = followers.toString() + binding.profileFollowerCountContainer.setOnClickListener { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, FollowActivity::class.java) + .putExtra("title", "Followers") + .putExtra("userId", user.id), + null + ) + } + + binding.profileFollowingCount.text = following.toString() + binding.profileFollowingCountContainer.setOnClickListener { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, FollowActivity::class.java) + .putExtra("title", "Following") + .putExtra("userId", user.id), + null + ) + } + + binding.profileAnimeCount.text = user.statistics.anime.count.toString() + binding.profileAnimeCountContainer.setOnClickListener { + ContextCompat.startActivity( + this@ProfileActivity, Intent(this@ProfileActivity, ListActivity::class.java) + .putExtra("anime", true) + .putExtra("userId", user.id) + .putExtra("username", user.name), null + ) + } + + binding.profileMangaCount.text = user.statistics.manga.count.toString() + binding.profileMangaCountContainer.setOnClickListener { + ContextCompat.startActivity( + this@ProfileActivity, Intent(this@ProfileActivity, ListActivity::class.java) + .putExtra("anime", false) + .putExtra("userId", user.id) + .putExtra("username", user.name), null + ) + } + } + } + } + + //Collapsing UI Stuff + private var isCollapsed = false + private val percent = 65 + private var mMaxScrollSize = 0 + private var screenWidth: Float = 0f + + override fun onOffsetChanged(appBar: AppBarLayout, i: Int) { + if (mMaxScrollSize == 0) mMaxScrollSize = appBar.totalScrollRange + val percentage = abs(i) * 100 / mMaxScrollSize + + binding.profileUserAvatarContainer.visibility = + if (binding.profileUserAvatarContainer.scaleX == 0f) View.GONE else View.VISIBLE + val duration = (200 * (PrefManager.getVal(PrefName.AnimationSpeed) as Float)).toLong() + val typedValue = TypedValue() + this@ProfileActivity.theme.resolveAttribute( + com.google.android.material.R.attr.colorSecondary, + typedValue, + true + ) + val color = typedValue.data + if (percentage >= percent && !isCollapsed) { + isCollapsed = true + ObjectAnimator.ofFloat(binding.profileUserDataContainer, "translationX", screenWidth) + .setDuration(duration).start() + ObjectAnimator.ofFloat(binding.profileUserAvatarContainer, "translationX", screenWidth) + .setDuration(duration).start() + ObjectAnimator.ofFloat(binding.profileButtonContainer, "translationX", screenWidth) + .setDuration(duration).start() + binding.profileBannerImage.pause() + } + if (percentage <= percent && isCollapsed) { + isCollapsed = false + ObjectAnimator.ofFloat(binding.profileUserDataContainer, "translationX", 0f) + .setDuration(duration).start() + ObjectAnimator.ofFloat(binding.profileUserAvatarContainer, "translationX", 0f) + .setDuration(duration).start() + ObjectAnimator.ofFloat(binding.profileButtonContainer, "translationX", 0f) + .setDuration(duration).start() + + if (PrefManager.getVal(PrefName.BannerAnimations)) binding.profileBannerImage.resume() + } + } + + override fun onResume() { + if (this::navBar.isInitialized) { + navBar.selectTabAt(selected) + } + super.onResume() + } + + private class ViewPagerAdapter( + fragmentManager: FragmentManager, + lifecycle: Lifecycle, + private val user: Query.UserProfile + ) : + FragmentStateAdapter(fragmentManager, lifecycle) { + + override fun getItemCount(): Int = 3 + override fun createFragment(position: Int): Fragment = when (position) { + 0 -> ProfileFragment.newInstance(user) + 1 -> FeedFragment.newInstance(user.id, false, -1) + 2 -> StatsFragment.newInstance(user) + else -> ProfileFragment.newInstance(user) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/ProfileFragment.kt b/app/src/main/java/ani/dantotsu/profile/ProfileFragment.kt new file mode 100644 index 00000000..980c70c5 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/ProfileFragment.kt @@ -0,0 +1,219 @@ +package ani.dantotsu.profile + +import android.content.Intent +import android.os.Bundle +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.LayoutAnimationController +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.LiveData +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import ani.dantotsu.R +import ani.dantotsu.connections.anilist.ProfileViewModel +import ani.dantotsu.connections.anilist.api.Query +import ani.dantotsu.databinding.FragmentProfileBinding +import ani.dantotsu.loadImage +import ani.dantotsu.media.Author +import ani.dantotsu.media.AuthorAdapter +import ani.dantotsu.media.Character +import ani.dantotsu.media.CharacterAdapter +import ani.dantotsu.media.Media +import ani.dantotsu.media.MediaAdaptor +import ani.dantotsu.media.user.ListActivity +import ani.dantotsu.setSlideIn +import ani.dantotsu.setSlideUp +import ani.dantotsu.util.AniMarkdown.Companion.getFullAniHTML +import ani.dantotsu.util.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + + +class ProfileFragment : Fragment() { + lateinit var binding: FragmentProfileBinding + private lateinit var activity: ProfileActivity + private lateinit var user: Query.UserProfile + private val favStaff = arrayListOf() + private val favCharacter = arrayListOf() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentProfileBinding.inflate(inflater, container, false) + return binding.root + } + + val model: ProfileViewModel by activityViewModels() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + activity = requireActivity() as ProfileActivity + + user = arguments?.getSerializable("user") as Query.UserProfile + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + model.setData(user.id) + } + binding.profileUserBio.settings.loadWithOverviewMode = true + binding.profileUserBio.settings.useWideViewPort = true + binding.profileUserBio.setInitialScale(1) + val styledHtml = getFullAniHTML( + user.about ?: "", + ContextCompat.getColor(activity, R.color.bg_opp) + ) + binding.profileUserBio.loadDataWithBaseURL( + null, + styledHtml, + "text/html; charset=utf-8", + "UTF-8", + null + ) + binding.profileUserBio.setBackgroundColor( + ContextCompat.getColor( + activity, + android.R.color.transparent + ) + ) + binding.profileUserBio.setLayerType(View.LAYER_TYPE_HARDWARE, null) + binding.profileUserBio.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + binding.profileUserBio.setBackgroundColor( + ContextCompat.getColor( + activity, + android.R.color.transparent + ) + ) + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + return true + } + } + + binding.userInfoContainer.visibility = + if (user.about != null) View.VISIBLE else View.GONE + + + binding.statsEpisodesWatched.text = user.statistics.anime.episodesWatched.toString() + binding.statsDaysWatched.text = + (user.statistics.anime.minutesWatched / (24 * 60)).toString() + binding.statsAnimeMeanScore.text = user.statistics.anime.meanScore.toString() + binding.statsChaptersRead.text = user.statistics.manga.chaptersRead.toString() + binding.statsVolumeRead.text = (user.statistics.manga.volumesRead).toString() + binding.statsMangaMeanScore.text = user.statistics.manga.meanScore.toString() + initRecyclerView( + model.getAnimeFav(), + binding.profileFavAnimeContainer, + binding.profileFavAnimeRecyclerView, + binding.profileFavAnimeProgressBar, + binding.profileFavAnime + ) + + initRecyclerView( + model.getMangaFav(), + binding.profileFavMangaContainer, + binding.profileFavMangaRecyclerView, + binding.profileFavMangaProgressBar, + binding.profileFavManga + ) + + user.favourites?.characters?.nodes?.forEach { i -> + favCharacter.add(Character(i.id, i.name.full, i.image.large, i.image.large, "", true)) + } + + user.favourites?.staff?.nodes?.forEach { i -> + favStaff.add(Author(i.id, i.name.full, i.image.large , "" )) + } + + setFavPeople() + } + + override fun onResume() { + super.onResume() + if (this::binding.isInitialized) { + binding.root.requestLayout() + setFavPeople() + model.refresh() + } + } + + private fun setFavPeople() { + if (favStaff.isEmpty()) { + binding.profileFavStaffContainer.visibility = View.GONE + } + binding.profileFavStaffRecycler.adapter = AuthorAdapter(favStaff) + binding.profileFavStaffRecycler.layoutManager = LinearLayoutManager( + activity, + LinearLayoutManager.HORIZONTAL, + false + ) + if (favCharacter.isEmpty()) { + binding.profileFavCharactersContainer.visibility = View.GONE + } + binding.profileFavCharactersRecycler.adapter = CharacterAdapter(favCharacter) + binding.profileFavCharactersRecycler.layoutManager = LinearLayoutManager( + activity, + LinearLayoutManager.HORIZONTAL, + false + ) + } + + private fun initRecyclerView( + mode: LiveData>, + container: View, + recyclerView: RecyclerView, + progress: View, + title: View + ) { + container.visibility = View.VISIBLE + progress.visibility = View.VISIBLE + recyclerView.visibility = View.GONE + title.visibility = View.INVISIBLE + + mode.observe(viewLifecycleOwner) { + recyclerView.visibility = View.GONE + if (it != null) { + if (it.isNotEmpty()) { + recyclerView.adapter = MediaAdaptor(0, it, activity, fav=true) + recyclerView.layoutManager = LinearLayoutManager( + activity, + LinearLayoutManager.HORIZONTAL, + false + ) + recyclerView.visibility = View.VISIBLE + recyclerView.layoutAnimation = + LayoutAnimationController(setSlideIn(), 0.25f) + + } else { + container.visibility = View.GONE + } + title.visibility = View.VISIBLE + title.startAnimation(setSlideUp()) + progress.visibility = View.GONE + } + } + } + + companion object { + fun newInstance(query: Query.UserProfile): ProfileFragment { + val args = Bundle().apply { + putSerializable("user", query) + } + return ProfileFragment().apply { + arguments = args + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/SingleStatActivity.kt b/app/src/main/java/ani/dantotsu/profile/SingleStatActivity.kt new file mode 100644 index 00000000..337d2c86 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/SingleStatActivity.kt @@ -0,0 +1,38 @@ +package ani.dantotsu.profile + +import android.content.pm.ActivityInfo +import android.os.Bundle +import android.util.TypedValue +import androidx.appcompat.app.AppCompatActivity +import ani.dantotsu.databinding.ActivitySingleStatBinding +import ani.dantotsu.initActivity +import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.toast +import com.github.aachartmodel.aainfographics.aachartcreator.AAOptions + +class SingleStatActivity : AppCompatActivity() +{ + private lateinit var binding: ActivitySingleStatBinding + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + binding = ActivitySingleStatBinding.inflate(layoutInflater) + setContentView(binding.root) + val chartOptions = chartOptions + if (chartOptions != null) { + val typedvalue = TypedValue() + theme.resolveAttribute(android.R.attr.windowBackground, typedvalue, true) + chartOptions.chart?.backgroundColor = typedvalue.data + binding.chartView.aa_drawChartWithChartOptions(chartOptions) + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } else { + toast("No chart data") + finish() + } + } + + companion object { + var chartOptions: AAOptions? = null // I cba to pass this through an intent + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/StatsFragment.kt b/app/src/main/java/ani/dantotsu/profile/StatsFragment.kt new file mode 100644 index 00000000..b90cba33 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/StatsFragment.kt @@ -0,0 +1,760 @@ +package ani.dantotsu.profile + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.R +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.Query +import ani.dantotsu.databinding.FragmentStatisticsBinding +import ani.dantotsu.profile.ChartBuilder.Companion.ChartPacket +import ani.dantotsu.profile.ChartBuilder.Companion.ChartType +import ani.dantotsu.profile.ChartBuilder.Companion.MediaType +import ani.dantotsu.profile.ChartBuilder.Companion.StatType +import ani.dantotsu.statusBarHeight +import com.github.aachartmodel.aainfographics.aachartcreator.AAChartType +import com.xwray.groupie.GroupieAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale + +class StatsFragment : + Fragment() { + private lateinit var binding: FragmentStatisticsBinding + private var adapter: GroupieAdapter = GroupieAdapter() + private var stats: MutableList = mutableListOf() + private var type: MediaType = MediaType.ANIME + private var statType: StatType = StatType.COUNT + private lateinit var user: Query.UserProfile + private lateinit var activity: ProfileActivity + private var loadedFirstTime = false + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentStatisticsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + activity = requireActivity() as ProfileActivity + user = arguments?.getSerializable("user") as Query.UserProfile + + binding.statisticList.adapter = adapter + binding.statisticList.recycledViewPool.setMaxRecycledViews(0, 0) + binding.statisticList.isNestedScrollingEnabled = true + binding.statisticList.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) + binding.statisticProgressBar.visibility = View.VISIBLE + binding.compare.visibility = if (user.id == Anilist.userid) View.GONE else View.VISIBLE + binding.filterContainer.updateLayoutParams { topMargin = statusBarHeight } + + binding.sourceType.setAdapter( + ArrayAdapter( + requireContext(), + R.layout.item_dropdown, + MediaType.entries.map { it.name.uppercase(Locale.ROOT).replace("_", " ") } + ) + ) + binding.sourceFilter.setAdapter( + ArrayAdapter( + requireContext(), + R.layout.item_dropdown, + StatType.entries.map { it.name.uppercase(Locale.ROOT).replace("_", " ") } + ) + ) + + binding.compare.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + activity.lifecycleScope.launch { + if (Anilist.userid != null) { + withContext(Dispatchers.Main) { + binding.statisticProgressBar.visibility = View.VISIBLE + binding.statisticList.visibility = View.GONE + } + val userStats = + Anilist.query.getUserStatistics(Anilist.userid!!)?.data?.user + if (userStats != null) { + stats.add(userStats) + withContext(Dispatchers.Main) { + loadStats(type == MediaType.ANIME) + binding.statisticProgressBar.visibility = View.GONE + binding.statisticList.visibility = View.VISIBLE + } + } + } + } + } else { + stats.removeAll( + stats.filter { it?.id == Anilist.userid } + ) + loadStats(type == MediaType.ANIME) + } + } + + binding.filterContainer.visibility = View.GONE + } + + override fun onPause() { + super.onPause() + binding.statisticList.visibility = View.GONE + } + + override fun onResume() { + super.onResume() + if (this::binding.isInitialized) { + binding.statisticList.visibility = View.VISIBLE + binding.root.requestLayout() + if (!loadedFirstTime) { + activity.lifecycleScope.launch { + stats.clear() + stats.add(Anilist.query.getUserStatistics(user.id)?.data?.user) + withContext(Dispatchers.Main) { + binding.filterContainer.visibility = View.VISIBLE + binding.sourceType.setOnItemClickListener { _, _, i, _ -> + type = MediaType.entries.toTypedArray()[i] + loadStats(type == MediaType.ANIME) + } + binding.sourceFilter.setOnItemClickListener { _, _, i, _ -> + statType = StatType.entries.toTypedArray()[i] + loadStats(type == MediaType.ANIME) + } + loadStats(type == MediaType.ANIME) + binding.statisticProgressBar.visibility = View.GONE + } + } + loadedFirstTime = true + } + } + loadStats(type == MediaType.ANIME) + } + + private fun loadStats(anime: Boolean) { + binding.statisticProgressBar.visibility = View.VISIBLE + binding.statisticList.visibility = View.GONE + adapter.clear() + loadFormatChart(anime) + loadScoreChart(anime) + loadStatusChart(anime) + loadReleaseYearChart(anime) + loadStartYearChart(anime) + loadLengthChart(anime) + loadGenreChart(anime) + loadTagChart(anime) + loadCountryChart(anime) + loadVoiceActorsChart(anime) + loadStudioChart(anime) + loadStaffChart(anime) + binding.statisticProgressBar.visibility = View.GONE + binding.statisticList.visibility = View.VISIBLE + } + + private fun loadFormatChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.formats?.map { it.format } ?: emptyList() + } else { + stat?.statistics?.manga?.formats?.map { it.format } ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.formats?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.formats?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.formats?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.formats?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.formats?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.formats?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() && values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val formatChart = ChartBuilder.buildChart( + activity, + ChartType.OneDimensional, + AAChartType.Pie, + statType, + type, + chartPackets, + xAxisName = "Format", + ) + adapter.add(ChartItem("Format", formatChart, activity)) + } + } + + private fun loadStatusChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.statuses?.map { it.status } ?: emptyList() + } else { + stat?.statistics?.manga?.statuses?.map { it.status } ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.statuses?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.statuses?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.statuses?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.statuses?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.statuses?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.statuses?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() && values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val statusChart = ChartBuilder.buildChart( + activity, + ChartType.OneDimensional, + AAChartType.Funnel, + statType, + type, + chartPackets, + xAxisName = "Status", + ) + adapter.add(ChartItem("Status", statusChart, activity)) + } + } + + private fun loadScoreChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.scores?.map { + convertScore( + it.score, + stat.mediaListOptions.scoreFormat + ) + } ?: emptyList() + } else { + stat?.statistics?.manga?.scores?.map { + convertScore( + it.score, + stat.mediaListOptions.scoreFormat + ) + } ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.scores?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.scores?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.scores?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.scores?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.scores?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.scores?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val scoreChart = ChartBuilder.buildChart( + activity, + ChartType.TwoDimensional, + AAChartType.Column, + statType, + type, + chartPackets, + xAxisName = "Score", + ) + adapter.add(ChartItem("Score", scoreChart, activity)) + } + } + + private fun loadLengthChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.lengths?.map { it.length ?: "unknown" } + ?: emptyList() + } else { + stat?.statistics?.manga?.lengths?.map { it.length ?: "unknown" } + ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.lengths?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.lengths?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.lengths?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.lengths?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.lengths?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.lengths?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val lengthChart = ChartBuilder.buildChart( + activity, + ChartType.OneDimensional, + AAChartType.Pyramid, + statType, + type, + chartPackets, + xAxisName = "Length", + ) + adapter.add(ChartItem("Length", lengthChart, activity)) + } + } + + private fun loadReleaseYearChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.releaseYears?.map { it.releaseYear } + ?: emptyList() + } else { + stat?.statistics?.manga?.releaseYears?.map { it.releaseYear } + ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.releaseYears?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.releaseYears?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.releaseYears?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.releaseYears?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.releaseYears?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.releaseYears?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val releaseYearChart = ChartBuilder.buildChart( + activity, + ChartType.TwoDimensional, + AAChartType.Bubble, + statType, + type, + chartPackets, + xAxisName = "Year", + scrollPos = 0.0f + ) + adapter.add(ChartItem("Release Year", releaseYearChart, activity)) + } + } + + private fun loadStartYearChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.startYears?.map { it.startYear } ?: emptyList() + } else { + stat?.statistics?.manga?.startYears?.map { it.startYear } ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.startYears?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.startYears?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.startYears?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.startYears?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.startYears?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.startYears?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val startYearChart = ChartBuilder.buildChart( + activity, + ChartType.TwoDimensional, + AAChartType.Bar, + statType, + type, + chartPackets, + xAxisName = "Year", + ) + adapter.add(ChartItem("Start Year", startYearChart, activity)) + } + } + + private fun loadGenreChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.genres?.map { it.genre } ?: emptyList() + } else { + stat?.statistics?.manga?.genres?.map { it.genre } ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.genres?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.genres?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.genres?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.genres?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.genres?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.genres?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val referenceNames = chartPackets.first().names.map { it.toString() } + val standardizedPackets = chartPackets.map { packet -> + val valuesMap = packet.names.map { it.toString() }.zip(packet.statData).toMap() + val standardizedValues = referenceNames.map { name -> + valuesMap[name] ?: 0 + } + + // Create a new ChartPacket with standardized names and values. + ChartPacket(packet.username, referenceNames, standardizedValues) + }.toMutableList() + chartPackets.clear() + chartPackets.addAll(standardizedPackets) + val genreChart = ChartBuilder.buildChart( + activity, + ChartType.TwoDimensional, + AAChartType.Areaspline, + statType, + type, + chartPackets, + xAxisName = "Genre", + polar = true, + passedCategories = chartPackets[0].names as List, + normalize = true + ) + adapter.add(ChartItem("Genre", genreChart, activity)) + } + } + + private fun loadTagChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.tags?.map { it.tag.name } ?: emptyList() + } else { + stat?.statistics?.manga?.tags?.map { it.tag.name } ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.tags?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.tags?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.tags?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.tags?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.tags?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.tags?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val referenceNames = chartPackets.first().names.map { it.toString() } + val standardizedPackets = chartPackets.map { packet -> + val valuesMap = packet.names.map { it.toString() }.zip(packet.statData).toMap() + val standardizedValues = referenceNames.map { name -> + valuesMap[name] ?: 0 + } + + // Create a new ChartPacket with standardized names and values. + ChartPacket(packet.username, referenceNames, standardizedValues) + }.toMutableList() + chartPackets.clear() + chartPackets.addAll(standardizedPackets) + val tagChart = ChartBuilder.buildChart( + activity, + ChartType.TwoDimensional, + AAChartType.Areaspline, + statType, + type, + chartPackets, + xAxisName = "Tag", + polar = false, + passedCategories = chartPackets[0].names as List, + scrollPos = 0.0f + ) + adapter.add(ChartItem("Tag", tagChart, activity)) + } + } + + private fun loadCountryChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.countries?.map { it.country } ?: emptyList() + } else { + stat?.statistics?.manga?.countries?.map { it.country } ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.countries?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.countries?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.countries?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.countries?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.countries?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.countries?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val referenceNames = chartPackets.first().names.map { it.toString() } + val standardizedPackets = chartPackets.map { packet -> + val valuesMap = packet.names.map { it.toString() }.zip(packet.statData).toMap() + val standardizedValues = referenceNames.map { name -> + valuesMap[name] ?: 0 + } + + // Create a new ChartPacket with standardized names and values. + ChartPacket(packet.username, referenceNames, standardizedValues) + }.toMutableList() + chartPackets.clear() + chartPackets.addAll(standardizedPackets) + val countryChart = ChartBuilder.buildChart( + activity, + ChartType.OneDimensional, + AAChartType.Pie, + statType, + type, + chartPackets, + xAxisName = "Country", + polar = false, + passedCategories = chartPackets[0].names as List, + scrollPos = null + ) + adapter.add(ChartItem("Country", countryChart, activity)) + } + } + + private fun loadVoiceActorsChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.voiceActors?.map { it.voiceActor.name.full ?: "unknown" } + ?: emptyList() + } else { + stat?.statistics?.manga?.voiceActors?.map { it.voiceActor.name.full ?: "unknown" } + ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.voiceActors?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.voiceActors?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.voiceActors?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.voiceActors?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.voiceActors?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.voiceActors?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val referenceNames = chartPackets.first().names.map { it.toString() } + val standardizedPackets = chartPackets.map { packet -> + val valuesMap = packet.names.map { it.toString() }.zip(packet.statData).toMap() + val standardizedValues = referenceNames.map { name -> + valuesMap[name] ?: 0 + } + + // Create a new ChartPacket with standardized names and values. + ChartPacket(packet.username, referenceNames, standardizedValues) + }.toMutableList() + chartPackets.clear() + chartPackets.addAll(standardizedPackets) + val voiceActorsChart = ChartBuilder.buildChart( + activity, + ChartType.TwoDimensional, + AAChartType.Column, + statType, + type, + chartPackets, + xAxisName = "Voice Actor", + polar = false, + passedCategories = chartPackets[0].names as List, + scrollPos = 0.0f + ) + adapter.add(ChartItem("Voice Actor", voiceActorsChart, activity)) + } + } + + private fun loadStudioChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.studios?.map { it.studio.name } ?: emptyList() + } else { + stat?.statistics?.manga?.studios?.map { it.studio.name } ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.studios?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.studios?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.studios?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.studios?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.studios?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.studios?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val referenceNames = chartPackets.first().names.map { it.toString() } + val standardizedPackets = chartPackets.map { packet -> + val valuesMap = packet.names.map { it.toString() }.zip(packet.statData).toMap() + val standardizedValues = referenceNames.map { name -> + valuesMap[name] ?: 0 + } + + // Create a new ChartPacket with standardized names and values. + ChartPacket(packet.username, referenceNames, standardizedValues) + }.toMutableList() + chartPackets.clear() + chartPackets.addAll(standardizedPackets) + val studioChart = ChartBuilder.buildChart( + activity, + ChartType.TwoDimensional, + AAChartType.Spline, + statType, + type, + chartPackets, + xAxisName = "Studio", + polar = true, + passedCategories = chartPackets[0].names as List, + scrollPos = null, + normalize = true + ) + adapter.add(ChartItem("Studio", studioChart, activity)) + } + } + + private fun loadStaffChart(anime: Boolean) { + val chartPackets = mutableListOf() + stats.forEach { stat -> + val names: List = if (anime) { + stat?.statistics?.anime?.staff?.map { it.staff.name.full ?: "unknown" } + ?: emptyList() + } else { + stat?.statistics?.manga?.staff?.map { it.staff.name.full ?: "unknown" } + ?: emptyList() + } + val values: List = if (anime) { + when (statType) { + StatType.COUNT -> stat?.statistics?.anime?.staff?.map { it.count } + StatType.TIME -> stat?.statistics?.anime?.staff?.map { it.minutesWatched / 60 } + StatType.AVG_SCORE -> stat?.statistics?.anime?.staff?.map { it.meanScore } + } ?: emptyList() + } else { + when (statType) { + StatType.COUNT -> stat?.statistics?.manga?.staff?.map { it.count } + StatType.TIME -> stat?.statistics?.manga?.staff?.map { it.chaptersRead } + StatType.AVG_SCORE -> stat?.statistics?.manga?.staff?.map { it.meanScore } + } ?: emptyList() + } + if (names.isNotEmpty() || values.isNotEmpty()) { + chartPackets.add(ChartPacket(stat?.name ?: "Unknown", names, values)) + } + } + if (chartPackets.isNotEmpty()) { + val referenceNames = chartPackets.first().names.map { it.toString() } + val standardizedPackets = chartPackets.map { packet -> + val valuesMap = packet.names.map { it.toString() }.zip(packet.statData).toMap() + val standardizedValues = referenceNames.map { name -> + valuesMap[name] ?: 0 + } + + // Create a new ChartPacket with standardized names and values. + ChartPacket(packet.username, referenceNames, standardizedValues) + }.toMutableList() + chartPackets.clear() + chartPackets.addAll(standardizedPackets) + val staffChart = ChartBuilder.buildChart( + activity, + ChartType.TwoDimensional, + AAChartType.Line, + statType, + type, + chartPackets, + xAxisName = "Staff", + polar = false, + passedCategories = chartPackets[0].names as List, + scrollPos = 0.0f + ) + adapter.add(ChartItem("Staff", staffChart, activity)) + } + } + + private fun convertScore(score: Int, type: String?): Int { + return when (type) { + "POINT_100" -> score + "POINT_10_DECIMAL" -> score + "POINT_10" -> score * 10 + "POINT_5" -> score * 20 + "POINT_3" -> score * 33 + else -> score + } + } + + companion object { + fun newInstance(user: Query.UserProfile): StatsFragment { + val args = Bundle().apply { + putSerializable("user", user) + } + return StatsFragment().apply { + arguments = args + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/User.kt b/app/src/main/java/ani/dantotsu/profile/User.kt new file mode 100644 index 00000000..53c41737 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/User.kt @@ -0,0 +1,8 @@ +package ani.dantotsu.profile + +data class User( + val id: Int, + val name: String, + val pfp: String?, + val banner: String?, +) \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/UsersDialogFragment.kt b/app/src/main/java/ani/dantotsu/profile/UsersDialogFragment.kt new file mode 100644 index 00000000..ae8bc091 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/UsersDialogFragment.kt @@ -0,0 +1,41 @@ +package ani.dantotsu.profile + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.BottomSheetDialogFragment +import ani.dantotsu.databinding.BottomSheetUsersBinding +import ani.dantotsu.profile.activity.UsersAdapter +import ani.dantotsu.settings.DevelopersAdapter + + +class UsersDialogFragment : BottomSheetDialogFragment() { + private var _binding: BottomSheetUsersBinding? = null + private val binding get() = _binding!! + + private var userList = arrayListOf() + fun userList(user: ArrayList){ + userList = user + } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetUsersBinding.inflate(inflater, container, false) + return binding.root + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.usersRecyclerView.adapter = UsersAdapter(userList) + binding.usersRecyclerView.layoutManager = LinearLayoutManager(requireContext()) + } + + override fun onDestroy() { + _binding = null + super.onDestroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/activity/ActivityItem.kt b/app/src/main/java/ani/dantotsu/profile/activity/ActivityItem.kt new file mode 100644 index 00000000..c55e6a82 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/activity/ActivityItem.kt @@ -0,0 +1,192 @@ +package ani.dantotsu.profile.activity + +import android.annotation.SuppressLint +import android.view.View +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.R +import ani.dantotsu.blurImage +import ani.dantotsu.buildMarkwon +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.Activity +import ani.dantotsu.databinding.ItemActivityBinding +import ani.dantotsu.loadImage +import ani.dantotsu.profile.User +import ani.dantotsu.profile.UsersDialogFragment +import ani.dantotsu.setAnimation +import ani.dantotsu.snackString +import ani.dantotsu.util.AniMarkdown.Companion.getBasicAniHTML +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.request.RequestOptions +import com.xwray.groupie.GroupieAdapter +import com.xwray.groupie.viewbinding.BindableItem +import jp.wasabeef.glide.transformations.BlurTransformation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ActivityItem( + private val activity: Activity, + val clickCallback: (Int, type: String) -> Unit, + private val fragActivity: FragmentActivity +) : BindableItem() { + private lateinit var binding: ItemActivityBinding + private lateinit var repliesAdapter: GroupieAdapter + + @SuppressLint("SetTextI18n") + override fun bind(viewBinding: ItemActivityBinding, position: Int) { + binding = viewBinding + setAnimation(binding.root.context, binding.root) + + repliesAdapter = GroupieAdapter() + binding.activityReplies.adapter = repliesAdapter + binding.activityReplies.layoutManager = LinearLayoutManager( + binding.root.context, + LinearLayoutManager.VERTICAL, + false + ) + binding.activityUserName.text = activity.user?.name ?: activity.messenger?.name + binding.activityUserAvatar.loadImage( + activity.user?.avatar?.medium ?: activity.messenger?.avatar?.medium + ) + binding.activityTime.text = ActivityItemBuilder.getDateTime(activity.createdAt) + val likeColor = ContextCompat.getColor(binding.root.context, R.color.yt_red) + val notLikeColor = ContextCompat.getColor(binding.root.context, R.color.bg_opp) + binding.activityLike.setColorFilter(if (activity.isLiked == true) likeColor else notLikeColor) + binding.commentRepliesContainer.visibility = + if (activity.replyCount > 0) View.VISIBLE else View.GONE + binding.commentRepliesContainer.setOnClickListener { + when (binding.activityReplies.visibility) { + View.GONE -> { + val replyItems = activity.replies?.map { + ActivityReplyItem(it) { id, type -> + clickCallback( + id, + type + ) + } + } ?: emptyList() + repliesAdapter.addAll(replyItems) + binding.activityReplies.visibility = View.VISIBLE + binding.commentTotalReplies.text = "Hide replies" + } + + else -> { + repliesAdapter.clear() + binding.activityReplies.visibility = View.GONE + binding.commentTotalReplies.text = "View replies" + + } + } + } + binding.activityLikeCount.text = (activity.likeCount ?: 0).toString() + binding.activityLike.setOnClickListener { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + scope.launch { + val res = Anilist.query.toggleLike(activity.id, "ACTIVITY") + withContext(Dispatchers.Main) { + if (res != null) { + + if (activity.isLiked == true) { + activity.likeCount = activity.likeCount?.minus(1) + } else { + activity.likeCount = activity.likeCount?.plus(1) + } + binding.activityLikeCount.text = (activity.likeCount ?: 0).toString() + activity.isLiked = !activity.isLiked!! + binding.activityLike.setColorFilter(if (activity.isLiked == true) likeColor else notLikeColor) + + } else { + snackString("Failed to like activity") + } + } + } + } + val context = binding.root.context + val userList = arrayListOf() + activity.likes?.forEach{ i -> + userList.add(User(i.id, i.name.toString(), i.avatar?.medium, i.bannerImage)) + } + binding.activityLike.setOnLongClickListener{ + UsersDialogFragment().apply { userList(userList) + show(fragActivity.supportFragmentManager, "dialog") + } + true + } + + + when (activity.typename) { + "ListActivity" -> { + val cover = activity.media?.coverImage?.large + val banner = activity.media?.bannerImage + binding.activityContent.visibility = View.GONE + binding.activityBannerContainer.visibility = View.VISIBLE + binding.activityMediaName.text = activity.media?.title?.userPreferred + binding.activityText.text = "${activity.user!!.name} ${activity.status} ${activity.progress ?: activity.media?.title?.userPreferred}" + binding.activityCover.loadImage(cover) + blurImage(binding.activityBannerImage, banner ?: cover) + binding.activityAvatarContainer.setOnClickListener { + clickCallback(activity.userId ?: -1, "USER") + } + binding.activityUserName.setOnClickListener { + clickCallback(activity.userId ?: -1, "USER") + } + binding.activityCoverContainer.setOnClickListener { + clickCallback(activity.media?.id ?: -1, "MEDIA") + } + binding.activityMediaName.setOnClickListener { + clickCallback(activity.media?.id ?: -1, "MEDIA") + } + } + + "TextActivity" -> { + binding.activityBannerContainer.visibility = View.GONE + binding.activityContent.visibility = View.VISIBLE + if (!(context as android.app.Activity).isDestroyed) { + val markwon = buildMarkwon(context, false) + markwon.setMarkdown( + binding.activityContent, + getBasicAniHTML(activity.text ?: "") + ) + } + binding.activityAvatarContainer.setOnClickListener { + clickCallback(activity.userId ?: -1, "USER") + } + binding.activityUserName.setOnClickListener { + clickCallback(activity.userId ?: -1, "USER") + } + } + + "MessageActivity" -> { + binding.activityBannerContainer.visibility = View.GONE + binding.activityContent.visibility = View.VISIBLE + if (!(context as android.app.Activity).isDestroyed) { + val markwon = buildMarkwon(context, false) + markwon.setMarkdown( + binding.activityContent, + getBasicAniHTML(activity.message ?: "") + ) + } + binding.activityAvatarContainer.setOnClickListener { + clickCallback(activity.messengerId ?: -1, "USER") + } + binding.activityUserName.setOnClickListener { + clickCallback(activity.messengerId ?: -1, "USER") + } + } + } + } + + override fun getLayout(): Int { + return R.layout.item_activity + } + + override fun initializeViewBinding(view: View): ItemActivityBinding { + return ItemActivityBinding.bind(view) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/activity/ActivityItemBuilder.kt b/app/src/main/java/ani/dantotsu/profile/activity/ActivityItemBuilder.kt new file mode 100644 index 00000000..ef50027e --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/activity/ActivityItemBuilder.kt @@ -0,0 +1,118 @@ +package ani.dantotsu.profile.activity + +import ani.dantotsu.connections.anilist.api.Notification +import ani.dantotsu.connections.anilist.api.NotificationType +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +class ActivityItemBuilder { + + companion object { + fun getContent(notification: Notification): String { + val notificationType: NotificationType = + NotificationType.valueOf(notification.notificationType) + return when (notificationType) { + NotificationType.ACTIVITY_MESSAGE -> { + "${notification.user?.name} sent you a message" + } + + NotificationType.ACTIVITY_REPLY -> { + "${notification.user?.name} replied to your activity" + } + + NotificationType.FOLLOWING -> { + "${notification.user?.name} followed you" + } + + NotificationType.ACTIVITY_MENTION -> { + "${notification.user?.name} mentioned you in their activity" + } + + NotificationType.THREAD_COMMENT_MENTION -> { + "${notification.user?.name} mentioned you in a forum comment" + } + + NotificationType.THREAD_SUBSCRIBED -> { + "${notification.user?.name} commented in one of your subscribed forum threads" + } + + NotificationType.THREAD_COMMENT_REPLY -> { + "${notification.user?.name} replied to your forum comment" + } + + NotificationType.AIRING -> { + "Episode ${notification.episode} of ${notification.media?.title?.english ?: notification.media?.title?.romaji} has aired" + } + + NotificationType.ACTIVITY_LIKE -> { + "${notification.user?.name} liked your activity" + } + + NotificationType.ACTIVITY_REPLY_LIKE -> { + "${notification.user?.name} liked your reply" + } + + NotificationType.THREAD_LIKE -> { + "${notification.user?.name} liked your forum thread" + } + + NotificationType.THREAD_COMMENT_LIKE -> { + "${notification.user?.name} liked your forum comment" + } + + NotificationType.ACTIVITY_REPLY_SUBSCRIBED -> { + "${notification.user?.name} replied to activity you have also replied to" + } + + NotificationType.RELATED_MEDIA_ADDITION -> { + "${notification.media?.title?.english ?: notification.media?.title?.romaji} has been added to the site" + } + + NotificationType.MEDIA_DATA_CHANGE -> { + "${notification.media?.title?.english ?: notification.media?.title?.romaji} has had a data change: ${notification.reason}" + } + + NotificationType.MEDIA_MERGE -> { + "${notification.deletedMediaTitles?.joinToString(", ")} have been merged into ${notification.media?.title?.english ?: notification.media?.title?.romaji}" + } + + NotificationType.MEDIA_DELETION -> { + "${notification.deletedMediaTitle} has been deleted from the site" + } + + NotificationType.COMMENT_REPLY -> { + notification.context ?: "You should not see this" + } + } + } + + + fun getDateTime(timestamp: Int): String { + + val targetDate = Date(timestamp * 1000L) + + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/activity/ActivityReplyItem.kt b/app/src/main/java/ani/dantotsu/profile/activity/ActivityReplyItem.kt new file mode 100644 index 00000000..b5b7aa89 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/activity/ActivityReplyItem.kt @@ -0,0 +1,46 @@ +package ani.dantotsu.profile.activity + +import android.view.View +import androidx.core.content.ContextCompat +import ani.dantotsu.R +import ani.dantotsu.buildMarkwon +import ani.dantotsu.connections.anilist.api.ActivityReply +import ani.dantotsu.databinding.ItemActivityReplyBinding +import ani.dantotsu.loadImage +import ani.dantotsu.util.AniMarkdown.Companion.getBasicAniHTML +import com.xwray.groupie.viewbinding.BindableItem + +class ActivityReplyItem( + private val reply: ActivityReply, + private val clickCallback: (Int, type: String) -> Unit +) : BindableItem() { + private lateinit var binding: ItemActivityReplyBinding + + override fun bind(viewBinding: ItemActivityReplyBinding, position: Int) { + binding = viewBinding + + binding.activityUserAvatar.loadImage(reply.user.avatar?.medium) + binding.activityUserName.text = reply.user.name + binding.activityTime.text = ActivityItemBuilder.getDateTime(reply.createdAt) + binding.activityLikeCount.text = reply.likeCount.toString() + val likeColor = ContextCompat.getColor(binding.root.context, R.color.yt_red) + val notLikeColor = ContextCompat.getColor(binding.root.context, R.color.bg_opp) + binding.activityLike.setColorFilter(if (reply.isLiked) likeColor else notLikeColor) + val markwon = buildMarkwon(binding.root.context) + markwon.setMarkdown(binding.activityContent, getBasicAniHTML(reply.text)) + binding.activityAvatarContainer.setOnClickListener { + clickCallback(reply.userId, "USER") + } + binding.activityUserName.setOnClickListener { + clickCallback(reply.userId, "USER") + } + } + + override fun getLayout(): Int { + return R.layout.item_activity_reply + } + + override fun initializeViewBinding(view: View): ItemActivityReplyBinding { + return ItemActivityReplyBinding.bind(view) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/activity/FeedActivity.kt b/app/src/main/java/ani/dantotsu/profile/activity/FeedActivity.kt new file mode 100644 index 00000000..98f0492b --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/activity/FeedActivity.kt @@ -0,0 +1,83 @@ +package ani.dantotsu.profile.activity + +import android.os.Bundle +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.viewpager2.adapter.FragmentStateAdapter +import ani.dantotsu.R +import ani.dantotsu.databinding.ActivityFeedBinding +import ani.dantotsu.initActivity +import ani.dantotsu.navBarHeight +import ani.dantotsu.statusBarHeight +import ani.dantotsu.themes.ThemeManager +import nl.joery.animatedbottombar.AnimatedBottomBar + +class FeedActivity : AppCompatActivity() { + private lateinit var binding: ActivityFeedBinding + private var selected: Int = 0 + private lateinit var navBar: AnimatedBottomBar + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + binding = ActivityFeedBinding.inflate(layoutInflater) + setContentView(binding.root) + navBar = binding.feedNavBar + navBar.updateLayoutParams { bottomMargin += navBarHeight } + val personalTab = navBar.createTab(R.drawable.ic_round_person_24, "Following") + val globalTab = navBar.createTab(R.drawable.ic_globe_24, "Global") + navBar.addTab(personalTab) + navBar.addTab(globalTab) + binding.listTitle.text = getString(R.string.activities) + binding.feedViewPager.updateLayoutParams { + bottomMargin += navBarHeight + topMargin += statusBarHeight + } + binding.listToolbar.updateLayoutParams { topMargin += statusBarHeight } + val activityId = intent.getIntExtra("activityId", -1) + binding.feedViewPager.adapter = + ViewPagerAdapter(supportFragmentManager, lifecycle, activityId) + binding.feedViewPager.setCurrentItem(selected, false) + binding.feedViewPager.isUserInputEnabled = false + navBar.selectTabAt(selected) + navBar.setOnTabSelectListener(object : AnimatedBottomBar.OnTabSelectListener { + override fun onTabSelected( + lastIndex: Int, + lastTab: AnimatedBottomBar.Tab?, + newIndex: Int, + newTab: AnimatedBottomBar.Tab + ) { + selected = newIndex + binding.feedViewPager.setCurrentItem(selected, true) + } + }) + binding.listBack.setOnClickListener { + onBackPressed() + } + } + + override fun onResume() { + super.onResume() + navBar.selectTabAt(selected) + } + + private class ViewPagerAdapter( + fragmentManager: FragmentManager, + lifecycle: Lifecycle, + private val activityId: Int + ) : FragmentStateAdapter(fragmentManager, lifecycle) { + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> FeedFragment.newInstance(null, false, activityId) + else -> FeedFragment.newInstance(null, true, -1) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/activity/FeedFragment.kt b/app/src/main/java/ani/dantotsu/profile/activity/FeedFragment.kt new file mode 100644 index 00000000..7ef09eb5 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/activity/FeedFragment.kt @@ -0,0 +1,158 @@ +package ani.dantotsu.profile.activity + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.AnilistQueries +import ani.dantotsu.connections.anilist.api.Activity +import ani.dantotsu.databinding.FragmentFeedBinding +import ani.dantotsu.media.MediaDetailsActivity +import ani.dantotsu.profile.ProfileActivity +import ani.dantotsu.snackString +import ani.dantotsu.util.Logger +import com.xwray.groupie.GroupieAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class FeedFragment : Fragment() { + private lateinit var binding: FragmentFeedBinding + private var adapter: GroupieAdapter = GroupieAdapter() + private var activityList: List = emptyList() + private lateinit var activity: androidx.activity.ComponentActivity + private var page: Int = 1 + private var loadedFirstTime = false + private var userId: Int? = null + private var global: Boolean = false + private var activityId: Int = -1 + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentFeedBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + activity = requireActivity() + binding.listRecyclerView.adapter = adapter + binding.listRecyclerView.layoutManager = + LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) + binding.listProgressBar.visibility = ViewGroup.VISIBLE + userId = arguments?.getInt("userId", -1) + activityId = arguments?.getInt("activityId", -1) ?: -1 + if (userId == -1) userId = null + global = arguments?.getBoolean("global", false) ?: false + } + + @SuppressLint("ClickableViewAccessibility") + override fun onResume() { + super.onResume() + if (this::binding.isInitialized) { + binding.root.requestLayout() + if (!loadedFirstTime) { + activity.lifecycleScope.launch(Dispatchers.IO) { + val nulledId = if (activityId == -1) null else activityId + val res = Anilist.query.getFeed(userId, global, activityId = nulledId) + withContext(Dispatchers.Main) { + res?.data?.page?.activities?.let { activities -> + activityList = activities + val filtered = activityList.filterNot { //filter out messages that are not directed to the user + it.recipient?.id != null && it.recipient.id != Anilist.userid + } + adapter.update(filtered.map { ActivityItem(it, ::onActivityClick,requireActivity()) }) + } + binding.listProgressBar.visibility = ViewGroup.GONE + val scrollView = binding.listRecyclerView + + binding.listRecyclerView.setOnTouchListener { _, event -> + if (event?.action == MotionEvent.ACTION_UP) { + if (activityList.size % AnilistQueries.ITEMS_PER_PAGE != 0 && !global) { + //snackString("No more activities") fix spam? + Logger.log("No more activities") + } else if (!scrollView.canScrollVertically(1) && !binding.feedRefresh.isVisible + && binding.listRecyclerView.adapter!!.itemCount != 0 && + (binding.listRecyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() == (binding.listRecyclerView.adapter!!.itemCount - 1) + ) { + page++ + binding.feedRefresh.visibility = ViewGroup.VISIBLE + loadPage { + binding.feedRefresh.visibility = ViewGroup.GONE + } + } + } + false + } + + binding.feedSwipeRefresh.setOnRefreshListener { + page = 1 + adapter.clear() + activityList = emptyList() + loadPage() + } + } + } + loadedFirstTime = true + } + } + } + + private fun loadPage(onFinish: () -> Unit = {}) { + activity.lifecycleScope.launch(Dispatchers.IO) { + val newRes = Anilist.query.getFeed(userId, global, page) + withContext(Dispatchers.Main) { + newRes?.data?.page?.activities?.let { activities -> + activityList += activities + val filtered = activities.filterNot { + it.recipient?.id != null && it.recipient.id != Anilist.userid + } + adapter.addAll(filtered.map { ActivityItem(it, ::onActivityClick,requireActivity()) }) + } + binding.feedSwipeRefresh.isRefreshing = false + onFinish() + } + } + } + + private fun onActivityClick(id: Int, type: String) { + when (type) { + "USER" -> { + ContextCompat.startActivity( + activity, Intent(activity, ProfileActivity::class.java) + .putExtra("userId", id), null + ) + } + "MEDIA" -> { + ContextCompat.startActivity( + activity, Intent(activity, MediaDetailsActivity::class.java) + .putExtra("mediaId", id), null + ) + } + } + } + + companion object { + fun newInstance(userId: Int?, global: Boolean, activityId: Int): FeedFragment { + val fragment = FeedFragment() + val args = Bundle() + args.putInt("userId", userId ?: -1) + args.putBoolean("global", global) + args.putInt("activityId", activityId) + fragment.arguments = args + return fragment + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/activity/NotificationActivity.kt b/app/src/main/java/ani/dantotsu/profile/activity/NotificationActivity.kt new file mode 100644 index 00000000..fe01db58 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/activity/NotificationActivity.kt @@ -0,0 +1,194 @@ +package ani.dantotsu.profile.activity + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.Notification +import ani.dantotsu.databinding.ActivityFollowBinding +import ani.dantotsu.initActivity +import ani.dantotsu.media.MediaDetailsActivity +import ani.dantotsu.navBarHeight +import ani.dantotsu.notifications.comment.CommentStore +import ani.dantotsu.profile.ProfileActivity +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 ani.dantotsu.util.Logger +import com.xwray.groupie.GroupieAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class NotificationActivity : AppCompatActivity() { + private lateinit var binding: ActivityFollowBinding + private var adapter: GroupieAdapter = GroupieAdapter() + private var notificationList: List = emptyList() + private var currentPage: Int = 1 + private var hasNextPage: Boolean = true + + @SuppressLint("SetTextI18n", "ClickableViewAccessibility") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + binding = ActivityFollowBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.listTitle.text = "Notifications" + binding.listToolbar.updateLayoutParams { + topMargin = statusBarHeight + } + binding.listFrameLayout.updateLayoutParams { + bottomMargin = navBarHeight + } + binding.listRecyclerView.adapter = adapter + binding.listRecyclerView.layoutManager = + LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) + binding.followerGrid.visibility = ViewGroup.GONE + binding.followerList.visibility = ViewGroup.GONE + binding.listBack.setOnClickListener { + onBackPressed() + } + binding.listProgressBar.visibility = ViewGroup.VISIBLE + val activityId = intent.getIntExtra("activityId", -1) + lifecycleScope.launch { + loadPage(activityId) { + binding.listProgressBar.visibility = ViewGroup.GONE + } + withContext(Dispatchers.Main) { + binding.listProgressBar.visibility = ViewGroup.GONE + binding.listRecyclerView.setOnTouchListener { _, event -> + if (event?.action == MotionEvent.ACTION_UP) { + if (hasNextPage && !binding.listRecyclerView.canScrollVertically(1) && !binding.followRefresh.isVisible + && binding.listRecyclerView.adapter!!.itemCount != 0 && + (binding.listRecyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() == (binding.listRecyclerView.adapter!!.itemCount - 1) + ) { + binding.followRefresh.visibility = ViewGroup.VISIBLE + loadPage(-1) { + binding.followRefresh.visibility = ViewGroup.GONE + } + } + } + false + } + + binding.followSwipeRefresh.setOnRefreshListener { + currentPage = 1 + hasNextPage = true + adapter.clear() + notificationList = emptyList() + loadPage(-1) { + binding.followSwipeRefresh.isRefreshing = false + } + } + } + } + } + + private fun loadPage(activityId: Int, onFinish: () -> Unit = {}) { + lifecycleScope.launch(Dispatchers.IO) { + val resetNotification = activityId == -1 + val res = Anilist.query.getNotifications( + Anilist.userid ?: PrefManager.getVal(PrefName.AnilistUserId).toIntOrNull() + ?: 0, currentPage, resetNotification = resetNotification + ) + withContext(Dispatchers.Main) { + val newNotifications: MutableList = mutableListOf() + res?.data?.page?.notifications?.let { notifications -> + Logger.log("Notifications: $notifications") + newNotifications += if (activityId != -1) { + notifications.filter { it.id == activityId } + } else { + notifications + }.toMutableList() + } + if (activityId == -1 && currentPage == 1) { + val commentStore = PrefManager.getNullableVal>( + PrefName.CommentNotificationStore, + null + ) ?: listOf() + commentStore.forEach { + val notification = Notification( + "COMMENT_REPLY", + System.currentTimeMillis().toInt(), + commentId = it.commentId, + notificationType = "COMMENT_REPLY", + mediaId = it.mediaId, + context = it.title + "\n" + it.content, + createdAt = (it.time / 1000L).toInt(), + ) + newNotifications += notification + } + } + + notificationList += newNotifications + adapter.addAll(newNotifications.map { + NotificationItem( + it, + ::onNotificationClick + ) + }) + currentPage = res?.data?.page?.pageInfo?.currentPage?.plus(1) ?: 1 + hasNextPage = res?.data?.page?.pageInfo?.hasNextPage ?: false + binding.followSwipeRefresh.isRefreshing = false + onFinish() + } + } + } + + private fun onNotificationClick(id: Int, optional: Int?, type: NotificationClickType) { + when (type) { + NotificationClickType.USER -> { + ContextCompat.startActivity( + this, Intent(this, ProfileActivity::class.java) + .putExtra("userId", id), null + ) + } + + NotificationClickType.MEDIA -> { + ContextCompat.startActivity( + this, Intent(this, MediaDetailsActivity::class.java) + .putExtra("mediaId", id), null + ) + } + + NotificationClickType.ACTIVITY -> { + ContextCompat.startActivity( + this, Intent(this, FeedActivity::class.java) + .putExtra("activityId", id), null + ) + } + + NotificationClickType.COMMENT -> { + ContextCompat.startActivity( + this, Intent(this, MediaDetailsActivity::class.java) + .putExtra("FRAGMENT_TO_LOAD", "COMMENTS") + .putExtra("mediaId", id) + .putExtra("commentId", optional ?: -1), + null + ) + + } + + NotificationClickType.UNDEFINED -> { + // Do nothing + } + } + } + + companion object { + enum class NotificationClickType { + USER, MEDIA, ACTIVITY, COMMENT, UNDEFINED + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/activity/NotificationItem.kt b/app/src/main/java/ani/dantotsu/profile/activity/NotificationItem.kt new file mode 100644 index 00000000..961d4c1a --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/activity/NotificationItem.kt @@ -0,0 +1,316 @@ +package ani.dantotsu.profile.activity + +import android.util.TypedValue +import android.view.View +import ani.dantotsu.R +import ani.dantotsu.blurImage +import ani.dantotsu.connections.anilist.api.Notification +import ani.dantotsu.connections.anilist.api.NotificationType +import ani.dantotsu.databinding.ItemNotificationBinding +import ani.dantotsu.loadImage +import ani.dantotsu.profile.activity.NotificationActivity.Companion.NotificationClickType +import ani.dantotsu.setAnimation +import com.xwray.groupie.viewbinding.BindableItem + +class NotificationItem( + private val notification: Notification, + val clickCallback: (Int, Int?, NotificationClickType) -> Unit +) : BindableItem() { + private lateinit var binding: ItemNotificationBinding + override fun bind(viewBinding: ItemNotificationBinding, position: Int) { + binding = viewBinding + setAnimation(binding.root.context, binding.root) + setBinding() + } + + override fun getLayout(): Int { + return R.layout.item_notification + } + + override fun initializeViewBinding(view: View): ItemNotificationBinding { + return ItemNotificationBinding.bind(view) + } + + private fun image(user: Boolean = false, commentNotification: Boolean = false) { + + val cover = if (user) notification.user?.bannerImage + ?: notification.user?.avatar?.medium else notification.media?.bannerImage + ?: notification.media?.coverImage?.large + blurImage(binding.notificationBannerImage, cover) + val defaultHeight = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 170f, + binding.root.context.resources.displayMetrics + ).toInt() + val userHeight = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 90f, + binding.root.context.resources.displayMetrics + ).toInt() + + if (user) { + binding.notificationCover.visibility = View.GONE + binding.notificationCoverUser.visibility = View.VISIBLE + binding.notificationCoverUserContainer.visibility = View.VISIBLE + if (commentNotification) { + binding.notificationCoverUser.setImageResource(R.drawable.ic_dantotsu_round) + binding.notificationCoverUser.scaleX = 1.4f + binding.notificationCoverUser.scaleY = 1.4f + } else { + binding.notificationCoverUser.loadImage(notification.user?.avatar?.large) + } + binding.notificationBannerImage.layoutParams.height = userHeight + } else { + binding.notificationCover.visibility = View.VISIBLE + binding.notificationCoverUser.visibility = View.VISIBLE + binding.notificationCoverUserContainer.visibility = View.GONE + binding.notificationCover.loadImage(notification.media?.coverImage?.large) + binding.notificationBannerImage.layoutParams.height = defaultHeight + } + } + + private fun setBinding() { + val notificationType: NotificationType = + NotificationType.valueOf(notification.notificationType) + binding.notificationText.text = ActivityItemBuilder.getContent(notification) + binding.notificationDate.text = ActivityItemBuilder.getDateTime(notification.createdAt) + + when (notificationType) { + NotificationType.ACTIVITY_MESSAGE -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.activityId ?: 0, null, NotificationClickType.ACTIVITY + ) + } + } + + NotificationType.ACTIVITY_REPLY -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.activityId ?: 0, null, NotificationClickType.ACTIVITY + ) + } + } + + NotificationType.FOLLOWING -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.userId ?: 0, null, NotificationClickType.USER + ) + } + } + + NotificationType.ACTIVITY_MENTION -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.activityId ?: 0, null, NotificationClickType.ACTIVITY + ) + } + } + + NotificationType.THREAD_COMMENT_MENTION -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + } + + NotificationType.THREAD_SUBSCRIBED -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + } + + NotificationType.THREAD_COMMENT_REPLY -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + } + + NotificationType.AIRING -> { + binding.notificationCover.loadImage(notification.media?.coverImage?.large) + image() + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.media?.id ?: 0, null, NotificationClickType.MEDIA + ) + } + } + + NotificationType.ACTIVITY_LIKE -> { + image(true) + binding.notificationCover.loadImage(notification.user?.avatar?.large) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.activityId ?: 0, null, NotificationClickType.ACTIVITY + ) + } + } + + NotificationType.ACTIVITY_REPLY_LIKE -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.activityId ?: 0, null, NotificationClickType.ACTIVITY + ) + } + } + + NotificationType.THREAD_LIKE -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + } + + NotificationType.THREAD_COMMENT_LIKE -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + } + + NotificationType.ACTIVITY_REPLY_SUBSCRIBED -> { + binding.notificationCover.loadImage(notification.user?.avatar?.large) + image(true) + binding.notificationCoverUser.setOnClickListener { + clickCallback( + notification.user?.id ?: 0, null, NotificationClickType.USER + ) + } + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.activityId ?: 0, null, NotificationClickType.ACTIVITY + ) + } + } + + NotificationType.RELATED_MEDIA_ADDITION -> { + binding.notificationCover.loadImage(notification.media?.coverImage?.large) + image() + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.media?.id ?: 0, null, NotificationClickType.MEDIA + ) + } + } + + NotificationType.MEDIA_DATA_CHANGE -> { + binding.notificationCover.loadImage(notification.media?.coverImage?.large) + image() + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.media?.id ?: 0, null, NotificationClickType.MEDIA + ) + } + } + + NotificationType.MEDIA_MERGE -> { + binding.notificationCover.loadImage(notification.media?.coverImage?.large) + image() + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.media?.id ?: 0, null, NotificationClickType.MEDIA + ) + } + } + + NotificationType.MEDIA_DELETION -> { + binding.notificationCover.visibility = View.GONE + } + + NotificationType.COMMENT_REPLY -> { + image(user = true, commentNotification = true) + if (notification.commentId != null && notification.mediaId != null) { + binding.notificationBannerImage.setOnClickListener { + clickCallback( + notification.mediaId, notification.commentId, NotificationClickType.COMMENT + ) + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/activity/UsersAdapter.kt b/app/src/main/java/ani/dantotsu/profile/activity/UsersAdapter.kt new file mode 100644 index 00000000..52763e09 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/activity/UsersAdapter.kt @@ -0,0 +1,49 @@ +package ani.dantotsu.profile.activity + +import android.content.Intent +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import ani.dantotsu.databinding.ItemFollowerBinding +import ani.dantotsu.loadImage +import ani.dantotsu.profile.ProfileActivity +import ani.dantotsu.profile.User +import ani.dantotsu.setAnimation + + +class UsersAdapter(private val user: ArrayList) : RecyclerView.Adapter() { + + inner class UsersViewHolder(val binding: ItemFollowerBinding) : + RecyclerView.ViewHolder(binding.root) { + init { + itemView.setOnClickListener { + ContextCompat.startActivity( + binding.root.context, Intent(binding.root.context, ProfileActivity::class.java) + .putExtra("userId", user[bindingAdapterPosition].id), null + ) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UsersViewHolder { + return UsersViewHolder( + ItemFollowerBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: UsersViewHolder, position: Int) { + val b = holder.binding + setAnimation(b.root.context, b.root) + val user = user[position] + b.profileUserAvatar.loadImage(user.pfp) + b.profileBannerImage.loadImage(user.banner) + b.profileUserName.text = user.name + } + + override fun getItemCount(): Int = user.size +} diff --git a/app/src/main/java/ani/dantotsu/settings/CurrentReaderSettings.kt b/app/src/main/java/ani/dantotsu/settings/CurrentReaderSettings.kt index 2aca1b3e..be076fbd 100644 --- a/app/src/main/java/ani/dantotsu/settings/CurrentReaderSettings.kt +++ b/app/src/main/java/ani/dantotsu/settings/CurrentReaderSettings.kt @@ -15,6 +15,7 @@ data class CurrentReaderSettings( var trueColors: Boolean = PrefManager.getVal(PrefName.TrueColors), var rotation: Boolean = PrefManager.getVal(PrefName.Rotation), var padding: Boolean = PrefManager.getVal(PrefName.Padding), + var hideScrollBar: Boolean = PrefManager.getVal(PrefName.HideScrollBar), var hidePageNumbers: Boolean = PrefManager.getVal(PrefName.HidePageNumbers), var horizontalScrollBar: Boolean = PrefManager.getVal(PrefName.HorizontalScrollBar), var keepScreenOn: Boolean = PrefManager.getVal(PrefName.KeepScreenOn), diff --git a/app/src/main/java/ani/dantotsu/settings/DevelopersDialogFragment.kt b/app/src/main/java/ani/dantotsu/settings/DevelopersDialogFragment.kt index b7a12ad1..564c0aaa 100644 --- a/app/src/main/java/ani/dantotsu/settings/DevelopersDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/DevelopersDialogFragment.kt @@ -25,6 +25,12 @@ class DevelopersDialogFragment : BottomSheetDialogFragment() { "Contributor", "https://github.com/aayush2622" ), + Developer( + "AbandonedCart", + "https://avatars.githubusercontent.com/u/1173913?v=4", + "Contributor", + "https://github.com/AbandonedCart" + ), Developer( "Sadwhy", "https://avatars.githubusercontent.com/u/99601717?v=4", diff --git a/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt b/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt index 5300bbcd..a97a644d 100644 --- a/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt @@ -9,6 +9,7 @@ import android.text.Editable import android.text.TextWatcher import android.view.View import android.view.ViewGroup +import android.view.WindowManager import android.widget.AutoCompleteTextView import androidx.appcompat.app.AppCompatActivity import androidx.core.view.updateLayoutParams @@ -17,6 +18,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.* import ani.dantotsu.databinding.ActivityExtensionsBinding +import ani.dantotsu.others.AndroidBug5497Workaround import ani.dantotsu.others.LanguageMapper import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName @@ -34,7 +36,22 @@ class ExtensionsActivity : AppCompatActivity() { ThemeManager(this).applyTheme() binding = ActivityExtensionsBinding.inflate(layoutInflater) setContentView(binding.root) + initActivity(this) + AndroidBug5497Workaround.assistActivity(this) { + if (it) { + binding.searchView.updateLayoutParams { + bottomMargin = statusBarHeight + } + } else { + binding.searchView.updateLayoutParams { + bottomMargin = statusBarHeight + navBarHeight + } + } + } + binding.searchView.updateLayoutParams { + bottomMargin = statusBarHeight + navBarHeight + } val tabLayout = findViewById(R.id.tabLayout) val viewPager = findViewById(R.id.viewPager) diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt index 97bde283..f7108bc7 100644 --- a/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt @@ -25,7 +25,7 @@ import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.databinding.FragmentAnimeExtensionsBinding -import ani.dantotsu.logger +import ani.dantotsu.util.Logger import ani.dantotsu.others.LanguageMapper import ani.dantotsu.parsers.AnimeSources import ani.dantotsu.settings.extensionprefs.AnimeSourcePreferencesFragment @@ -146,7 +146,7 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { }, { error -> Injekt.get().logException(error) - Log.e("AnimeExtensionsAdapter", "Error: ", error) // Log the error + Logger.log(error) // Log the error val builder = NotificationCompat.Builder( context, Notifications.CHANNEL_DOWNLOADER_ERROR diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt index a102c76c..2da2a777 100644 --- a/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt @@ -32,6 +32,7 @@ import ani.dantotsu.settings.extensionprefs.MangaSourcePreferencesFragment import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString +import ani.dantotsu.util.Logger import com.google.android.material.tabs.TabLayout import com.google.android.material.textfield.TextInputLayout import eu.kanade.tachiyomi.data.notification.Notifications @@ -143,7 +144,7 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { }, { error -> Injekt.get().logException(error) - Log.e("MangaExtensionsAdapter", "Error: ", error) // Log the error + Logger.log(error) // Log the error val builder = NotificationCompat.Builder( context, Notifications.CHANNEL_DOWNLOADER_ERROR diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt index 8353596d..5c46a5c9 100644 --- a/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt @@ -30,6 +30,7 @@ import ani.dantotsu.parsers.novel.NovelExtensionManager import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString +import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.data.notification.Notifications import kotlinx.coroutines.launch import rx.android.schedulers.AndroidSchedulers @@ -72,7 +73,7 @@ class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { }, { error -> Injekt.get().logException(error) - Log.e("NovelExtensionsAdapter", "Error: ", error) // Log the error + Logger.log(error) // Log the error val builder = NotificationCompat.Builder( context, Notifications.CHANNEL_DOWNLOADER_ERROR @@ -216,7 +217,7 @@ class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_extension, parent, false) - Log.d("NovelExtensionsAdapter", "onCreateViewHolder: $view") + Logger.log("onCreateViewHolder: $view") return ViewHolder(view) } diff --git a/app/src/main/java/ani/dantotsu/settings/NovelExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/NovelExtensionsFragment.kt index ffcc17e9..83b93c9c 100644 --- a/app/src/main/java/ani/dantotsu/settings/NovelExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/NovelExtensionsFragment.kt @@ -59,7 +59,6 @@ class NovelExtensionsFragment : Fragment(), lifecycleScope.launch { viewModel.pagerFlow.collectLatest { pagingData -> - Log.d("NovelExtensionsFragment", "collectLatest") adapter.submitData(pagingData) } } diff --git a/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt index b7a40da9..5faccc17 100644 --- a/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt @@ -129,6 +129,11 @@ class PlayerSettingsActivity : AppCompatActivity() { PrefManager.setVal(PrefName.TimeStampsEnabled, isChecked) } + binding.playerSettingsTimeStampsAutoHide.isChecked = PrefManager.getVal(PrefName.AutoHideTimeStamps) + binding.playerSettingsTimeStampsAutoHide.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.AutoHideTimeStamps, isChecked) + } + binding.playerSettingsTimeStampsProxy.isChecked = PrefManager.getVal(PrefName.UseProxyForTimeStamps) binding.playerSettingsTimeStampsProxy.setOnCheckedChangeListener { _, isChecked -> @@ -162,6 +167,14 @@ class PlayerSettingsActivity : AppCompatActivity() { PrefManager.getVal(PrefName.AskIndividualPlayer) binding.playerSettingsAskUpdateProgress.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.AskIndividualPlayer, isChecked) + binding.playerSettingsAskChapterZero.isEnabled = !isChecked + } + binding.playerSettingsAskChapterZero.isChecked = + PrefManager.getVal(PrefName.ChapterZeroPlayer) + binding.playerSettingsAskChapterZero.isEnabled = + !PrefManager.getVal(PrefName.AskIndividualPlayer) + binding.playerSettingsAskChapterZero.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.ChapterZeroPlayer, isChecked) } binding.playerSettingsAskUpdateHentai.isChecked = PrefManager.getVal(PrefName.UpdateForHPlayer) @@ -442,7 +455,8 @@ class PlayerSettingsActivity : AppCompatActivity() { "Poppins", "Poppins Thin", "Century Gothic", - "Century Gothic Bold" + "Levenim MT Bold", + "Blocky" ) val fontDialog = AlertDialog.Builder(this, R.style.MyPopup) .setTitle(getString(R.string.subtitle_font)) diff --git a/app/src/main/java/ani/dantotsu/settings/ReaderSettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/ReaderSettingsActivity.kt index f3846245..911b49de 100644 --- a/app/src/main/java/ani/dantotsu/settings/ReaderSettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/ReaderSettingsActivity.kt @@ -148,6 +148,12 @@ class ReaderSettingsActivity : AppCompatActivity() { PrefManager.setVal(PrefName.KeepScreenOn, isChecked) } + binding.readerSettingsHideScrollBar.isChecked = defaultSettings.hideScrollBar + binding.readerSettingsHideScrollBar.setOnCheckedChangeListener { _, isChecked -> + defaultSettings.hideScrollBar = isChecked + PrefManager.setVal(PrefName.HideScrollBar, isChecked) + } + binding.readerSettingsHidePageNumbers.isChecked = defaultSettings.hidePageNumbers binding.readerSettingsHidePageNumbers.setOnCheckedChangeListener { _, isChecked -> defaultSettings.hidePageNumbers = isChecked @@ -349,6 +355,14 @@ class ReaderSettingsActivity : AppCompatActivity() { PrefManager.getVal(PrefName.AskIndividualReader) binding.readerSettingsAskUpdateProgress.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.AskIndividualReader, isChecked) + binding.readerSettingsAskChapterZero.isEnabled = !isChecked + } + binding.readerSettingsAskChapterZero.isChecked = + PrefManager.getVal(PrefName.ChapterZeroReader) + binding.readerSettingsAskChapterZero.isEnabled = + !PrefManager.getVal(PrefName.AskIndividualReader) + binding.readerSettingsAskChapterZero.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.ChapterZeroReader, isChecked) } binding.readerSettingsAskUpdateDoujins.isChecked = PrefManager.getVal(PrefName.UpdateForHReader) diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt index b9aed382..4394156c 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt @@ -1,9 +1,12 @@ package ani.dantotsu.settings import android.annotation.SuppressLint +import android.app.AlarmManager import android.app.AlertDialog +import android.content.Context import android.content.Intent import android.graphics.drawable.Animatable +import android.os.Build import android.os.Build.BRAND import android.os.Build.DEVICE import android.os.Build.SUPPORTED_ABIS @@ -14,6 +17,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.AnimationUtils import android.view.inputmethod.EditorInfo import android.widget.ArrayAdapter import android.widget.TextView @@ -32,6 +36,7 @@ import ani.dantotsu.BuildConfig import ani.dantotsu.R import ani.dantotsu.Refresh import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.NotificationType import ani.dantotsu.connections.discord.Discord import ani.dantotsu.connections.mal.MAL import ani.dantotsu.copyToClipboard @@ -43,9 +48,14 @@ import ani.dantotsu.download.video.ExoplayerDownloadService import ani.dantotsu.downloadsPermission import ani.dantotsu.initActivity import ani.dantotsu.loadImage -import ani.dantotsu.logger +import ani.dantotsu.util.Logger import ani.dantotsu.navBarHeight +import ani.dantotsu.notifications.TaskScheduler +import ani.dantotsu.notifications.comment.CommentNotificationWorker +import ani.dantotsu.notifications.anilist.AnilistNotificationWorker +import ani.dantotsu.notifications.subscription.SubscriptionNotificationWorker.Companion.checkIntervals import ani.dantotsu.openLinkInBrowser +import ani.dantotsu.openSettings import ani.dantotsu.others.AppUpdater import ani.dantotsu.others.CustomBottomDialog import ani.dantotsu.pop @@ -59,11 +69,6 @@ import ani.dantotsu.settings.saving.internal.PreferencePackager import ani.dantotsu.snackString import ani.dantotsu.startMainActivity import ani.dantotsu.statusBarHeight -import ani.dantotsu.subcriptions.Notifications -import ani.dantotsu.subcriptions.Notifications.Companion.openSettings -import ani.dantotsu.subcriptions.Subscription.Companion.defaultTime -import ani.dantotsu.subcriptions.Subscription.Companion.startSubscription -import ani.dantotsu.subcriptions.Subscription.Companion.timeMinutes import ani.dantotsu.themes.ThemeManager import ani.dantotsu.toast import com.google.android.material.snackbar.Snackbar @@ -225,7 +230,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene val tag = "colorPicker" CustomColorDialog().title("Custom Theme") .colorPreset(originalColor) - .colors(this, SimpleColorDialog.BEIGE_COLOR_PALLET) + .colors(this, SimpleColorDialog.MATERIAL_COLOR_PALLET) .allowCustom(true) .showOutline(0x46000000) .gridNumColumn(5) @@ -615,7 +620,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene Toast.makeText(this, "youwu have been cuwsed :pwayge:", Toast.LENGTH_LONG).show() val url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" openLinkInBrowser(url) - //PrefManager.setVal(PrefName.SomethingSpecial, !PrefManager.getVal(PrefName.SomethingSpecial, false)) + //PrefManager.setVal(PrefName.ImageUrl, !PrefManager.getVal(PrefName.ImageUrl, false)) } else { snackString(array[(Math.random() * array.size).toInt()], this) } @@ -643,8 +648,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } } - var curTime = PrefManager.getVal(PrefName.SubscriptionsTimeS, defaultTime) - val timeNames = timeMinutes.map { + var curTime = PrefManager.getVal(PrefName.SubscriptionNotificationInterval) + val timeNames = checkIntervals.map { val mins = it % 60 val hours = it / 60 if (it > 0) "${if (hours > 0) "$hours hrs " else ""}${if (mins > 0) "$mins mins" else ""}" @@ -659,38 +664,141 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene curTime = i binding.settingsSubscriptionsTime.text = getString(R.string.subscriptions_checking_time_s, timeNames[i]) - PrefManager.setVal(PrefName.SubscriptionsTimeS, curTime) + PrefManager.setVal(PrefName.SubscriptionNotificationInterval, curTime) dialog.dismiss() - startSubscription(true) + TaskScheduler.create(this, + PrefManager.getVal(PrefName.UseAlarmManager) + ).scheduleAllTasks(this) }.show() dialog.window?.setDimAmount(0.8f) } binding.settingsSubscriptionsTime.setOnLongClickListener { - startSubscription(true) + TaskScheduler.create(this, + PrefManager.getVal(PrefName.UseAlarmManager) + ).scheduleAllTasks(this) true } + val aTimeNames = AnilistNotificationWorker.checkIntervals.map { it.toInt() } + val aItems = aTimeNames.map { + val mins = it % 60 + val hours = it / 60 + if (it > 0) "${if (hours > 0) "$hours hrs " else ""}${if (mins > 0) "$mins mins" else ""}" + else getString(R.string.do_not_update) + } + binding.settingsAnilistSubscriptionsTime.text = + getString(R.string.anilist_notifications_checking_time, aItems[PrefManager.getVal(PrefName.AnilistNotificationInterval)]) + binding.settingsAnilistSubscriptionsTime.setOnClickListener { + + val selected = PrefManager.getVal(PrefName.AnilistNotificationInterval) + val dialog = AlertDialog.Builder(this, R.style.MyPopup) + .setTitle(R.string.subscriptions_checking_time) + .setSingleChoiceItems(aItems.toTypedArray(), selected) { dialog, i -> + PrefManager.setVal(PrefName.AnilistNotificationInterval, i) + binding.settingsAnilistSubscriptionsTime.text = + getString(R.string.anilist_notifications_checking_time, aItems[i]) + dialog.dismiss() + TaskScheduler.create(this, + PrefManager.getVal(PrefName.UseAlarmManager) + ).scheduleAllTasks(this) + } + .create() + dialog.window?.setDimAmount(0.8f) + dialog.show() + } + + binding.settingsAnilistNotifications.setOnClickListener { + val types = NotificationType.entries.map { it.name } + val filteredTypes = PrefManager.getVal>(PrefName.AnilistFilteredTypes).toMutableSet() + val selected = types.map { filteredTypes.contains(it) }.toBooleanArray() + val dialog = AlertDialog.Builder(this, R.style.MyPopup) + .setTitle(R.string.anilist_notification_filters) + .setMultiChoiceItems(types.toTypedArray(), selected) { _, which, isChecked -> + val type = types[which] + if (isChecked) { + filteredTypes.add(type) + } else { + filteredTypes.remove(type) + } + PrefManager.setVal(PrefName.AnilistFilteredTypes, filteredTypes) + } + .create() + dialog.window?.setDimAmount(0.8f) + dialog.show() + } + + val cTimeNames = CommentNotificationWorker.checkIntervals.map { it.toInt() } + val cItems = cTimeNames.map { + val mins = it % 60 + val hours = it / 60 + if (it > 0) "${if (hours > 0) "$hours hrs " else ""}${if (mins > 0) "$mins mins" else ""}" + else getString(R.string.do_not_update) + } + binding.settingsCommentSubscriptionsTime.text = + getString(R.string.comment_notification_checking_time, cItems[PrefManager.getVal(PrefName.CommentNotificationInterval)]) + binding.settingsCommentSubscriptionsTime.setOnClickListener { + val selected = PrefManager.getVal(PrefName.CommentNotificationInterval) + val dialog = AlertDialog.Builder(this, R.style.MyPopup) + .setTitle(R.string.subscriptions_checking_time) + .setSingleChoiceItems(cItems.toTypedArray(), selected) { dialog, i -> + PrefManager.setVal(PrefName.CommentNotificationInterval, i) + binding.settingsCommentSubscriptionsTime.text = + getString(R.string.comment_notification_checking_time, cItems[i]) + dialog.dismiss() + TaskScheduler.create(this, + PrefManager.getVal(PrefName.UseAlarmManager) + ).scheduleAllTasks(this) + } + .create() + dialog.window?.setDimAmount(0.8f) + dialog.show() + } + binding.settingsNotificationsCheckingSubscriptions.isChecked = PrefManager.getVal(PrefName.SubscriptionCheckingNotifications) binding.settingsNotificationsCheckingSubscriptions.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.SubscriptionCheckingNotifications, isChecked) - if (isChecked) - Notifications.createChannel( - this, - null, - "subscription_checking", - getString(R.string.checking_subscriptions), - false - ) - else - Notifications.deleteChannel(this, "subscription_checking") } binding.settingsNotificationsCheckingSubscriptions.setOnLongClickListener { openSettings(this, null) } + binding.settingsNotificationsUseAlarmManager.isChecked = + PrefManager.getVal(PrefName.UseAlarmManager) + + binding.settingsNotificationsUseAlarmManager.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + val alertDialog = AlertDialog.Builder(this, R.style.MyPopup) + .setTitle("Use Alarm Manager") + .setMessage("Using Alarm Manger can help fight against battery optimization, but may consume more battery. It also requires the Alarm Manager permission.") + .setPositiveButton("Use") { dialog, _ -> + PrefManager.setVal(PrefName.UseAlarmManager, true) + if (SDK_INT >= Build.VERSION_CODES.S) { + if (!(getSystemService(Context.ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()) { + val intent = Intent("android.settings.REQUEST_SCHEDULE_EXACT_ALARM") + startActivity(intent) + binding.settingsNotificationsCheckingSubscriptions.isChecked = true + } + } + dialog.dismiss() + } + .setNegativeButton("Cancel") { dialog, _ -> + binding.settingsNotificationsCheckingSubscriptions.isChecked = false + PrefManager.setVal(PrefName.UseAlarmManager, false) + dialog.dismiss() + } + .create() + alertDialog.window?.setDimAmount(0.8f) + alertDialog.show() + } else { + PrefManager.setVal(PrefName.UseAlarmManager, false) + TaskScheduler.create(this, true).cancelAllTasks() + TaskScheduler.create(this, false).scheduleAllTasks(this) + } + } + if (!BuildConfig.FLAVOR.contains("fdroid")) { binding.settingsLogo.setOnLongClickListener { lifecycleScope.launch(Dispatchers.IO) { @@ -728,6 +836,16 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene binding.settingsShareUsername.isChecked = false } + binding.settingsLogToFile.isChecked = PrefManager.getVal(PrefName.LogToFile) + binding.settingsLogToFile.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.LogToFile, isChecked) + restartApp() + } + + binding.settingsShareLog.setOnClickListener { + Logger.shareLog(this) + } + binding.settingsAccountHelp.setOnClickListener { val title = getString(R.string.account_help) val full = getString(R.string.full_account_help) @@ -805,7 +923,41 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene restartMainActivity.isEnabled = true reload() } + + binding.imageSwitcher.visibility = View.VISIBLE + var initialStatus = when (PrefManager.getVal(PrefName.DiscordStatus)) { + "online" -> R.drawable.discord_status_online + "idle" -> R.drawable.discord_status_idle + "dnd" -> R.drawable.discord_status_dnd + else -> R.drawable.discord_status_online + } + binding.imageSwitcher.setImageResource(initialStatus) + + val zoomInAnimation = AnimationUtils.loadAnimation(this, R.anim.bounce_zoom) + binding.imageSwitcher.setOnClickListener { + var status = "online" + initialStatus = when (initialStatus) { + R.drawable.discord_status_online -> { + status = "idle" + R.drawable.discord_status_idle + } + R.drawable.discord_status_idle -> { + status = "dnd" + R.drawable.discord_status_dnd + } + R.drawable.discord_status_dnd -> { + status = "online" + R.drawable.discord_status_online + } + else -> R.drawable.discord_status_online + } + + PrefManager.setVal(PrefName.DiscordStatus, status) + binding.imageSwitcher.setImageResource(initialStatus) + binding.imageSwitcher.startAnimation(zoomInAnimation) + } } else { + binding.imageSwitcher.visibility = View.GONE binding.settingsDiscordAvatar.setImageResource(R.drawable.ic_round_person_24) binding.settingsDiscordUsername.visibility = View.GONE binding.settingsDiscordLogin.setText(R.string.login) @@ -848,7 +1000,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene if (dialogTag == "colorPicker") { val color = extras.getInt(SimpleColorDialog.COLOR) PrefManager.setVal(PrefName.CustomThemeInt, color) - logger("Custom Theme: $color") + Logger.log("Custom Theme: $color") } } return true @@ -950,4 +1102,4 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene ?: "Unknown Architecture" } } -} +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsDialogFragment.kt b/app/src/main/java/ani/dantotsu/settings/SettingsDialogFragment.kt index 7b349929..89a65b17 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsDialogFragment.kt @@ -1,5 +1,6 @@ package ani.dantotsu.settings +import android.app.AlertDialog import android.content.Intent import android.graphics.Color import android.os.Bundle @@ -7,8 +8,10 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.content.ContextCompat import ani.dantotsu.BottomSheetDialogFragment import ani.dantotsu.MainActivity +import ani.dantotsu.profile.ProfileActivity import ani.dantotsu.R import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.databinding.BottomSheetSettingsBinding @@ -21,9 +24,9 @@ import ani.dantotsu.home.MangaFragment import ani.dantotsu.home.NoInternet import ani.dantotsu.incognitoNotification import ani.dantotsu.loadImage +import ani.dantotsu.profile.activity.NotificationActivity import ani.dantotsu.offline.OfflineFragment -import ani.dantotsu.openLinkInBrowser -import ani.dantotsu.others.imagesearch.ImageSearchActivity +import ani.dantotsu.profile.activity.FeedActivity import ani.dantotsu.setSafeOnClickListener import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName @@ -58,13 +61,28 @@ class SettingsDialogFragment : BottomSheetDialogFragment() { val theme = requireContext().theme theme.resolveAttribute(com.google.android.material.R.attr.colorSurface, typedValue, true) window?.navigationBarColor = typedValue.data + val notificationIcon = if (Anilist.unreadNotificationCount > 0) { + R.drawable.ic_round_notifications_active_24 + } else { + R.drawable.ic_round_notifications_none_24 + } + binding.settingsNotification.setImageResource(notificationIcon) if (Anilist.token != null) { binding.settingsLogin.setText(R.string.logout) binding.settingsLogin.setOnClickListener { - Anilist.removeSavedToken() - dismiss() - startMainActivity(requireActivity()) + val alertDialog = AlertDialog.Builder(requireContext(), R.style.MyPopup) + .setTitle("Logout") + .setMessage("Are you sure you want to logout?") + .setPositiveButton("Yes") { _, _ -> + Anilist.removeSavedToken() + dismiss() + startMainActivity(requireActivity()) + } + .setNegativeButton("No") { _, _ -> } + .create() + alertDialog.window?.setDimAmount(0.8f) + alertDialog.show() } binding.settingsUsername.text = Anilist.username binding.settingsUserAvatar.loadImage(Anilist.avatar) @@ -76,31 +94,40 @@ class SettingsDialogFragment : BottomSheetDialogFragment() { Anilist.loginIntent(requireActivity()) } } + binding.settingsNotificationCount.visibility = if (Anilist.unreadNotificationCount > 0) View.VISIBLE else View.GONE + binding.settingsNotificationCount.text = Anilist.unreadNotificationCount.toString() + binding.settingsUserAvatar.setOnClickListener{ + ContextCompat.startActivity( + requireContext(), Intent(requireContext(), ProfileActivity::class.java) + .putExtra("userId", Anilist.userid), null + ) + } - binding.settingsIncognito.isChecked = - PrefManager.getVal(PrefName.Incognito) - + binding.settingsIncognito.isChecked = PrefManager.getVal(PrefName.Incognito) binding.settingsIncognito.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.Incognito, isChecked) incognitoNotification(requireContext()) } + binding.settingsExtensionSettings.setSafeOnClickListener { startActivity(Intent(activity, ExtensionsActivity::class.java)) dismiss() } + binding.settingsSettings.setSafeOnClickListener { startActivity(Intent(activity, SettingsActivity::class.java)) dismiss() } - binding.settingsAnilistSettings.setOnClickListener { - openLinkInBrowser("https://anilist.co/settings/lists") - dismiss() - } - binding.imageSearch.setOnClickListener { - startActivity(Intent(activity, ImageSearchActivity::class.java)) + + binding.settingsActivity.setSafeOnClickListener { + startActivity(Intent(activity, FeedActivity::class.java)) dismiss() } + binding.settingsNotification.setOnClickListener { + startActivity(Intent(activity, NotificationActivity::class.java)) + dismiss() + } binding.settingsDownloads.isChecked = PrefManager.getVal(PrefName.OfflineMode) binding.settingsDownloads.setOnCheckedChangeListener { _, isChecked -> Timer().schedule(300) { diff --git a/app/src/main/java/ani/dantotsu/settings/UserInterfaceSettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/UserInterfaceSettingsActivity.kt index 2c5fc602..3dedb3c2 100644 --- a/app/src/main/java/ani/dantotsu/settings/UserInterfaceSettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/UserInterfaceSettingsActivity.kt @@ -36,18 +36,20 @@ class UserInterfaceSettingsActivity : AppCompatActivity() { onBackPressedDispatcher.onBackPressed() } - val views = resources.getStringArray(R.array.home_layouts) binding.uiSettingsHomeLayout.setOnClickListener { + val set = PrefManager.getVal>(PrefName.HomeLayoutShow).toMutableList() + val views = resources.getStringArray(R.array.home_layouts) val dialog = AlertDialog.Builder(this, R.style.MyPopup) .setTitle(getString(R.string.home_layout_show)).apply { setMultiChoiceItems( views, PrefManager.getVal>(PrefName.HomeLayoutShow).toBooleanArray() ) { _, i, value -> - val set = PrefManager.getVal>(PrefName.HomeLayoutShow) - .toMutableList() set[i] = value + } + setPositiveButton("Done") { _, _ -> PrefManager.setVal(PrefName.HomeLayoutShow, set) + restartApp() } }.show() dialog.window?.setDimAmount(0.8f) @@ -94,8 +96,21 @@ class UserInterfaceSettingsActivity : AppCompatActivity() { PrefManager.setVal(PrefName.AnimationSpeed, map[value] ?: 1f) restartApp() } - - + binding.uiSettingsBlurBanners.isChecked = PrefManager.getVal(PrefName.BlurBanners) + binding.uiSettingsBlurBanners.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.BlurBanners, isChecked) + restartApp() + } + binding.uiSettingsBlurRadius.value = (PrefManager.getVal(PrefName.BlurRadius) as Float) + binding.uiSettingsBlurRadius.addOnChangeListener { _, value, _ -> + PrefManager.setVal(PrefName.BlurRadius, value) + restartApp() + } + binding.uiSettingsBlurSampling.value = (PrefManager.getVal(PrefName.BlurSampling) as Float) + binding.uiSettingsBlurSampling.addOnChangeListener { _, value, _ -> + PrefManager.setVal(PrefName.BlurSampling, value) + restartApp() + } } private fun restartApp() { @@ -116,4 +131,4 @@ class UserInterfaceSettingsActivity : AppCompatActivity() { show() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/ani/dantotsu/settings/extensionprefs/AnimePreferenceFragmentCompat.kt b/app/src/main/java/ani/dantotsu/settings/extensionprefs/AnimePreferenceFragmentCompat.kt index c7fd31a6..0751454e 100644 --- a/app/src/main/java/ani/dantotsu/settings/extensionprefs/AnimePreferenceFragmentCompat.kt +++ b/app/src/main/java/ani/dantotsu/settings/extensionprefs/AnimePreferenceFragmentCompat.kt @@ -61,11 +61,7 @@ class AnimeSourcePreferencesFragment : PreferenceFragmentCompat() { pref.isIconSpaceReserved = false if (pref is DialogPreference) { pref.dialogTitle = pref.title - //println("pref.dialogTitle: ${pref.dialogTitle}") //TODO: could be useful for dub/sub selection } - /*for (entry in sharedPreferences.all.entries) { - Log.d("Preferences", "Key: ${entry.key}, Value: ${entry.value}") - }*/ // Apply incognito IME for EditTextPreference if (pref is EditTextPreference) { diff --git a/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt b/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt index f66d1353..3a13cb88 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt @@ -7,6 +7,7 @@ import ani.dantotsu.settings.saving.internal.Compat import ani.dantotsu.settings.saving.internal.Location import ani.dantotsu.settings.saving.internal.PreferencePackager import ani.dantotsu.snackString +import ani.dantotsu.util.Logger import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.ObjectInputStream @@ -15,6 +16,7 @@ import java.io.ObjectOutputStream object PrefManager { private var generalPreferences: SharedPreferences? = null + private var uiPreferences: SharedPreferences? = null private var playerPreferences: SharedPreferences? = null private var readerPreferences: SharedPreferences? = null private var irrelevantPreferences: SharedPreferences? = null @@ -22,8 +24,11 @@ object PrefManager { private var protectedPreferences: SharedPreferences? = null fun init(context: Context) { //must be called in Application class or will crash + if (generalPreferences != null) return generalPreferences = context.getSharedPreferences(Location.General.location, Context.MODE_PRIVATE) + uiPreferences = + context.getSharedPreferences(Location.UI.location, Context.MODE_PRIVATE) playerPreferences = context.getSharedPreferences(Location.Player.location, Context.MODE_PRIVATE) readerPreferences = @@ -352,7 +357,7 @@ object PrefManager { private fun getPrefLocation(prefLoc: Location): SharedPreferences { return when (prefLoc) { Location.General -> generalPreferences - Location.UI -> generalPreferences + Location.UI -> uiPreferences Location.Player -> playerPreferences Location.Reader -> readerPreferences Location.NovelReader -> readerPreferences @@ -374,6 +379,7 @@ object PrefManager { pref.edit().putString(key, serialized).apply() } catch (e: Exception) { snackString("Error serializing preference: ${e.message}") + Logger.log(e) } } @@ -392,8 +398,7 @@ object PrefManager { default } } catch (e: Exception) { - snackString("Error deserializing preference: ${e.message}") - e.printStackTrace() + Logger.log(e) default } } diff --git a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt index 3810cf3f..5149c031 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt @@ -1,7 +1,9 @@ package ani.dantotsu.settings.saving import android.graphics.Color +import ani.dantotsu.connections.comments.AuthResponse import ani.dantotsu.connections.mal.MAL +import ani.dantotsu.notifications.comment.CommentStore import ani.dantotsu.settings.saving.internal.Location import ani.dantotsu.settings.saving.internal.Pref @@ -10,12 +12,11 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files SharedUserID(Pref(Location.General, Boolean::class, true)), OfflineView(Pref(Location.General, Int::class, 0)), DownloadManager(Pref(Location.General, Int::class, 0)), - NSFWExtension(Pref(Location.General, Boolean::class, false)), + NSFWExtension(Pref(Location.General, Boolean::class, true)), SdDl(Pref(Location.General, Boolean::class, false)), ContinueMedia(Pref(Location.General, Boolean::class, true)), RecentlyListOnly(Pref(Location.General, Boolean::class, false)), SettingsPreferDub(Pref(Location.General, Boolean::class, false)), - SubscriptionsTimeS(Pref(Location.General, Int::class, 0)), SubscriptionCheckingNotifications(Pref(Location.General, Boolean::class, true)), CheckUpdate(Pref(Location.General, Boolean::class, true)), VerboseLogging(Pref(Location.General, Boolean::class, false)), @@ -32,6 +33,12 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files MangaSourcesOrder(Pref(Location.General, List::class, listOf())), MangaSearchHistory(Pref(Location.General, Set::class, setOf())), NovelSourcesOrder(Pref(Location.General, List::class, listOf())), + CommentNotificationInterval(Pref(Location.General, Int::class, 0)), + AnilistNotificationInterval(Pref(Location.General, Int::class, 3)), + SubscriptionNotificationInterval(Pref(Location.General, Int::class, 2)), + LastAnilistNotificationId(Pref(Location.General, Int::class, 0)), + AnilistFilteredTypes(Pref(Location.General, Set::class, setOf())), + UseAlarmManager(Pref(Location.General, Boolean::class, false)), //User Interface UseOLED(Pref(Location.UI, Boolean::class, false)), @@ -45,6 +52,9 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files ShowYtButton(Pref(Location.UI, Boolean::class, true)), AnimeDefaultView(Pref(Location.UI, Int::class, 0)), MangaDefaultView(Pref(Location.UI, Int::class, 0)), + BlurBanners(Pref(Location.UI, Boolean::class, true)), + BlurRadius(Pref(Location.UI, Float::class, 2f)), + BlurSampling(Pref(Location.UI, Float::class, 2f)), ImmersiveMode(Pref(Location.UI, Boolean::class, false)), SmallView(Pref(Location.UI, Boolean::class, true)), DefaultStartUpTab(Pref(Location.UI, Int::class, 1)), @@ -63,6 +73,8 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files PopularAnimeList(Pref(Location.UI, Boolean::class, true)), AnimeListSortOrder(Pref(Location.UI, String::class, "score")), MangaListSortOrder(Pref(Location.UI, String::class, "score")), + CommentSortOrder(Pref(Location.UI, String::class, "newest")), + FollowerLayout(Pref(Location.UI, Int::class, 0)), //Player DefaultSpeed(Pref(Location.Player, Int::class, 5)), @@ -78,12 +90,14 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files FontSize(Pref(Location.Player, Int::class, 20)), Locale(Pref(Location.Player, Int::class, 2)), TimeStampsEnabled(Pref(Location.Player, Boolean::class, true)), + AutoHideTimeStamps(Pref(Location.Player, Boolean::class, true)), UseProxyForTimeStamps(Pref(Location.Player, Boolean::class, false)), ShowTimeStampButton(Pref(Location.Player, Boolean::class, true)), AutoSkipOPED(Pref(Location.Player, Boolean::class, false)), AutoPlay(Pref(Location.Player, Boolean::class, true)), AutoSkipFiller(Pref(Location.Player, Boolean::class, false)), AskIndividualPlayer(Pref(Location.Player, Boolean::class, true)), + ChapterZeroPlayer(Pref(Location.Player, Boolean::class, true)), UpdateForHPlayer(Pref(Location.Player, Boolean::class, false)), WatchPercentage(Pref(Location.Player, Float::class, 0.8f)), AlwaysContinue(Pref(Location.Player, Boolean::class, true)), @@ -97,13 +111,13 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files UseInternalCast(Pref(Location.Player, Boolean::class, false)), Pip(Pref(Location.Player, Boolean::class, true)), RotationPlayer(Pref(Location.Player, Boolean::class, true)), - ContinuedAnime(Pref(Location.Player, List::class, listOf())), //Reader ShowSource(Pref(Location.Reader, Boolean::class, true)), ShowSystemBars(Pref(Location.Reader, Boolean::class, false)), AutoDetectWebtoon(Pref(Location.Reader, Boolean::class, true)), AskIndividualReader(Pref(Location.Reader, Boolean::class, true)), + ChapterZeroReader(Pref(Location.Reader, Boolean::class, true)), UpdateForHReader(Pref(Location.Reader, Boolean::class, false)), Direction(Pref(Location.Reader, Int::class, 0)), LayoutReader(Pref(Location.Reader, Int::class, 2)), @@ -112,6 +126,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files TrueColors(Pref(Location.Reader, Boolean::class, false)), Rotation(Pref(Location.Reader, Boolean::class, true)), Padding(Pref(Location.Reader, Boolean::class, true)), + HideScrollBar(Pref(Location.Reader, Boolean::class, false)), HidePageNumbers(Pref(Location.Reader, Boolean::class, false)), HorizontalScrollBar(Pref(Location.Reader, Boolean::class, true)), KeepScreenOn(Pref(Location.Reader, Boolean::class, false)), @@ -141,9 +156,10 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files //Irrelevant Incognito(Pref(Location.Irrelevant, Boolean::class, false)), OfflineMode(Pref(Location.Irrelevant, Boolean::class, false)), + DiscordStatus(Pref(Location.Irrelevant, String::class, "online")), DownloadsKeys(Pref(Location.Irrelevant, String::class, "")), NovelLastExtCheck(Pref(Location.Irrelevant, Long::class, 0L)), - SomethingSpecial(Pref(Location.Irrelevant, Boolean::class, false)), + ImageUrl(Pref(Location.Irrelevant, String::class, "")), AllowOpeningLinks(Pref(Location.Irrelevant, Boolean::class, false)), SearchStyle(Pref(Location.Irrelevant, Int::class, 0)), HasUpdatedPrefs(Pref(Location.Irrelevant, Boolean::class, false)), @@ -152,6 +168,13 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files TagsListIsAdult(Pref(Location.Irrelevant, Set::class, setOf())), TagsListNonAdult(Pref(Location.Irrelevant, Set::class, setOf())), MakeDefault(Pref(Location.Irrelevant, Boolean::class, true)), + FirstComment(Pref(Location.Irrelevant, Boolean::class, true)), + CommentAuthResponse(Pref(Location.Irrelevant, AuthResponse::class, "")), + CommentTokenExpiry(Pref(Location.Irrelevant, Long::class, 0L)), + LogToFile(Pref(Location.Irrelevant, Boolean::class, false)), + RecentGlobalNotification(Pref(Location.Irrelevant, Int::class, 0)), + CommentNotificationStore(Pref(Location.Irrelevant, List::class, listOf())), + UnreadCommentNotifications(Pref(Location.Irrelevant, Int::class, 0)), //Protected DiscordToken(Pref(Location.Protected, String::class, "")), @@ -160,6 +183,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files DiscordAvatar(Pref(Location.Protected, String::class, "")), AnilistToken(Pref(Location.Protected, String::class, "")), AnilistUserName(Pref(Location.Protected, String::class, "")), + AnilistUserId(Pref(Location.Protected, String::class, "")), MALCodeChallenge(Pref(Location.Protected, String::class, "")), MALToken(Pref(Location.Protected, MAL.ResponseToken::class, "")), } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/saving/internal/PreferencePackager.kt b/app/src/main/java/ani/dantotsu/settings/saving/internal/PreferencePackager.kt index d37424b7..f8e87da7 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/internal/PreferencePackager.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/internal/PreferencePackager.kt @@ -41,7 +41,7 @@ class PreferencePackager { val value = typeValueMap["value"] innerMap[key] = - when (typeName) { //wierdly null sometimes so cast to string + when (typeName) { //weirdly null sometimes so cast to string "kotlin.Int" -> (value as? Double)?.toInt() "kotlin.String" -> value.toString() "kotlin.Boolean" -> value as? Boolean diff --git a/app/src/main/java/ani/dantotsu/subcriptions/AlarmReceiver.kt b/app/src/main/java/ani/dantotsu/subcriptions/AlarmReceiver.kt deleted file mode 100644 index c1c17e1f..00000000 --- a/app/src/main/java/ani/dantotsu/subcriptions/AlarmReceiver.kt +++ /dev/null @@ -1,60 +0,0 @@ -package ani.dantotsu.subcriptions - -import android.app.AlarmManager -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import ani.dantotsu.currContext -import ani.dantotsu.isOnline -import ani.dantotsu.logger -import ani.dantotsu.settings.saving.PrefManager -import ani.dantotsu.settings.saving.PrefName -import ani.dantotsu.subcriptions.Subscription.Companion.defaultTime -import ani.dantotsu.subcriptions.Subscription.Companion.startSubscription -import ani.dantotsu.subcriptions.Subscription.Companion.timeMinutes -import ani.dantotsu.tryWith -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class AlarmReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - Intent.ACTION_BOOT_COMPLETED -> tryWith(true) { - logger("Starting Dantotsu Subscription Service on Boot") - context?.startSubscription() - } - } - - CoroutineScope(Dispatchers.IO).launch { - val con = context ?: currContext() ?: return@launch - if (isOnline(con)) Subscription.perform(con) - } - } - - companion object { - - fun alarm(context: Context) { - val alarmIntent = Intent(context, AlarmReceiver::class.java) - alarmIntent.action = "ani.dantotsu.ACTION_ALARM" - - val pendingIntent = PendingIntent.getBroadcast( - context, 0, alarmIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val curTime = PrefManager.getVal(PrefName.SubscriptionsTimeS, defaultTime) - - if (timeMinutes[curTime] > 0) - alarmManager.setRepeating( - AlarmManager.RTC, - System.currentTimeMillis(), - (timeMinutes[curTime] * 60 * 1000), - pendingIntent - ) - else alarmManager.cancel(pendingIntent) - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/subcriptions/Notifications.kt b/app/src/main/java/ani/dantotsu/subcriptions/Notifications.kt deleted file mode 100644 index 0599a5be..00000000 --- a/app/src/main/java/ani/dantotsu/subcriptions/Notifications.kt +++ /dev/null @@ -1,176 +0,0 @@ -package ani.dantotsu.subcriptions - -import android.app.NotificationChannel -import android.app.NotificationChannelGroup -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Context.NOTIFICATION_SERVICE -import android.content.Intent -import android.os.Build -import android.provider.Settings -import androidx.core.app.NotificationCompat -import ani.dantotsu.FileUrl -import ani.dantotsu.R -import ani.dantotsu.connections.anilist.UrlMedia -import com.bumptech.glide.Glide -import com.bumptech.glide.load.model.GlideUrl -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@Suppress("MemberVisibilityCanBePrivate", "unused") -class Notifications { - enum class Group(val title: String, val icon: Int) { - ANIME_GROUP("New Episodes", R.drawable.ic_round_movie_filter_24), - MANGA_GROUP("New Chapters", R.drawable.ic_round_menu_book_24) - } - - companion object { - - 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 - } - - 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, 0, notifyIntent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT - } else { - PendingIntent.FLAG_ONE_SHOT - } - ) - } - - fun createChannel( - context: Context, - group: Group?, - id: String, - name: String, - silent: Boolean = false - ) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val importance = - if (!silent) NotificationManager.IMPORTANCE_HIGH else NotificationManager.IMPORTANCE_LOW - val mChannel = NotificationChannel(id, name, importance) - - val notificationManager = - context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager - - if (group != null) { - notificationManager.createNotificationChannelGroup( - NotificationChannelGroup( - group.name, - group.title - ) - ) - mChannel.group = group.name - } - - notificationManager.createNotificationChannel(mChannel) - } - } - - fun deleteChannel(context: Context, id: String) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val notificationManager = - context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.deleteNotificationChannel(id) - } - } - - fun getNotification( - context: Context, - group: Group?, - channelId: String, - title: String, - text: String?, - silent: Boolean = false - ): NotificationCompat.Builder { - createChannel(context, group, channelId, title, silent) - return NotificationCompat.Builder(context, channelId) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setSmallIcon(group?.icon ?: R.drawable.monochrome) - .setContentTitle(title) - .setContentText(text) - .setAutoCancel(true) - } - - suspend fun getNotification( - context: Context, - group: Group?, - channelId: String, - title: String, - text: String, - img: FileUrl?, - silent: Boolean = false, - largeImg: FileUrl? - ): NotificationCompat.Builder { - val builder = getNotification(context, group, channelId, title, text, silent) - return if (img != null) { - val bitmap = withContext(Dispatchers.IO) { - Glide.with(context) - .asBitmap() - .load(GlideUrl(img.url) { img.headers }) - .submit() - .get() - } - - @Suppress("BlockingMethodInNonBlockingContext") - val largeBitmap = if (largeImg != null) Glide.with(context) - .asBitmap() - .load(GlideUrl(largeImg.url) { largeImg.headers }) - .submit() - .get() - else null - - if (largeBitmap != null) builder.setStyle( - NotificationCompat - .BigPictureStyle() - .bigPicture(largeBitmap) - .bigLargeIcon(bitmap) - ) - - builder.setLargeIcon(bitmap) - } else builder - } - - suspend fun getNotification( - context: Context, - group: Group?, - channelId: String, - title: String, - text: String, - img: String? = null, - silent: Boolean = false, - largeImg: FileUrl? = null - ): NotificationCompat.Builder { - return getNotification( - context, - group, - channelId, - title, - text, - if (img != null) FileUrl(img) else null, - silent, - largeImg - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/subcriptions/Subscription.kt b/app/src/main/java/ani/dantotsu/subcriptions/Subscription.kt deleted file mode 100644 index 050953ce..00000000 --- a/app/src/main/java/ani/dantotsu/subcriptions/Subscription.kt +++ /dev/null @@ -1,146 +0,0 @@ -package ani.dantotsu.subcriptions - -import android.annotation.SuppressLint -import android.app.Notification -import android.content.Context -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import ani.dantotsu.* -import ani.dantotsu.parsers.Episode -import ani.dantotsu.parsers.MangaChapter -import ani.dantotsu.settings.saving.PrefManager -import ani.dantotsu.settings.saving.PrefName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@SuppressLint("MissingPermission") -class Subscription { - companion object { - const val defaultTime = 1 - val timeMinutes = arrayOf(0L, 720, 1440) - - private var alreadyStarted = false - fun Context.startSubscription(force: Boolean = false) { - if (!alreadyStarted || force) { - alreadyStarted = true - SubscriptionWorker.enqueue(this) - AlarmReceiver.alarm(this) - } else logger("Already Subscribed") - } - - private var currentlyPerforming = false - - suspend fun perform(context: Context) { - if (!currentlyPerforming) tryWithSuspend { - currentlyPerforming = true - App.context = context - - 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) { - notificationManager.notify(progressNotificationId, 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(progressNotificationId) - } - } - - fun progress(progress: Int, parser: String, media: String) { - if (progressNotification != null) - notificationManager.notify( - progressNotificationId, - 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(context, media.isAdult, media.id) - progress(index[it.first]!!, parser.name, media.name) - val ep: Episode? = - SubscriptionHelper.getEpisode(context, parser, media.id, media.isAdult) - if (ep != null) currActivity()!!.getString(R.string.episode) + "${ep.number}${ - if (ep.title != null) " : ${ep.title}" else "" - }${ - if (ep.isFiller) " [Filler]" else "" - } " + currActivity()!!.getString(R.string.just_released) to ep.thumbnail - else null - } else { - val parser = - SubscriptionHelper.getMangaParser(context, media.isAdult, media.id) - progress(index[it.first]!!, parser.name, media.name) - val ep: MangaChapter? = - SubscriptionHelper.getChapter(context, parser, media.id, media.isAdult) - if (ep != null) ep.number + " " + currActivity()!!.getString(R.string.just_released) to null - else null - } ?: return@map - createNotification(context.applicationContext, media, text.first, text.second) - } - - if (progressNotification != null) notificationManager.cancel(progressNotificationId) - currentlyPerforming = false - } - } - - fun getChannelId(isAnime: Boolean, mediaId: Int) = - "${if (isAnime) "anime" else "manga"}_${mediaId}" - - private suspend fun createNotification( - context: Context, - media: SubscriptionHelper.Companion.SubscribeMedia, - text: String, - thumbnail: FileUrl? - ) { - val notificationManager = NotificationManagerCompat.from(context) - - val notification = Notifications.getNotification( - context, - if (media.isAnime) Notifications.Group.ANIME_GROUP else Notifications.Group.MANGA_GROUP, - getChannelId(media.isAnime, media.id), - media.name, - text, - media.image, - false, - thumbnail - ).setContentIntent(Notifications.getIntent(context, media.id)).build() - - notification.flags = Notification.FLAG_AUTO_CANCEL - //+100 to have extra ids for other notifications? - notificationManager.notify(100 + media.id, notification) - } - - private const val progressNotificationId = 100 - - private fun getProgressNotification( - context: Context, - size: Int - ): NotificationCompat.Builder { - return Notifications.getNotification( - context, - null, - "subscription_checking", - currContext()!!.getString(R.string.checking_subscriptions_title), - null, - true - ).setOngoing(true).setProgress(size, 0, false).setAutoCancel(false) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/subcriptions/SubscriptionWorker.kt b/app/src/main/java/ani/dantotsu/subcriptions/SubscriptionWorker.kt deleted file mode 100644 index 00c7650f..00000000 --- a/app/src/main/java/ani/dantotsu/subcriptions/SubscriptionWorker.kt +++ /dev/null @@ -1,51 +0,0 @@ -package ani.dantotsu.subcriptions - -import android.content.Context -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import ani.dantotsu.settings.saving.PrefManager -import ani.dantotsu.settings.saving.PrefName -import ani.dantotsu.subcriptions.Subscription.Companion.defaultTime -import ani.dantotsu.subcriptions.Subscription.Companion.timeMinutes -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.concurrent.TimeUnit - -class SubscriptionWorker(val context: Context, params: WorkerParameters) : - CoroutineWorker(context, params) { - - override suspend fun doWork(): Result { - withContext(Dispatchers.IO) { - Subscription.perform(context) - } - return Result.success() - } - - companion object { - - private const val SUBSCRIPTION_WORK_NAME = "work_subscription" - fun enqueue(context: Context) { - val curTime = PrefManager.getVal(PrefName.SubscriptionsTimeS, defaultTime) - if (timeMinutes[curTime] > 0L) { - val constraints = - Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() - val periodicSyncDataWork = PeriodicWorkRequest.Builder( - SubscriptionWorker::class.java, 6, TimeUnit.HOURS - ).apply { - addTag(SUBSCRIPTION_WORK_NAME) - setConstraints(constraints) - }.build() - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - SUBSCRIPTION_WORK_NAME, - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - periodicSyncDataWork - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/themes/ThemeManager.kt b/app/src/main/java/ani/dantotsu/themes/ThemeManager.kt index d3086353..05e1c5de 100644 --- a/app/src/main/java/ani/dantotsu/themes/ThemeManager.kt +++ b/app/src/main/java/ani/dantotsu/themes/ThemeManager.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap +import android.os.Build import android.view.Window import android.view.WindowManager import ani.dantotsu.R @@ -44,6 +45,7 @@ class ThemeManager(private val context: Activity) { "GREEN" -> if (useOLED) R.style.Theme_Dantotsu_GreenOLED else R.style.Theme_Dantotsu_Green "PURPLE" -> if (useOLED) R.style.Theme_Dantotsu_PurpleOLED else R.style.Theme_Dantotsu_Purple "PINK" -> if (useOLED) R.style.Theme_Dantotsu_PinkOLED else R.style.Theme_Dantotsu_Pink + "ORIAX" -> if (useOLED) R.style.Theme_Dantotsu_OriaxOLED else R.style.Theme_Dantotsu_Oriax "SAIKOU" -> if (useOLED) R.style.Theme_Dantotsu_SaikouOLED else R.style.Theme_Dantotsu_Saikou "RED" -> if (useOLED) R.style.Theme_Dantotsu_RedOLED else R.style.Theme_Dantotsu_Red "LAVENDER" -> if (useOLED) R.style.Theme_Dantotsu_LavenderOLED else R.style.Theme_Dantotsu_Lavender @@ -53,7 +55,10 @@ class ThemeManager(private val context: Activity) { } val window = context.window - window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + @Suppress("DEPRECATION") + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + } window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) window.statusBarColor = 0x00000000 context.setTheme(themeToApply) @@ -127,6 +132,7 @@ class ThemeManager(private val context: Activity) { GREEN("GREEN"), PURPLE("PURPLE"), PINK("PINK"), + ORIAX("ORIAX"), SAIKOU("SAIKOU"), RED("RED"), LAVENDER("LAVENDER"), @@ -135,7 +141,7 @@ class ThemeManager(private val context: Activity) { companion object { fun fromString(value: String): Theme { - return values().find { it.theme == value } ?: PURPLE + return entries.find { it.theme == value } ?: PURPLE } } } diff --git a/app/src/main/java/ani/dantotsu/util/AniMarkdown.kt b/app/src/main/java/ani/dantotsu/util/AniMarkdown.kt new file mode 100644 index 00000000..6bac3fcc --- /dev/null +++ b/app/src/main/java/ani/dantotsu/util/AniMarkdown.kt @@ -0,0 +1,102 @@ +package ani.dantotsu.util + +import ani.dantotsu.util.ColorEditor.Companion.toCssColor +import ani.dantotsu.util.ColorEditor.Companion.toHexColor + +class AniMarkdown { //istg anilist has the worst api + companion object { + private fun convertNestedImageToHtml(markdown: String): String { + val regex = """\[\!\[(.*?)\]\((.*?)\)\]\((.*?)\)""".toRegex() + return regex.replace(markdown) { matchResult -> + val altText = matchResult.groupValues[1] + val imageUrl = matchResult.groupValues[2] + val linkUrl = matchResult.groupValues[3] + """$altText""" + } + } + + private fun convertImageToHtml(markdown: String): String { + val regex = """\!\[(.*?)\]\((.*?)\)""".toRegex() + return regex.replace(markdown) { matchResult -> + val altText = matchResult.groupValues[1] + val imageUrl = matchResult.groupValues[2] + """$altText""" + } + } + + private fun convertLinkToHtml(markdown: String): String { + val regex = """\[(.*?)\]\((.*?)\)""".toRegex() + return regex.replace(markdown) { matchResult -> + val linkText = matchResult.groupValues[1] + val linkUrl = matchResult.groupValues[2] + """$linkText""" + } + } + + private fun replaceLeftovers(html: String): String { + return html.replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("
", "")
+                .replace("`", "")
+                .replace("~", "")
+                .replace(">\n<", "><")
+                .replace("\n", "
") + } + + private fun underlineToHtml(html: String): String { + return html.replace("(?s)___(.*?)___".toRegex(), "
$1
") + .replace("(?s)__(.*?)__".toRegex(), "
$1
") + .replace("(?s)[\\s]+_([^_]+)_[\\s]+".toRegex(), "$1") + } + + fun getBasicAniHTML(html: String): String { + val step0 = convertNestedImageToHtml(html) + val step1 = convertImageToHtml(step0) + val step2 = convertLinkToHtml(step1) + val step3 = replaceLeftovers(step2) + return underlineToHtml(step3) + } + + fun getFullAniHTML(html: String, textColor: Int): String { + val basicHtml = getBasicAniHTML(html) + + + val returnHtml = """ + + + + + + + $basicHtml + + + """.trimIndent() + return returnHtml + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/util/ColorEditor.kt b/app/src/main/java/ani/dantotsu/util/ColorEditor.kt new file mode 100644 index 00000000..d4f9662c --- /dev/null +++ b/app/src/main/java/ani/dantotsu/util/ColorEditor.kt @@ -0,0 +1,101 @@ +package ani.dantotsu.util + +import android.graphics.Color +import kotlin.math.pow + +class ColorEditor { + companion object { + fun oppositeColor(color: Int): Int { + val hsv = FloatArray(3) + Color.colorToHSV(color, hsv) + hsv[0] = (hsv[0] + 180) % 360 + return adjustColorForContrast(Color.HSVToColor(hsv), color) + } + + fun generateColorPalette( + baseColor: Int, + size: Int, + hueDelta: Float = 8f, + saturationDelta: Float = 2.02f, + valueDelta: Float = 2.02f + ): List { + val palette = mutableListOf() + val hsv = FloatArray(3) + Color.colorToHSV(baseColor, hsv) + + for (i in 0 until size) { + val newHue = + (hsv[0] + hueDelta * i) % 360 // Ensure hue stays within the 0-360 range + val newSaturation = (hsv[1] + saturationDelta * i).coerceIn(0f, 1f) + val newValue = (hsv[2] + valueDelta * i).coerceIn(0f, 1f) + + val newHsv = floatArrayOf(newHue, newSaturation, newValue) + palette.add(Color.HSVToColor(newHsv)) + } + + return palette + } + + fun getLuminance(color: Int): Double { + val r = Color.red(color) / 255.0 + val g = Color.green(color) / 255.0 + val b = Color.blue(color) / 255.0 + + val rL = if (r <= 0.03928) r / 12.92 else ((r + 0.055) / 1.055).pow(2.4) + val gL = if (g <= 0.03928) g / 12.92 else ((g + 0.055) / 1.055).pow(2.4) + val bL = if (b <= 0.03928) b / 12.92 else ((b + 0.055) / 1.055).pow(2.4) + + return 0.2126 * rL + 0.7152 * gL + 0.0722 * bL + } + + fun getContrastRatio(color1: Int, color2: Int): Double { + val l1 = getLuminance(color1) + val l2 = getLuminance(color2) + + return if (l1 > l2) (l1 + 0.05) / (l2 + 0.05) else (l2 + 0.05) / (l1 + 0.05) + } + + fun adjustColorForContrast(originalColor: Int, backgroundColor: Int): Int { + var adjustedColor = originalColor + var contrastRatio = getContrastRatio(adjustedColor, backgroundColor) + val isBackgroundDark = getLuminance(backgroundColor) < 0.5 + + while (contrastRatio < 4.5) { + // Adjust brightness by modifying the RGB values + val r = Color.red(adjustedColor) + val g = Color.green(adjustedColor) + val b = Color.blue(adjustedColor) + + // Calculate the amount to adjust + val adjustment = if (isBackgroundDark) 10 else -10 + + // Adjust the color + val newR = (r + adjustment).coerceIn(0, 255) + val newG = (g + adjustment).coerceIn(0, 255) + val newB = (b + adjustment).coerceIn(0, 255) + + adjustedColor = Color.rgb(newR, newG, newB) + contrastRatio = getContrastRatio(adjustedColor, backgroundColor) + + // Break the loop if the color adjustment does not change (to avoid infinite loop) + if (newR == r && newG == g && newB == b) { + break + } + } + return adjustedColor + } + + fun Int.toCssColor(): String { + var base = "rgba(" + base += "${Color.red(this)}, " + base += "${Color.green(this)}, " + base += "${Color.blue(this)}, " + base += "${Color.alpha(this) / 255.0})" + return base + } + + fun Int.toHexColor(): String { + return String.format("#%06X", 0xFFFFFF and this) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/util/Logger.kt b/app/src/main/java/ani/dantotsu/util/Logger.kt new file mode 100644 index 00000000..5820abd8 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/util/Logger.kt @@ -0,0 +1,154 @@ +package ani.dantotsu.util + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.content.FileProvider +import ani.dantotsu.BuildConfig +import ani.dantotsu.connections.crashlytics.CrashlyticsInterface +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.snackString +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.File +import java.util.Date +import java.util.concurrent.Executors + +object Logger { + var file: File? = null + private val loggerExecutor = Executors.newSingleThreadExecutor() + + fun init(context: Context) { + try { + if (!PrefManager.getVal(PrefName.LogToFile) || file != null) return + file = File(context.getExternalFilesDir(null), "log.txt") + if (file?.exists() == true) { + val oldFile = File(context.getExternalFilesDir(null), "old_log.txt") + file?.copyTo(oldFile, true) + } else { + file?.createNewFile() + } + file?.writeText("log started\n") + file?.appendText("date/time: ${Date()}\n") + file?.appendText("device: ${Build.MODEL}\n") + file?.appendText("os version: ${Build.VERSION.RELEASE}\n") + file?.appendText( + "app version: ${ + context.packageManager.getPackageInfo( + context.packageName, + 0 + ).versionName + }\n" + ) + file?.appendText( + "app version code: ${ + context.packageManager.getPackageInfo( + context.packageName, + 0 + ).run { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + longVersionCode + else + @Suppress("DEPRECATION") versionCode + + } + }\n" + ) + file?.appendText("sdk version: ${Build.VERSION.SDK_INT}\n") + file?.appendText("manufacturer: ${Build.MANUFACTURER}\n") + file?.appendText("brand: ${Build.BRAND}\n") + file?.appendText("product: ${Build.PRODUCT}\n") + file?.appendText("device: ${Build.DEVICE}\n") + file?.appendText("hardware: ${Build.HARDWARE}\n") + file?.appendText("host: ${Build.HOST}\n") + file?.appendText("id: ${Build.ID}\n") + file?.appendText("type: ${Build.TYPE}\n") + file?.appendText("user: ${Build.USER}\n") + file?.appendText("tags: ${Build.TAGS}\n") + file?.appendText("time: ${Build.TIME}\n") + file?.appendText("radio: ${Build.getRadioVersion()}\n") + file?.appendText("bootloader: ${Build.BOOTLOADER}\n") + file?.appendText("board: ${Build.BOARD}\n") + file?.appendText("fingerprint: ${Build.FINGERPRINT}\n") + file?.appendText("supported_abis: ${Build.SUPPORTED_ABIS.joinToString()}\n") + file?.appendText("supported_32_bit_abis: ${Build.SUPPORTED_32_BIT_ABIS.joinToString()}\n") + file?.appendText("supported_64_bit_abis: ${Build.SUPPORTED_64_BIT_ABIS.joinToString()}\n") + file?.appendText("is emulator: ${Build.FINGERPRINT.contains("generic")}\n") + file?.appendText("--------------------------------\n") + } catch (e: Exception) { + Injekt.get().logException(e) + file = null + } + } + + fun log(message: String) { + val trace = Thread.currentThread().stackTrace[3] + loggerExecutor.execute { + if (file == null) Log.d("Internal Logger", message) + else { + val className = trace.className + val methodName = trace.methodName + val lineNumber = trace.lineNumber + file?.appendText("date/time: ${Date()} | $className.$methodName($lineNumber)\n") + file?.appendText("message: $message\n-\n") + } + } + } + + fun log(e: Exception) { + loggerExecutor.execute { + if (file == null) e.printStackTrace() else { + file?.appendText("---------------------------Exception---------------------------\n") + file?.appendText("date/time: ${Date()} | ${e.message}\n") + file?.appendText("trace: ${e.stackTraceToString()}\n") + } + } + } + + fun log(e: Throwable) { + loggerExecutor.execute { + if (file == null) e.printStackTrace() else { + file?.appendText("---------------------------Exception---------------------------\n") + file?.appendText("date/time: ${Date()} | ${e.message}\n") + file?.appendText("trace: ${e.stackTraceToString()}\n") + } + } + } + + fun shareLog(context: Context) { + if (file == null) { + snackString("No log file found") + return + } + val oldFile = File(context.getExternalFilesDir(null), "old_log.txt") + val fileToUse = if (oldFile.exists()) { + file?.readText()?.let { oldFile.appendText(it) } + oldFile + } else { + file + } + val shareIntent = Intent(Intent.ACTION_SEND) + shareIntent.type = "text/plain" + shareIntent.putExtra( + Intent.EXTRA_STREAM, + FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", fileToUse!!) + ) + shareIntent.putExtra(Intent.EXTRA_SUBJECT, "Log file") + shareIntent.putExtra(Intent.EXTRA_TEXT, "Log file") + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startActivity(Intent.createChooser(shareIntent, "Share log file")) + } +} + +class FinalExceptionHandler : Thread.UncaughtExceptionHandler { + private val defaultUEH: Thread.UncaughtExceptionHandler? = + Thread.getDefaultUncaughtExceptionHandler() + + override fun uncaughtException(t: Thread, e: Throwable) { + Logger.log(e) + Injekt.get().logException(e) + defaultUEH?.uncaughtException(t, e) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/widgets/CurrentlyAiringRemoteViewsFactory.kt b/app/src/main/java/ani/dantotsu/widgets/CurrentlyAiringRemoteViewsFactory.kt index 3fd890bb..75f35588 100644 --- a/app/src/main/java/ani/dantotsu/widgets/CurrentlyAiringRemoteViewsFactory.kt +++ b/app/src/main/java/ani/dantotsu/widgets/CurrentlyAiringRemoteViewsFactory.kt @@ -7,7 +7,7 @@ import android.graphics.BitmapFactory import android.widget.RemoteViews import android.widget.RemoteViewsService import ani.dantotsu.R -import ani.dantotsu.logger +import ani.dantotsu.util.Logger import java.io.InputStream import java.net.HttpURLConnection import java.net.URL @@ -19,7 +19,7 @@ class CurrentlyAiringRemoteViewsFactory(private val context: Context, intent: In override fun onCreate() { // 4 items for testing widgetItems.clear() - logger("CurrentlyAiringRemoteViewsFactory onCreate") + Logger.log("CurrentlyAiringRemoteViewsFactory onCreate") widgetItems = List(4) { WidgetItem( "Show $it", @@ -31,7 +31,7 @@ class CurrentlyAiringRemoteViewsFactory(private val context: Context, intent: In override fun onDataSetChanged() { // 4 items for testing - logger("CurrentlyAiringRemoteViewsFactory onDataSetChanged") + Logger.log("CurrentlyAiringRemoteViewsFactory onDataSetChanged") widgetItems.clear() widgetItems.add( WidgetItem( @@ -79,7 +79,7 @@ class CurrentlyAiringRemoteViewsFactory(private val context: Context, intent: In } override fun getViewAt(position: Int): RemoteViews { - logger("CurrentlyAiringRemoteViewsFactory getViewAt") + Logger.log("CurrentlyAiringRemoteViewsFactory getViewAt") val item = widgetItems[position] val rv = RemoteViews(context.packageName, R.layout.item_currently_airing_widget).apply { setTextViewText(R.id.text_show_title, item.title) diff --git a/app/src/main/java/ani/dantotsu/widgets/CurrentlyAiringRemoteViewsService.kt b/app/src/main/java/ani/dantotsu/widgets/CurrentlyAiringRemoteViewsService.kt index 9770ff86..a0ff2efc 100644 --- a/app/src/main/java/ani/dantotsu/widgets/CurrentlyAiringRemoteViewsService.kt +++ b/app/src/main/java/ani/dantotsu/widgets/CurrentlyAiringRemoteViewsService.kt @@ -2,11 +2,11 @@ package ani.dantotsu.widgets import android.content.Intent import android.widget.RemoteViewsService -import ani.dantotsu.logger +import ani.dantotsu.util.Logger class CurrentlyAiringRemoteViewsService : RemoteViewsService() { override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { - logger("CurrentlyAiringRemoteViewsFactory onGetViewFactory") + Logger.log("CurrentlyAiringRemoteViewsFactory onGetViewFactory") return CurrentlyAiringRemoteViewsFactory(applicationContext, intent) } } diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index fd4e2a2b..b34758c4 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -17,9 +17,7 @@ class BasePreferences( fun incognitoMode() = preferenceStore.getBoolean("incognito_mode", false) fun extensionInstaller() = ExtensionInstallerPreference(context, preferenceStore) - - fun acraEnabled() = preferenceStore.getBoolean("acra.enable", true) - + fun deviceHasPip() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && context.packageManager.hasSystemFeature( PackageManager.FEATURE_PICTURE_IN_PICTURE diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index 6696d3cf..1b8810e2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -19,18 +19,6 @@ object Notifications { const val CHANNEL_COMMON = "common_channel" const val ID_DOWNLOAD_IMAGE = 2 - /** - * Notification channel and ids used by the library updater. - */ - private const val GROUP_LIBRARY = "group_library" - const val CHANNEL_LIBRARY_PROGRESS = "library_progress_channel" - const val ID_LIBRARY_PROGRESS = -101 - const val ID_LIBRARY_SIZE_WARNING = -103 - const val CHANNEL_LIBRARY_ERROR = "library_errors_channel" - const val ID_LIBRARY_ERROR = -102 - const val CHANNEL_LIBRARY_SKIPPED = "library_skipped_channel" - const val ID_LIBRARY_SKIPPED = -104 - /** * Notification channel and ids used by the downloader. */ @@ -51,23 +39,40 @@ object Notifications { const val GROUP_NEW_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS" const val GROUP_NEW_EPISODES = "eu.kanade.tachiyomi.NEW_EPISODES" - /** - * Notification channel and ids used by the backup/restore system. - */ - private const val GROUP_BACKUP_RESTORE = "group_backup_restore" - const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel" - const val ID_BACKUP_PROGRESS = -501 - const val ID_RESTORE_PROGRESS = -503 - const val CHANNEL_BACKUP_RESTORE_COMPLETE = "backup_restore_complete_channel_v2" - const val ID_BACKUP_COMPLETE = -502 - const val ID_RESTORE_COMPLETE = -504 - /** * Notification channel used for Incognito Mode */ const val CHANNEL_INCOGNITO_MODE = "incognito_mode_channel" const val ID_INCOGNITO_MODE = -701 + /** + * Notification channel used for comment notifications + */ + private const val GROUP_COMMENTS = "group_comments" + const val CHANNEL_COMMENTS = "comments_channel" + const val CHANNEL_COMMENT_WARING = "comment_warning_channel" + const val ID_COMMENT_REPLY = -801 + + + const val CHANNEL_APP_GLOBAL = "app_global" + + /** + * Notification channel and ids used for anilist updates. + */ + const val GROUP_ANILIST = "group_anilist" + const val CHANNEL_ANILIST = "anilist_channel" + const val ID_ANILIST = -901 + + /** + * Notification channel and ids used subscription checks. + */ + const val GROUP_SUBSCRIPTION_CHECK = "group_subscription_check" + const val CHANNEL_SUBSCRIPTION_CHECK = "subscription_check_channel" + const val CHANNEL_SUBSCRIPTION_CHECK_PROGRESS = "subscription_check_progress_channel" + const val ID_SUBSCRIPTION_CHECK = -1001 + const val ID_SUBSCRIPTION_CHECK_PROGRESS = -1002 + + /** * Notification channel and ids used for app and extension updates. */ @@ -88,6 +93,12 @@ object Notifications { "updates_ext_channel", "downloader_cache_renewal", "crash_logs_channel", + "backup_restore_complete_channel_v2", + "backup_restore_progress_channel", + "group_backup_restore", + "library_skipped_channel", + "library_errors_channel", + "library_progress_channel", ) /** @@ -104,18 +115,21 @@ object Notifications { notificationManager.createNotificationChannelGroupsCompat( listOf( - buildNotificationChannelGroup(GROUP_BACKUP_RESTORE) { - setName("Backup & Restore") - }, buildNotificationChannelGroup(GROUP_DOWNLOADER) { setName("Downloader") }, - buildNotificationChannelGroup(GROUP_LIBRARY) { - setName("Library") - }, buildNotificationChannelGroup(GROUP_APK_UPDATES) { setName("App & Extension Updates") }, + buildNotificationChannelGroup(GROUP_COMMENTS) { + setName("Comments") + }, + buildNotificationChannelGroup(GROUP_ANILIST) { + setName("Anilist") + }, + buildNotificationChannelGroup(GROUP_SUBSCRIPTION_CHECK) { + setName("Subscription Checks") + }, ), ) @@ -124,21 +138,6 @@ object Notifications { buildNotificationChannel(CHANNEL_COMMON, IMPORTANCE_LOW) { setName("Common") }, - buildNotificationChannel(CHANNEL_LIBRARY_PROGRESS, IMPORTANCE_LOW) { - setName("Library Progress") - setGroup(GROUP_LIBRARY) - setShowBadge(false) - }, - buildNotificationChannel(CHANNEL_LIBRARY_ERROR, IMPORTANCE_LOW) { - setName("Library Errors") - setGroup(GROUP_LIBRARY) - setShowBadge(false) - }, - buildNotificationChannel(CHANNEL_LIBRARY_SKIPPED, IMPORTANCE_LOW) { - setName("Library Skipped") - setGroup(GROUP_LIBRARY) - setShowBadge(false) - }, buildNotificationChannel(CHANNEL_NEW_CHAPTERS_EPISODES, IMPORTANCE_DEFAULT) { setName("New Chapters & Episodes") }, @@ -152,20 +151,32 @@ object Notifications { setGroup(GROUP_DOWNLOADER) setShowBadge(false) }, - buildNotificationChannel(CHANNEL_BACKUP_RESTORE_PROGRESS, IMPORTANCE_LOW) { - setName("Backup & Restore Progress") - setGroup(GROUP_BACKUP_RESTORE) - setShowBadge(false) - }, - buildNotificationChannel(CHANNEL_BACKUP_RESTORE_COMPLETE, IMPORTANCE_HIGH) { - setName("Backup & Restore Complete") - setGroup(GROUP_BACKUP_RESTORE) - setShowBadge(false) - setSound(null, null) - }, buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) { setName("Incognito Mode") }, + buildNotificationChannel(CHANNEL_COMMENTS, IMPORTANCE_HIGH) { + setName("Comments") + setGroup(GROUP_COMMENTS) + }, + buildNotificationChannel(CHANNEL_COMMENT_WARING, IMPORTANCE_HIGH) { + setName("Comment Warnings") + setGroup(GROUP_COMMENTS) + }, + buildNotificationChannel(CHANNEL_ANILIST, IMPORTANCE_DEFAULT) { + setName("Anilist") + setGroup(GROUP_ANILIST) + }, + buildNotificationChannel(CHANNEL_SUBSCRIPTION_CHECK, IMPORTANCE_LOW) { + setName("Subscription Checks") + setGroup(GROUP_SUBSCRIPTION_CHECK) + }, + buildNotificationChannel(CHANNEL_SUBSCRIPTION_CHECK_PROGRESS, IMPORTANCE_LOW) { + setName("Subscription Checks Progress") + setGroup(GROUP_SUBSCRIPTION_CHECK) + }, + buildNotificationChannel(CHANNEL_APP_GLOBAL, IMPORTANCE_HIGH) { + setName("Global Updates") + }, buildNotificationChannel(CHANNEL_APP_UPDATE, IMPORTANCE_DEFAULT) { setGroup(GROUP_APK_UPDATES) setName("App Updates") diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/AnimeExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/AnimeExtensionManager.kt index 82588463..198f557c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/AnimeExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/AnimeExtensionManager.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.anime import android.content.Context import android.graphics.drawable.Drawable import ani.dantotsu.snackString +import ani.dantotsu.util.Logger import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.InstallStep import eu.kanade.tachiyomi.extension.anime.api.AnimeExtensionGithubApi @@ -16,11 +17,9 @@ import eu.kanade.tachiyomi.util.preference.plusAssign import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import logcat.LogPriority import rx.Observable import tachiyomi.core.util.lang.launchNow import tachiyomi.core.util.lang.withUIContext -import tachiyomi.core.util.system.logcat import tachiyomi.domain.source.anime.model.AnimeSourceData import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -120,7 +119,7 @@ class AnimeExtensionManager( val extensions: List = try { api.findExtensions() } catch (e: Exception) { - logcat(LogPriority.ERROR, e) + Logger.log(e) withUIContext { snackString("Failed to get extensions list") } emptyList() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionGithubApi.kt index a927373d..e71c6422 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionGithubApi.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.extension.anime.api import android.content.Context +import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension @@ -13,11 +14,9 @@ import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import logcat.LogPriority import tachiyomi.core.preference.Preference import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.util.lang.withIOContext -import tachiyomi.core.util.system.logcat import uy.kohesive.injekt.injectLazy import java.util.Date import kotlin.time.Duration.Companion.days @@ -45,7 +44,7 @@ internal class AnimeExtensionGithubApi { .newCall(GET("${REPO_URL_PREFIX}index.min.json")) .awaitSuccess() } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" } + Logger.log("Failed to get extensions from GitHub") requiresFallbackSource = true null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/installer/PackageInstallerInstallerAnime.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/installer/PackageInstallerInstallerAnime.kt index ffa12c70..baa7411e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/installer/PackageInstallerInstallerAnime.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/installer/PackageInstallerInstallerAnime.kt @@ -10,12 +10,11 @@ import android.content.pm.PackageInstaller import android.os.Build import androidx.core.content.ContextCompat import ani.dantotsu.snackString +import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.extension.InstallStep import eu.kanade.tachiyomi.util.lang.use import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat import eu.kanade.tachiyomi.util.system.getUriSize -import logcat.LogPriority -import tachiyomi.core.util.system.logcat class PackageInstallerInstallerAnime(private val service: Service) : InstallerAnime(service) { @@ -30,7 +29,7 @@ class PackageInstallerInstallerAnime(private val service: Service) : InstallerAn PackageInstaller.STATUS_PENDING_USER_ACTION -> { val userAction = intent.getParcelableExtraCompat(Intent.EXTRA_INTENT) if (userAction == null) { - logcat(LogPriority.ERROR) { "Fatal error for $intent" } + Logger.log("Fatal error for $intent") continueQueue(InstallStep.Error) return } @@ -89,11 +88,8 @@ class PackageInstallerInstallerAnime(private val service: Service) : InstallerAn session.commit(intentSender) } } catch (e: Exception) { - logcat( - LogPriority.ERROR, - e - ) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } - logcat(LogPriority.ERROR) { "Exception: $e" } + Logger.log(e) + Logger.log("Failed to install extension ${entry.downloadId} ${entry.uri}") snackString("Failed to install extension ${entry.downloadId} ${entry.uri}") activeSession?.let { (_, sessionId) -> packageInstaller.abandonSession(sessionId) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallReceiver.kt index 0dd3302d..9ccc0f7b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallReceiver.kt @@ -5,15 +5,14 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.core.content.ContextCompat +import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.extension.anime.model.AnimeLoadResult import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async -import logcat.LogPriority import tachiyomi.core.util.lang.launchNow -import tachiyomi.core.util.system.logcat /** * Broadcast receiver that listens for the system's packages installed, updated or removed, and only @@ -103,7 +102,7 @@ internal class AnimeExtensionInstallReceiver(private val listener: Listener) : private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): AnimeLoadResult { val pkgName = getPackageNameFromIntent(intent) if (pkgName == null) { - logcat(LogPriority.WARN) { "Package name not found" } + Logger.log("Package name not found") return AnimeLoadResult.Error } return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallService.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallService.kt index 87e6ab6a..7dd3fc80 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstallService.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.Build import android.os.IBinder import ani.dantotsu.R +import ani.dantotsu.util.Logger import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.extension.anime.installer.InstallerAnime @@ -15,8 +16,6 @@ import eu.kanade.tachiyomi.extension.anime.installer.PackageInstallerInstallerAn import eu.kanade.tachiyomi.extension.anime.util.AnimeExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat import eu.kanade.tachiyomi.util.system.notificationBuilder -import logcat.LogPriority -import tachiyomi.core.util.system.logcat class AnimeExtensionInstallService : Service() { @@ -60,7 +59,7 @@ class AnimeExtensionInstallService : Service() { ) else -> { - logcat(LogPriority.ERROR) { "Not implemented for installer $installerUsed" } + Logger.log("Not implemented for installer $installerUsed") stopSelf() return START_NOT_STICKY } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstaller.kt index 41bfdd9b..70f7da53 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionInstaller.kt @@ -10,16 +10,15 @@ import android.os.Environment import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.net.toUri +import ani.dantotsu.util.Logger import com.jakewharton.rxrelay.PublishRelay import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.extension.InstallStep import eu.kanade.tachiyomi.extension.anime.installer.InstallerAnime import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.util.storage.getUriCompat -import logcat.LogPriority import rx.Observable import rx.android.schedulers.AndroidSchedulers -import tachiyomi.core.util.system.logcat import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File @@ -248,7 +247,7 @@ internal class AnimeExtensionInstaller(private val context: Context) { // Set next installation step if (uri == null) { - logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" } + Logger.log("Couldn't locate downloaded APK") downloadsRelay.call(id to InstallStep.Error) return } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt index cce7b7e9..2ce2aaf7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/util/AnimeExtensionLoader.kt @@ -6,6 +6,7 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import androidx.core.content.pm.PackageInfoCompat +import ani.dantotsu.util.Logger import dalvik.system.PathClassLoader import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource @@ -17,8 +18,6 @@ import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.system.getApplicationIcon import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking -import logcat.LogPriority -import tachiyomi.core.util.system.logcat import uy.kohesive.injekt.injectLazy /** @@ -91,11 +90,11 @@ internal object AnimeExtensionLoader { context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS) } catch (error: PackageManager.NameNotFoundException) { // Unlikely, but the package may have been uninstalled at this point - logcat(LogPriority.ERROR, error) + Logger.log(error) return AnimeLoadResult.Error } if (!isPackageAnExtension(pkgInfo)) { - logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" } + Logger.log("Tried to load a package that wasn't a extension ($pkgName)") return AnimeLoadResult.Error } return loadExtension(context, pkgName, pkgInfo) @@ -119,7 +118,7 @@ internal object AnimeExtensionLoader { pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) } catch (error: PackageManager.NameNotFoundException) { // Unlikely, but the package may have been uninstalled at this point - logcat(LogPriority.ERROR, error) + Logger.log(error) return AnimeLoadResult.Error } @@ -128,24 +127,23 @@ internal object AnimeExtensionLoader { val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo) if (versionName.isNullOrEmpty()) { - logcat(LogPriority.WARN) { "Missing versionName for extension $extName" } + Logger.log("Missing versionName for extension $extName") return AnimeLoadResult.Error } // Validate lib version val libVersion = versionName.substringBeforeLast('.').toDoubleOrNull() if (libVersion == null || libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { - logcat(LogPriority.WARN) { - "Lib version is $libVersion, while only versions " + + Logger.log("Lib version is $libVersion, while only versions " + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed" - } + ) return AnimeLoadResult.Error } val signatureHash = getSignatureHash(pkgInfo) if (signatureHash == null) { - logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } + Logger.log("Package $pkgName isn't signed") return AnimeLoadResult.Error } else if (signatureHash !in trustedSignatures) { val extension = AnimeExtension.Untrusted( @@ -156,13 +154,13 @@ internal object AnimeExtensionLoader { libVersion, signatureHash ) - logcat(LogPriority.WARN, message = { "Extension $pkgName isn't trusted" }) + Logger.log("Extension $pkgName isn't trusted") return AnimeLoadResult.Untrusted(extension) } val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1 if (!loadNsfwSource && isNsfw) { - logcat(LogPriority.WARN) { "NSFW extension $pkgName not allowed" } + Logger.log("NSFW extension $pkgName not allowed") return AnimeLoadResult.Error } @@ -189,7 +187,7 @@ internal object AnimeExtensionLoader { else -> throw Exception("Unknown source class type! ${obj.javaClass}") } } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" } + Logger.log("Extension load error: $extName ($it)") return AnimeLoadResult.Error } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/MangaExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/MangaExtensionManager.kt index fe098455..f581f4e1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/MangaExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/MangaExtensionManager.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.extension.manga import android.content.Context import android.graphics.drawable.Drawable import ani.dantotsu.snackString +import ani.dantotsu.util.Logger import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.InstallStep import eu.kanade.tachiyomi.extension.manga.api.MangaExtensionGithubApi @@ -16,11 +17,9 @@ import eu.kanade.tachiyomi.util.preference.plusAssign import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import logcat.LogPriority import rx.Observable import tachiyomi.core.util.lang.launchNow import tachiyomi.core.util.lang.withUIContext -import tachiyomi.core.util.system.logcat import tachiyomi.domain.source.manga.model.MangaSourceData import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -117,7 +116,7 @@ class MangaExtensionManager( val extensions: List = try { api.findExtensions() } catch (e: Exception) { - logcat(LogPriority.ERROR, e) + Logger.log(e) withUIContext { snackString("Failed to get manga extensions") } emptyList() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/api/MangaExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/api/MangaExtensionGithubApi.kt index ad21741f..a5fdbc5a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/api/MangaExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/api/MangaExtensionGithubApi.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.extension.manga.api import android.content.Context +import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager import eu.kanade.tachiyomi.extension.manga.model.AvailableMangaSources @@ -13,11 +14,9 @@ import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import logcat.LogPriority import tachiyomi.core.preference.Preference import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.util.lang.withIOContext -import tachiyomi.core.util.system.logcat import uy.kohesive.injekt.injectLazy import java.util.Date import kotlin.time.Duration.Companion.days @@ -45,7 +44,7 @@ internal class MangaExtensionGithubApi { .newCall(GET("${REPO_URL_PREFIX}index.min.json")) .awaitSuccess() } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" } + Logger.log("Failed to get extensions from GitHub") requiresFallbackSource = true null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/installer/PackageInstallerInstallerManga.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/installer/PackageInstallerInstallerManga.kt index 3802be8f..d8da743b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/installer/PackageInstallerInstallerManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/installer/PackageInstallerInstallerManga.kt @@ -10,12 +10,11 @@ import android.content.pm.PackageInstaller import android.os.Build import androidx.core.content.ContextCompat import ani.dantotsu.snackString +import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.extension.InstallStep import eu.kanade.tachiyomi.util.lang.use import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat import eu.kanade.tachiyomi.util.system.getUriSize -import logcat.LogPriority -import tachiyomi.core.util.system.logcat class PackageInstallerInstallerManga(private val service: Service) : InstallerManga(service) { @@ -30,7 +29,7 @@ class PackageInstallerInstallerManga(private val service: Service) : InstallerMa PackageInstaller.STATUS_PENDING_USER_ACTION -> { val userAction = intent.getParcelableExtraCompat(Intent.EXTRA_INTENT) if (userAction == null) { - logcat(LogPriority.ERROR) { "Fatal error for $intent" } + Logger.log("Fatal error for $intent") continueQueue(InstallStep.Error) return } @@ -89,8 +88,8 @@ class PackageInstallerInstallerManga(private val service: Service) : InstallerMa session.commit(intentSender) } } catch (e: Exception) { - logcat(LogPriority.ERROR) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } - logcat(LogPriority.ERROR) { "Exception: $e" } + Logger.log("Failed to install extension ${entry.downloadId} ${entry.uri}") + Logger.log(e) snackString("Failed to install extension ${entry.downloadId} ${entry.uri}") activeSession?.let { (_, sessionId) -> packageInstaller.abandonSession(sessionId) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallReceiver.kt index ad3c7053..4db3e42c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallReceiver.kt @@ -5,15 +5,14 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.core.content.ContextCompat +import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.extension.manga.model.MangaLoadResult import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async -import logcat.LogPriority import tachiyomi.core.util.lang.launchNow -import tachiyomi.core.util.system.logcat /** * Broadcast receiver that listens for the system's packages installed, updated or removed, and only @@ -103,7 +102,7 @@ internal class MangaExtensionInstallReceiver(private val listener: Listener) : private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): MangaLoadResult { val pkgName = getPackageNameFromIntent(intent) if (pkgName == null) { - logcat(LogPriority.WARN) { "Package name not found" } + Logger.log("Package name not found") return MangaLoadResult.Error } return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallService.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallService.kt index f6cc24cc..c698d3dc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstallService.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.Build import android.os.IBinder import ani.dantotsu.R +import ani.dantotsu.util.Logger import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.extension.manga.installer.InstallerManga @@ -15,8 +16,6 @@ import eu.kanade.tachiyomi.extension.manga.installer.PackageInstallerInstallerMa import eu.kanade.tachiyomi.extension.manga.util.MangaExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat import eu.kanade.tachiyomi.util.system.notificationBuilder -import logcat.LogPriority -import tachiyomi.core.util.system.logcat class MangaExtensionInstallService : Service() { @@ -60,7 +59,7 @@ class MangaExtensionInstallService : Service() { ) else -> { - logcat(LogPriority.ERROR) { "Not implemented for installer $installerUsed" } + Logger.log("Not implemented for installer $installerUsed") stopSelf() return START_NOT_STICKY } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstaller.kt index f05db16c..53bfb599 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionInstaller.kt @@ -10,16 +10,15 @@ import android.os.Environment import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.net.toUri +import ani.dantotsu.util.Logger import com.jakewharton.rxrelay.PublishRelay import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.extension.InstallStep import eu.kanade.tachiyomi.extension.manga.installer.InstallerManga import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.util.storage.getUriCompat -import logcat.LogPriority import rx.Observable import rx.android.schedulers.AndroidSchedulers -import tachiyomi.core.util.system.logcat import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File @@ -245,7 +244,7 @@ internal class MangaExtensionInstaller(private val context: Context) { // Set next installation step if (uri == null) { - logcat(LogPriority.ERROR) { "Couldn't locate downloaded APK" } + Logger.log("Couldn't locate downloaded APK") downloadsRelay.call(id to InstallStep.Error) return } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionLoader.kt index c76bd0bb..0fd6f987 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/util/MangaExtensionLoader.kt @@ -6,6 +6,7 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import androidx.core.content.pm.PackageInfoCompat +import ani.dantotsu.util.Logger import dalvik.system.PathClassLoader import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.manga.model.MangaExtension @@ -17,8 +18,6 @@ import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.system.getApplicationIcon import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking -import logcat.LogPriority -import tachiyomi.core.util.system.logcat import uy.kohesive.injekt.injectLazy /** @@ -91,11 +90,11 @@ internal object MangaExtensionLoader { context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS) } catch (error: PackageManager.NameNotFoundException) { // Unlikely, but the package may have been uninstalled at this point - logcat(LogPriority.ERROR, error) + Logger.log(error) return MangaLoadResult.Error } if (!isPackageAnExtension(pkgInfo)) { - logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" } + Logger.log("Tried to load a package that wasn't a extension ($pkgName)") return MangaLoadResult.Error } return loadMangaExtension(context, pkgName, pkgInfo) @@ -119,7 +118,7 @@ internal object MangaExtensionLoader { pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) } catch (error: PackageManager.NameNotFoundException) { // Unlikely, but the package may have been uninstalled at this point - logcat(LogPriority.ERROR, error) + Logger.log(error) return MangaLoadResult.Error } @@ -129,17 +128,16 @@ internal object MangaExtensionLoader { val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo) if (versionName.isNullOrEmpty()) { - logcat(LogPriority.WARN) { "Missing versionName for extension $extName" } + Logger.log("Missing versionName for extension $extName") return MangaLoadResult.Error } // Validate lib version val libVersion = versionName.substringBeforeLast('.').toDoubleOrNull() if (libVersion == null || libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { - logcat(LogPriority.WARN) { - "Lib version is $libVersion, while only versions " + + Logger.log("Lib version is $libVersion, while only versions " + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed" - } + ) return MangaLoadResult.Error } @@ -147,18 +145,18 @@ internal object MangaExtensionLoader { /* temporarily disabling signature check TODO: remove? if (signatureHash == null) { - logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } + Logger.log("Package $pkgName isn't signed") return MangaLoadResult.Error } else if (signatureHash !in trustedSignatures) { val extension = MangaExtension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash) - logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" } + Logger.log("Extension $pkgName isn't trusted") return MangaLoadResult.Untrusted(extension) } */ val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1 if (!loadNsfwSource && isNsfw) { - logcat(LogPriority.WARN) { "NSFW extension $pkgName not allowed" } + Logger.log("NSFW extension $pkgName not allowed") return MangaLoadResult.Error } @@ -185,7 +183,7 @@ internal object MangaExtensionLoader { else -> throw Exception("Unknown source class type! ${obj.javaClass}") } } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" } + Logger.log("Extension load error: $extName ($it)") return MangaLoadResult.Error } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt index 055bab09..2b57445f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt @@ -1,18 +1,24 @@ package eu.kanade.tachiyomi.network import android.webkit.CookieManager +import ani.dantotsu.snackString import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl class AndroidCookieJar : CookieJar { - val manager: CookieManager = CookieManager.getInstance() + val manager: CookieManager? = try { + CookieManager.getInstance() + } catch (e: Exception) { + snackString("Webview is outdated, please update your webview") + null + } override fun saveFromResponse(url: HttpUrl, cookies: List) { val urlString = url.toString() - cookies.forEach { manager.setCookie(urlString, it.toString()) } + cookies.forEach { manager?.setCookie(urlString, it.toString()) } } override fun loadForRequest(url: HttpUrl): List { @@ -20,9 +26,9 @@ class AndroidCookieJar : CookieJar { } fun get(url: HttpUrl): List { - val cookies = manager.getCookie(url.toString()) + val cookies = manager?.getCookie(url.toString()) - return if (cookies != null && cookies.isNotEmpty()) { + return if (!cookies.isNullOrEmpty()) { cookies.split(";").mapNotNull { Cookie.parse(url, it) } } else { emptyList() @@ -31,7 +37,7 @@ class AndroidCookieJar : CookieJar { fun remove(url: HttpUrl, cookieNames: List? = null, maxAge: Int = -1): Int { val urlString = url.toString() - val cookies = manager.getCookie(urlString) ?: return 0 + val cookies = manager?.getCookie(urlString) ?: return 0 fun List.filterNames(): List { return if (cookieNames != null) { @@ -49,6 +55,6 @@ class AndroidCookieJar : CookieJar { } fun removeAll() { - manager.removeAllCookies {} + manager?.removeAllCookies {} } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index a4fe08b4..8ae4a8fd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -34,8 +34,8 @@ class NetworkHelper( maxSize = 5L * 1024 * 1024, // 5 MiB ), ) - .addInterceptor(BrotliInterceptor) .addInterceptor(UncaughtExceptionInterceptor()) + .addInterceptor(BrotliInterceptor) .addInterceptor(UserAgentInterceptor(::defaultUserAgentProvider)) if (PrefManager.getVal(PrefName.VerboseLogging)) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index d7cacb64..280af466 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -14,6 +14,7 @@ import android.os.PowerManager import android.util.TypedValue import androidx.annotation.AttrRes import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker import androidx.core.content.getSystemService import androidx.core.graphics.alpha @@ -21,10 +22,9 @@ import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red import androidx.core.net.toUri +import ani.dantotsu.util.Logger import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.util.lang.truncateCenter -import logcat.LogPriority -import tachiyomi.core.util.system.logcat import java.io.File import kotlin.math.roundToInt @@ -47,7 +47,7 @@ fun Context.copyToClipboard(label: String, content: String) { toast("Copied to clipboard: " + content.truncateCenter(50)) } } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) + Logger.log(e) toast("Failed to copy to clipboard") } } @@ -86,7 +86,7 @@ fun Context.getThemeColor(attr: Int): Int { val tv = TypedValue() return if (this.theme.resolveAttribute(attr, tv, true)) { if (tv.resourceId != 0) { - getColor(tv.resourceId) + ContextCompat.getColor(this, tv.resourceId) } else { tv.data } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/DeviceUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/DeviceUtil.kt index 57950eda..95a38aee 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/DeviceUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/DeviceUtil.kt @@ -2,8 +2,7 @@ package eu.kanade.tachiyomi.util.system import android.annotation.SuppressLint import android.os.Build -import logcat.LogPriority -import tachiyomi.core.util.system.logcat +import ani.dantotsu.util.Logger object DeviceUtil { @@ -72,7 +71,7 @@ object DeviceUtil { .getDeclaredMethod("get", String::class.java) .invoke(null, key) as String } catch (e: Exception) { - logcat(LogPriority.WARN, e) { "Unable to use SystemProperties.get()" } + Logger.log("Unable to use SystemProperties.get()") null } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt index 67ce5a03..53049e4b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt @@ -10,6 +10,7 @@ import androidx.core.app.NotificationChannelGroupCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag +import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker import androidx.core.content.getSystemService @@ -65,7 +66,7 @@ fun Context.notificationBuilder( block: (NotificationCompat.Builder.() -> Unit)? = null ): NotificationCompat.Builder { val builder = NotificationCompat.Builder(this, channelId) - .setColor(getColor(android.R.color.holo_blue_dark)) + .setColor(ContextCompat.getColor(this, android.R.color.holo_blue_dark)) if (block != null) { builder.block() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt index beef906d..88158ecd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt @@ -6,8 +6,7 @@ import android.content.pm.PackageManager import android.webkit.CookieManager import android.webkit.WebSettings import android.webkit.WebView -import logcat.LogPriority -import tachiyomi.core.util.system.logcat +import ani.dantotsu.util.Logger object WebViewUtil { const val SPOOF_PACKAGE_NAME = "org.chromium.chrome" @@ -20,7 +19,7 @@ object WebViewUtil { // is not installed CookieManager.getInstance() } catch (e: Throwable) { - logcat(LogPriority.ERROR, e) + Logger.log(e) return false } diff --git a/app/src/main/res/anim/bounce_zoom.xml b/app/src/main/res/anim/bounce_zoom.xml new file mode 100644 index 00000000..c19927cf --- /dev/null +++ b/app/src/main/res/anim/bounce_zoom.xml @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/discord_status_dnd.xml b/app/src/main/res/drawable/discord_status_dnd.xml new file mode 100644 index 00000000..db415306 --- /dev/null +++ b/app/src/main/res/drawable/discord_status_dnd.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/discord_status_idle.xml b/app/src/main/res/drawable/discord_status_idle.xml new file mode 100644 index 00000000..ed0e0880 --- /dev/null +++ b/app/src/main/res/drawable/discord_status_idle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/discord_status_online.xml b/app/src/main/res/drawable/discord_status_online.xml new file mode 100644 index 00000000..cc81654f --- /dev/null +++ b/app/src/main/res/drawable/discord_status_online.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_crown.xml b/app/src/main/res/drawable/ic_crown.xml new file mode 100644 index 00000000..00b5cc98 --- /dev/null +++ b/app/src/main/res/drawable/ic_crown.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_globe_24.xml b/app/src/main/res/drawable/ic_globe_24.xml new file mode 100644 index 00000000..ff6e71b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_globe_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_label_24.xml b/app/src/main/res/drawable/ic_label_24.xml new file mode 100644 index 00000000..ad3f2592 --- /dev/null +++ b/app/src/main/res/drawable/ic_label_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_label_off_24.xml b/app/src/main/res/drawable/ic_label_off_24.xml new file mode 100644 index 00000000..d4dbdd00 --- /dev/null +++ b/app/src/main/res/drawable/ic_label_off_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_open_24.xml b/app/src/main/res/drawable/ic_open_24.xml new file mode 100644 index 00000000..52b21320 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_color_picker_24.xml b/app/src/main/res/drawable/ic_round_color_picker_24.xml index c4e79808..72723411 100644 --- a/app/src/main/res/drawable/ic_round_color_picker_24.xml +++ b/app/src/main/res/drawable/ic_round_color_picker_24.xml @@ -2,6 +2,7 @@ + + diff --git a/app/src/main/res/drawable/ic_round_dots_vertical_24.xml b/app/src/main/res/drawable/ic_round_dots_vertical_24.xml index 9f9d4f5c..25709d54 100644 --- a/app/src/main/res/drawable/ic_round_dots_vertical_24.xml +++ b/app/src/main/res/drawable/ic_round_dots_vertical_24.xml @@ -1,6 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_round_edit_note_24.xml b/app/src/main/res/drawable/ic_round_edit_note_24.xml index a2fdf236..110180d7 100644 --- a/app/src/main/res/drawable/ic_round_edit_note_24.xml +++ b/app/src/main/res/drawable/ic_round_edit_note_24.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_round_no_scroll_bar.xml b/app/src/main/res/drawable/ic_round_no_scroll_bar.xml new file mode 100644 index 00000000..c11b94ee --- /dev/null +++ b/app/src/main/res/drawable/ic_round_no_scroll_bar.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_round_person_24.xml b/app/src/main/res/drawable/ic_round_person_24.xml index 4cecf074..1191a1e8 100644 --- a/app/src/main/res/drawable/ic_round_person_24.xml +++ b/app/src/main/res/drawable/ic_round_person_24.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_round_send_24.xml b/app/src/main/res/drawable/ic_round_send_24.xml new file mode 100644 index 00000000..a951e7f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_send_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_upvote_active_24.xml b/app/src/main/res/drawable/ic_round_upvote_active_24.xml new file mode 100644 index 00000000..683aae88 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_upvote_active_24.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_upvote_inactive_24.xml b/app/src/main/res/drawable/ic_round_upvote_inactive_24.xml new file mode 100644 index 00000000..dceee11c --- /dev/null +++ b/app/src/main/res/drawable/ic_round_upvote_inactive_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_shield.xml b/app/src/main/res/drawable/ic_shield.xml new file mode 100644 index 00000000..c29effe3 --- /dev/null +++ b/app/src/main/res/drawable/ic_shield.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_stats_24.xml b/app/src/main/res/drawable/ic_stats_24.xml new file mode 100644 index 00000000..d0734d7b --- /dev/null +++ b/app/src/main/res/drawable/ic_stats_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/inbox_empty.xml b/app/src/main/res/drawable/inbox_empty.xml new file mode 100644 index 00000000..bb6540aa --- /dev/null +++ b/app/src/main/res/drawable/inbox_empty.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/inbox_filled.xml b/app/src/main/res/drawable/inbox_filled.xml new file mode 100644 index 00000000..08ad4bf1 --- /dev/null +++ b/app/src/main/res/drawable/inbox_filled.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/invert_all_boxes.xml b/app/src/main/res/drawable/invert_all_boxes.xml new file mode 100644 index 00000000..2a5d5763 --- /dev/null +++ b/app/src/main/res/drawable/invert_all_boxes.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/linear_gradient_nav_inv.xml b/app/src/main/res/drawable/linear_gradient_nav_inv.xml new file mode 100644 index 00000000..32352abd --- /dev/null +++ b/app/src/main/res/drawable/linear_gradient_nav_inv.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/notification_circle.xml b/app/src/main/res/drawable/notification_circle.xml new file mode 100644 index 00000000..d864d738 --- /dev/null +++ b/app/src/main/res/drawable/notification_circle.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/notification_icon.xml b/app/src/main/res/drawable/notification_icon.xml new file mode 100644 index 00000000..29e3dd6d --- /dev/null +++ b/app/src/main/res/drawable/notification_icon.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/main/res/drawable/tick_all_boxes.xml b/app/src/main/res/drawable/tick_all_boxes.xml new file mode 100644 index 00000000..59eddd41 --- /dev/null +++ b/app/src/main/res/drawable/tick_all_boxes.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/untick_all_boxes.xml b/app/src/main/res/drawable/untick_all_boxes.xml new file mode 100644 index 00000000..4e86043d --- /dev/null +++ b/app/src/main/res/drawable/untick_all_boxes.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/font/blocky.ttf b/app/src/main/res/font/blocky.ttf new file mode 100644 index 00000000..61b4610b Binary files /dev/null and b/app/src/main/res/font/blocky.ttf differ diff --git a/app/src/main/res/font/century_gothic_bold.TTF b/app/src/main/res/font/century_gothic_bold.TTF deleted file mode 100644 index 93faeb3d..00000000 Binary files a/app/src/main/res/font/century_gothic_bold.TTF and /dev/null differ diff --git a/app/src/main/res/font/levenim_mt_bold.ttf b/app/src/main/res/font/levenim_mt_bold.ttf new file mode 100644 index 00000000..4dfeab47 Binary files /dev/null and b/app/src/main/res/font/levenim_mt_bold.ttf differ diff --git a/app/src/main/res/layout-land/activity_media.xml b/app/src/main/res/layout-land/activity_media.xml index 4c7dd397..247c86a9 100644 --- a/app/src/main/res/layout-land/activity_media.xml +++ b/app/src/main/res/layout-land/activity_media.xml @@ -6,20 +6,67 @@ android:layout_height="match_parent" android:orientation="horizontal"> - + android:layout_gravity="center|bottom" + android:orientation="vertical"> + + + + + + + + android:visibility="gone"/> + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-v23/activity_media.xml b/app/src/main/res/layout-v23/activity_media.xml new file mode 100644 index 00000000..dd6fefbd --- /dev/null +++ b/app/src/main/res/layout-v23/activity_media.xml @@ -0,0 +1,497 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +