diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 629f508c..46e64586 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -1,17 +1,25 @@ name: Build APK and Notify Discord + on: push: - branches: - - dev + branches-ignore: + - main + - l10n_dev_crowdin + - custom-download-location paths-ignore: - '**/README.md' + tags: + - "v*.*.*" jobs: build: runs-on: ubuntu-latest + permissions: + contents: write env: CI: true + SKIP_BUILD: false steps: - name: Checkout repo @@ -19,14 +27,12 @@ jobs: with: fetch-depth: 0 - - name: Download last SHA artifact uses: dawidd6/action-download-artifact@v6 with: workflow: beta.yml name: last-sha path: . - continue-on-error: true - name: Get Commits Since Last Run @@ -39,7 +45,9 @@ jobs: fi echo "Commits since $LAST_SHA:" # Accumulate commit logs in a shell variable - COMMIT_LOGS=$(git log $LAST_SHA..HEAD --pretty=format:"● %s ~%an") + COMMIT_LOGS=$(git log $LAST_SHA..HEAD --pretty=format:"● %s ~%an [֍](https://github.com/${{ github.repository }}/commit/%H)" --max-count=10) + # Replace commit messages with pull request links + COMMIT_LOGS=$(echo "$COMMIT_LOGS" | sed -E 's/#([0-9]+)/[#\1](https:\/\/github.com\/rebelonion\/Dantotsu\/pull\/\1)/g') # URL-encode the newline characters for GitHub Actions COMMIT_LOGS="${COMMIT_LOGS//'%'/'%25'}" COMMIT_LOGS="${COMMIT_LOGS//$'\n'/'%0A'}" @@ -49,6 +57,10 @@ jobs: # Debugging: Print the variable to check its content echo "$COMMIT_LOGS" echo "$COMMIT_LOGS" > commit_log.txt + # Extract branch name from github.ref + BRANCH=${{ github.ref }} + BRANCH=${BRANCH#refs/heads/} + echo "BRANCH=${BRANCH}" >> $GITHUB_ENV shell: /usr/bin/bash -e {0} env: CI: true @@ -65,53 +77,278 @@ jobs: echo "Version $VERSION" echo "VERSION=$VERSION" >> $GITHUB_ENV + - name: List files in the directory + run: ls -l + - name: Setup JDK 17 + if: ${{ env.SKIP_BUILD != 'true' }} uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: 17 cache: gradle - - - name: Decode Keystore File - run: echo "${{ secrets.KEYSTORE_FILE }}" | base64 -d > $GITHUB_WORKSPACE/key.keystore - - - name: List files in the directory - run: ls -l - - - name: Make gradlew executable - run: chmod +x ./gradlew - - - name: Build with Gradle - 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: Decode Keystore File + if: ${{ github.repository == 'rebelonion/Dantotsu' }} + run: echo "${{ secrets.KEYSTORE_FILE }}" | base64 -d > $GITHUB_WORKSPACE/key.keystore + + - name: Make gradlew executable + if: ${{ env.SKIP_BUILD != 'true' }} + run: chmod +x ./gradlew + + - name: Build with Gradle + if: ${{ env.SKIP_BUILD != 'true' }} + run: | + if [ "${{ github.repository }}" == "rebelonion/Dantotsu" ]; then + ./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 }}; + else + ./gradlew assembleGoogleAlpha; + fi + - name: Upload a Build Artifact + if: ${{ env.SKIP_BUILD != 'true' }} uses: actions/upload-artifact@v4 with: name: Dantotsu retention-days: 5 compression-level: 9 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 + # Prepare Discord embed + fetch_user_details() { + local login=$1 + user_details=$(curl -s "https://api.github.com/users/$login") + name=$(echo "$user_details" | jq -r '.name // .login') + login=$(echo "$user_details" | jq -r '.login') + avatar_url=$(echo "$user_details" | jq -r '.avatar_url') + echo "$name|$login|$avatar_url" + } + + # Additional information for the goats + declare -A additional_info + additional_info["ibo"]="\n Discord: <@951737931159187457>\n AniList: [takarealist112]()" + additional_info["aayush262"]="\n Discord: <@918825160654598224>\n AniList: [aayush262]()" + additional_info["rebelonion"]="\n Discord: <@714249925248024617>\n AniList: [rebelonion]()\n PornHub: [rebelonion]()" + additional_info["Ankit Grai"]="\n Discord: <@1125628254330560623>\n AniList: [bheshnarayan]()" + + # Decimal color codes for contributors + declare -A contributor_colors + default_color="16721401" + contributor_colors["grayankit"]="#350297" + contributor_colors["ibo"]="#ff9b46" + contributor_colors["aayush262"]="#5d689d" + contributor_colors["Sadwhy"]="#ff7e95" + contributor_colors["rebelonion"]="#d4e5ed" + + hex_to_decimal() { printf '%d' "0x${1#"#"}"; } + + # Count recent commits and create an associative array Okay + declare -A recent_commit_counts + echo "Debug: Processing COMMIT_LOG:" + echo "$COMMIT_LOG" + while read -r count name; do + recent_commit_counts["$name"]=$count + echo "Debug: Commit count for $name: $count" + done < <(echo "$COMMIT_LOG" | sed 's/%0A/\n/g' | grep -oP '(?<=~)[^[]*' | sort | uniq -c | sort -rn) + + echo "Debug: Fetching contributors from GitHub" + # Fetch contributors from GitHub + contributors=$(curl -s "https://api.github.com/repos/${{ github.repository }}/contributors") + echo "Debug: Contributors response:" + echo "$contributors" + + # Create a sorted list of contributors based on recent commit counts + sorted_contributors=$(for login in $(echo "$contributors" | jq -r '.[].login'); do + user_info=$(fetch_user_details "$login") + name=$(echo "$user_info" | cut -d'|' -f1) + count=${recent_commit_counts["$name"]:-0} + echo "$count|$login" + done | sort -rn | cut -d'|' -f2) + + # Initialize needed variables + developers="" + committers_count=0 + max_commits=0 + top_contributor="" + top_contributor_count=0 + top_contributor_avatar="" + embed_color=$default_color + + # Process contributors in the new order + while read -r login; do + user_info=$(fetch_user_details "$login") + name=$(echo "$user_info" | cut -d'|' -f1) + login=$(echo "$user_info" | cut -d'|' -f2) + avatar_url=$(echo "$user_info" | cut -d'|' -f3) + + # Only process if they have recent commits + commit_count=${recent_commit_counts["$name"]:-0} + if [ $commit_count -gt 0 ]; then + # Update top contributor information + if [ $commit_count -gt $max_commits ]; then + max_commits=$commit_count + top_contributors=("$login") + top_contributor_count=1 + top_contributor_avatar="$avatar_url" + embed_color=$(hex_to_decimal "${contributor_colors[$name]:-$default_color}") + elif [ $commit_count -eq $max_commits ]; then + top_contributors+=("$login") + top_contributor_count=$((top_contributor_count + 1)) + embed_color=$default_color + fi + echo "Debug top contributors:" + echo "$top_contributors" + + # Get commit count for this contributor on the dev branch + branch_commit_count=$(git log --author="$login" --author="$name" --oneline | awk '!seen[$0]++' | wc -l) + + # Debug: Print recent_commit_counts + echo "Debug: recent_commit_counts contents:" + for key in "${!recent_commit_counts[@]}"; do + echo "$key: ${recent_commit_counts[$key]}" + done + + extra_info="${additional_info[$name]}" + if [ -n "$extra_info" ]; then + extra_info=$(echo "$extra_info" | sed 's/\\n/\n- /g') + fi + + # Construct the developer entry + developer_entry="◗ **${name}** ${extra_info} + - Github: [${login}](https://github.com/${login}) + - Commits: ${branch_commit_count}" + + # Add the entry to developers, with a newline if it's not the first entry + if [ -n "$developers" ]; then + developers="${developers} + ${developer_entry}" + else + developers="${developer_entry}" + fi + committers_count=$((committers_count + 1)) + fi + done <<< "$sorted_contributors" + + # Set the thumbnail URL and color based on top contributor(s) + if [ $top_contributor_count -eq 1 ]; then + thumbnail_url="$top_contributor_avatar" + else + thumbnail_url="https://i.imgur.com/5o3Y9Jb.gif" + embed_color=$default_color + fi + + # Truncate field values + max_length=1000 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 [ ${#developers} -gt $max_length ]; then + developers="${developers:0:$max_length}... (truncated)" + fi if [ ${#commit_messages} -gt $max_length ]; then commit_messages="${commit_messages:0:$max_length}... (truncated)" fi - contentbody=$( jq -nc --arg msg "Alpha-Build: <@&1225347048321191996> **$VERSION**:" --arg commits "$commit_messages" '{"content": ($msg + "\n" + $commits)}' ) - curl -F "payload_json=${contentbody}" -F "dantotsu_debug=@app/build/outputs/apk/google/alpha/app-google-alpha.apk" ${{ secrets.DISCORD_WEBHOOK }} - - #Telegram - curl -F "chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }}" \ - -F "document=@app/build/outputs/apk/google/alpha/app-google-universal-alpha.apk" \ - -F "caption=Alpha-Build: ${VERSION}: ${commit_messages}" \ - https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument + # Construct Discord payload + discord_data=$(jq -nc \ + --arg field_value "$commit_messages" \ + --arg author_value "$developers" \ + --arg footer_text "Version $VERSION" \ + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \ + --arg thumbnail_url "$thumbnail_url" \ + --arg embed_color "$embed_color" \ + '{ + "content": "<@&1225347048321191996>", + "embeds": [ + { + "title": "New Alpha-Build dropped", + "color": $embed_color, + "fields": [ + { + "name": "Commits:", + "value": $field_value, + "inline": true + }, + { + "name": "Developers:", + "value": $author_value, + "inline": false + } + ], + "footer": { + "text": $footer_text + }, + "timestamp": $timestamp, + "thumbnail": { + "url": $thumbnail_url + } + } + ], + "attachments": [] + }') + echo "Debug: Final Discord payload:" + echo "$discord_data" + + # Send Discord message + curl -H "Content-Type: application/json" \ + -d "$discord_data" \ + ${{ secrets.DISCORD_WEBHOOK }} + echo "You have only send an embed to discord due to SKIP_BUILD being set to true" + + # Upload APK to Discord + if [ "$SKIP_BUILD" != "true" ]; then + curl -F "payload_json=${contentbody}" \ + -F "dantotsu_debug=@app/build/outputs/apk/google/alpha/app-google-alpha.apk" \ + ${{ secrets.DISCORD_WEBHOOK }} + else + echo "Skipping APK upload to Discord due to SKIP_BUILD being set to true" + fi + + # Format commit messages for Telegram + telegram_commit_messages=$(echo "$COMMIT_LOG" | sed 's/%0A/\n/g' | while read -r line; do + message=$(echo "$line" | sed -E 's/● (.*) ~(.*) \[֍\]\((.*)\)/● \1 ~\2 ֍<\/a>/') + message=$(echo "$message" | sed -E 's/\[#([0-9]+)\]\((https:\/\/github\.com\/[^)]+)\)/#\1<\/a>/g') + echo "$message" + done) + telegram_commit_messages="
${telegram_commit_messages}
" + + # Configuring dev info + echo "$developers" > dev_info.txt + echo "$developers" + # making the file executable + chmod +x workflowscripts/tel_parser.sed + ./workflowscripts/tel_parser.sed dev_info.txt >> output.txt + dev_info_tel=$(< output.txt) + + telegram_dev_info="
${dev_info_tel}
" + echo "$telegram_dev_info" + + # Upload APK to Telegram + if [ "$SKIP_BUILD" != "true" ]; then + APK_PATH="app/build/outputs/apk/google/alpha/app-google-alpha.apk" + response=$(curl -sS -f -X POST \ + "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument" \ + -F "chat_id=-1002117798698" \ + -F "message_thread_id=7044" \ + -F "document=@$APK_PATH" \ + -F "caption=New Alpha-Build dropped 🔥 + + Commits: + ${telegram_commit_messages} + Dev: + ${telegram_dev_info} + version: ${VERSION}" \ + -F "parse_mode=HTML") + else + echo "skipping because skip build set to true" + fi + env: COMMIT_LOG: ${{ env.COMMIT_LOG }} VERSION: ${{ env.VERSION }} diff --git a/.gitignore b/.gitignore index 2ccc322f..0fc4c86f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .gradle/ build/ +#kotlin +.kotlin/ + # Local configuration file (sdk path, etc) local.properties @@ -33,4 +36,7 @@ output.json scripts/ #crowdin -crowdin.yml \ No newline at end of file +crowdin.yml + +#vscode +.vscode \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 811cab90..5d422b29 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,12 +11,12 @@ def gitCommitHash = providers.exec { }.standardOutput.asText.get().trim() android { - compileSdk 34 + compileSdk 35 defaultConfig { applicationId "ani.dantotsu" minSdk 21 - targetSdk 34 + targetSdk 35 versionCode((System.currentTimeMillis() / 60000).toInteger()) versionName "3.1.0" versionCode 300100000 @@ -101,6 +101,8 @@ dependencies { implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.webkit:webkit:1.11.0' implementation "com.anggrayudi:storage:1.5.5" + implementation "androidx.biometric:biometric:1.1.0" + // Glide ext.glide_version = '4.16.0' @@ -111,7 +113,7 @@ dependencies { implementation 'jp.wasabeef:glide-transformations:4.3.0' // Exoplayer - ext.exo_version = '1.3.1' + ext.exo_version = '1.5.0' implementation "androidx.media3:media3-exoplayer:$exo_version" implementation "androidx.media3:media3-ui:$exo_version" implementation "androidx.media3:media3-exoplayer-hls:$exo_version" @@ -121,6 +123,8 @@ dependencies { // Media3 Casting implementation "androidx.media3:media3-cast:$exo_version" implementation "androidx.mediarouter:mediarouter:1.7.0" +// Media3 extension + implementation "com.github.anilbeesetti.nextlib:nextlib-media3ext:0.8.3" // UI implementation 'com.google.android.material:material:1.12.0' @@ -131,7 +135,7 @@ dependencies { 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:7.2.1' + implementation 'com.github.AAChartModel:AAChartCore-Kotlin:7.2.3' // Markwon ext.markwon_version = '4.6.2' @@ -157,7 +161,7 @@ dependencies { implementation 'ru.beryukhov:flowreactivenetwork:1.0.4' implementation 'ca.gosyer:voyager-navigator:1.0.0-rc07' implementation 'com.squareup.logcat:logcat:0.1' - implementation 'com.github.inorichi.injekt:injekt-core:65b0440' + implementation 'uy.kohesive.injekt:injekt-core:1.16.+' 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' diff --git a/app/src/fdroid/java/ani/dantotsu/others/AppUpdater.kt b/app/src/fdroid/java/ani/dantotsu/others/AppUpdater.kt index bb9893f6..60d06857 100644 --- a/app/src/fdroid/java/ani/dantotsu/others/AppUpdater.kt +++ b/app/src/fdroid/java/ani/dantotsu/others/AppUpdater.kt @@ -1,9 +1,40 @@ package ani.dantotsu.others import androidx.fragment.app.FragmentActivity +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.text.SimpleDateFormat +import java.util.Locale object AppUpdater { suspend fun check(activity: FragmentActivity, post: Boolean = false) { - //no-op + // no-op } -} \ No newline at end of file + + @Serializable + data class GithubResponse( + @SerialName("html_url") + val htmlUrl: String, + @SerialName("tag_name") + val tagName: String, + val prerelease: Boolean, + @SerialName("created_at") + val createdAt: String, + val body: String? = null, + val assets: List? = null + ) { + @Serializable + data class Asset( + @SerialName("browser_download_url") + val browserDownloadURL: String + ) + + fun timeStamp(): Long { + return dateFormat.parse(createdAt)!!.time + } + + companion object { + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()) + } + } +} diff --git a/app/src/google/java/ani/dantotsu/others/AppUpdater.kt b/app/src/google/java/ani/dantotsu/others/AppUpdater.kt index 67b7ce43..f6f8e8ca 100644 --- a/app/src/google/java/ani/dantotsu/others/AppUpdater.kt +++ b/app/src/google/java/ani/dantotsu/others/AppUpdater.kt @@ -29,6 +29,7 @@ import ani.dantotsu.util.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +import kotlinx.coroutines.time.delay import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray @@ -69,7 +70,11 @@ object AppUpdater { ) addView( TextView(activity).apply { - val markWon = buildMarkwon(activity, false) + val markWon = try { //slower phones can destroy the activity before this is done + buildMarkwon(activity, false) + } catch (e: IllegalArgumentException) { + return@runOnUiThread + } markWon.setMarkdown(this, md) } ) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 43c9c29f..644d218a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ + @@ -115,7 +116,8 @@ - + + @@ -131,10 +133,11 @@ - - + + + @@ -155,7 +158,8 @@ android:parentActivityName=".MainActivity" /> + android:parentActivityName=".MainActivity" + android:windowSoftInputMode="adjustPan"/> @@ -194,14 +198,15 @@ android:label="Inbox Activity" android:parentActivityName=".MainActivity" /> + android:name=".util.ActivityMarkdownCreator" + android:windowSoftInputMode="adjustResize|stateVisible" /> - - - - + + + + + + + + + + (PrefName.CommentsEnabled) == 0) { + if (BuildConfig.FLAVOR.contains("fdroid")) { + PrefManager.setVal(PrefName.CommentsEnabled, 2) + } else { + PrefManager.setVal(PrefName.CommentsEnabled, 1) + } + } + CoroutineScope(Dispatchers.IO).launch { animeExtensionManager = Injekt.get() animeExtensionManager.findAvailableExtensions() @@ -128,7 +136,9 @@ class App : MultiDexApplication() { downloadAddonManager = Injekt.get() torrentAddonManager.init() downloadAddonManager.init() - CommentsAPI.fetchAuthToken(this@App) + if (PrefManager.getVal(PrefName.CommentsEnabled) == 1) { + CommentsAPI.fetchAuthToken(this@App) + } val useAlarmManager = PrefManager.getVal(PrefName.UseAlarmManager) val scheduler = TaskScheduler.create(this@App, useAlarmManager) diff --git a/app/src/main/java/ani/dantotsu/Functions.kt b/app/src/main/java/ani/dantotsu/Functions.kt index 04b83f52..e4e18ed9 100644 --- a/app/src/main/java/ani/dantotsu/Functions.kt +++ b/app/src/main/java/ani/dantotsu/Functions.kt @@ -68,7 +68,6 @@ import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.annotation.AttrRes -import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatDelegate import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -92,12 +91,12 @@ import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.BuildConfig.APPLICATION_ID import ani.dantotsu.connections.anilist.Genre import ani.dantotsu.connections.anilist.api.FuzzyDate -import ani.dantotsu.connections.bakaupdates.MangaUpdates import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.databinding.ItemCountDownBinding import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsActivity import ani.dantotsu.notifications.IncognitoNotificationClickReceiver +import ani.dantotsu.others.AlignTagHandler import ani.dantotsu.others.ImageViewDialog import ani.dantotsu.others.SpoilerPlugin import ani.dantotsu.parsers.ShowResponse @@ -106,7 +105,6 @@ 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.util.CountUpTimer import ani.dantotsu.util.Logger import com.bumptech.glide.Glide import com.bumptech.glide.RequestBuilder @@ -119,8 +117,8 @@ import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withC 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.CustomTarget import com.bumptech.glide.request.target.Target -import com.bumptech.glide.request.target.ViewTarget import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -154,6 +152,7 @@ import java.io.FileOutputStream import java.io.OutputStream import java.lang.reflect.Field import java.util.Calendar +import java.util.Locale import java.util.TimeZone import java.util.Timer import java.util.TimerTask @@ -314,6 +313,7 @@ fun Activity.reloadActivity() { Refresh.all() finish() startActivity(Intent(this, this::class.java)) + overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) initActivity(this) } @@ -854,7 +854,7 @@ fun savePrefsToDownloads( } ) } - +@SuppressLint("StringFormatMatches") fun savePrefs(serialized: String, path: String, title: String, context: Context): File? { var file = File(path, "$title.ani") var counter = 1 @@ -874,6 +874,7 @@ fun savePrefs(serialized: String, path: String, title: String, context: Context) } } +@SuppressLint("StringFormatMatches") fun savePrefs( serialized: String, path: String, @@ -920,7 +921,7 @@ fun shareImage(title: String, bitmap: Bitmap, context: Context) { intent.putExtra(Intent.EXTRA_STREAM, contentUri) context.startActivity(Intent.createChooser(intent, "Share $title")) } - +@SuppressLint("StringFormatMatches") fun saveImage(image: Bitmap, path: String, imageFileName: String): File? { val imageFile = File(path, "$imageFileName.png") return try { @@ -1010,47 +1011,10 @@ fun countDown(media: Media, view: ViewGroup) { } } -fun sinceWhen(media: Media, view: ViewGroup) { - if (media.status != "RELEASING" && media.status != "HIATUS") return - CoroutineScope(Dispatchers.IO).launch { - MangaUpdates().search(media.mangaName(), media.startDate)?.let { - val latestChapter = MangaUpdates.getLatestChapter(view.context, it) - val timeSince = (System.currentTimeMillis() - - (it.metadata.series.lastUpdated!!.timestamp * 1000)) / 1000 - - withContext(Dispatchers.Main) { - val v = - ItemCountDownBinding.inflate(LayoutInflater.from(view.context), view, false) - view.addView(v.root, 0) - v.mediaCountdownText.text = - currActivity()?.getString(R.string.chapter_release_timeout, latestChapter) - - object : CountUpTimer(86400000) { - override fun onTick(second: Int) { - val a = second + timeSince - v.mediaCountdown.text = currActivity()?.getString( - R.string.time_format, - a / 86400, - a % 86400 / 3600, - a % 86400 % 3600 / 60, - a % 86400 % 3600 % 60 - ) - } - - override fun onFinish() { - // The legend will never die. - } - }.start() - } - } - } -} - fun displayTimer(media: Media, view: ViewGroup) { when { media.anime != null -> countDown(media, view) - media.format == "MANGA" || media.format == "ONE_SHOT" -> sinceWhen(media, view) - else -> {} // No timer yet + else -> {} } } @@ -1447,6 +1411,8 @@ fun openOrCopyAnilistLink(link: String) { } else { copyToClipboard(link, true) } + } else if (getYoutubeId(link).isNotEmpty()) { + openLinkInYouTube(link) } else { copyToClipboard(link, true) } @@ -1483,6 +1449,7 @@ fun buildMarkwon( TagHandlerNoOp.create("h1", "h2", "h3", "h4", "h5", "h6", "hr", "pre", "a") ) } + plugin.addHandler(AlignTagHandler()) }) .usePlugin(GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore { @@ -1500,7 +1467,6 @@ fun buildMarkwon( } return false } - override fun onLoadFailed( e: GlideException?, model: Any?, @@ -1527,3 +1493,34 @@ fun buildMarkwon( .build() return markwon } + + + +fun getYoutubeId(url: String): String { + val regex = """(?:youtube\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|(?:youtu\.be|youtube\.com)/)([^"&?/\s]{11})|youtube\.com/""".toRegex() + val matchResult = regex.find(url) + return matchResult?.groupValues?.getOrNull(1) ?: "" +} + +fun getLanguageCode(language: String): CharSequence { + val locales = Locale.getAvailableLocales() + for (locale in locales) { + if (locale.displayLanguage.equals(language, ignoreCase = true)) { + val lang: CharSequence = locale.language + return lang + + } + } + val out: CharSequence = "null" + return out +} + +fun getLanguageName(language: String): String? { + val locales = Locale.getAvailableLocales() + for (locale in locales) { + if (locale.language.equals(language, ignoreCase = true)) { + return locale.displayLanguage + } + } + return null +} diff --git a/app/src/main/java/ani/dantotsu/MainActivity.kt b/app/src/main/java/ani/dantotsu/MainActivity.kt index f9740533..2faddd5d 100644 --- a/app/src/main/java/ani/dantotsu/MainActivity.kt +++ b/app/src/main/java/ani/dantotsu/MainActivity.kt @@ -2,7 +2,6 @@ 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.graphics.drawable.Animatable @@ -51,7 +50,8 @@ import ani.dantotsu.others.CustomBottomDialog import ani.dantotsu.others.calc.CalcActivity import ani.dantotsu.profile.ProfileActivity import ani.dantotsu.profile.activity.FeedActivity -import ani.dantotsu.profile.activity.NotificationActivity +import ani.dantotsu.profile.notification.NotificationActivity +import ani.dantotsu.settings.AddRepositoryBottomSheet import ani.dantotsu.settings.ExtensionsActivity import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefManager.asLiveBool @@ -60,10 +60,11 @@ import ani.dantotsu.settings.saving.SharedPreferenceBooleanLiveData import ani.dantotsu.settings.saving.internal.PreferenceKeystore import ani.dantotsu.settings.saving.internal.PreferencePackager import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.util.AudioHelper import ani.dantotsu.util.Logger +import ani.dantotsu.util.customAlertDialog 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 @@ -116,58 +117,8 @@ class MainActivity : AppCompatActivity() { } } - 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") - } + if (Intent.ACTION_VIEW == intent.action) { + handleViewIntent(intent) } val bottomNavBar = findViewById(R.id.navbar) @@ -287,7 +238,7 @@ class MainActivity : AppCompatActivity() { .get() > 0 || preferences.mangaExtensionUpdatesCount().get() > 0 ) { snackString(R.string.extension_updates_available) - ?.setDuration(Snackbar.LENGTH_LONG) + ?.setDuration(Snackbar.LENGTH_SHORT) ?.setAction(R.string.review) { startActivity(Intent(this, ExtensionsActivity::class.java)) } @@ -365,7 +316,6 @@ class MainActivity : AppCompatActivity() { } 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) } launched = true @@ -455,7 +405,10 @@ class MainActivity : AppCompatActivity() { } } } - + if (PrefManager.getVal(PrefName.OC)) { + AudioHelper.run(this, R.raw.audio) + PrefManager.setVal(PrefName.OC, false) + } val torrentManager = Injekt.get() fun startTorrent() { if (torrentManager.isAvailable() && PrefManager.getVal(PrefName.TorrentEnabled)) { @@ -490,39 +443,101 @@ class MainActivity : AppCompatActivity() { params.updateMargins(bottom = margin.toPx) } + private fun handleViewIntent(intent: Intent) { + val uri: Uri? = intent.data + try { + if (uri == null) { + throw Exception("Uri is null") + } + if ((uri.scheme == "tachiyomi" || uri.scheme == "aniyomi") && uri.host == "add-repo") { + val url = uri.getQueryParameter("url") ?: throw Exception("No url for repo import") + val prefName = if (uri.scheme == "tachiyomi") { + PrefName.MangaExtensionRepos + } else { + PrefName.AnimeExtensionRepos + } + val savedRepos: Set = PrefManager.getVal(prefName) + val newRepos = savedRepos.toMutableSet() + AddRepositoryBottomSheet.addRepoWarning(this) { + newRepos.add(url) + PrefManager.setVal(prefName, newRepos) + toast("${if (uri.scheme == "tachiyomi") "Manga" else "Anime"} Extension Repo added") + } + return + } + if (intent.type == null) return + 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") + } + } + private fun passwordAlertDialog(callback: (CharArray?) -> Unit) { val password = CharArray(16).apply { fill('0') } // Inflate the dialog layout - val dialogView = DialogUserAgentBinding.inflate(layoutInflater) - dialogView.userAgentTextBox.hint = "Password" - dialogView.subtitle.visibility = View.VISIBLE - dialogView.subtitle.text = getString(R.string.enter_password_to_decrypt_file) - - val dialog = AlertDialog.Builder(this, R.style.MyPopup) - .setTitle("Enter Password") - .setView(dialogView.root) - .setPositiveButton("OK", null) - .setNegativeButton("Cancel") { dialog, _ -> + val dialogView = DialogUserAgentBinding.inflate(layoutInflater).apply { + userAgentTextBox.hint = "Password" + subtitle.visibility = View.VISIBLE + subtitle.text = getString(R.string.enter_password_to_decrypt_file) + } + customAlertDialog().apply { + setTitle("Enter Password") + setCustomView(dialogView.root) + setPosButton(R.string.yes) { + val editText = dialogView.userAgentTextBox + if (editText.text?.isNotBlank() == true) { + editText.text?.toString()?.trim()?.toCharArray(password) + callback(password) + } else { + toast("Password cannot be empty") + } + } + setNegButton(R.string.cancel) { 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") - } + show() } } 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 51f3bc9d..6acb67a4 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt @@ -15,6 +15,8 @@ import ani.dantotsu.snackString import ani.dantotsu.toast import ani.dantotsu.util.Logger import java.util.Calendar +import java.util.Locale +import kotlin.math.abs object Anilist { val query: AnilistQueries = AnilistQueries() @@ -22,7 +24,7 @@ object Anilist { var token: String? = null var username: String? = null - var adult: Boolean = false + var userid: Int? = null var avatar: String? = null var bg: String? = null @@ -36,6 +38,17 @@ object Anilist { var rateLimitReset: Long = 0 var initialized = false + var adult: Boolean = false + var titleLanguage: String? = null + var staffNameLanguage: String? = null + var airingNotifications: Boolean = false + var restrictMessagesToFollowing: Boolean = false + var scoreFormat: String? = null + var rowOrder: String? = null + var activityMergeTime: Int? = null + var timezone: String? = null + var animeCustomLists: List? = null + var mangaCustomLists: List? = null val sortBy = listOf( "SCORE_DESC", @@ -96,6 +109,86 @@ object Anilist { "Original Creator", "Story & Art", "Story" ) + val timeZone = listOf( + "(GMT-11:00) Pago Pago", + "(GMT-10:00) Hawaii Time", + "(GMT-09:00) Alaska Time", + "(GMT-08:00) Pacific Time", + "(GMT-07:00) Mountain Time", + "(GMT-06:00) Central Time", + "(GMT-05:00) Eastern Time", + "(GMT-04:00) Atlantic Time - Halifax", + "(GMT-03:00) Sao Paulo", + "(GMT-02:00) Mid-Atlantic", + "(GMT-01:00) Azores", + "(GMT+00:00) London", + "(GMT+01:00) Berlin", + "(GMT+02:00) Helsinki", + "(GMT+03:00) Istanbul", + "(GMT+04:00) Dubai", + "(GMT+04:30) Kabul", + "(GMT+05:00) Maldives", + "(GMT+05:30) India Standard Time", + "(GMT+05:45) Kathmandu", + "(GMT+06:00) Dhaka", + "(GMT+06:30) Cocos", + "(GMT+07:00) Bangkok", + "(GMT+08:00) Hong Kong", + "(GMT+08:30) Pyongyang", + "(GMT+09:00) Tokyo", + "(GMT+09:30) Central Time - Darwin", + "(GMT+10:00) Eastern Time - Brisbane", + "(GMT+10:30) Central Time - Adelaide", + "(GMT+11:00) Eastern Time - Melbourne, Sydney", + "(GMT+12:00) Nauru", + "(GMT+13:00) Auckland", + "(GMT+14:00) Kiritimati", + ) + + val titleLang = listOf( + "English (Attack on Titan)", + "Romaji (Shingeki no Kyojin)", + "Native (進撃の巨人)" + ) + + val staffNameLang = listOf( + "Romaji, Western Order (Killua Zoldyck)", + "Romaji (Zoldyck Killua)", + "Native (キルア=ゾルディック)" + ) + + val scoreFormats = listOf( + "100 Point (55/100)", + "10 Point Decimal (5.5/10)", + "10 Point (5/10)", + "5 Star (3/5)", + "3 Point Smiley :)" + ) + + val rowOrderMap = mapOf( + "Score" to "score", + "Title" to "title", + "Last Updated" to "updatedAt", + "Last Added" to "id" + ) + + val activityMergeTimeMap = mapOf( + "Never" to 0, + "30 mins" to 30, + "69 mins" to 69, + "1 hour" to 60, + "2 hours" to 120, + "3 hours" to 180, + "6 hours" to 360, + "12 hours" to 720, + "1 day" to 1440, + "2 days" to 2880, + "3 days" to 4320, + "1 week" to 10080, + "2 weeks" to 20160, + "Always" to 29160 + ) + private val cal: Calendar = Calendar.getInstance() private val currentYear = cal.get(Calendar.YEAR) private val currentSeason: Int = when (cal.get(Calendar.MONTH)) { @@ -106,6 +199,33 @@ object Anilist { else -> 0 } + fun getDisplayTimezone(apiTimezone: String, context: Context): String { + val noTimezone = context.getString(R.string.selected_no_time_zone) + val parts = apiTimezone.split(":") + if (parts.size != 2) return noTimezone + + val hours = parts[0].toIntOrNull() ?: 0 + val minutes = parts[1].toIntOrNull() ?: 0 + val sign = if (hours >= 0) "+" else "-" + val formattedHours = String.format(Locale.US, "%02d", abs(hours)) + val formattedMinutes = String.format(Locale.US, "%02d", minutes) + + val searchString = "(GMT$sign$formattedHours:$formattedMinutes)" + return timeZone.find { it.contains(searchString) } ?: noTimezone + } + + fun getApiTimezone(displayTimezone: String): String { + val regex = """\(GMT([+-])(\d{2}):(\d{2})\)""".toRegex() + val matchResult = regex.find(displayTimezone) + return if (matchResult != null) { + val (sign, hours, minutes) = matchResult.destructured + val formattedSign = if (sign == "+") "" else "-" + "$formattedSign$hours:$minutes" + } else { + "00:00" + } + } + private fun getSeason(next: Boolean): Pair { var newSeason = if (next) currentSeason + 1 else currentSeason - 1 var newYear = currentYear @@ -191,6 +311,7 @@ object Anilist { ) val remaining = json.headers["X-RateLimit-Remaining"]?.toIntOrNull() ?: -1 Logger.log("Remaining requests: $remaining") + println("Remaining requests: $remaining") if (json.code == 429) { val retry = json.headers["Retry-After"]?.toIntOrNull() ?: -1 val passedLimitReset = json.headers["X-RateLimit-Reset"]?.toLongOrNull() ?: 0 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 a8bd8904..de0f6e2f 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt @@ -3,16 +3,99 @@ package ani.dantotsu.connections.anilist import ani.dantotsu.connections.anilist.Anilist.executeQuery import ani.dantotsu.connections.anilist.api.FuzzyDate import ani.dantotsu.connections.anilist.api.Query +import ani.dantotsu.connections.anilist.api.ToggleLike import ani.dantotsu.currContext import com.google.gson.Gson -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject class AnilistMutations { + suspend fun updateSettings( + timezone: String? = null, + titleLanguage: String? = null, + staffNameLanguage: String? = null, + activityMergeTime: Int? = null, + airingNotifications: Boolean? = null, + displayAdultContent: Boolean? = null, + restrictMessagesToFollowing: Boolean? = null, + scoreFormat: String? = null, + rowOrder: String? = null, + ) { + val query = """ + mutation ( + ${"$"}timezone: String, + ${"$"}titleLanguage: UserTitleLanguage, + ${"$"}staffNameLanguage: UserStaffNameLanguage, + ${"$"}activityMergeTime: Int, + ${"$"}airingNotifications: Boolean, + ${"$"}displayAdultContent: Boolean, + ${"$"}restrictMessagesToFollowing: Boolean, + ${"$"}scoreFormat: ScoreFormat, + ${"$"}rowOrder: String + ) { + UpdateUser( + timezone: ${"$"}timezone, + titleLanguage: ${"$"}titleLanguage, + staffNameLanguage: ${"$"}staffNameLanguage, + activityMergeTime: ${"$"}activityMergeTime, + airingNotifications: ${"$"}airingNotifications, + displayAdultContent: ${"$"}displayAdultContent, + restrictMessagesToFollowing: ${"$"}restrictMessagesToFollowing, + scoreFormat: ${"$"}scoreFormat, + rowOrder: ${"$"}rowOrder, + ) { + id + options { + timezone + titleLanguage + staffNameLanguage + activityMergeTime + airingNotifications + displayAdultContent + restrictMessagesToFollowing + } + mediaListOptions { + scoreFormat + rowOrder + } + } + } + """.trimIndent() + + val variables = """ + { + ${timezone?.let { """"timezone":"$it"""" } ?: ""} + ${titleLanguage?.let { """"titleLanguage":"$it"""" } ?: ""} + ${staffNameLanguage?.let { """"staffNameLanguage":"$it"""" } ?: ""} + ${activityMergeTime?.let { """"activityMergeTime":$it""" } ?: ""} + ${airingNotifications?.let { """"airingNotifications":$it""" } ?: ""} + ${displayAdultContent?.let { """"displayAdultContent":$it""" } ?: ""} + ${restrictMessagesToFollowing?.let { """"restrictMessagesToFollowing":$it""" } ?: ""} + ${scoreFormat?.let { """"scoreFormat":"$it"""" } ?: ""} + ${rowOrder?.let { """"rowOrder":"$it"""" } ?: ""} + } + """.trimIndent().replace("\n", "").replace(""" """, "").replace(",}", "}") + + executeQuery(query, variables) + } + suspend fun toggleFav(anime: Boolean = true, id: Int) { - val query = - """mutation (${"$"}animeId: Int,${"$"}mangaId:Int) { ToggleFavourite(animeId:${"$"}animeId,mangaId:${"$"}mangaId){ anime { edges { id } } manga { edges { id } } } }""" + val query = """ + mutation (${"$"}animeId: Int, ${"$"}mangaId: Int) { + ToggleFavourite(animeId: ${"$"}animeId, mangaId: ${"$"}mangaId) { + anime { + edges { + id + } + } + manga { + edges { + id + } + } + } + } + """.trimIndent() val variables = if (anime) """{"animeId":"$id"}""" else """{"mangaId":"$id"}""" executeQuery(query, variables) } @@ -25,7 +108,17 @@ class AnilistMutations { FavType.STAFF -> "staffId" FavType.STUDIO -> "studioId" } - val query = """mutation{ToggleFavourite($filter:$id){anime{pageInfo{total}}}}""" + val query = """ + mutation { + ToggleFavourite($filter: $id) { + anime { + pageInfo { + total + } + } + } + } + """.trimIndent() val result = executeQuery(query) return result?.get("errors") == null && result != null } @@ -34,6 +127,51 @@ class AnilistMutations { ANIME, MANGA, CHARACTER, STAFF, STUDIO } + suspend fun deleteCustomList(name: String, type: String): Boolean { + val query = """ + mutation (${"$"}name: String, ${"$"}type: MediaType) { + DeleteCustomList(customList: ${"$"}name, type: ${"$"}type) { + deleted + } + } + """.trimIndent() + val variables = """ + { + "name": "$name", + "type": "$type" + } + """.trimIndent() + val result = executeQuery(query, variables) + return result?.get("errors") == null + } + + suspend fun updateCustomLists(animeCustomLists: List?, mangaCustomLists: List?): Boolean { + val query = """ + mutation (${"$"}animeListOptions: MediaListOptionsInput, ${"$"}mangaListOptions: MediaListOptionsInput) { + UpdateUser(animeListOptions: ${"$"}animeListOptions, mangaListOptions: ${"$"}mangaListOptions) { + mediaListOptions { + animeList { + customLists + } + mangaList { + customLists + } + } + } + } + """.trimIndent() + val variables = """ + { + ${animeCustomLists?.let { """"animeListOptions": {"customLists": ${Gson().toJson(it)}}""" } ?: ""} + ${if (animeCustomLists != null && mangaCustomLists != null) "," else ""} + ${mangaCustomLists?.let { """"mangaListOptions": {"customLists": ${Gson().toJson(it)}}""" } ?: ""} + } + """.trimIndent().replace("\n", "").replace(""" """, "").replace(",}", "}") + + val result = executeQuery(query, variables) + return result?.get("errors") == null + } + suspend fun editList( mediaID: Int, progress: Int? = null, @@ -46,14 +184,45 @@ class AnilistMutations { completedAt: FuzzyDate? = null, customList: List? = null ) { - val query = """ - mutation ( ${"$"}mediaID: Int, ${"$"}progress: Int,${"$"}private:Boolean,${"$"}repeat: Int, ${"$"}notes: String, ${"$"}customLists: [String], ${"$"}scoreRaw:Int, ${"$"}status:MediaListStatus, ${"$"}start:FuzzyDateInput${if (startedAt != null) "=" + startedAt.toVariableString() else ""}, ${"$"}completed:FuzzyDateInput${if (completedAt != null) "=" + completedAt.toVariableString() else ""} ) { - SaveMediaListEntry( mediaId: ${"$"}mediaID, progress: ${"$"}progress, repeat: ${"$"}repeat, notes: ${"$"}notes, private: ${"$"}private, scoreRaw: ${"$"}scoreRaw, status:${"$"}status, startedAt: ${"$"}start, completedAt: ${"$"}completed , customLists: ${"$"}customLists ) { - score(format:POINT_10_DECIMAL) startedAt{year month day} completedAt{year month day} + mutation ( + ${"$"}mediaID: Int, + ${"$"}progress: Int, + ${"$"}private: Boolean, + ${"$"}repeat: Int, + ${"$"}notes: String, + ${"$"}customLists: [String], + ${"$"}scoreRaw: Int, + ${"$"}status: MediaListStatus, + ${"$"}start: FuzzyDateInput${if (startedAt != null) "=" + startedAt.toVariableString() else ""}, + ${"$"}completed: FuzzyDateInput${if (completedAt != null) "=" + completedAt.toVariableString() else ""} + ) { + SaveMediaListEntry( + mediaId: ${"$"}mediaID, + progress: ${"$"}progress, + repeat: ${"$"}repeat, + notes: ${"$"}notes, + private: ${"$"}private, + scoreRaw: ${"$"}scoreRaw, + status: ${"$"}status, + startedAt: ${"$"}start, + completedAt: ${"$"}completed, + customLists: ${"$"}customLists + ) { + score(format: POINT_10_DECIMAL) + startedAt { + year + month + day + } + completedAt { + year + month + day + } } } - """.replace("\n", "").replace(""" """, "") + """.trimIndent() val variables = """{"mediaID":$mediaID ${if (private != null) ""","private":$private""" else ""} @@ -69,43 +238,168 @@ class AnilistMutations { } suspend fun deleteList(listId: Int) { - val query = "mutation(${"$"}id:Int){DeleteMediaListEntry(id:${"$"}id){deleted}}" + val query = """ + mutation(${"$"}id: Int) { + DeleteMediaListEntry(id: ${"$"}id) { + deleted + } + } + """.trimIndent() val variables = """{"id":"$listId"}""" executeQuery(query, variables) } - suspend fun rateReview(reviewId: Int, rating: String): Query.RateReviewResponse? { - val query = "mutation{RateReview(reviewId:$reviewId,rating:$rating){id mediaId mediaType summary body(asHtml:true)rating ratingAmount userRating score private siteUrl createdAt updatedAt user{id name bannerImage avatar{medium large}}}}" + val query = """ + mutation { + RateReview(reviewId: $reviewId, rating: $rating) { + id + mediaId + mediaType + summary + body(asHtml: true) + rating + ratingAmount + userRating + score + private + siteUrl + createdAt + updatedAt + user { + id + name + bannerImage + avatar { + medium + large + } + } + } + } + """.trimIndent() return executeQuery(query) } - suspend fun postActivity(text:String): String { + suspend fun toggleFollow(id: Int): Query.ToggleFollow? { + return executeQuery( + """ + mutation { + ToggleFollow(userId: $id) { + id + isFollowing + isFollower + } + } + """.trimIndent()) + } + + suspend fun toggleLike(id: Int, type: String): ToggleLike? { + return executeQuery( + """ + mutation Like { + ToggleLikeV2(id: $id, type: $type) { + __typename + } + } + """.trimIndent()) + } + + suspend fun postActivity(text: String, edit: Int? = null): String { val encodedText = text.stringSanitizer() - val query = "mutation{SaveTextActivity(text:$encodedText){siteUrl}}" + val query = """ + mutation { + SaveTextActivity(${if (edit != null) "id: $edit," else ""} text: $encodedText) { + siteUrl + } + } + """.trimIndent() val result = executeQuery(query) val errors = result?.get("errors") - return errors?.toString() - ?: (currContext()?.getString(ani.dantotsu.R.string.success) ?: "Success") + return errors?.toString() ?: (currContext()?.getString(ani.dantotsu.R.string.success) ?: "Success") + } + + suspend fun postMessage(userId: Int, text: String, edit: Int? = null, isPrivate: Boolean = false): String { + val encodedText = text.replace("", "").stringSanitizer() + val query = """ + mutation { + SaveMessageActivity( + ${if (edit != null) "id: $edit," else ""} + recipientId: $userId, + message: $encodedText, + private: $isPrivate + ) { + id + } + } + """.trimIndent() + val result = executeQuery(query) + val errors = result?.get("errors") + return errors?.toString() ?: (currContext()?.getString(ani.dantotsu.R.string.success) ?: "Success") + } + + suspend fun postReply(activityId: Int, text: String, edit: Int? = null): String { + val encodedText = text.stringSanitizer() + val query = """ + mutation { + SaveActivityReply( + ${if (edit != null) "id: $edit," else ""} + activityId: $activityId, + text: $encodedText + ) { + id + } + } + """.trimIndent() + val result = executeQuery(query) + val errors = result?.get("errors") + return errors?.toString() ?: (currContext()?.getString(ani.dantotsu.R.string.success) ?: "Success") } suspend fun postReview(summary: String, body: String, mediaId: Int, score: Int): String { val encodedSummary = summary.stringSanitizer() val encodedBody = body.stringSanitizer() - val query = "mutation{SaveReview(mediaId:$mediaId,summary:$encodedSummary,body:$encodedBody,score:$score){siteUrl}}" + val query = """ + mutation { + SaveReview( + mediaId: $mediaId, + summary: $encodedSummary, + body: $encodedBody, + score: $score + ) { + siteUrl + } + } + """.trimIndent() val result = executeQuery(query) val errors = result?.get("errors") - return errors?.toString() - ?: (currContext()?.getString(ani.dantotsu.R.string.success) ?: "Success") + return errors?.toString() ?: (currContext()?.getString(ani.dantotsu.R.string.success) ?: "Success") } - suspend fun postReply(activityId: Int, text: String): String { - val encodedText = text.stringSanitizer() - val query = "mutation{SaveActivityReply(activityId:$activityId,text:$encodedText){id}}" + suspend fun deleteActivityReply(activityId: Int): Boolean { + val query = """ + mutation { + DeleteActivityReply(id: $activityId) { + deleted + } + } + """.trimIndent() val result = executeQuery(query) val errors = result?.get("errors") - return errors?.toString() - ?: (currContext()?.getString(ani.dantotsu.R.string.success) ?: "Success") + return errors == null + } + + suspend fun deleteActivity(activityId: Int): Boolean { + val query = """ + mutation { + DeleteActivity(id: $activityId) { + deleted + } + } + """.trimIndent() + val result = executeQuery(query) + val errors = result?.get("errors") + return errors == null } private fun String.stringSanitizer(): String { 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 4315ad13..4c1ececd 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt @@ -8,11 +8,12 @@ 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.MediaEdge +import ani.dantotsu.connections.anilist.api.MediaList 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.ReplyResponse -import ani.dantotsu.connections.anilist.api.ToggleLike import ani.dantotsu.currContext import ani.dantotsu.isOnline import ani.dantotsu.logError @@ -27,6 +28,7 @@ import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -41,8 +43,8 @@ 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}}unreadNotificationCount}}""") + response = executeQuery( + """{Viewer{name options{timezone titleLanguage staffNameLanguage activityMergeTime airingNotifications displayAdultContent restrictMessagesToFollowing} avatar{medium} bannerImage id mediaListOptions{scoreFormat rowOrder animeList{customLists} mangaList{customLists}} statistics{anime{episodesWatched} manga{chaptersRead}} unreadNotificationCount}}""") }.also { println("time : $it") } val user = response?.data?.user ?: return false @@ -59,6 +61,27 @@ class AnilistQueries { val unread = PrefManager.getVal(PrefName.UnreadCommentNotifications) Anilist.unreadNotificationCount += unread Anilist.initialized = true + + user.options?.let { + Anilist.titleLanguage = it.titleLanguage.toString() + Anilist.staffNameLanguage = it.staffNameLanguage.toString() + Anilist.airingNotifications = it.airingNotifications ?: false + Anilist.restrictMessagesToFollowing = it.restrictMessagesToFollowing ?: false + Anilist.timezone = it.timezone + Anilist.activityMergeTime = it.activityMergeTime + } + user.mediaListOptions?.let { + Anilist.scoreFormat = it.scoreFormat.toString() + Anilist.rowOrder = it.rowOrder + + it.animeList?.let { animeList -> + Anilist.animeCustomLists = animeList.customLists + } + + it.mangaList?.let { mangaList -> + Anilist.mangaCustomLists = mangaList.customLists + } + } return true } @@ -75,7 +98,7 @@ class AnilistQueries { media.cameFromContinue = false val query = - """{Media(id:${media.id}){id favourites popularity episodes chapters mediaListEntry{id status score(format:POINT_100)progress private notes repeat customLists updatedAt startedAt{year month day}completedAt{year month day}}reviews(perPage:3, sort:SCORE_DESC){nodes{id mediaId mediaType summary body(asHtml:true) rating ratingAmount userRating score private siteUrl createdAt updatedAt user{id name bannerImage avatar{medium large}}}}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 voiceActors { id name { first middle last full native userPreferred } image { large medium } languageV2 } 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}}Page(page:1){pageInfo{total perPage currentPage lastPage hasNextPage}mediaList(isFollowing:true,sort:[STATUS],mediaId:${media.id}){id status score(format: POINT_100) progress progressVolumes user{id name avatar{large medium}}}}}""" + """{Media(id:${media.id}){id favourites popularity episodes chapters streamingEpisodes {title thumbnail url site} mediaListEntry{id status score(format:POINT_100)progress private notes repeat customLists updatedAt startedAt{year month day}completedAt{year month day}}reviews(perPage:3, sort:SCORE_DESC){nodes{id mediaId mediaType summary body(asHtml:true) rating ratingAmount userRating score private siteUrl createdAt updatedAt user{id name bannerImage avatar{medium large}}}}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 voiceActors { id name { first middle last full native userPreferred } image { large medium } languageV2 } 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}}Page(page:1){pageInfo{total perPage currentPage lastPage hasNextPage}mediaList(isFollowing:true,sort:[STATUS],mediaId:${media.id}){id status score(format: POINT_100) progress progressVolumes user{id name avatar{large medium}}}}}""" runBlocking { val anilist = async { var response = executeQuery(query, force = true) @@ -90,7 +113,7 @@ class AnilistQueries { media.popularity = fetchedMedia.popularity media.startDate = fetchedMedia.startDate media.endDate = fetchedMedia.endDate - + media.streamingEpisodes = fetchedMedia.streamingEpisodes if (fetchedMedia.genres != null) { media.genres = arrayListOf() fetchedMedia.genres?.forEach { i -> @@ -211,7 +234,7 @@ class AnilistQueries { } } } - if (fetchedMedia.reviews?.nodes != null){ + if (fetchedMedia.reviews?.nodes != null) { media.review = fetchedMedia.reviews!!.nodes as ArrayList } if (user?.mediaList?.isNotEmpty() == true) { @@ -376,9 +399,8 @@ class AnilistQueries { } return media } - private fun continueMediaQuery(type: String, status: String): String { - 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 } } } } } """ - } + + private suspend fun favMedia(anime: Boolean, id: Int? = Anilist.userid): ArrayList { var hasNextPage = true @@ -404,10 +426,68 @@ class AnilistQueries { return responseArray } + + + suspend fun getUserStatus(): ArrayList? { + val toShow: List = + PrefManager.getVal(PrefName.HomeLayout) + if (toShow.getOrNull(7) != true) return null + val query = """{Page1:${status(1)}Page2:${status(2)}}""" + val response = executeQuery(query) + val list = mutableListOf() + val threeDaysAgo = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, -3) + }.timeInMillis + if (response?.data?.page1 != null && response.data.page2 != null) { + val activities = listOf( + response.data.page1.activities, + response.data.page2.activities + ).asSequence().flatten() + .filter { it.typename != "MessageActivity" } + .filter { if (Anilist.adult) true else it.media?.isAdult != true } + .filter { it.createdAt * 1000L > threeDaysAgo }.toList() + .sortedByDescending { it.createdAt } + val anilistActivities = mutableListOf() + val groupedActivities = activities.groupBy { it.userId } + + groupedActivities.forEach { (_, userActivities) -> + val user = userActivities.firstOrNull()?.user + if (user != null) { + val userToAdd = User( + user.id, + user.name ?: "", + user.avatar?.medium, + user.bannerImage, + activity = userActivities.sortedBy { it.createdAt }.toList() + ) + if (user.id == Anilist.userid) { + anilistActivities.add(0, userToAdd) + + } else { + list.add(userToAdd) + } + } + } + + if (anilistActivities.isEmpty() && Anilist.token != null) { + anilistActivities.add( + 0, + User( + Anilist.userid!!, + Anilist.username!!, + Anilist.avatar, + Anilist.bg, + activity = listOf() + ) + ) + } + list.addAll(0, anilistActivities) + return list.toCollection(ArrayList()) + } else return null + } 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}}}}}}""" } - private fun recommendationQuery(): String { return """ Page(page: 1, perPage:30) { pageInfo { total currentPage hasNextPage } recommendations(sort: RATING_DESC, onList: true) { rating userRating mediaRecommendation { id idMal isAdult mediaListEntry { progress private score(format:POINT_100) status } chapters isFavourite format episodes nextAiringEpisode {episode} popularity meanScore isFavourite format title {english romaji userPreferred } type status(version: 2) bannerImage coverImage { large } } } } """ } @@ -415,250 +495,183 @@ class AnilistQueries { private fun recommendationPlannedQuery(type: String): String { 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> { + private fun continueMediaQuery(type: String, status: String): String { + 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 initHomePage(): Map> { val removeList = PrefManager.getCustomVal("removeList", setOf()) + val hidePrivate = PrefManager.getVal(PrefName.HidePrivate) val removedMedia = ArrayList() val toShow: List = - PrefManager.getVal(PrefName.HomeLayout) // anime continue, anime fav, anime planned, manga continue, manga fav, manga planned, recommendations - var query = """{""" - if (toShow.getOrNull(0) == true) query += """currentAnime: ${ - continueMediaQuery( - "ANIME", - "CURRENT" - ) - }, repeatingAnime: ${continueMediaQuery("ANIME", "REPEATING")}""" - if (toShow.getOrNull(1) == true) query += """favoriteAnime: ${favMediaQuery(true, 1)}""" - if (toShow.getOrNull(2) == true) query += """plannedAnime: ${ - continueMediaQuery( - "ANIME", - "PLANNING" - ) - }""" - if (toShow.getOrNull(3) == true) query += """currentManga: ${ - continueMediaQuery( - "MANGA", - "CURRENT" - ) - }, repeatingManga: ${continueMediaQuery("MANGA", "REPEATING")}""" - if (toShow.getOrNull(4) == true) query += """favoriteManga: ${favMediaQuery(false, 1)}""" - if (toShow.getOrNull(5) == true) query += """plannedManga: ${ - continueMediaQuery( - "MANGA", - "PLANNING" - ) - }""" - if (toShow.getOrNull(6) == true) query += """recommendationQuery: ${recommendationQuery()}, recommendationPlannedQueryAnime: ${ - recommendationPlannedQuery( - "ANIME" - ) - }, recommendationPlannedQueryManga: ${recommendationPlannedQuery("MANGA")}""" - if (toShow.getOrNull(7) == true) query += "Page1:${status(1)}Page2:${status(2)}" - query += """}""".trimEnd(',') + PrefManager.getVal(PrefName.HomeLayout) // list of booleans for what to show + val queries = mutableListOf() + if (toShow.getOrNull(0) == true) { + queries.add("""currentAnime: ${continueMediaQuery("ANIME", "CURRENT")}""") + queries.add("""repeatingAnime: ${continueMediaQuery("ANIME", "REPEATING")}""") + } + if (toShow.getOrNull(1) == true) queries.add("""favoriteAnime: ${favMediaQuery(true, 1)}""") + if (toShow.getOrNull(2) == true) queries.add( + """plannedAnime: ${ + continueMediaQuery( + "ANIME", + "PLANNING" + ) + }""" + ) + if (toShow.getOrNull(3) == true) { + queries.add("""currentManga: ${continueMediaQuery("MANGA", "CURRENT")}""") + queries.add("""repeatingManga: ${continueMediaQuery("MANGA", "REPEATING")}""") + } + if (toShow.getOrNull(4) == true) queries.add( + """favoriteManga: ${ + favMediaQuery( + false, + 1 + ) + }""" + ) + if (toShow.getOrNull(5) == true) queries.add( + """plannedManga: ${ + continueMediaQuery( + "MANGA", + "PLANNING" + ) + }""" + ) + if (toShow.getOrNull(6) == true) { + queries.add("""recommendationQuery: ${recommendationQuery()}""") + queries.add("""recommendationPlannedQueryAnime: ${recommendationPlannedQuery("ANIME")}""") + queries.add("""recommendationPlannedQueryManga: ${recommendationPlannedQuery("MANGA")}""") + } + + val query = "{${queries.joinToString(",")}}" val response = executeQuery(query, show = true) - val returnMap = mutableMapOf>() - fun current(type: String) { + val returnMap = mutableMapOf>() + + fun processMedia( + type: String, + currentMedia: List?, + repeatingMedia: List? + ) { val subMap = mutableMapOf() val returnArray = arrayListOf() - val current = - if (type == "Anime") response?.data?.currentAnime else response?.data?.currentManga - val repeating = - if (type == "Anime") response?.data?.repeatingAnime else response?.data?.repeatingManga - current?.lists?.forEach { li -> - li.entries?.reversed()?.forEach { - val m = Media(it) - if (m.id !in removeList) { - m.cameFromContinue = true - subMap[m.id] = m - } else { - removedMedia.add(m) - } + + (currentMedia ?: emptyList()).forEach { entry -> + val media = Media(entry) + if (media.id !in removeList && (!hidePrivate || !media.isListPrivate)) { + media.cameFromContinue = true + subMap[media.id] = media + } else { + removedMedia.add(media) } } - repeating?.lists?.forEach { li -> - li.entries?.reversed()?.forEach { - val m = Media(it) - if (m.id !in removeList) { - m.cameFromContinue = true - subMap[m.id] = m - } else { - removedMedia.add(m) - } + (repeatingMedia ?: emptyList()).forEach { entry -> + val media = Media(entry) + if (media.id !in removeList && (!hidePrivate || !media.isListPrivate)) { + media.cameFromContinue = true + subMap[media.id] = media + } else { + removedMedia.add(media) } } - if (type != "Anime") { + @Suppress("UNCHECKED_CAST") + val list = PrefManager.getNullableCustomVal( + "continue${type}List", + listOf(), + List::class.java + ) as List + if (list.isNotEmpty()) { + list.reversed().forEach { id -> + subMap[id]?.let { returnArray.add(it) } + } + subMap.values.forEach { + if (!returnArray.contains(it)) returnArray.add(it) + } + } else { returnArray.addAll(subMap.values) - returnMap["current$type"] = returnArray - return } - @Suppress("UNCHECKED_CAST") - 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) { - if (i.value !in returnArray) returnArray.add(i.value) - } - } else returnArray.addAll(subMap.values) returnMap["current$type"] = returnArray - } - fun planned(type: String) { - val subMap = mutableMapOf() - val returnArray = arrayListOf() - val current = - if (type == "Anime") response?.data?.plannedAnime else response?.data?.plannedManga - current?.lists?.forEach { li -> - li.entries?.reversed()?.forEach { - val m = Media(it) - if (m.id !in removeList) { - m.cameFromContinue = true - subMap[m.id] = m - } else { - removedMedia.add(m) - } - } - } - @Suppress("UNCHECKED_CAST") - 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) { - if (i.value !in returnArray) returnArray.add(i.value) - } - } else returnArray.addAll(subMap.values) - returnMap["planned$type"] = returnArray - } + if (toShow.getOrNull(0) == true) processMedia( + "Anime", + response?.data?.currentAnime?.lists?.flatMap { it.entries ?: emptyList() }?.reversed(), + response?.data?.repeatingAnime?.lists?.flatMap { it.entries ?: emptyList() }?.reversed() + ) + if (toShow.getOrNull(2) == true) processMedia( + "AnimePlanned", + response?.data?.plannedAnime?.lists?.flatMap { it.entries ?: emptyList() }?.reversed(), + null + ) + if (toShow.getOrNull(3) == true) processMedia( + "Manga", + response?.data?.currentManga?.lists?.flatMap { it.entries ?: emptyList() }?.reversed(), + response?.data?.repeatingManga?.lists?.flatMap { it.entries ?: emptyList() }?.reversed() + ) + if (toShow.getOrNull(5) == true) processMedia( + "MangaPlanned", + response?.data?.plannedManga?.lists?.flatMap { it.entries ?: emptyList() }?.reversed(), + null + ) - fun favorite(type: String) { - val favourites = - if (type == "Anime") response?.data?.favoriteAnime?.favourites else response?.data?.favoriteManga?.favourites - val apiMediaList = if (type == "Anime") favourites?.anime else favourites?.manga + fun processFavorites(type: String, favorites: List?) { val returnArray = arrayListOf() - apiMediaList?.edges?.forEach { - it.node?.let { i -> - val m = Media(i).apply { isFav = true } - if (m.id !in removeList) { - returnArray.add(m) + favorites?.forEach { edge -> + edge.node?.let { + val media = Media(it).apply { isFav = true } + if (media.id !in removeList && (!hidePrivate || !media.isListPrivate)) { + returnArray.add(media) } else { - removedMedia.add(m) + removedMedia.add(media) } } } returnMap["favorite$type"] = returnArray } - if (toShow.getOrNull(0) == true) { - current("Anime") - } - if (toShow.getOrNull(1) == true) { - favorite("Anime") - } - if (toShow.getOrNull(2) == true) { - planned("Anime") - } - if (toShow.getOrNull(3) == true) { - current("Manga") - } - if (toShow.getOrNull(4) == true) { - favorite("Manga") - } - if (toShow.getOrNull(5) == true) { - planned("Manga") - } + if (toShow.getOrNull(1) == true) processFavorites( + "Anime", + response?.data?.favoriteAnime?.favourites?.anime?.edges + ) + if (toShow.getOrNull(4) == true) processFavorites( + "Manga", + response?.data?.favoriteManga?.favourites?.manga?.edges + ) + if (toShow.getOrNull(6) == true) { val subMap = mutableMapOf() - response?.data?.recommendationQuery?.apply { - recommendations?.onEach { - val json = it.mediaRecommendation - if (json != null) { - val m = Media(json) - m.relation = json.type?.toString() - subMap[m.id] = m - } + response?.data?.recommendationQuery?.recommendations?.forEach { + it.mediaRecommendation?.let { json -> + val media = Media(json) + media.relation = json.type?.toString() + subMap[media.id] = media } } - response?.data?.recommendationPlannedQueryAnime?.apply { - lists?.forEach { li -> - li.entries?.forEach { - val m = Media(it) - if (m.status == "RELEASING" || m.status == "FINISHED") { - m.relation = it.media?.type?.toString() - subMap[m.id] = m - } - } + response?.data?.recommendationPlannedQueryAnime?.lists?.flatMap { + it.entries ?: emptyList() + }?.forEach { + val media = Media(it) + if (media.status in listOf("RELEASING", "FINISHED")) { + media.relation = it.media?.type?.toString() + subMap[media.id] = media } } - response?.data?.recommendationPlannedQueryManga?.apply { - lists?.forEach { li -> - li.entries?.forEach { - val m = Media(it) - if (m.status == "RELEASING" || m.status == "FINISHED") { - m.relation = it.media?.type?.toString() - subMap[m.id] = m - } - } + response?.data?.recommendationPlannedQueryManga?.lists?.flatMap { + it.entries ?: emptyList() + }?.forEach { + val media = Media(it) + if (media.status in listOf("RELEASING", "FINISHED")) { + media.relation = it.media?.type?.toString() + subMap[media.id] = media } } - val list = ArrayList(subMap.values.toList()) - list.sortByDescending { it.meanScore } + val list = ArrayList(subMap.values).apply { sortByDescending { it.meanScore } } returnMap["recommendations"] = list } - if (toShow.getOrNull(7) == true) { - val list = mutableListOf() - val threeDaysAgo = Calendar.getInstance().apply { - add(Calendar.DAY_OF_MONTH, -3) - }.timeInMillis - if (response?.data?.page1 != null && response.data.page2 != null) { - val activities = listOf( - response.data.page1.activities, - response.data.page2.activities - ).asSequence().flatten() - .filter { it.typename != "MessageActivity" } - .filter { if (Anilist.adult) true else it.media?.isAdult == false } - .filter { it.createdAt * 1000L > threeDaysAgo }.toList() - .sortedByDescending { it.createdAt } - val anilistActivities = mutableListOf() - val groupedActivities = activities.groupBy { it.userId } - groupedActivities.forEach { (_, userActivities) -> - val user = userActivities.firstOrNull()?.user - if (user != null) { - val userToAdd = User( - user.id, - user.name ?: "", - user.avatar?.medium, - user.bannerImage, - activity = userActivities.sortedBy { it.createdAt }.toList() - ) - if (user.id == Anilist.userid) { - anilistActivities.add(0, userToAdd) - } else { - list.add(userToAdd) - } - } - } - - - list.addAll(0, anilistActivities) - returnMap["status"] = ArrayList(list) - } - returnMap["hidden"] = removedMedia.distinctBy { it.id } as ArrayList - } + returnMap["hidden"] = removedMedia.distinctBy { it.id }.toCollection(arrayListOf()) return returnMap } @@ -1034,153 +1047,105 @@ query (${"$"}page: Int = 1, ${"$"}id: Int, ${"$"}type: MediaType, ${"$"}isAdult: return null } - private val onListAnime = - (if (PrefManager.getVal(PrefName.IncludeAnimeList)) "" else "onList:false").replace( - "\"", - "" - ) - private val isAdult = - (if (PrefManager.getVal(PrefName.AdultOnly)) "isAdult:true" else "").replace("\"", "") + private fun mediaList(media1: Page?): ArrayList { + val combinedList = arrayListOf() + media1?.media?.mapTo(combinedList) { Media(it) } + return combinedList + } + + private fun getPreference(pref: PrefName): Boolean = PrefManager.getVal(pref) + + private fun buildQueryString( + sort: String, + type: String, + format: String? = null, + country: String? = null + ): String { + val includeList = when { + type == "ANIME" && !getPreference(PrefName.IncludeAnimeList) -> "onList:false" + type == "MANGA" && !getPreference(PrefName.IncludeMangaList) -> "onList:false" + else -> "" + } + val isAdult = if (getPreference(PrefName.AdultOnly)) "isAdult:true" else "" + val formatFilter = format?.let { "format:$it, " } ?: "" + val countryFilter = country?.let { "countryOfOrigin:$it, " } ?: "" + + return buildString { + append("""Page(page:1,perPage:50){pageInfo{hasNextPage total}media(sort:$sort, type:$type, $formatFilter $countryFilter $includeList $isAdult){id idMal status chapters episodes nextAiringEpisode{episode} isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large} title{english romaji userPreferred} mediaListEntry{progress private score(format:POINT_100) status}}}""") + } + } private fun recentAnimeUpdates(page: Int): String { - return """Page(page:$page,perPage:50){pageInfo{hasNextPage total}airingSchedules(airingAt_greater:0 airingAt_lesser:${System.currentTimeMillis() / 1000 - 10000} sort:TIME_DESC){episode airingAt media{id idMal status chapters episodes nextAiringEpisode{episode} isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large} title{english romaji userPreferred} mediaListEntry{progress private score(format:POINT_100) status}}}}""" - } - - private fun trendingMovies(page: Int): String { - return """Page(page:$page,perPage:50){pageInfo{hasNextPage total}media(sort:POPULARITY_DESC, type: ANIME, format: MOVIE, $onListAnime, $isAdult){id idMal status chapters episodes nextAiringEpisode{episode}isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""" - } - - private fun topRatedAnime(page: Int): String { - return """Page(page:$page,perPage:50){pageInfo{hasNextPage total}media(sort: SCORE_DESC, type: ANIME, $onListAnime, $isAdult){id idMal status chapters episodes nextAiringEpisode{episode}isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""" - } - - private fun mostFavAnime(page: Int): String { - return """Page(page:$page,perPage:50){pageInfo{hasNextPage total}media(sort:FAVOURITES_DESC,type: ANIME, $onListAnime, $isAdult){id idMal status chapters episodes nextAiringEpisode{episode}isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""" - } - - suspend fun loadAnimeList(): Map> { - val list = mutableMapOf>() - fun query(): String { - return """{ - recentUpdates:${recentAnimeUpdates(1)} - recentUpdates2:${recentAnimeUpdates(2)} - trendingMovies:${trendingMovies(1)} - trendingMovies2:${trendingMovies(2)} - topRated:${topRatedAnime(1)} - topRated2:${topRatedAnime(2)} - mostFav:${mostFavAnime(1)} - mostFav2:${mostFavAnime(2)} - }""".trimIndent() + val currentTime = System.currentTimeMillis() / 1000 + return buildString { + append("""Page(page:$page,perPage:50){pageInfo{hasNextPage total}airingSchedules(airingAt_greater:0 airingAt_lesser:${currentTime - 10000} sort:TIME_DESC){episode airingAt media{id idMal status chapters episodes nextAiringEpisode{episode} isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large} title{english romaji userPreferred} mediaListEntry{progress private score(format:POINT_100) status}}}}""") } - executeQuery(query(), force = true)?.data?.apply { - val listOnly: Boolean = PrefManager.getVal(PrefName.RecentlyListOnly) - val adultOnly: Boolean = PrefManager.getVal(PrefName.AdultOnly) - val idArr = mutableListOf() - list["recentUpdates"] = recentUpdates?.airingSchedules?.mapNotNull { i -> - i.media?.let { - if (!idArr.contains(it.id)) - if (!listOnly && it.countryOfOrigin == "JP" && Anilist.adult && adultOnly && it.isAdult == true) { - idArr.add(it.id) - Media(it) - } else if (!listOnly && !adultOnly && (it.countryOfOrigin == "JP" && it.isAdult == false)) { - idArr.add(it.id) - Media(it) - } else if ((listOnly && it.mediaListEntry != null)) { - idArr.add(it.id) - Media(it) - } else null - else null + } + + private fun queryAnimeList(): String { + return buildString { + append("""{recentUpdates:${recentAnimeUpdates(1)} recentUpdates2:${recentAnimeUpdates(2)} trendingMovies:${buildQueryString("POPULARITY_DESC", "ANIME", "MOVIE")} topRated:${buildQueryString("SCORE_DESC", "ANIME")} mostFav:${buildQueryString("FAVOURITES_DESC", "ANIME")}}""") + } + } + + private fun queryMangaList(): String { + return buildString { + append("""{trendingManga:${buildQueryString("POPULARITY_DESC", "MANGA", country = "JP")} trendingManhwa:${buildQueryString("POPULARITY_DESC", "MANGA", country = "KR")} trendingNovel:${buildQueryString("POPULARITY_DESC", "MANGA", format = "NOVEL", country = "JP")} topRated:${buildQueryString("SCORE_DESC", "MANGA")} mostFav:${buildQueryString("FAVOURITES_DESC", "MANGA")}}""") + } + } + + suspend fun loadAnimeList(): Map> = coroutineScope { + val list = mutableMapOf>() + + fun filterRecentUpdates(page: Page?): ArrayList { + val listOnly = getPreference(PrefName.RecentlyListOnly) + val adultOnly = getPreference(PrefName.AdultOnly) + val idArr = mutableSetOf() + return page?.airingSchedules?.mapNotNull { i -> + i.media?.takeIf { !idArr.contains(it.id) }?.let { + val shouldAdd = when { + !listOnly && it.countryOfOrigin == "JP" && adultOnly && it.isAdult == true -> true + !listOnly && !adultOnly && it.countryOfOrigin == "JP" && it.isAdult == false -> true + listOnly && it.mediaListEntry != null -> true + else -> false + } + if (shouldAdd) { + idArr.add(it.id) + Media(it) + } else null } }?.toCollection(ArrayList()) ?: arrayListOf() - - list["trendingMovies"] = trendingMovies?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf() - list["topRated"] = topRated?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf() - list["mostFav"] = mostFav?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf() - - list["recentUpdates"]?.addAll(recentUpdates2?.airingSchedules?.mapNotNull { i -> - i.media?.let { - if (!idArr.contains(it.id)) - if (!listOnly && it.countryOfOrigin == "JP" && Anilist.adult && adultOnly && it.isAdult == true) { - idArr.add(it.id) - Media(it) - } else if (!listOnly && !adultOnly && (it.countryOfOrigin == "JP" && it.isAdult == false)) { - idArr.add(it.id) - Media(it) - } else if ((listOnly && it.mediaListEntry != null)) { - idArr.add(it.id) - Media(it) - } else null - else null - } - }?.toCollection(ArrayList()) ?: arrayListOf()) - list["trendingMovies"]?.addAll(trendingMovies2?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf()) - list["topRated"]?.addAll(topRated2?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf()) - list["mostFav"]?.addAll(mostFav2?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf()) } - return list + + val animeList = async { executeQuery(queryAnimeList(), force = true) } + + animeList.await()?.data?.apply { + list["recentUpdates"] = filterRecentUpdates(recentUpdates) + list["trendingMovies"] = mediaList(trendingMovies) + list["topRated"] = mediaList(topRated) + list["mostFav"] = mediaList(mostFav) + } + + list } - private val onListManga = - (if (PrefManager.getVal(PrefName.IncludeMangaList)) "" else "onList:false").replace( - "\"", - "" - ) - - private fun trendingManga(page: Int): String { - return """Page(page:$page,perPage:50){pageInfo{hasNextPage total}media(sort:POPULARITY_DESC, type: MANGA,countryOfOrigin:JP, $onListManga, $isAdult){id idMal status chapters episodes nextAiringEpisode{episode}isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""" - } - - private fun trendingManhwa(page: Int): String { - return """Page(page:$page,perPage:50){pageInfo{hasNextPage total}media(sort:POPULARITY_DESC, type: MANGA, countryOfOrigin:KR, $onListManga, $isAdult){id idMal status chapters episodes nextAiringEpisode{episode}isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""" - } - - private fun trendingNovel(page: Int): String { - return """Page(page:$page,perPage:50){pageInfo{hasNextPage total}media(sort:POPULARITY_DESC, type: MANGA, format: NOVEL, countryOfOrigin:JP, $onListManga, $isAdult){id idMal status chapters episodes nextAiringEpisode{episode}isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""" - } - - private fun topRatedManga(page: Int): String { - return """Page(page:$page,perPage:50){pageInfo{hasNextPage total}media(sort: SCORE_DESC, type: MANGA, $onListManga, $isAdult){id idMal status chapters episodes nextAiringEpisode{episode}isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""" - } - - private fun mostFavManga(page: Int): String { - return """Page(page:$page,perPage:50){pageInfo{hasNextPage total}media(sort:FAVOURITES_DESC,type: MANGA, $onListManga, $isAdult){id idMal status chapters episodes nextAiringEpisode{episode}isAdult type meanScore isFavourite format bannerImage countryOfOrigin coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""" - } - - suspend fun loadMangaList(): Map> { + suspend fun loadMangaList(): Map> = coroutineScope { val list = mutableMapOf>() - fun query(): String { - return """{ - trendingManga:${trendingManga(1)} - trendingManga2:${trendingManga(2)} - trendingManhwa:${trendingManhwa(1)} - trendingManhwa2:${trendingManhwa(2)} - trendingNovel:${trendingNovel(1)} - trendingNovel2:${trendingNovel(2)} - topRated:${topRatedManga(1)} - topRated2:${topRatedManga(2)} - mostFav:${mostFavManga(1)} - mostFav2:${mostFavManga(2)} - }""".trimIndent() + + val mangaList = async { executeQuery(queryMangaList(), force = true) } + + mangaList.await()?.data?.apply { + list["trendingManga"] = mediaList(trendingManga) + list["trendingManhwa"] = mediaList(trendingManhwa) + list["trendingNovel"] = mediaList(trendingNovel) + list["topRated"] = mediaList(topRated) + list["mostFav"] = mediaList(mostFav) } - executeQuery(query(), force = true)?.data?.apply { - list["trendingManga"] = trendingManga?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf() - list["trendingManhwa"] = trendingManhwa?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf() - list["trendingNovel"] = trendingNovel?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf() - list["topRated"] = topRated?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf() - list["mostFav"] = mostFav?.media?.map { Media(it) }?.toCollection(ArrayList()) ?: arrayListOf() - - list["trendingManga"]?.addAll(trendingManga2?.media?.map { Media(it) }?.toList() ?: arrayListOf()) - list["trendingManhwa"]?.addAll(trendingManhwa2?.media?.map { Media(it) }?.toList() ?: arrayListOf()) - list["trendingNovel"]?.addAll(trendingNovel2?.media?.map { Media(it) }?.toList() ?: arrayListOf()) - list["topRated"]?.addAll(topRated2?.media?.map { Media(it) }?.toList() ?: arrayListOf()) - list["mostFav"]?.addAll(mostFav2?.media?.map { Media(it) }?.toList() ?: arrayListOf()) - } - - - return list + list } + suspend fun recentlyUpdated( greater: Long = 0, lesser: Long = System.currentTimeMillis() / 1000 - 10000 @@ -1509,25 +1474,17 @@ Page(page:$page,perPage:50) { return author } - suspend fun getReviews(mediaId: Int, page: Int = 1, sort: String = "SCORE_DESC"): Query.ReviewsResponse? { + suspend fun getReviews( + mediaId: Int, + page: Int = 1, + sort: String = "SCORE_DESC" + ): Query.ReviewsResponse? { return executeQuery( """{Page(page:$page,perPage:10){pageInfo{currentPage,hasNextPage,total}reviews(mediaId:$mediaId,sort:$sort){id,mediaId,mediaType,summary,body(asHtml:true)rating,ratingAmount,userRating,score,private,siteUrl,createdAt,updatedAt,user{id,name,bannerImage avatar{medium,large}}}}}""", force = true ) } - 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}}""", @@ -1586,11 +1543,13 @@ Page(page:$page,perPage:50) { suspend fun getNotifications( id: Int, page: Int = 1, - resetNotification: Boolean = true + resetNotification: Boolean = true, + type: Boolean? = null ): NotificationResponse? { + val typeIn = "type_in:[AIRING,MEDIA_MERGE,MEDIA_DELETION,MEDIA_DATA_CHANGE]" 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,}}}}""", + """{User(id:$id){unreadNotificationCount}Page(page:$page,perPage:$ITEMS_PER_PAGE){pageInfo{currentPage,hasNextPage}notifications(resetNotificationCount:$reset , ${if (type == true) typeIn else ""}){__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) { @@ -1612,8 +1571,9 @@ Page(page:$page,perPage:50) { else if (userId != null) "userId:$userId," else if (global) "isFollowing:false,hasRepliesOrTypeText:true," else "isFollowing:true," + val typeIn = if (filter == "isFollowing:true,") "type_in:[TEXT,ANIME_LIST,MANGA_LIST,MEDIA_LIST]," else "" 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}isAdult}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}}}}}}""", + """{Page(page:$page,perPage:$ITEMS_PER_PAGE){activities(${filter}${typeIn}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}isAdult}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 ) } @@ -1621,13 +1581,14 @@ Page(page:$page,perPage:50) { suspend fun getReplies( activityId: Int, page: Int = 1 - ) : ReplyResponse? { - val query = """{Page(page:$page,perPage:50){activityReplies(activityId:$activityId){id userId activityId text(asHtml:true)likeCount isLiked createdAt user{id name bannerImage avatar{medium large}}likes{id name bannerImage avatar{medium large}}}}}""" + ): ReplyResponse? { + val query = + """{Page(page:$page,perPage:50){activityReplies(activityId:$activityId){id userId activityId text(asHtml:true)likeCount isLiked createdAt user{id name bannerImage avatar{medium large}}likes{id name bannerImage avatar{medium large}}}}}""" return executeQuery(query, force = true) } private fun status(page: Int = 1): String { - return """Page(page:$page,perPage:50){activities(isFollowing: true,sort:ID_DESC){__typename ... on TextActivity{id userId type replyCount text(asHtml:true)siteUrl isLocked isSubscribed replyCount likeCount isLiked createdAt user{id name bannerImage avatar{medium large}}likes{id name bannerImage avatar{medium large}}}... on ListActivity{id userId type replyCount status progress siteUrl isLocked isSubscribed replyCount likeCount isLiked isPinned createdAt user{id name bannerImage avatar{medium large}}media{id isAdult title{english romaji native userPreferred}bannerImage coverImage{extraLarge medium large}}likes{id name bannerImage avatar{medium large}}}... on MessageActivity{id type createdAt}}}""" + return """Page(page:$page,perPage:50){activities(isFollowing: true,type_in:[TEXT,ANIME_LIST,MANGA_LIST,MEDIA_LIST],sort:ID_DESC){__typename ... on TextActivity{id userId type replyCount text(asHtml:true)siteUrl isLocked isSubscribed replyCount likeCount isLiked createdAt user{id name bannerImage avatar{medium large}}likes{id name bannerImage avatar{medium large}}}... on ListActivity{id userId type replyCount status progress siteUrl isLocked isSubscribed replyCount likeCount isLiked isPinned createdAt user{id name bannerImage avatar{medium large}}media{id isAdult title{english romaji native userPreferred}bannerImage coverImage{extraLarge medium large}}likes{id name bannerImage avatar{medium large}}}... on MessageActivity{id type createdAt}}}""" } suspend fun getUpcomingAnime(id: String): List { @@ -1674,4 +1635,4 @@ Page(page:$page,perPage:50) { 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 6cda8e7a..ee978214 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext suspend fun getUserId(context: Context, block: () -> Unit) { - if (!Anilist.initialized) { + if (!Anilist.initialized && PrefManager.getVal(PrefName.AnilistToken) != "") { if (Anilist.query.getUserData()) { tryWithSuspend { if (MAL.token != null && !MAL.query.getUserData()) @@ -81,24 +81,26 @@ class AnilistHomeViewModel : ViewModel() { MutableLiveData>(null) fun getUserStatus(): LiveData> = userStatus + suspend fun initUserStatus() { + val res = Anilist.query.getUserStatus() + res?.let { userStatus.postValue(it) } + } private val hidden: MutableLiveData> = MutableLiveData>(null) fun getHidden(): LiveData> = hidden - @Suppress("UNCHECKED_CAST") suspend fun initHomePage() { val res = Anilist.query.initHomePage() - res["currentAnime"]?.let { animeContinue.postValue(it as ArrayList?) } - res["favoriteAnime"]?.let { animeFav.postValue(it as ArrayList?) } - res["plannedAnime"]?.let { animePlanned.postValue(it as ArrayList?) } - res["currentManga"]?.let { mangaContinue.postValue(it as ArrayList?) } - res["favoriteManga"]?.let { mangaFav.postValue(it as ArrayList?) } - res["plannedManga"]?.let { mangaPlanned.postValue(it as ArrayList?) } - res["recommendations"]?.let { recommendation.postValue(it as ArrayList?) } - res["hidden"]?.let { hidden.postValue(it as ArrayList?) } - res["status"]?.let { userStatus.postValue(it as ArrayList?) } + res["currentAnime"]?.let { animeContinue.postValue(it) } + res["favoriteAnime"]?.let { animeFav.postValue(it) } + res["currentAnimePlanned"]?.let { animePlanned.postValue(it) } + res["currentManga"]?.let { mangaContinue.postValue(it) } + res["favoriteManga"]?.let { mangaFav.postValue(it) } + res["currentMangaPlanned"]?.let { mangaPlanned.postValue(it) } + res["recommendations"]?.let { recommendation.postValue(it) } + res["hidden"]?.let { hidden.postValue(it) } } suspend fun loadMain(context: FragmentActivity) { 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 e6635b9a..e909a3bc 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 @@ -163,13 +163,9 @@ class Query { @Serializable data class Data( @SerialName("recentUpdates") val recentUpdates: ani.dantotsu.connections.anilist.api.Page?, - @SerialName("recentUpdates2") val recentUpdates2: ani.dantotsu.connections.anilist.api.Page?, @SerialName("trendingMovies") val trendingMovies: ani.dantotsu.connections.anilist.api.Page?, - @SerialName("trendingMovies2") val trendingMovies2: ani.dantotsu.connections.anilist.api.Page?, @SerialName("topRated") val topRated: ani.dantotsu.connections.anilist.api.Page?, - @SerialName("topRated2") val topRated2: ani.dantotsu.connections.anilist.api.Page?, @SerialName("mostFav") val mostFav: ani.dantotsu.connections.anilist.api.Page?, - @SerialName("mostFav2") val mostFav2: ani.dantotsu.connections.anilist.api.Page?, ) } @@ -181,15 +177,10 @@ class Query { @Serializable data class Data( @SerialName("trendingManga") val trendingManga: ani.dantotsu.connections.anilist.api.Page?, - @SerialName("trendingManga2") val trendingManga2: ani.dantotsu.connections.anilist.api.Page?, @SerialName("trendingManhwa") val trendingManhwa: ani.dantotsu.connections.anilist.api.Page?, - @SerialName("trendingManhwa2") val trendingManhwa2: ani.dantotsu.connections.anilist.api.Page?, @SerialName("trendingNovel") val trendingNovel: ani.dantotsu.connections.anilist.api.Page?, - @SerialName("trendingNovel2") val trendingNovel2: ani.dantotsu.connections.anilist.api.Page?, @SerialName("topRated") val topRated: ani.dantotsu.connections.anilist.api.Page?, - @SerialName("topRated2") val topRated2: ani.dantotsu.connections.anilist.api.Page?, @SerialName("mostFav") val mostFav: ani.dantotsu.connections.anilist.api.Page?, - @SerialName("mostFav2") val mostFav2: ani.dantotsu.connections.anilist.api.Page?, ) } 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 23c79045..a54e3ad8 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 @@ -143,7 +143,7 @@ data class Media( @SerialName("externalLinks") var externalLinks: List?, // Data and links to legal streaming episodes on external sites - // @SerialName("streamingEpisodes") var streamingEpisodes: List?, + @SerialName("streamingEpisodes") var streamingEpisodes: List?, // The ranking of the media in a particular time span and format compared to other media // @SerialName("rankings") var rankings: List?, @@ -239,7 +239,20 @@ data class AiringSchedule( // The associate media of the airing episode @SerialName("media") var media: Media?, ) +@Serializable +data class MediaStreamingEpisode( + // The title of the episode + @SerialName("title") var title: String?, + // The thumbnail image of the episode + @SerialName("thumbnail") var thumbnail: String?, + + // The url of the episode + @SerialName("url") var url: String?, + + // The site location of the streaming episode + @SerialName("site") var site: String?, +) @Serializable data class MediaCoverImage( // The cover image url of the media at its largest size. If this size isn't available, large will be provided instead. 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 9117c144..ed20cb26 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 @@ -74,7 +74,7 @@ data class User( @Serializable data class UserOptions( // The language the user wants to see media titles in - // @SerialName("titleLanguage") var titleLanguage: UserTitleLanguage?, + @SerialName("titleLanguage") var titleLanguage: UserTitleLanguage?, // Whether the user has enabled viewing of 18+ content @SerialName("displayAdultContent") var displayAdultContent: Boolean?, @@ -88,17 +88,17 @@ data class UserOptions( // // Notification options // // @SerialName("notificationOptions") var notificationOptions: List?, // - // // The user's timezone offset (Auth user only) - // @SerialName("timezone") var timezone: String?, + // The user's timezone offset (Auth user only) + @SerialName("timezone") var timezone: String?, // - // // Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always. - // @SerialName("activityMergeTime") var activityMergeTime: Int?, + // Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always. + @SerialName("activityMergeTime") var activityMergeTime: Int?, // - // // The language the user wants to see staff and character names in - // // @SerialName("staffNameLanguage") var staffNameLanguage: UserStaffNameLanguage?, + // The language the user wants to see staff and character names in + @SerialName("staffNameLanguage") var staffNameLanguage: UserStaffNameLanguage?, // - // // Whether the user only allow messages from users they follow - // @SerialName("restrictMessagesToFollowing") var restrictMessagesToFollowing: Boolean?, + // Whether the user only allow messages from users they follow + @SerialName("restrictMessagesToFollowing") var restrictMessagesToFollowing: Boolean?, // The list activity types the user has disabled from being created from list updates // @SerialName("disabledListActivity") var disabledListActivity: List?, @@ -119,6 +119,40 @@ data class UserStatisticTypes( @SerialName("manga") var manga: UserStatistics? ) +@Serializable +enum class UserTitleLanguage { + @SerialName("ENGLISH") + ENGLISH, + @SerialName("ROMAJI") + ROMAJI, + @SerialName("NATIVE") + NATIVE +} + +@Serializable +enum class UserStaffNameLanguage { + @SerialName("ROMAJI_WESTERN") + ROMAJI_WESTERN, + @SerialName("ROMAJI") + ROMAJI, + @SerialName("NATIVE") + NATIVE +} + +@Serializable +enum class ScoreFormat { + @SerialName("POINT_100") + POINT_100, + @SerialName("POINT_10_DECIMAL") + POINT_10_DECIMAL, + @SerialName("POINT_10") + POINT_10, + @SerialName("POINT_5") + POINT_5, + @SerialName("POINT_3") + POINT_3, +} + @Serializable data class UserStatistics( // @@ -164,7 +198,7 @@ data class Favourites( @Serializable data class MediaListOptions( // The score format the user is using for media lists - @SerialName("scoreFormat") var scoreFormat: String?, + @SerialName("scoreFormat") var scoreFormat: ScoreFormat?, // The default order list rows should be displayed in @SerialName("rowOrder") var rowOrder: String?, @@ -181,8 +215,8 @@ data class MediaListTypeOptions( // The order each list should be displayed in @SerialName("sectionOrder") var sectionOrder: List?, - // If the completed sections of the list should be separated by format - @SerialName("splitCompletedSectionByFormat") var splitCompletedSectionByFormat: Boolean?, + // // If the completed sections of the list should be separated by format + // @SerialName("splitCompletedSectionByFormat") var splitCompletedSectionByFormat: Boolean?, // The names of the user's custom lists @SerialName("customLists") var customLists: List?, diff --git a/app/src/main/java/ani/dantotsu/connections/bakaupdates/MangaUpdates.kt b/app/src/main/java/ani/dantotsu/connections/bakaupdates/MangaUpdates.kt deleted file mode 100644 index 53a88815..00000000 --- a/app/src/main/java/ani/dantotsu/connections/bakaupdates/MangaUpdates.kt +++ /dev/null @@ -1,133 +0,0 @@ -package ani.dantotsu.connections.bakaupdates - -import android.content.Context -import ani.dantotsu.R -import ani.dantotsu.client -import ani.dantotsu.connections.anilist.api.FuzzyDate -import ani.dantotsu.tryWithSuspend -import ani.dantotsu.util.Logger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import okio.ByteString.Companion.encode -import org.json.JSONException -import org.json.JSONObject -import java.nio.charset.Charset - - -class MangaUpdates { - - private val Int?.dateFormat get() = String.format("%02d", this) - - private val apiUrl = "https://api.mangaupdates.com/v1/releases/search" - - suspend fun search(title: String, startDate: FuzzyDate?): MangaUpdatesResponse.Results? { - return tryWithSuspend { - val query = JSONObject().apply { - try { - put("search", title.encode(Charset.forName("UTF-8"))) - startDate?.let { - put( - "start_date", - "${it.year}-${it.month.dateFormat}-${it.day.dateFormat}" - ) - } - put("include_metadata", true) - } catch (e: JSONException) { - e.printStackTrace() - } - } - val res = try { - client.post(apiUrl, json = query).parsed() - } catch (e: Exception) { - Logger.log(e.toString()) - return@tryWithSuspend null - } - coroutineScope { - res.results?.map { - async(Dispatchers.IO) { - Logger.log(it.toString()) - } - } - }?.awaitAll() - res.results?.first { - it.metadata.series.lastUpdated?.timestamp != null - && (it.metadata.series.latestChapter != null - || (it.record.volume.isNullOrBlank() && it.record.chapter != null)) - } - } - } - - companion object { - fun getLatestChapter(context: Context, results: MangaUpdatesResponse.Results): String { - return results.metadata.series.latestChapter?.let { - context.getString(R.string.chapter_number, it) - } ?: results.record.chapter!!.substringAfterLast("-").trim().let { chapter -> - chapter.takeIf { - it.toIntOrNull() == null - } ?: context.getString(R.string.chapter_number, chapter.toInt()) - } - } - } - - @Serializable - data class MangaUpdatesResponse( - @SerialName("total_hits") - val totalHits: Int?, - @SerialName("page") - val page: Int?, - @SerialName("per_page") - val perPage: Int?, - val results: List? = null - ) { - @Serializable - data class Results( - val record: Record, - val metadata: MetaData - ) { - @Serializable - data class Record( - @SerialName("id") - val id: Int, - @SerialName("title") - val title: String, - @SerialName("volume") - val volume: String?, - @SerialName("chapter") - val chapter: String?, - @SerialName("release_date") - val releaseDate: String - ) - - @Serializable - data class MetaData( - val series: Series - ) { - @Serializable - data class Series( - @SerialName("series_id") - val seriesId: Long?, - @SerialName("title") - val title: String?, - @SerialName("latest_chapter") - val latestChapter: Int?, - @SerialName("last_updated") - val lastUpdated: LastUpdated? - ) { - @Serializable - data class LastUpdated( - @SerialName("timestamp") - val timestamp: Long, - @SerialName("as_rfc3339") - val asRfc3339: String, - @SerialName("as_string") - val asString: 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 index 57d08531..8f6fb98d 100644 --- a/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt +++ b/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt @@ -27,8 +27,11 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get object CommentsAPI { - private const val ADDRESS: String = "https://api.dantotsu.app" + private const val API_ADDRESS: String = "https://api.dantotsu.app" + private const val LOCAL_HOST: String = "https://127.0.0.1" private var isOnline: Boolean = true + private var commentsEnabled = PrefManager.getVal(PrefName.CommentsEnabled) == 1 + private val ADDRESS: String get() = if (commentsEnabled) API_ADDRESS else LOCAL_HOST var authToken: String? = null var userId: String? = null var isBanned: Boolean = false @@ -369,10 +372,9 @@ object CommentsAPI { } errorMessage("Failed to login after multiple attempts") } - private fun errorMessage(reason: String) { - Logger.log(reason) - if (isOnline) snackString(reason) + if (commentsEnabled) Logger.log(reason) + if (isOnline && commentsEnabled) snackString(reason) } fun logout() { 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 9318e5e6..f6e791f4 100644 --- a/app/src/main/java/ani/dantotsu/connections/discord/Discord.kt +++ b/app/src/main/java/ani/dantotsu/connections/discord/Discord.kt @@ -70,7 +70,7 @@ object Discord { const val application_Id = "1163925779692912771" const val small_Image: String = - "mp:external/GJEe4hKzr8w56IW6ZKQz43HFVEo8pOtA_C-dJiWwxKo/https/cdn.discordapp.com/app-icons/1163925779692912771/f6b42d41dfdf0b56fcc79d4a12d2ac66.png" + "mp:external/9NqpMxXs4ZNQtMG42L7hqINW92GqqDxgxS9Oh0Sp880/%3Fsize%3D48%26quality%3Dlossless%26name%3DDantotsu/https/cdn.discordapp.com/emojis/1167344924874784828.gif" const val small_Image_AniList: String = - "mp:external/rHOIjjChluqQtGyL_UHk6Z4oAqiVYlo_B7HSGPLSoUg/%3Fsize%3D128/https/cdn.discordapp.com/icons/210521487378087947/a_f54f910e2add364a3da3bb2f2fce0c72.webp" + "https://anilist.co/img/icons/android-chrome-512x512.png" } \ No newline at end of file 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 f46dd63d..468e75cf 100644 --- a/app/src/main/java/ani/dantotsu/connections/discord/RPC.kt +++ b/app/src/main/java/ani/dantotsu/connections/discord/RPC.kt @@ -1,24 +1,19 @@ package ani.dantotsu.connections.discord +import ani.dantotsu.connections.discord.Discord.token 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 +import okhttp3.OkHttpClient import kotlin.coroutines.CoroutineContext -import ani.dantotsu.client as app +import java.util.concurrent.TimeUnit.SECONDS @Suppress("MemberVisibilityCanBePrivate") open class RPC(val token: String, val coroutineContext: CoroutineContext) { - private val json = Json { - encodeDefaults = true - allowStructuredMapKeys = true - ignoreUnknownKeys = true - } - enum class Type { PLAYING, STREAMING, LISTENING, WATCHING, COMPETING } @@ -27,7 +22,7 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) { companion object { data class RPCData( - val applicationId: String? = null, + val applicationId: String, val type: Type? = null, val activityName: String? = null, val details: String? = null, @@ -39,23 +34,21 @@ open class RPC(val token: String, val coroutineContext: CoroutineContext) { val stopTimestamp: Long? = null, val buttons: MutableList = mutableListOf() ) - - @Serializable - data class KizzyApi(val id: String) - - val api = "https://kizzy-api.vercel.app/image?url=" - private suspend fun String.discordUrl(): String? { - if (startsWith("mp:")) return this - val json = app.get("$api$this").parsedSafe() - return json?.id - } - suspend fun createPresence(data: RPCData): String { val json = Json { encodeDefaults = true allowStructuredMapKeys = true ignoreUnknownKeys = true } + val client = OkHttpClient.Builder() + .connectTimeout(10, SECONDS) + .readTimeout(10, SECONDS) + .writeTimeout(10, SECONDS) + .build() + + val assetApi = RPCExternalAsset(data.applicationId, token!!, client, json) + suspend fun String.discordUrl() = assetApi.getDiscordUri(this) + return json.encodeToString(Presence.Response( 3, Presence( diff --git a/app/src/main/java/ani/dantotsu/connections/discord/RpcExternalAsset.kt b/app/src/main/java/ani/dantotsu/connections/discord/RpcExternalAsset.kt new file mode 100644 index 00000000..c16f4b82 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/connections/discord/RpcExternalAsset.kt @@ -0,0 +1,59 @@ +package ani.dantotsu.connections.discord + + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class RPCExternalAsset( + applicationId: String, + private val token: String, + private val client: OkHttpClient, + private val json: Json +) { + + @Serializable + data class ExternalAsset( + val url: String? = null, + @SerialName("external_asset_path") + val externalAssetPath: String? = null + ) + + private val api = "https://discord.com/api/v9/applications/$applicationId/external-assets" + suspend fun getDiscordUri(imageUrl: String): String? { + if (imageUrl.startsWith("mp:")) return imageUrl + val request = Request.Builder().url(api).header("Authorization", token) + .post("{\"urls\":[\"$imageUrl\"]}".toRequestBody("application/json".toMediaType())) + .build() + return runCatching { + val res = client.newCall(request).await() + json.decodeFromString>(res.body.string()) + .firstOrNull()?.externalAssetPath?.let { "mp:$it" } + }.getOrNull() + } + + private suspend inline fun Call.await(): Response { + return suspendCoroutine { + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + it.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + it.resume(response) + } + }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/discord/serializers/Activity.kt b/app/src/main/java/ani/dantotsu/connections/discord/serializers/Activity.kt index 2e6366ac..4416fe5c 100644 --- a/app/src/main/java/ani/dantotsu/connections/discord/serializers/Activity.kt +++ b/app/src/main/java/ani/dantotsu/connections/discord/serializers/Activity.kt @@ -40,6 +40,7 @@ data class Activity( @Serializable data class Timestamps( val start: Long? = null, + @SerialName("end") val stop: Long? = null ) } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/github/Contributors.kt b/app/src/main/java/ani/dantotsu/connections/github/Contributors.kt index 337e4c49..64ef15bb 100644 --- a/app/src/main/java/ani/dantotsu/connections/github/Contributors.kt +++ b/app/src/main/java/ani/dantotsu/connections/github/Contributors.kt @@ -28,6 +28,7 @@ class Contributors { "rebelonion" -> "Owner & Maintainer" "sneazy-ibo" -> "Contributor & Comment Moderator" "WaiWhat" -> "Icon Designer" + "itsmechinmoy" -> "Discord and Telegram Admin/Helper, Comment Moderator & Translator" else -> "Contributor" } developers = developers.plus( @@ -89,9 +90,15 @@ class Contributors { "Comment Moderator and Arabic Translator", "https://anilist.co/user/6049773" ), + Developer( + "Dawnusedyeet", + "https://s4.anilist.co/file/anilistcdn/user/avatar/large/b6237399-RHFvRHriXjwS.png", + "Contributor", + "https://anilist.co/user/Dawnusedyeet/" + ), Developer( "hastsu", - "https://cdn.discordapp.com/avatars/602422545077108749/20b4a6efa4314550e4ed51cdbe4fef3d.webp?size=160", + "https://s4.anilist.co/file/anilistcdn/user/avatar/large/b6183359-9os7zUhYdF64.jpg", "Comment Moderator and Arabic Translator", "https://anilist.co/user/6183359" ), @@ -111,4 +118,4 @@ class Contributors { @SerialName("html_url") val htmlUrl: String ) -} \ No newline at end of file +} diff --git a/app/src/main/java/ani/dantotsu/download/DownloadCompat.kt b/app/src/main/java/ani/dantotsu/download/DownloadCompat.kt index 892c6521..bd4e5c84 100644 --- a/app/src/main/java/ani/dantotsu/download/DownloadCompat.kt +++ b/app/src/main/java/ani/dantotsu/download/DownloadCompat.kt @@ -125,7 +125,7 @@ class DownloadCompat { Logger.log(e) Injekt.get().logException(e) return OfflineAnimeModel( - "unknown", + downloadedType.titleName, "0", "??", "??", @@ -188,7 +188,7 @@ class DownloadCompat { Logger.log(e) Injekt.get().logException(e) return OfflineMangaModel( - "unknown", + downloadedType.titleName, "0", "??", "??", diff --git a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt index 3b44e66d..771684db 100644 --- a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt +++ b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt @@ -13,7 +13,6 @@ import ani.dantotsu.snackString import ani.dantotsu.util.Logger import com.anggrayudi.storage.callback.FolderCallback import com.anggrayudi.storage.file.deleteRecursively -import com.anggrayudi.storage.file.findFolder import com.anggrayudi.storage.file.moveFolderTo import com.google.gson.Gson import com.google.gson.reflect.TypeToken @@ -279,6 +278,7 @@ class DownloadsManager(private val context: Context) { * @param type the type of media * @return the base directory */ + @Synchronized private fun getBaseDirectory(context: Context, type: MediaType): DocumentFile? { val baseDirectory = Uri.parse(PrefManager.getVal(PrefName.DownloadsDir)) if (baseDirectory == Uri.EMPTY) return null @@ -307,6 +307,7 @@ class DownloadsManager(private val context: Context) { * @param chapter the chapter of the media * @return the subdirectory */ + @Synchronized fun getSubDirectory( context: Context, type: MediaType, @@ -344,23 +345,34 @@ class DownloadsManager(private val context: Context) { } } + @Synchronized private fun getBaseDirectory(context: Context): DocumentFile? { val baseDirectory = Uri.parse(PrefManager.getVal(PrefName.DownloadsDir)) if (baseDirectory == Uri.EMPTY) return null - return DocumentFile.fromTreeUri(context, baseDirectory) + val base = DocumentFile.fromTreeUri(context, baseDirectory) ?: return null + return base.findOrCreateFolder(BASE_LOCATION, false) } + private val lock = Any() + private fun DocumentFile.findOrCreateFolder( name: String, overwrite: Boolean ): DocumentFile? { - return if (overwrite) { - findFolder(name.findValidName())?.delete() - createDirectory(name.findValidName()) - } else { - findFolder(name.findValidName()) ?: createDirectory(name.findValidName()) + val validName = name.findValidName() + synchronized(lock) { + return if (overwrite) { + findFolder(validName)?.delete() + createDirectory(validName) + } else { + val folder = findFolder(validName) + folder ?: createDirectory(validName) + } } } + private fun DocumentFile.findFolder(name: String): DocumentFile? = + listFiles().find { it.name == name && it.isDirectory } + private const val RATIO_THRESHOLD = 95 fun Media.compareName(name: String): Boolean { val mainName = mainName().findValidName().lowercase() @@ -379,7 +391,7 @@ class DownloadsManager(private val context: Context) { private const val RESERVED_CHARS = "|\\?*<\":>+[]/'" fun String?.findValidName(): String { - return this?.replace("/","_")?.filterNot { RESERVED_CHARS.contains(it) } ?: "" + return this?.replace("/", "_")?.filterNot { RESERVED_CHARS.contains(it) } ?: "" } data class DownloadedType( 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 ebeb17b4..96b4e39e 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt @@ -181,7 +181,6 @@ class AnimeDownloaderService : Service() { } private fun updateNotification() { - // Update the notification to reflect the current state of the queue val pendingDownloads = AnimeServiceDataSingleton.downloadQueue.size val text = if (pendingDownloads > 0) { "Pending downloads: $pendingDownloads" @@ -201,8 +200,8 @@ class AnimeDownloaderService : Service() { @androidx.annotation.OptIn(UnstableApi::class) suspend fun download(task: AnimeDownloadTask) { - try { - withContext(Dispatchers.Main) { + withContext(Dispatchers.IO) { + try { val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( this@AnimeDownloaderService, @@ -214,22 +213,34 @@ class AnimeDownloaderService : Service() { builder.setContentText("Downloading ${getTaskName(task.title, task.episode)}") if (notifi) { - notificationManager.notify(NOTIFICATION_ID, builder.build()) + withContext(Dispatchers.Main) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } } - val outputDir = getSubDirectory( + val baseOutputDir = getSubDirectory( this@AnimeDownloaderService, MediaType.ANIME, false, + task.title + ) ?: throw Exception("Failed to create output directory") + val outputDir = getSubDirectory( + this@AnimeDownloaderService, + MediaType.ANIME, + true, task.title, task.episode ) ?: throw Exception("Failed to create output directory") val extension = ffExtension!!.getFileExtension() - outputDir.findFile("${task.getTaskName().findValidName()}.${extension.first}")?.delete() + outputDir.findFile("${task.getTaskName().findValidName()}.${extension.first}") + ?.delete() val outputFile = - outputDir.createFile(extension.second, "${task.getTaskName()}.${extension.first}") + outputDir.createFile( + extension.second, + "${task.getTaskName()}.${extension.first}" + ) ?: throw Exception("Failed to create output file") var percent = 0 @@ -273,7 +284,7 @@ class AnimeDownloaderService : Service() { currentTasks.find { it.getTaskName() == task.getTaskName() }?.sessionId = ffTask - saveMediaInfo(task) + saveMediaInfo(task, baseOutputDir) // periodically check if the download is complete while (ffExtension.getState(ffTask) != "COMPLETED") { @@ -287,7 +298,11 @@ class AnimeDownloaderService : Service() { ) } Download failed" ) - notificationManager.notify(NOTIFICATION_ID, builder.build()) + if (notifi) { + withContext(Dispatchers.Main) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + } toast("${getTaskName(task.title, task.episode)} Download failed") Logger.log("Download failed: ${ffExtension.getStackTrace(ffTask)}") downloadsManager.removeDownload( @@ -320,7 +335,9 @@ class AnimeDownloaderService : Service() { percent.coerceAtMost(99) ) if (notifi) { - notificationManager.notify(NOTIFICATION_ID, builder.build()) + withContext(Dispatchers.Main) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } } kotlinx.coroutines.delay(2000) } @@ -335,7 +352,11 @@ class AnimeDownloaderService : Service() { ) } Download failed" ) - notificationManager.notify(NOTIFICATION_ID, builder.build()) + if (notifi) { + withContext(Dispatchers.Main) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + } snackString("${getTaskName(task.title, task.episode)} Download failed") downloadsManager.removeDownload( DownloadedType( @@ -367,7 +388,11 @@ class AnimeDownloaderService : Service() { ) } Download completed" ) - notificationManager.notify(NOTIFICATION_ID, builder.build()) + if (notifi) { + withContext(Dispatchers.Main) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + } snackString("${getTaskName(task.title, task.episode)} Download completed") PrefManager.getAnimeDownloadPreferences().edit().putString( task.getTaskName(), @@ -385,23 +410,20 @@ class AnimeDownloaderService : Service() { broadcastDownloadFinished(task.episode) } else throw Exception("Download failed") + } catch (e: Exception) { + if (e.message?.contains("Coroutine was cancelled") == false) { //wut + Logger.log("Exception while downloading file: ${e.message}") + snackString("Exception while downloading file: ${e.message}") + e.printStackTrace() + Injekt.get().logException(e) + } + broadcastDownloadFailed(task.episode) } - } catch (e: Exception) { - if (e.message?.contains("Coroutine was cancelled") == false) { //wut - Logger.log("Exception while downloading file: ${e.message}") - snackString("Exception while downloading file: ${e.message}") - e.printStackTrace() - Injekt.get().logException(e) - } - broadcastDownloadFailed(task.episode) } } - private fun saveMediaInfo(task: AnimeDownloadTask) { + private fun saveMediaInfo(task: AnimeDownloadTask, directory: DocumentFile) { CoroutineScope(Dispatchers.IO).launch { - val directory = - getSubDirectory(this@AnimeDownloaderService, MediaType.ANIME, false, task.title) - ?: throw Exception("Directory not found") directory.findFile("media.json")?.forceDelete(this@AnimeDownloaderService) val file = directory.createFile("application/json", "media.json") ?: throw Exception("File not created") 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 cdf7e993..4f3876fe 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt @@ -30,6 +30,7 @@ import ani.dantotsu.bottomBar import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.currActivity import ani.dantotsu.currContext +import ani.dantotsu.download.DownloadCompat import ani.dantotsu.download.DownloadCompat.Companion.loadMediaCompat import ani.dantotsu.download.DownloadCompat.Companion.loadOfflineAnimeModelCompat import ani.dantotsu.download.DownloadedType @@ -48,6 +49,7 @@ import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import ani.dantotsu.util.customAlertDialog import com.anggrayudi.storage.file.openInputStream import com.google.android.material.card.MaterialCardView import com.google.android.material.imageview.ShapeableImageView @@ -202,25 +204,22 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { val type: MediaType = MediaType.ANIME // Alert dialog to confirm deletion - val builder = - androidx.appcompat.app.AlertDialog.Builder(requireContext(), R.style.MyPopup) - builder.setTitle("Delete ${item.title}?") - builder.setMessage("Are you sure you want to delete ${item.title}?") - builder.setPositiveButton("Yes") { _, _ -> - downloadManager.removeMedia(item.title, type) - val mediaIds = - PrefManager.getAnimeDownloadPreferences().all?.filter { it.key.contains(item.title) }?.values - ?: emptySet() - if (mediaIds.isEmpty()) { - snackString("No media found") // if this happens, terrible things have happened + requireContext().customAlertDialog().apply { + setTitle("Delete ${item.title}?") + setMessage("Are you sure you want to delete ${item.title}?") + setPosButton(R.string.yes) { + downloadManager.removeMedia(item.title, type) + val mediaIds = PrefManager.getAnimeDownloadPreferences().all?.filter { it.key.contains(item.title) }?.values ?: emptySet() + if (mediaIds.isEmpty()) { + snackString("No media found") // if this happens, terrible things have happened + } + getDownloads() } - getDownloads() + setNegButton(R.string.no) { + // Do nothing + } + show() } - builder.setNegativeButton("No") { _, _ -> - // Do nothing - } - val dialog = builder.show() - dialog.window?.setDimAmount(0.8f) true } } @@ -319,17 +318,20 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { ) val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { - SChapterImpl() // Provide an instance of SChapterImpl + SChapterImpl() }) .registerTypeAdapter(SAnime::class.java, InstanceCreator { - SAnimeImpl() // Provide an instance of SAnimeImpl + SAnimeImpl() }) .registerTypeAdapter(SEpisode::class.java, InstanceCreator { - SEpisodeImpl() // Provide an instance of SEpisodeImpl + SEpisodeImpl() }) .create() val media = directory?.findFile("media.json") - ?: return loadMediaCompat(downloadedType) + if (media == null) { + Logger.log("No media.json found at ${directory?.uri?.path}") + return loadMediaCompat(downloadedType) + } val mediaJson = media.openInputStream(context ?: currContext()!!)?.bufferedReader().use { it?.readText() @@ -394,6 +396,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { bannerUri ) } catch (e: Exception) { + Logger.log(e) return try { loadOfflineAnimeModelCompat(downloadedType) } catch (e: Exception) { @@ -401,7 +404,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { Logger.log(e) Injekt.get().logException(e) OfflineAnimeModel( - "unknown", + downloadedType.titleName, "0", "??", "??", 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 08ddb3a7..ed3cb02c 100644 --- a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt @@ -32,6 +32,7 @@ import ani.dantotsu.media.manga.MangaReadFragment.Companion.ACTION_DOWNLOAD_STAR import ani.dantotsu.media.manga.MangaReadFragment.Companion.EXTRA_CHAPTER_NUMBER import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import ani.dantotsu.util.NumberConverter.Companion.ofLength import com.anggrayudi.storage.file.deleteRecursively import com.anggrayudi.storage.file.forceDelete import com.anggrayudi.storage.file.openOutputStream @@ -134,15 +135,15 @@ class MangaDownloaderService : Service() { mutex.withLock { downloadJobs[task.chapter] = job } - job.join() // Wait for the job to complete before continuing to the next task + job.join() mutex.withLock { downloadJobs.remove(task.chapter) } - updateNotification() // Update the notification after each task is completed + updateNotification() } if (MangaServiceDataSingleton.downloadQueue.isEmpty()) { withContext(Dispatchers.Main) { - stopSelf() // Stop the service when the queue is empty + stopSelf() } } } @@ -181,7 +182,7 @@ class MangaDownloaderService : Service() { suspend fun download(task: DownloadTask) { try { - withContext(Dispatchers.Main) { + withContext(Dispatchers.IO) { val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( this@MangaDownloaderService, @@ -194,18 +195,27 @@ class MangaDownloaderService : Service() { val deferredMap = mutableMapOf>() builder.setContentText("Downloading ${task.title} - ${task.chapter}") if (notifi) { - notificationManager.notify(NOTIFICATION_ID, builder.build()) + withContext(Dispatchers.Main) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } } - getSubDirectory( + val baseOutputDir = getSubDirectory( + this@MangaDownloaderService, + MediaType.MANGA, + false, + task.title + ) ?: throw Exception("Base output directory not found") + val outputDir = getSubDirectory( this@MangaDownloaderService, MediaType.MANGA, false, task.title, task.chapter - )?.deleteRecursively(this@MangaDownloaderService) + ) ?: throw Exception("Output directory not found") + + outputDir.deleteRecursively(this@MangaDownloaderService, true) - // Loop through each ImageData object from the task var farthest = 0 for ((index, image) in task.imageData.withIndex()) { if (deferredMap.size >= task.simultaneousDownloads) { @@ -226,30 +236,36 @@ class MangaDownloaderService : Service() { } if (bitmap != null) { - saveToDisk("$index.jpg", bitmap, task.title, task.chapter) + saveToDisk("${index.ofLength(3)}.jpg", outputDir, bitmap) } farthest++ + builder.setProgress(task.imageData.size, farthest, false) + broadcastDownloadProgress( task.chapter, farthest * 100 / task.imageData.size ) if (notifi) { - notificationManager.notify(NOTIFICATION_ID, builder.build()) + withContext(Dispatchers.Main) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } } - bitmap } } - // Wait for any remaining deferred to complete deferredMap.values.awaitAll() - builder.setContentText("${task.title} - ${task.chapter} Download complete") - .setProgress(0, 0, false) - notificationManager.notify(NOTIFICATION_ID, builder.build()) + withContext(Dispatchers.Main) { + builder.setContentText("${task.title} - ${task.chapter} Download complete") + .setProgress(0, 0, false) + if (notifi) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + } - saveMediaInfo(task) + saveMediaInfo(task, baseOutputDir) downloadsManager.addDownload( DownloadedType( task.title, @@ -269,17 +285,16 @@ class MangaDownloaderService : Service() { } - private fun saveToDisk(fileName: String, bitmap: Bitmap, title: String, chapter: String) { + private fun saveToDisk( + fileName: String, + directory: DocumentFile, + bitmap: Bitmap + ) { try { - // Define the directory within the private external storage space - val directory = getSubDirectory(this, MediaType.MANGA, false, title, chapter) - ?: throw Exception("Directory not found") directory.findFile(fileName)?.forceDelete(this) - // Create a file reference within that directory for the image val file = directory.createFile("image/jpeg", fileName) ?: throw Exception("File not created") - // Use a FileOutputStream to write the bitmap to the file file.openOutputStream(this, false).use { outputStream -> if (outputStream == null) throw Exception("Output stream is null") bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) @@ -292,11 +307,8 @@ class MangaDownloaderService : Service() { } @OptIn(DelicateCoroutinesApi::class) - private fun saveMediaInfo(task: DownloadTask) { + private fun saveMediaInfo(task: DownloadTask, directory: DocumentFile) { launchIO { - val directory = - getSubDirectory(this@MangaDownloaderService, MediaType.MANGA, false, task.title) - ?: throw Exception("Directory not found") directory.findFile("media.json")?.forceDelete(this@MangaDownloaderService) val file = directory.createFile("application/json", "media.json") ?: throw Exception("File not created") 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 36e90e60..dcb462e8 100644 --- a/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt +++ b/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt @@ -46,6 +46,7 @@ import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import ani.dantotsu.util.customAlertDialog import com.anggrayudi.storage.file.openInputStream import com.google.android.material.card.MaterialCardView import com.google.android.material.imageview.ShapeableImageView @@ -171,7 +172,11 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { val item = adapter.getItem(position) as OfflineMangaModel val media = downloadManager.mangaDownloadedTypes.firstOrNull { it.titleName.compareName(item.title) } - ?: downloadManager.novelDownloadedTypes.firstOrNull { it.titleName.compareName(item.title) } + ?: downloadManager.novelDownloadedTypes.firstOrNull { + it.titleName.compareName( + item.title + ) + } media?.let { lifecycleScope.launch { ContextCompat.startActivity( @@ -197,19 +202,15 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { MediaType.NOVEL } // Alert dialog to confirm deletion - val builder = - androidx.appcompat.app.AlertDialog.Builder(requireContext(), R.style.MyPopup) - builder.setTitle("Delete ${item.title}?") - builder.setMessage("Are you sure you want to delete ${item.title}?") - builder.setPositiveButton("Yes") { _, _ -> - downloadManager.removeMedia(item.title, type) - getDownloads() - } - builder.setNegativeButton("No") { _, _ -> - // Do nothing - } - val dialog = builder.show() - dialog.window?.setDimAmount(0.8f) + requireContext().customAlertDialog().apply { + setTitle("Delete ${item.title}?") + setMessage("Are you sure you want to delete ${item.title}?") + setPosButton(R.string.yes) { + downloadManager.removeMedia(item.title, type) + getDownloads() + } + setNegButton(R.string.no) + }.show() true } } @@ -279,10 +280,12 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { downloads = listOf() downloadsJob = Job() CoroutineScope(Dispatchers.IO + downloadsJob).launch { - val mangaTitles = downloadManager.mangaDownloadedTypes.map { it.titleName.findValidName() }.distinct() + val mangaTitles = + downloadManager.mangaDownloadedTypes.map { it.titleName.findValidName() }.distinct() val newMangaDownloads = mutableListOf() for (title in mangaTitles) { - val tDownloads = downloadManager.mangaDownloadedTypes.filter { it.titleName.findValidName() == title } + val tDownloads = + downloadManager.mangaDownloadedTypes.filter { it.titleName.findValidName() == title } val download = tDownloads.firstOrNull() ?: continue val offlineMangaModel = loadOfflineMangaModel(download) newMangaDownloads += offlineMangaModel @@ -291,7 +294,8 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { val novelTitles = downloadManager.novelDownloadedTypes.map { it.titleName }.distinct() val newNovelDownloads = mutableListOf() for (title in novelTitles) { - val tDownloads = downloadManager.novelDownloadedTypes.filter { it.titleName.findValidName() == title } + val tDownloads = + downloadManager.novelDownloadedTypes.filter { it.titleName.findValidName() == title } val download = tDownloads.firstOrNull() ?: continue val offlineMangaModel = loadOfflineMangaModel(download) newNovelDownloads += offlineMangaModel @@ -320,11 +324,14 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { ) val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { - SChapterImpl() // Provide an instance of SChapterImpl + SChapterImpl() }) .create() val media = directory?.findFile("media.json") - ?: return DownloadCompat.loadMediaCompat(downloadedType) + if (media == null) { + Logger.log("No media.json found at ${directory?.uri?.path}") + return DownloadCompat.loadMediaCompat(downloadedType) + } val mediaJson = media.openInputStream(context ?: currContext()!!)?.bufferedReader().use { it?.readText() @@ -340,7 +347,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { private suspend fun loadOfflineMangaModel(downloadedType: DownloadedType): OfflineMangaModel { val type = downloadedType.type.asText() - //load media.json and convert to media class with gson try { val directory = getSubDirectory( context ?: currContext()!!, downloadedType.type, @@ -378,6 +384,7 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { bannerUri ) } catch (e: Exception) { + Logger.log(e) return try { loadOfflineMangaModelCompat(downloadedType) } catch (e: Exception) { @@ -385,7 +392,7 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { Logger.log(e) Injekt.get().logException(e) return OfflineMangaModel( - "unknown", + downloadedType.titleName, "0", "??", "??", 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 bc1d8e31..38f7e231 100644 --- a/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt @@ -239,6 +239,13 @@ class NovelDownloaderService : Service() { return@withContext } + val baseDirectory = getSubDirectory( + this@NovelDownloaderService, + MediaType.NOVEL, + false, + task.title + ) ?: throw Exception("Directory not found") + // Start the download withContext(Dispatchers.IO) { try { @@ -334,7 +341,7 @@ class NovelDownloaderService : Service() { notificationManager.notify(NOTIFICATION_ID, builder.build()) } - saveMediaInfo(task) + saveMediaInfo(task, baseDirectory) downloadsManager.addDownload( DownloadedType( task.title, @@ -354,15 +361,8 @@ class NovelDownloaderService : Service() { } @OptIn(DelicateCoroutinesApi::class) - private fun saveMediaInfo(task: DownloadTask) { + private fun saveMediaInfo(task: DownloadTask, directory: DocumentFile) { launchIO { - val directory = - getSubDirectory( - this@NovelDownloaderService, - MediaType.NOVEL, - false, - task.title - ) ?: throw Exception("Directory not found") directory.findFile("media.json")?.forceDelete(this@NovelDownloaderService) val file = directory.createFile("application/json", "media.json") ?: throw Exception("File not created") 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 b207d634..ab8dd73d 100644 --- a/app/src/main/java/ani/dantotsu/download/video/Helper.kt +++ b/app/src/main/java/ani/dantotsu/download/video/Helper.kt @@ -3,7 +3,6 @@ package ani.dantotsu.download.video import android.Manifest import android.annotation.SuppressLint import android.app.Activity -import android.app.AlertDialog import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -29,10 +28,10 @@ import ani.dantotsu.download.anime.AnimeDownloaderService import ani.dantotsu.download.anime.AnimeServiceDataSingleton import ani.dantotsu.media.Media import ani.dantotsu.media.MediaType -import ani.dantotsu.parsers.Subtitle import ani.dantotsu.parsers.Video import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.util.Logger +import ani.dantotsu.util.customAlertDialog import eu.kanade.tachiyomi.network.NetworkHelper import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -72,19 +71,19 @@ object Helper { episodeImage ) - val downloadsManger = Injekt.get() - val downloadCheck = downloadsManger + val downloadsManager = Injekt.get() + val downloadCheck = downloadsManager .queryDownload(title, episode, MediaType.ANIME) if (downloadCheck) { - AlertDialog.Builder(context, R.style.MyPopup) - .setTitle("Download Exists") - .setMessage("A download for this episode already exists. Do you want to overwrite it?") - .setPositiveButton("Yes") { _, _ -> + context.customAlertDialog().apply { + setTitle("Download Exists") + setMessage("A download for this episode already exists. Do you want to overwrite it?") + setPosButton(R.string.yes) { PrefManager.getAnimeDownloadPreferences().edit() .remove(animeDownloadTask.getTaskName()) .apply() - downloadsManger.removeDownload( + downloadsManager.removeDownload( DownloadedType( title, episode, @@ -99,8 +98,9 @@ object Helper { } } } - .setNegativeButton("No") { _, _ -> } - .show() + setNegButton(R.string.no) + show() + } } else { AnimeServiceDataSingleton.downloadQueue.offer(animeDownloadTask) if (!AnimeServiceDataSingleton.isServiceRunning) { diff --git a/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt b/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt index f9a9f596..87713885 100644 --- a/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt +++ b/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt @@ -38,6 +38,7 @@ import ani.dantotsu.snackString import ani.dantotsu.statusBarHeight import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -289,15 +290,20 @@ class AnimeFragment : Fragment() { } } } - model.loaded = true - model.loadTrending(1) - model.loadAll() + } + model.loaded = true + val loadTrending = async(Dispatchers.IO) { model.loadTrending(1) } + val loadAll = async(Dispatchers.IO) { model.loadAll() } + val loadPopular = async(Dispatchers.IO) { model.loadPopular( - "ANIME", sort = Anilist.sortBy[1], onList = PrefManager.getVal( - PrefName.PopularAnimeList - ) + "ANIME", + sort = Anilist.sortBy[1], + onList = PrefManager.getVal(PrefName.PopularAnimeList) ) } + loadTrending.await() + loadAll.await() + loadPopular.await() live.postValue(false) _binding?.animeRefresh?.isRefreshing = false running = false diff --git a/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt b/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt index df0e0939..0f31fca2 100644 --- a/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt +++ b/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt @@ -111,8 +111,8 @@ class AnimePageAdapter : RecyclerView.Adapter 0) View.VISIBLE else View.GONE + trendingBinding.notificationCount.isVisible = Anilist.unreadNotificationCount > 0 + && PrefManager.getVal(PrefName.ShowNotificationRedDot) == true trendingBinding.notificationCount.text = Anilist.unreadNotificationCount.toString() listOf( @@ -268,8 +268,9 @@ class AnimePageAdapter : RecyclerView.Adapter 0) View.VISIBLE else View.GONE + trendingBinding.notificationCount.isVisible = Anilist.unreadNotificationCount > 0 + && PrefManager.getVal(PrefName.ShowNotificationRedDot) == true trendingBinding.notificationCount.text = Anilist.unreadNotificationCount.toString() } } diff --git a/app/src/main/java/ani/dantotsu/home/HomeFragment.kt b/app/src/main/java/ani/dantotsu/home/HomeFragment.kt index fc729b9d..dfd39e6a 100644 --- a/app/src/main/java/ani/dantotsu/home/HomeFragment.kt +++ b/app/src/main/java/ani/dantotsu/home/HomeFragment.kt @@ -50,6 +50,7 @@ import ani.dantotsu.statusBarHeight import ani.dantotsu.util.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.max @@ -92,6 +93,7 @@ class HomeFragment : Fragment() { ) binding.homeUserDataProgressBar.visibility = View.GONE binding.homeNotificationCount.isVisible = Anilist.unreadNotificationCount > 0 + && PrefManager.getVal(PrefName.ShowNotificationRedDot) == true binding.homeNotificationCount.text = Anilist.unreadNotificationCount.toString() binding.homeAnimeList.setOnClickListener { @@ -456,51 +458,56 @@ class HomeFragment : Fragment() { var running = false val live = Refresh.activity.getOrPut(1) { MutableLiveData(true) } - live.observe(viewLifecycleOwner) - { - if (!running && it) { + live.observe(viewLifecycleOwner) { shouldRefresh -> + if (!running && shouldRefresh) { running = true scope.launch { withContext(Dispatchers.IO) { - //Get userData First - Anilist.userid = - PrefManager.getNullableVal(PrefName.AnilistUserId, null) - ?.toIntOrNull() + // Get user data first + Anilist.userid = PrefManager.getNullableVal(PrefName.AnilistUserId, null)?.toIntOrNull() if (Anilist.userid == null) { - getUserId(requireContext()) { - load() - } - } else { - CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.Main) { getUserId(requireContext()) { load() } } + } else { + getUserId(requireContext()) { + load() + } } model.loaded = true - CoroutineScope(Dispatchers.IO).launch { - model.setListImages() - } - var empty = true - val homeLayoutShow: List = - PrefManager.getVal(PrefName.HomeLayout) - model.initHomePage() - (array.indices).forEach { i -> + model.setListImages() + } + + var empty = true + val homeLayoutShow: List = PrefManager.getVal(PrefName.HomeLayout) + + withContext(Dispatchers.Main) { + homeLayoutShow.indices.forEach { i -> if (homeLayoutShow.elementAt(i)) { empty = false - } else withContext(Dispatchers.Main) { + } else { containers[i].visibility = View.GONE } } - model.empty.postValue(empty) } + + val initHomePage = async(Dispatchers.IO) { model.initHomePage() } + val initUserStatus = async(Dispatchers.IO) { model.initUserStatus() } + initHomePage.await() + initUserStatus.await() + + withContext(Dispatchers.Main) { + model.empty.postValue(empty) + binding.homeHiddenItemsContainer.visibility = View.GONE + } + live.postValue(false) _binding?.homeRefresh?.isRefreshing = false running = false } - binding.homeHiddenItemsContainer.visibility = View.GONE } - } } @@ -508,6 +515,7 @@ class HomeFragment : Fragment() { if (!model.loaded) Refresh.activity[1]!!.postValue(true) if (_binding != null) { binding.homeNotificationCount.isVisible = Anilist.unreadNotificationCount > 0 + && PrefManager.getVal(PrefName.ShowNotificationRedDot) == true binding.homeNotificationCount.text = Anilist.unreadNotificationCount.toString() } super.onResume() diff --git a/app/src/main/java/ani/dantotsu/home/LoginFragment.kt b/app/src/main/java/ani/dantotsu/home/LoginFragment.kt index 5f89464e..13fde1e5 100644 --- a/app/src/main/java/ani/dantotsu/home/LoginFragment.kt +++ b/app/src/main/java/ani/dantotsu/home/LoginFragment.kt @@ -12,12 +12,14 @@ import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import ani.dantotsu.R import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.databinding.DialogUserAgentBinding import ani.dantotsu.databinding.FragmentLoginBinding 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 ani.dantotsu.util.customAlertDialog import com.google.android.material.textfield.TextInputEditText class LoginFragment : Fragment() { @@ -94,38 +96,31 @@ class LoginFragment : Fragment() { val password = CharArray(16).apply { fill('0') } // Inflate the dialog layout - val dialogView = - LayoutInflater.from(requireActivity()).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 dialogView = DialogUserAgentBinding.inflate(layoutInflater).apply { + userAgentTextBox.hint = "Password" + subtitle.visibility = View.VISIBLE + subtitle.text = getString(R.string.enter_password_to_decrypt_file) + } - val dialog = AlertDialog.Builder(requireActivity(), R.style.MyPopup) - .setTitle("Enter Password") - .setView(dialogView) - .setPositiveButton("OK", null) - .setNegativeButton("Cancel") { dialog, _ -> + requireActivity().customAlertDialog().apply { + setTitle("Enter Password") + setCustomView(dialogView.root) + setPosButton(R.string.ok){ + val editText = dialogView.userAgentTextBox + if (editText.text?.isNotBlank() == true) { + editText.text?.toString()?.trim()?.toCharArray(password) + callback(password) + } else { + toast("Password cannot be empty") + } + } + setNegButton(R.string.cancel) { password.fill('0') - dialog.dismiss() callback(null) } - .create() + }.show() - 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") - } - } } private fun restartApp() { diff --git a/app/src/main/java/ani/dantotsu/home/MangaFragment.kt b/app/src/main/java/ani/dantotsu/home/MangaFragment.kt index c878c584..0ed19510 100644 --- a/app/src/main/java/ani/dantotsu/home/MangaFragment.kt +++ b/app/src/main/java/ani/dantotsu/home/MangaFragment.kt @@ -35,6 +35,7 @@ import ani.dantotsu.snackString import ani.dantotsu.statusBarHeight import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -274,15 +275,22 @@ class MangaFragment : Fragment() { } } } - model.loaded = true - model.loadTrending() - model.loadAll() + } + model.loaded = true + val loadTrending = async(Dispatchers.IO) { model.loadTrending() } + val loadAll = async(Dispatchers.IO) { model.loadAll() } + val loadPopular = async(Dispatchers.IO) { model.loadPopular( - "MANGA", sort = Anilist.sortBy[1], onList = PrefManager.getVal( - PrefName.PopularMangaList - ) + "MANGA", + sort = Anilist.sortBy[1], + onList = PrefManager.getVal(PrefName.PopularAnimeList) ) } + + loadTrending.await() + loadAll.await() + loadPopular.await() + live.postValue(false) _binding?.mangaRefresh?.isRefreshing = false running = false diff --git a/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt b/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt index 2e3e6a8c..2577e3b2 100644 --- a/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt +++ b/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt @@ -80,6 +80,7 @@ class MangaPageAdapter : RecyclerView.Adapter 0 + && PrefManager.getVal(PrefName.ShowNotificationRedDot) == true trendingBinding.notificationCount.text = Anilist.unreadNotificationCount.toString() trendingBinding.searchBar.hint = "MANGA" trendingBinding.searchBarText.setOnClickListener { @@ -296,8 +297,8 @@ class MangaPageAdapter : RecyclerView.Adapter 0) View.VISIBLE else View.GONE + trendingBinding.notificationCount.isVisible = Anilist.unreadNotificationCount > 0 + && PrefManager.getVal(PrefName.ShowNotificationRedDot) == true trendingBinding.notificationCount.text = Anilist.unreadNotificationCount.toString() } } diff --git a/app/src/main/java/ani/dantotsu/home/status/StatusActivity.kt b/app/src/main/java/ani/dantotsu/home/status/StatusActivity.kt index 43b2cb55..ced8b2d8 100644 --- a/app/src/main/java/ani/dantotsu/home/status/StatusActivity.kt +++ b/app/src/main/java/ani/dantotsu/home/status/StatusActivity.kt @@ -16,6 +16,8 @@ import ani.dantotsu.navBarHeight import ani.dantotsu.profile.User import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.statusBarHeight +import ani.dantotsu.toast +import ani.dantotsu.util.Logger class StatusActivity : AppCompatActivity(), StoriesCallback { private lateinit var activity: ArrayList @@ -44,10 +46,17 @@ class StatusActivity : AppCompatActivity(), StoriesCallback { val key = "activities" val watchedActivity = PrefManager.getCustomVal>(key, setOf()) - val startFrom = findFirstNonMatch(watchedActivity, activity[position].activity ) - val startIndex = if ( startFrom > 0) startFrom else 0 - binding.stories.setStoriesList(activity[position].activity, this, startIndex + 1) - + if (activity.getOrNull(position) != null) { + val startFrom = findFirstNonMatch(watchedActivity, activity[position].activity ) + val startIndex = if ( startFrom > 0) startFrom else 0 + binding.stories.setStoriesList( + activityList = activity[position].activity, + startIndex = startIndex + 1 + ) + } else { + Logger.log("index out of bounds for position $position of size ${activity.size}") + finish() + } } private fun findFirstNonMatch(watchedActivity: Set, activity: List): Int { @@ -58,13 +67,16 @@ class StatusActivity : AppCompatActivity(), StoriesCallback { } return -1 } + override fun onPause() { super.onPause() binding.stories.pause() } + override fun onResume() { super.onResume() - binding.stories.resume() + if (hasWindowFocus()) + binding.stories.resume() } override fun onWindowFocusChanged(hasFocus: Boolean) { @@ -83,7 +95,7 @@ class StatusActivity : AppCompatActivity(), StoriesCallback { val startFrom = findFirstNonMatch(watchedActivity, activity[position].activity ) val startIndex= if ( startFrom > 0) startFrom else 0 binding.stories.startAnimation(slideOutLeft) - binding.stories.setStoriesList(activity[position].activity, this, startIndex + 1) + binding.stories.setStoriesList(activity[position].activity, startIndex + 1) binding.stories.startAnimation(slideInRight) } else { finish() @@ -92,13 +104,13 @@ class StatusActivity : AppCompatActivity(), StoriesCallback { override fun onStoriesStart() { position -= 1 - if (position >= 0) { + if (position >= 0 && activity[position].activity.isNotEmpty()) { val key = "activities" val watchedActivity = PrefManager.getCustomVal>(key, setOf()) val startFrom = findFirstNonMatch(watchedActivity, activity[position].activity ) val startIndex = if ( startFrom > 0) startFrom else 0 binding.stories.startAnimation(slideOutRight) - binding.stories.setStoriesList(activity[position].activity, this, startIndex + 1) + binding.stories.setStoriesList(activity[position].activity,startIndex + 1) binding.stories.startAnimation(slideInLeft) } else { finish() diff --git a/app/src/main/java/ani/dantotsu/home/status/Stories.kt b/app/src/main/java/ani/dantotsu/home/status/Stories.kt index 7f2c60ec..09158aae 100644 --- a/app/src/main/java/ani/dantotsu/home/status/Stories.kt +++ b/app/src/main/java/ani/dantotsu/home/status/Stories.kt @@ -30,6 +30,7 @@ import ani.dantotsu.profile.ProfileActivity import ani.dantotsu.profile.User import ani.dantotsu.profile.UsersDialogFragment import ani.dantotsu.profile.activity.ActivityItemBuilder +import ani.dantotsu.profile.activity.RepliesBottomDialog import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString @@ -48,7 +49,6 @@ import kotlin.math.abs class Stories @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), View.OnTouchListener { - private lateinit var activity: FragmentActivity private lateinit var binding: FragmentStatusBinding private lateinit var activityList: List private lateinit var storiesListener: StoriesCallback @@ -74,16 +74,14 @@ class Stories @JvmOverloads constructor( if (context is StoriesCallback) storiesListener = context as StoriesCallback - binding.leftTouchPanel.setOnTouchListener(this) - binding.rightTouchPanel.setOnTouchListener(this) + binding.touchPanel.setOnTouchListener(this) } fun setStoriesList( - activityList: List, activity: FragmentActivity, startIndex: Int = 1 + activityList: List, startIndex: Int = 1 ) { this.activityList = activityList - this.activity = activity this.storyIndex = startIndex addLoadingViews(activityList) } @@ -264,49 +262,7 @@ class Stories @JvmOverloads constructor( } - private var startClickTime = 0L - private var startX = 0f - private var startY = 0f - private var isLongPress = false - private val swipeThreshold = 100 - override fun onTouch(view: View?, event: MotionEvent?): Boolean { - val maxClickDuration = 200 - when (event?.action) { - MotionEvent.ACTION_DOWN -> { - startX = event.x - startY = event.y - startClickTime = Calendar.getInstance().timeInMillis - pause() - isLongPress = false - } - MotionEvent.ACTION_MOVE -> { - val deltaX = event.x - startX - val deltaY = event.y - startY - if (!isLongPress && (abs(deltaX) > swipeThreshold || abs(deltaY) > swipeThreshold)) { - isLongPress = true - } - } - - MotionEvent.ACTION_UP -> { - val clickDuration = Calendar.getInstance().timeInMillis - startClickTime - if (clickDuration < maxClickDuration && !isLongPress) { - when (view?.id) { - R.id.leftTouchPanel -> leftPanelTouch() - R.id.rightTouchPanel -> rightPanelTouch() - } - } else { - resume() - } - val deltaX = event.x - startX - if (abs(deltaX) > swipeThreshold) { - if (deltaX > 0) onStoriesPrevious() - else onStoriesCompleted() - } - } - } - return true - } private fun rightPanelTouch() { Logger.log("rightPanelTouch: $storyIndex") @@ -359,6 +315,7 @@ class Stories @JvmOverloads constructor( timer.resume() } + @SuppressLint("ClickableViewAccessibility") private fun loadStory(story: Activity) { val key = "activities" val set = PrefManager.getCustomVal>(key, setOf()).plus((story.id)) @@ -374,6 +331,15 @@ class Stories @JvmOverloads constructor( null ) } + + binding.textActivity.setOnTouchListener { v, event -> + onTouchView(v, event, true) + v.onTouchEvent(event) + } + binding.textActivityContainer.setOnTouchListener { v, event -> + onTouchView(v, event, true) + v.onTouchEvent(event) + } fun visible(isList: Boolean) { binding.textActivity.isVisible = !isList binding.textActivityContainer.isVisible = !isList @@ -397,15 +363,17 @@ class Stories @JvmOverloads constructor( } } } ${story.progress ?: story.media?.title?.userPreferred} " + - if ( - story.status?.contains("completed") == false && - !story.status.contains("plans") && - !story.status.contains("repeating") - ) { - "of ${story.media?.title?.userPreferred}" - } else { - "" - } + if ( + story.status?.contains("completed") == false && + !story.status.contains("plans") && + !story.status.contains("repeating")&& + !story.status.contains("paused")&& + !story.status.contains("dropped") + ) { + "of ${story.media?.title?.userPreferred}" + } else { + "" + } binding.infoText.text = text val bannerAnimations: Boolean = PrefManager.getVal(PrefName.BannerAnimations) blurImage( @@ -421,7 +389,7 @@ class Stories @JvmOverloads constructor( story.media?.id ), ActivityOptionsCompat.makeSceneTransitionAnimation( - activity, + (it.context as FragmentActivity), binding.coverImage, ViewCompat.getTransitionName(binding.coverImage)!! ).toBundle() @@ -455,22 +423,21 @@ class Stories @JvmOverloads constructor( } val likeColor = ContextCompat.getColor(context, R.color.yt_red) val notLikeColor = ContextCompat.getColor(context, R.color.bg_opp) + binding.replyCount.text = story.replyCount.toString() + binding.activityReplies.setColorFilter(ContextCompat.getColor(context, R.color.bg_opp)) binding.activityRepliesContainer.setOnClickListener { RepliesBottomDialog.newInstance(story.id) - .show(activity.supportFragmentManager, "replies") + .show((it.context as FragmentActivity).supportFragmentManager, "replies") } binding.activityLike.setColorFilter(if (story.isLiked == true) likeColor else notLikeColor) - binding.replyCount.text = story.replyCount.toString() binding.activityLikeCount.text = story.likeCount.toString() - binding.activityReplies.setColorFilter(ContextCompat.getColor(context, R.color.bg_opp)) binding.activityLikeContainer.setOnClickListener { like() } binding.activityLikeContainer.setOnLongClickListener { - val context = activity UsersDialogFragment().apply { userList(userList) - show(context.supportFragmentManager, "dialog") + show((it.context as FragmentActivity).supportFragmentManager, "dialog") } true } @@ -484,7 +451,7 @@ class Stories @JvmOverloads constructor( val notLikeColor = ContextCompat.getColor(context, R.color.bg_opp) val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope.launch { - val res = Anilist.query.toggleLike(story.id, "ACTIVITY") + val res = Anilist.mutation.toggleLike(story.id, "ACTIVITY") withContext(Dispatchers.Main) { if (res != null) { if (story.isLiked == true) { @@ -502,4 +469,69 @@ class Stories @JvmOverloads constructor( } } } + private var startClickTime = 0L + private var startX = 0f + private var startY = 0f + private var isLongPress = false + private val swipeThreshold = 100 + override fun onTouch(view: View, event: MotionEvent): Boolean { + onTouchView(view, event) + return true + } + private fun onTouchView(view: View, event: MotionEvent, isText: Boolean = false){ + val maxClickDuration = 200 + val screenWidth = view.width + val leftHalf = screenWidth / 2 + val leftQuarter = screenWidth * 0.15 + val rightQuarter = screenWidth * 0.85 + when (event.action) { + MotionEvent.ACTION_DOWN -> { + startX = event.x + startY = event.y + startClickTime = Calendar.getInstance().timeInMillis + pause() + isLongPress = false + } + MotionEvent.ACTION_MOVE -> { + val deltaX = event.x - startX + val deltaY = event.y - startY + if (!isLongPress && (abs(deltaX) > swipeThreshold || abs(deltaY) > swipeThreshold)) { + isLongPress = true + } + } + MotionEvent.ACTION_UP -> { + val clickDuration = Calendar.getInstance().timeInMillis - startClickTime + if (isText) { + if (clickDuration < maxClickDuration && !isLongPress) { + if (event.x < leftQuarter) { + leftPanelTouch() + } else if (event.x > rightQuarter) { + rightPanelTouch() + } else { + resume() + } + } else { + resume() + } + } else { + if (clickDuration < maxClickDuration && !isLongPress) { + if (event.x < leftHalf) { + leftPanelTouch() + } else { + rightPanelTouch() + } + } else { + resume() + } + } + val deltaX = event.x - startX + val deltaY = event.y - startY + if (abs(deltaX) > swipeThreshold && !(abs(deltaY) > 10)) { + if (deltaX > 0) onStoriesPrevious() + else onStoriesCompleted() + } + + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/home/status/UserStatusAdapter.kt b/app/src/main/java/ani/dantotsu/home/status/UserStatusAdapter.kt index 5daebcd9..c8db4c99 100644 --- a/app/src/main/java/ani/dantotsu/home/status/UserStatusAdapter.kt +++ b/app/src/main/java/ani/dantotsu/home/status/UserStatusAdapter.kt @@ -1,6 +1,5 @@ package ani.dantotsu.home.status -import android.content.Context import android.content.Intent import android.view.LayoutInflater import android.view.ViewGroup @@ -15,6 +14,8 @@ import ani.dantotsu.profile.ProfileActivity import ani.dantotsu.profile.User import ani.dantotsu.setAnimation import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.snackString +import ani.dantotsu.util.ActivityMarkdownCreator class UserStatusAdapter(private val user: ArrayList) : RecyclerView.Adapter() { @@ -23,6 +24,10 @@ class UserStatusAdapter(private val user: ArrayList) : RecyclerView.ViewHolder(binding.root) { init { itemView.setOnClickListener { + if (user[bindingAdapterPosition].activity.isEmpty()) { + snackString("No activity") + return@setOnClickListener + } StatusActivity.user = user ContextCompat.startActivity( itemView.context, @@ -34,14 +39,23 @@ class UserStatusAdapter(private val user: ArrayList) : ) } itemView.setOnLongClickListener { - ContextCompat.startActivity( - itemView.context, - Intent( + if (user[bindingAdapterPosition].id == Anilist.userid) { + ContextCompat.startActivity( itemView.context, - ProfileActivity::class.java - ).putExtra("userId", user[bindingAdapterPosition].id), - null - ) + Intent(itemView.context, ActivityMarkdownCreator::class.java) + .putExtra("type", "activity"), + null + ) + }else{ + ContextCompat.startActivity( + itemView.context, + Intent( + itemView.context, + ProfileActivity::class.java + ).putExtra("userId", user[bindingAdapterPosition].id), + null + ) + } true } } diff --git a/app/src/main/java/ani/dantotsu/media/CalendarActivity.kt b/app/src/main/java/ani/dantotsu/media/CalendarActivity.kt index 66cec7e4..230f3a4e 100644 --- a/app/src/main/java/ani/dantotsu/media/CalendarActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/CalendarActivity.kt @@ -30,6 +30,7 @@ class CalendarActivity : AppCompatActivity() { private lateinit var binding: ActivityListBinding private val scope = lifecycleScope private var selectedTabIdx = 1 + private var showOnlyLibrary = false private val model: OtherDetailsViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -38,8 +39,6 @@ class CalendarActivity : AppCompatActivity() { ThemeManager(this).applyTheme() binding = ActivityListBinding.inflate(layoutInflater) - - val primaryColor = getThemeColor(com.google.android.material.R.attr.colorSurface) val primaryTextColor = getThemeColor(com.google.android.material.R.attr.colorPrimary) val secondaryTextColor = getThemeColor(com.google.android.material.R.attr.colorOutline) @@ -79,6 +78,17 @@ class CalendarActivity : AppCompatActivity() { override fun onTabReselected(tab: TabLayout.Tab?) {} }) + binding.listed.setOnClickListener { + showOnlyLibrary = !showOnlyLibrary + binding.listed.setImageResource( + if (showOnlyLibrary) R.drawable.ic_round_collections_bookmark_24 + else R.drawable.ic_round_library_books_24 + ) + scope.launch { + model.loadCalendar(showOnlyLibrary) + } + } + model.getCalendar().observe(this) { if (it != null) { binding.listProgressBar.visibility = View.GONE @@ -97,11 +107,10 @@ class CalendarActivity : AppCompatActivity() { live.observe(this) { if (it) { scope.launch { - withContext(Dispatchers.IO) { model.loadCalendar() } + withContext(Dispatchers.IO) { model.loadCalendar(showOnlyLibrary) } live.postValue(false) } } } - } } diff --git a/app/src/main/java/ani/dantotsu/media/CharacterAdapter.kt b/app/src/main/java/ani/dantotsu/media/CharacterAdapter.kt index d97e138d..2186791d 100644 --- a/app/src/main/java/ani/dantotsu/media/CharacterAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/CharacterAdapter.kt @@ -9,6 +9,7 @@ import androidx.core.content.ContextCompat import androidx.core.util.Pair import androidx.core.view.ViewCompat import androidx.recyclerview.widget.RecyclerView +import ani.dantotsu.copyToClipboard import ani.dantotsu.databinding.ItemCharacterBinding import ani.dantotsu.loadImage import ani.dantotsu.setAnimation @@ -55,6 +56,7 @@ class CharacterAdapter( ).toBundle() ) } + itemView.setOnLongClickListener { copyToClipboard(characterList[bindingAdapterPosition].name ?: ""); true } } } } \ 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 a935dd4a..a3453c99 100644 --- a/app/src/main/java/ani/dantotsu/media/Media.kt +++ b/app/src/main/java/ani/dantotsu/media/Media.kt @@ -1,14 +1,22 @@ package ani.dantotsu.media import android.graphics.Bitmap +import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.connections.anilist.api.FuzzyDate import ani.dantotsu.connections.anilist.api.MediaEdge import ani.dantotsu.connections.anilist.api.MediaList +import ani.dantotsu.connections.anilist.api.MediaStreamingEpisode import ani.dantotsu.connections.anilist.api.MediaType import ani.dantotsu.connections.anilist.api.Query +import ani.dantotsu.connections.mal.MAL import ani.dantotsu.media.anime.Anime import ani.dantotsu.media.manga.Manga import ani.dantotsu.profile.User +import ani.dantotsu.settings.saving.PrefManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.Serializable import ani.dantotsu.connections.anilist.api.Media as ApiMedia @@ -76,7 +84,7 @@ data class Media( var nameMAL: String? = null, var shareLink: String? = null, var selected: Selected? = null, - + var streamingEpisodes: List? = null, var idKitsu: String? = null, var cameFromContinue: Boolean = false @@ -129,6 +137,37 @@ data class Media( fun mangaName() = if (countryOfOrigin != "JP") mainName() else nameRomaji } +fun Media?.deleteFromList( + scope: CoroutineScope, + onSuccess: suspend () -> Unit, + onError: suspend (e: Exception) -> Unit, + onNotFound: suspend () -> Unit +) { + val id = this?.userListId + scope.launch { + withContext(Dispatchers.IO) { + this@deleteFromList?.let { media -> + val _id = id ?: Anilist.query.userMediaDetails(media).userListId; + _id?.let { listId -> + try { + Anilist.mutation.deleteList(listId) + MAL.query.deleteList(media.anime != null, media.idMAL) + + val removeList = PrefManager.getCustomVal("removeList", setOf()) + PrefManager.setCustomVal( + "removeList", removeList.minus(listId) + ) + + onSuccess() + } catch (e: Exception) { + onError(e) + } + } ?: onNotFound() + } + } + } +} + fun emptyMedia() = Media( id = 0, name = "No media found", diff --git a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt index 44b04457..151327f9 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt @@ -293,7 +293,7 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi binding.mediaTotal.visibility = View.VISIBLE binding.mediaAddToList.text = userStatus } else { - binding.mediaAddToList.setText(R.string.add) + binding.mediaAddToList.setText(R.string.add_list) } total() binding.mediaAddToList.setOnClickListener { @@ -372,7 +372,9 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi navBar.createTab(R.drawable.ic_round_comment_24, R.string.comments, R.id.comment) navBar.addTab(infoTab) navBar.addTab(watchTab) - navBar.addTab(commentTab) + if (PrefManager.getVal(PrefName.CommentsEnabled) == 1) { + navBar.addTab(commentTab) + } if (model.continueMedia == null && media.cameFromContinue) { model.continueMedia = PrefManager.getVal(PrefName.ContinueMedia) selected = 1 @@ -424,7 +426,8 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi } override fun onResume() { - navBar.selectTabAt(selected) + if (::navBar.isInitialized) + navBar.selectTabAt(selected) super.onResume() } diff --git a/app/src/main/java/ani/dantotsu/media/MediaDetailsViewModel.kt b/app/src/main/java/ani/dantotsu/media/MediaDetailsViewModel.kt index cfd9c736..4a7ed075 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaDetailsViewModel.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaDetailsViewModel.kt @@ -13,6 +13,7 @@ import ani.dantotsu.media.anime.Episode import ani.dantotsu.media.anime.SelectorDialogFragment import ani.dantotsu.media.manga.MangaChapter import ani.dantotsu.others.AniSkip +import ani.dantotsu.others.Anify import ani.dantotsu.others.Jikan import ani.dantotsu.others.Kitsu import ani.dantotsu.parsers.AnimeSources @@ -99,6 +100,15 @@ class MediaDetailsViewModel : ViewModel() { if (kitsuEpisodes.value == null) kitsuEpisodes.postValue(Kitsu.getKitsuEpisodesDetails(s)) } } + private val anifyEpisodes: MutableLiveData> = + MutableLiveData>(null) + + fun getAnifyEpisodes(): LiveData> = anifyEpisodes + suspend fun loadAnifyEpisodes(s: Int) { + tryWithSuspend { + if (anifyEpisodes.value == null) anifyEpisodes.postValue(Anify.fetchAndParseMetadata(s)) + } + } private val fillerEpisodes: MutableLiveData> = MutableLiveData>(null) diff --git a/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt b/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt index 216c937f..02305938 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt @@ -105,8 +105,8 @@ class MediaInfoFragment : Fragment() { } if (media.name != null) binding.mediaInfoNameRomajiContainer.visibility = View.VISIBLE - val infoNameRomanji = tripleTab + media.nameRomaji - binding.mediaInfoNameRomaji.text = infoNameRomanji + val infoNameRomaji = tripleTab + media.nameRomaji + binding.mediaInfoNameRomaji.text = infoNameRomaji binding.mediaInfoNameRomaji.setOnLongClickListener { copyToClipboard(media.nameRomaji) true diff --git a/app/src/main/java/ani/dantotsu/media/MediaListDialogFragment.kt b/app/src/main/java/ani/dantotsu/media/MediaListDialogFragment.kt index 1117f342..ea0450a7 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaListDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaListDialogFragment.kt @@ -271,29 +271,23 @@ class MediaListDialogFragment : BottomSheetDialogFragment() { } binding.mediaListDelete.setOnClickListener { - 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) - } + media?.deleteFromList(scope, onSuccess = { + Refresh.all() + snackString(getString(R.string.deleted_from_list)) + dismissAllowingStateLoss() + }, onError = { e -> + withContext(Dispatchers.Main) { + snackString( + getString( + R.string.delete_fail_reason, e.message + ) + ) } - } - PrefManager.setCustomVal("removeList", removeList.minus(media?.id)) - } - if (id != null) { - Refresh.all() - snackString(getString(R.string.deleted_from_list)) - dismissAllowingStateLoss() - } else { - snackString(getString(R.string.no_list_id)) + }, onNotFound = { + snackString(getString(R.string.no_list_id)) + }) + } } } diff --git a/app/src/main/java/ani/dantotsu/media/MediaListDialogSmallFragment.kt b/app/src/main/java/ani/dantotsu/media/MediaListDialogSmallFragment.kt index caf882e6..53a2d6f2 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaListDialogSmallFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaListDialogSmallFragment.kt @@ -63,36 +63,24 @@ class MediaListDialogSmallFragment : BottomSheetDialogFragment() { 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(getString(R.string.delete_fail_reason, 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) { + scope.launch { + media.deleteFromList(scope, onSuccess = { Refresh.all() snackString(getString(R.string.deleted_from_list)) dismissAllowingStateLoss() - } else { + }, onError = { e -> + withContext(Dispatchers.Main) { + snackString( + getString( + R.string.delete_fail_reason, e.message + ) + ) + } + }, onNotFound = { snackString(getString(R.string.no_list_id)) - } + }) + } } } diff --git a/app/src/main/java/ani/dantotsu/media/OtherDetailsViewModel.kt b/app/src/main/java/ani/dantotsu/media/OtherDetailsViewModel.kt index 0be0fc22..a086a765 100644 --- a/app/src/main/java/ani/dantotsu/media/OtherDetailsViewModel.kt +++ b/app/src/main/java/ani/dantotsu/media/OtherDetailsViewModel.kt @@ -26,25 +26,50 @@ class OtherDetailsViewModel : ViewModel() { if (author.value == null) author.postValue(Anilist.query.getAuthorDetails(m)) } + private var cachedAllCalendarData: Map>? = null + private var cachedLibraryCalendarData: Map>? = null private val calendar: MutableLiveData>> = MutableLiveData(null) fun getCalendar(): LiveData>> = calendar - suspend fun loadCalendar() { - val curr = System.currentTimeMillis() / 1000 - val res = Anilist.query.recentlyUpdated(curr - 86400, curr + (86400 * 6)) - val df = DateFormat.getDateInstance(DateFormat.FULL) - val map = mutableMapOf>() - val idMap = mutableMapOf>() - res?.forEach { - val v = it.relation?.split(",")?.map { i -> i.toLong() }!! - val dateInfo = df.format(Date(v[1] * 1000)) - val list = map.getOrPut(dateInfo) { mutableListOf() } - val idList = idMap.getOrPut(dateInfo) { mutableListOf() } - it.relation = "Episode ${v[0]}" - if (!idList.contains(it.id)) { - idList.add(it.id) - list.add(it) + suspend fun loadCalendar(showOnlyLibrary: Boolean = false) { + if (cachedAllCalendarData == null || cachedLibraryCalendarData == null) { + val curr = System.currentTimeMillis() / 1000 + val res = Anilist.query.recentlyUpdated(curr - 86400, curr + (86400 * 6)) + val df = DateFormat.getDateInstance(DateFormat.FULL) + val allMap = mutableMapOf>() + val libraryMap = mutableMapOf>() + val idMap = mutableMapOf>() + + val userId = Anilist.userid ?: 0 + val userLibrary = Anilist.query.getMediaLists(true, userId) + val libraryMediaIds = userLibrary.flatMap { it.value }.map { it.id } + + res.forEach { + val v = it.relation?.split(",")?.map { i -> i.toLong() }!! + val dateInfo = df.format(Date(v[1] * 1000)) + val list = allMap.getOrPut(dateInfo) { mutableListOf() } + val libraryList = if (libraryMediaIds.contains(it.id)) { + libraryMap.getOrPut(dateInfo) { mutableListOf() } + } else { + null + } + val idList = idMap.getOrPut(dateInfo) { mutableListOf() } + it.relation = "Episode ${v[0]}" + if (!idList.contains(it.id)) { + idList.add(it.id) + list.add(it) + libraryList?.add(it) + } } + + cachedAllCalendarData = allMap + cachedLibraryCalendarData = libraryMap } - calendar.postValue(map) + + val cacheToUse: Map> = if (showOnlyLibrary) { + cachedLibraryCalendarData ?: emptyMap() + } else { + cachedAllCalendarData ?: emptyMap() + } + calendar.postValue(cacheToUse) } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/ReviewActivity.kt b/app/src/main/java/ani/dantotsu/media/ReviewActivity.kt index 6b08734c..0f6d0e4f 100644 --- a/app/src/main/java/ani/dantotsu/media/ReviewActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/ReviewActivity.kt @@ -3,7 +3,6 @@ package ani.dantotsu.media import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle -import android.text.SpannableString import android.view.MotionEvent import android.view.View import android.view.ViewGroup @@ -21,7 +20,7 @@ import ani.dantotsu.initActivity import ani.dantotsu.navBarHeight import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager -import ani.dantotsu.util.MarkdownCreatorActivity +import ani.dantotsu.util.ActivityMarkdownCreator import com.xwray.groupie.GroupieAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -59,7 +58,7 @@ class ReviewActivity : AppCompatActivity() { binding.followFilterButton.setOnClickListener { ContextCompat.startActivity( this, - Intent(this, MarkdownCreatorActivity::class.java) + Intent(this, ActivityMarkdownCreator::class.java) .putExtra("type", "review"), null ) diff --git a/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt b/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt index ecf3692c..21f0b683 100644 --- a/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt @@ -183,6 +183,12 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri binding.searchByImage.setOnClickListener { activity.startActivity(Intent(activity, ImageSearchActivity::class.java)) } + binding.clearHistory.setOnClickListener { + it.startAnimation(fadeOutAnimation()) + it.visibility = View.GONE + searchHistoryAdapter.clearHistory() + } + updateClearHistoryVisibility() fun searchTitle() { activity.result.apply { search = @@ -300,11 +306,17 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri } binding.searchResultLayout.visibility = View.VISIBLE + binding.clearHistory.visibility = View.GONE binding.searchHistoryList.visibility = View.GONE binding.searchByImage.visibility = View.GONE } } + private fun updateClearHistoryVisibility() { + binding.clearHistory.visibility = + if (searchHistoryAdapter.itemCount > 0) View.VISIBLE else View.GONE + } + private fun fadeInAnimation(): Animation { return AlphaAnimation(0f, 1f).apply { duration = 150 @@ -375,4 +387,3 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri override fun getItemCount(): Int = chips.size } } - diff --git a/app/src/main/java/ani/dantotsu/media/SearchHistoryAdapter.kt b/app/src/main/java/ani/dantotsu/media/SearchHistoryAdapter.kt index f1519e2b..4e2988e3 100644 --- a/app/src/main/java/ani/dantotsu/media/SearchHistoryAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/SearchHistoryAdapter.kt @@ -49,6 +49,12 @@ class SearchHistoryAdapter(private val type: String, private val searchClicked: PrefManager.setVal(historyType, searchHistory) } + fun clearHistory() { + searchHistory?.clear() + PrefManager.setVal(historyType, searchHistory) + submitList(searchHistory?.toList()) + } + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int diff --git a/app/src/main/java/ani/dantotsu/media/anime/Anime.kt b/app/src/main/java/ani/dantotsu/media/anime/Anime.kt index bee71d03..84672e1b 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/Anime.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/Anime.kt @@ -26,4 +26,5 @@ data class Anime( var slug: String? = null, var kitsuEpisodes: Map? = null, var fillerEpisodes: Map? = null, + var anifyEpisodes: Map? = null, ) : Serializable \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt index a2406a21..d53880bf 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt @@ -8,8 +8,8 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.ImageButton import android.widget.LinearLayout -import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getString import androidx.core.content.ContextCompat.startActivity import androidx.core.view.isGone import androidx.core.view.isVisible @@ -18,8 +18,9 @@ import androidx.recyclerview.widget.RecyclerView import ani.dantotsu.FileUrl import ani.dantotsu.R import ani.dantotsu.currActivity +import ani.dantotsu.currContext import ani.dantotsu.databinding.DialogLayoutBinding -import ani.dantotsu.databinding.ItemAnimeWatchBinding +import ani.dantotsu.databinding.ItemMediaSourceBinding import ani.dantotsu.databinding.ItemChipBinding import ani.dantotsu.displayTimer import ani.dantotsu.isOnline @@ -33,12 +34,15 @@ import ani.dantotsu.others.LanguageMapper import ani.dantotsu.others.webview.CookieCatcher import ani.dantotsu.parsers.AnimeSources import ani.dantotsu.parsers.DynamicAnimeParser +import ani.dantotsu.parsers.OfflineAnimeParser import ani.dantotsu.parsers.WatchSources import ani.dantotsu.px import ani.dantotsu.settings.FAQActivity import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.snackString import ani.dantotsu.toast +import ani.dantotsu.util.customAlertDialog import com.google.android.material.chip.Chip import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_SUBSCRIPTION_CHECK @@ -54,16 +58,13 @@ class AnimeWatchAdapter( ) : RecyclerView.Adapter() { private var autoSelect = true var subscribe: MediaDetailsActivity.PopImageButton? = null - private var _binding: ItemAnimeWatchBinding? = null + private var _binding: ItemMediaSourceBinding? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val bind = ItemAnimeWatchBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val bind = ItemMediaSourceBinding.inflate(LayoutInflater.from(parent.context), parent, false) return ViewHolder(bind) } - private var nestedDialog: AlertDialog? = null - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { val binding = holder.binding _binding = binding @@ -75,7 +76,7 @@ class AnimeWatchAdapter( null ) } - //Youtube + // Youtube if (media.anime?.youtube != null && PrefManager.getVal(PrefName.ShowYtButton)) { binding.animeSourceYT.visibility = View.VISIBLE binding.animeSourceYT.setOnClickListener { @@ -89,7 +90,7 @@ class AnimeWatchAdapter( R.string.subbed ) - //PreferDub + // PreferDub var changing = false binding.animeSourceDubbed.setOnCheckedChangeListener { _, isChecked -> binding.animeSourceDubbedText.text = @@ -99,8 +100,8 @@ class AnimeWatchAdapter( if (!changing) fragment.onDubClicked(isChecked) } - //Wrong Title - binding.animeSourceSearch.setOnClickListener { + // Wrong Title + binding.mediaSourceSearch.setOnClickListener { SourceSearchDialogFragment().show( fragment.requireActivity().supportFragmentManager, null @@ -108,37 +109,37 @@ class AnimeWatchAdapter( } val offline = !isOnline(binding.root.context) || PrefManager.getVal(PrefName.OfflineMode) - binding.animeSourceNameContainer.isGone = offline - binding.animeSourceSettings.isGone = offline - binding.animeSourceSearch.isGone = offline - binding.animeSourceTitle.isGone = offline + binding.mediaSourceNameContainer.isGone = offline + binding.mediaSourceSettings.isGone = offline + binding.mediaSourceSearch.isGone = offline + binding.mediaSourceTitle.isGone = offline - //Source Selection + // Source Selection var source = media.selected!!.sourceIndex.let { if (it >= watchSources.names.size) 0 else it } setLanguageList(media.selected!!.langIndex, source) if (watchSources.names.isNotEmpty() && source in 0 until watchSources.names.size) { - binding.animeSource.setText(watchSources.names[source]) + binding.mediaSource.setText(watchSources.names[source]) watchSources[source].apply { this.selectDub = media.selected!!.preferDub - binding.animeSourceTitle.text = showUserText - showUserTextListener = { MainScope().launch { binding.animeSourceTitle.text = it } } + binding.mediaSourceTitle.text = showUserText + showUserTextListener = { MainScope().launch { binding.mediaSourceTitle.text = it } } binding.animeSourceDubbedCont.isVisible = isDubAvailableSeparately() } } - binding.animeSource.setAdapter( + binding.mediaSource.setAdapter( ArrayAdapter( fragment.requireContext(), R.layout.item_dropdown, watchSources.names ) ) - binding.animeSourceTitle.isSelected = true - binding.animeSource.setOnItemClickListener { _, _, i, _ -> + binding.mediaSourceTitle.isSelected = true + binding.mediaSource.setOnItemClickListener { _, _, i, _ -> fragment.onSourceChange(i).apply { - binding.animeSourceTitle.text = showUserText - showUserTextListener = { MainScope().launch { binding.animeSourceTitle.text = it } } + binding.mediaSourceTitle.text = showUserText + showUserTextListener = { MainScope().launch { binding.mediaSourceTitle.text = it } } changing = true binding.animeSourceDubbed.isChecked = selectDub changing = false @@ -150,15 +151,15 @@ class AnimeWatchAdapter( fragment.loadEpisodes(i, false) } - binding.animeSourceLanguage.setOnItemClickListener { _, _, i, _ -> + binding.mediaSourceLanguage.setOnItemClickListener { _, _, i, _ -> // Check if 'extension' and 'selected' properties exist and are accessible (watchSources[source] as? DynamicAnimeParser)?.let { ext -> ext.sourceLanguage = i fragment.onLangChange(i) fragment.onSourceChange(media.selected!!.sourceIndex).apply { - binding.animeSourceTitle.text = showUserText + binding.mediaSourceTitle.text = showUserText showUserTextListener = - { MainScope().launch { binding.animeSourceTitle.text = it } } + { MainScope().launch { binding.mediaSourceTitle.text = it } } changing = true binding.animeSourceDubbed.isChecked = selectDub changing = false @@ -170,19 +171,19 @@ class AnimeWatchAdapter( } ?: run { } } - //settings - binding.animeSourceSettings.setOnClickListener { + // Settings + binding.mediaSourceSettings.setOnClickListener { (watchSources[source] as? DynamicAnimeParser)?.let { ext -> fragment.openSettings(ext.extension) } } - //Icons + // Icons - //subscribe + // Subscribe subscribe = MediaDetailsActivity.PopImageButton( fragment.lifecycleScope, - binding.animeSourceSubscribe, + binding.mediaSourceSubscribe, R.drawable.ic_round_notifications_active_24, R.drawable.ic_round_notifications_none_24, R.color.bg_opp, @@ -190,125 +191,164 @@ class AnimeWatchAdapter( fragment.subscribed, true ) { - fragment.onNotificationPressed(it, binding.animeSource.text.toString()) + fragment.onNotificationPressed(it, binding.mediaSource.text.toString()) } subscribeButton(false) - binding.animeSourceSubscribe.setOnLongClickListener { + binding.mediaSourceSubscribe.setOnLongClickListener { openSettings(fragment.requireContext(), CHANNEL_SUBSCRIPTION_CHECK) } - //Nested Button - binding.animeNestedButton.setOnClickListener { - val dialogView = - LayoutInflater.from(fragment.requireContext()).inflate(R.layout.dialog_layout, null) - val dialogBinding = DialogLayoutBinding.bind(dialogView) - var refresh = false - var run = false - var reversed = media.selected!!.recyclerReversed - var style = - media.selected!!.recyclerStyle ?: PrefManager.getVal(PrefName.AnimeDefaultView) - dialogBinding.animeSourceTop.rotation = if (reversed) -90f else 90f - dialogBinding.sortText.text = if (reversed) "Down to Up" else "Up to Down" - dialogBinding.animeSourceTop.setOnClickListener { - reversed = !reversed - dialogBinding.animeSourceTop.rotation = if (reversed) -90f else 90f - dialogBinding.sortText.text = if (reversed) "Down to Up" else "Up to Down" - run = true - } - //Grids - var selected = when (style) { - 0 -> dialogBinding.animeSourceList - 1 -> dialogBinding.animeSourceGrid - 2 -> dialogBinding.animeSourceCompact - else -> dialogBinding.animeSourceList - } - when (style) { - 0 -> dialogBinding.layoutText.setText(R.string.list) - 1 -> dialogBinding.layoutText.setText(R.string.grid) - 2 -> dialogBinding.layoutText.setText(R.string.compact) - else -> dialogBinding.animeSourceList - } - selected.alpha = 1f - fun selected(it: ImageButton) { - selected.alpha = 0.33f - selected = it - selected.alpha = 1f - } - dialogBinding.animeSourceList.setOnClickListener { - selected(it as ImageButton) - style = 0 - dialogBinding.layoutText.setText(R.string.list) - run = true - } - dialogBinding.animeSourceGrid.setOnClickListener { - selected(it as ImageButton) - style = 1 - dialogBinding.layoutText.setText(R.string.grid) - run = true - } - dialogBinding.animeSourceCompact.setOnClickListener { - selected(it as ImageButton) - style = 2 - dialogBinding.layoutText.setText(R.string.compact) - run = true - } - dialogBinding.animeWebviewContainer.setOnClickListener { - if (!WebViewUtil.supportsWebView(fragment.requireContext())) { - toast(R.string.webview_not_installed) + // Nested Button + binding.mediaNestedButton.setOnClickListener { + val dialogBinding = DialogLayoutBinding.inflate(fragment.layoutInflater) + dialogBinding.apply { + var refresh = false + var run = false + var reversed = media.selected!!.recyclerReversed + var style = + media.selected!!.recyclerStyle ?: PrefManager.getVal(PrefName.AnimeDefaultView) + + mediaSourceTop.rotation = if (reversed) -90f else 90f + sortText.text = if (reversed) "Down to Up" else "Up to Down" + mediaSourceTop.setOnClickListener { + reversed = !reversed + mediaSourceTop.rotation = if (reversed) -90f else 90f + sortText.text = if (reversed) "Down to Up" else "Up to Down" + run = true } - //start CookieCatcher activity - if (watchSources.names.isNotEmpty() && source in 0 until watchSources.names.size) { - val sourceAHH = watchSources[source] as? DynamicAnimeParser - val sourceHttp = - sourceAHH?.extension?.sources?.firstOrNull() as? AnimeHttpSource - val url = sourceHttp?.baseUrl - url?.let { - refresh = true - val headersMap = try { - sourceHttp.headers.toMultimap() - .mapValues { it.value.getOrNull(0) ?: "" } - } catch (e: Exception) { - emptyMap() + // Grids + var selected = when (style) { + 0 -> mediaSourceList + 1 -> mediaSourceGrid + 2 -> mediaSourceCompact + else -> mediaSourceList + } + when (style) { + 0 -> layoutText.setText(R.string.list) + 1 -> layoutText.setText(R.string.grid) + 2 -> layoutText.setText(R.string.compact) + else -> mediaSourceList + } + selected.alpha = 1f + fun selected(it: ImageButton) { + selected.alpha = 0.33f + selected = it + selected.alpha = 1f + } + mediaSourceList.setOnClickListener { + selected(it as ImageButton) + style = 0 + layoutText.setText(R.string.list) + run = true + } + mediaSourceGrid.setOnClickListener { + selected(it as ImageButton) + style = 1 + layoutText.setText(R.string.grid) + run = true + } + mediaSourceCompact.setOnClickListener { + selected(it as ImageButton) + style = 2 + layoutText.setText(R.string.compact) + run = true + } + mediaWebviewContainer.setOnClickListener { + if (!WebViewUtil.supportsWebView(fragment.requireContext())) { + toast(R.string.webview_not_installed) + } + // Start CookieCatcher activity + if (watchSources.names.isNotEmpty() && source in 0 until watchSources.names.size) { + val sourceAHH = watchSources[source] as? DynamicAnimeParser + val sourceHttp = + sourceAHH?.extension?.sources?.firstOrNull() as? AnimeHttpSource + val url = sourceHttp?.baseUrl + url?.let { + refresh = true + val headersMap = try { + sourceHttp.headers.toMultimap() + .mapValues { it.value.getOrNull(0) ?: "" } + } catch (e: Exception) { + emptyMap() + } + val intent = + Intent(fragment.requireContext(), CookieCatcher::class.java) + .putExtra("url", url) + .putExtra("headers", headersMap as HashMap) + startActivity(fragment.requireContext(), intent, null) } - val intent = Intent(fragment.requireContext(), CookieCatcher::class.java) - .putExtra("url", url) - .putExtra("headers", headersMap as HashMap) - startActivity(fragment.requireContext(), intent, null) } } + resetProgress.setOnClickListener { + fragment.requireContext().customAlertDialog().apply { + setTitle(" Delete Progress for all episodes of ${media.nameRomaji}") + setMessage("This will delete all the locally stored progress for all episodes") + setPosButton(R.string.ok){ + val prefix = "${media.id}_" + val regex = Regex("^${prefix}\\d+$") + + PrefManager.getAllCustomValsForMedia(prefix) + .keys + .filter { it.matches(regex) } + .onEach { key -> PrefManager.removeCustomVal(key) } + snackString("Deleted the progress of all Episodes for ${media.nameRomaji}") + } + setNegButton(R.string.no) + show() + } + } + + resetProgressDef.text = getString(currContext()!!,R.string.clear_stored_episode) + + // Hidden + mangaScanlatorContainer.visibility = View.GONE + animeDownloadContainer.visibility = View.GONE + fragment.requireContext().customAlertDialog().apply { + setTitle("Options") + setCustomView(dialogBinding.root) + setPosButton("OK") { + if (run) fragment.onIconPressed(style, reversed) + if (refresh) fragment.loadEpisodes(source, true) + } + setNegButton("Cancel") { + if (refresh) fragment.loadEpisodes(source, true) + } + show() + } } - - //hidden - dialogBinding.animeScanlatorContainer.visibility = View.GONE - dialogBinding.animeDownloadContainer.visibility = View.GONE - - nestedDialog = AlertDialog.Builder(fragment.requireContext(), R.style.MyPopup) - .setTitle("Options") - .setView(dialogView) - .setPositiveButton("OK") { _, _ -> - if (run) fragment.onIconPressed(style, reversed) - if (refresh) fragment.loadEpisodes(source, true) - } - .setNegativeButton("Cancel") { _, _ -> - if (refresh) fragment.loadEpisodes(source, true) - } - .setOnCancelListener { - if (refresh) fragment.loadEpisodes(source, true) - } - .create() - nestedDialog?.show() } - //Episode Handling + // Episode Handling handleEpisodes() + + //clear progress + binding.sourceTitle.setOnLongClickListener { + fragment.requireContext().customAlertDialog().apply { + setTitle(" Delete Progress for all episodes of ${media.nameRomaji}") + setMessage("This will delete all the locally stored progress for all episodes") + setPosButton(R.string.ok){ + val prefix = "${media.id}_" + val regex = Regex("^${prefix}\\d+$") + + PrefManager.getAllCustomValsForMedia(prefix) + .keys + .filter { it.matches(regex) } + .onEach { key -> PrefManager.removeCustomVal(key) } + snackString("Deleted the progress of all Episodes for ${media.nameRomaji}") + } + setNegButton(R.string.no) + show() + } + true + } } fun subscribeButton(enabled: Boolean) { subscribe?.enabled(enabled) } - //Chips + // Chips fun updateChips(limit: Int, names: Array, arr: Array, selected: Int = 0) { val binding = _binding if (binding != null) { @@ -319,13 +359,13 @@ class AnimeWatchAdapter( val chip = ItemChipBinding.inflate( LayoutInflater.from(fragment.context), - binding.animeSourceChipGroup, + binding.mediaSourceChipGroup, false ).root chip.isCheckable = true fun selected() { chip.isChecked = true - binding.animeWatchChipScroll.smoothScrollTo( + binding.mediaWatchChipScroll.smoothScrollTo( (chip.left - screenWidth / 2) + (chip.width / 2), 0 ) @@ -344,14 +384,14 @@ class AnimeWatchAdapter( selected() fragment.onChipClicked(position, limit * (position), last - 1) } - binding.animeSourceChipGroup.addView(chip) + binding.mediaSourceChipGroup.addView(chip) if (selected == position) { selected() select = chip } } if (select != null) - binding.animeWatchChipScroll.apply { + binding.mediaWatchChipScroll.apply { post { scrollTo( (select.left - screenWidth / 2) + (select.width / 2), @@ -363,7 +403,7 @@ class AnimeWatchAdapter( } fun clearChips() { - _binding?.animeSourceChipGroup?.removeAllViews() + _binding?.mediaSourceChipGroup?.removeAllViews() } fun handleEpisodes() { @@ -379,15 +419,15 @@ class AnimeWatchAdapter( var continueEp = (if (anilistEp > appEp) anilistEp else appEp).toString() if (episodes.contains(continueEp)) { - binding.animeSourceContinue.visibility = View.VISIBLE + binding.sourceContinue.visibility = View.VISIBLE handleProgress( - binding.itemEpisodeProgressCont, - binding.itemEpisodeProgress, - binding.itemEpisodeProgressEmpty, + binding.itemMediaProgressCont, + binding.itemMediaProgress, + binding.itemMediaProgressEmpty, media.id, continueEp ) - if ((binding.itemEpisodeProgress.layoutParams as LinearLayout.LayoutParams).weight > PrefManager.getVal( + if ((binding.itemMediaProgress.layoutParams as LinearLayout.LayoutParams).weight > PrefManager.getVal( PrefName.WatchPercentage ) ) { @@ -395,9 +435,9 @@ class AnimeWatchAdapter( if (e != -1 && e + 1 < episodes.size) { continueEp = episodes[e + 1] handleProgress( - binding.itemEpisodeProgressCont, - binding.itemEpisodeProgress, - binding.itemEpisodeProgressEmpty, + binding.itemMediaProgressCont, + binding.itemMediaProgress, + binding.itemMediaProgressEmpty, media.id, continueEp ) @@ -407,51 +447,63 @@ class AnimeWatchAdapter( val cleanedTitle = ep.title?.let { MediaNameAdapter.removeEpisodeNumber(it) } - binding.itemEpisodeImage.loadImage( + binding.itemMediaImage.loadImage( ep.thumb ?: FileUrl[media.banner ?: media.cover], 0 ) if (ep.filler) binding.itemEpisodeFillerView.visibility = View.VISIBLE - binding.animeSourceContinueText.text = + binding.mediaSourceContinueText.text = currActivity()!!.getString( R.string.continue_episode, ep.number, if (ep.filler) currActivity()!!.getString(R.string.filler_tag) else "", cleanedTitle ) - binding.animeSourceContinue.setOnClickListener { + binding.sourceContinue.setOnClickListener { fragment.onEpisodeClick(continueEp) } if (fragment.continueEp) { if ( - (binding.itemEpisodeProgress.layoutParams as LinearLayout.LayoutParams) + (binding.itemMediaProgress.layoutParams as LinearLayout.LayoutParams) .weight < PrefManager.getVal(PrefName.WatchPercentage) ) { - binding.animeSourceContinue.performClick() + binding.sourceContinue.performClick() fragment.continueEp = false } } } else { - binding.animeSourceContinue.visibility = View.GONE + binding.sourceContinue.visibility = View.GONE } - binding.animeSourceProgressBar.visibility = View.GONE + binding.sourceProgressBar.visibility = View.GONE val sourceFound = media.anime.episodes!!.isNotEmpty() - binding.animeSourceNotFound.isGone = sourceFound + val isDownloadedSource = watchSources[media.selected!!.sourceIndex] is OfflineAnimeParser + + if (isDownloadedSource) { + binding.sourceNotFound.text = if (sourceFound) { + currActivity()!!.getString(R.string.source_not_found) + } else { + currActivity()!!.getString(R.string.download_not_found) + } + } else { + binding.sourceNotFound.text = currActivity()!!.getString(R.string.source_not_found) + } + + binding.sourceNotFound.isGone = sourceFound binding.faqbutton.isGone = sourceFound if (!sourceFound && PrefManager.getVal(PrefName.SearchSources) && autoSelect) { - if (binding.animeSource.adapter.count > media.selected!!.sourceIndex + 1) { + if (binding.mediaSource.adapter.count > media.selected!!.sourceIndex + 1) { val nextIndex = media.selected!!.sourceIndex + 1 - binding.animeSource.setText( - binding.animeSource.adapter + binding.mediaSource.setText( + binding.mediaSource.adapter .getItem(nextIndex).toString(), false ) fragment.onSourceChange(nextIndex).apply { - binding.animeSourceTitle.text = showUserText + binding.mediaSourceTitle.text = showUserText showUserTextListener = - { MainScope().launch { binding.animeSourceTitle.text = it } } + { MainScope().launch { binding.mediaSourceTitle.text = it } } binding.animeSourceDubbed.isChecked = selectDub binding.animeSourceDubbedCont.isVisible = isDubAvailableSeparately() setLanguageList(0, nextIndex) @@ -460,13 +512,13 @@ class AnimeWatchAdapter( fragment.loadEpisodes(nextIndex, false) } } - binding.animeSource.setOnClickListener { autoSelect = false } + binding.mediaSource.setOnClickListener { autoSelect = false } } else { - binding.animeSourceContinue.visibility = View.GONE - binding.animeSourceNotFound.visibility = View.GONE + binding.sourceContinue.visibility = View.GONE + binding.sourceNotFound.visibility = View.GONE binding.faqbutton.visibility = View.GONE clearChips() - binding.animeSourceProgressBar.visibility = View.VISIBLE + binding.sourceProgressBar.visibility = View.VISIBLE } } } @@ -480,9 +532,9 @@ class AnimeWatchAdapter( ext.sourceLanguage = lang } try { - binding?.animeSourceLanguage?.setText(parser.extension.sources[lang].lang) + binding?.mediaSourceLanguage?.setText(parser.extension.sources[lang].lang) } catch (e: IndexOutOfBoundsException) { - binding?.animeSourceLanguage?.setText( + binding?.mediaSourceLanguage?.setText( parser.extension.sources.firstOrNull()?.lang ?: "Unknown" ) } @@ -493,9 +545,9 @@ class AnimeWatchAdapter( ) val items = adapter.count - binding?.animeSourceLanguageContainer?.visibility = + binding?.mediaSourceLanguageContainer?.visibility = if (items > 1) View.VISIBLE else View.GONE - binding?.animeSourceLanguage?.setAdapter(adapter) + binding?.mediaSourceLanguage?.setAdapter(adapter) } } @@ -503,7 +555,7 @@ class AnimeWatchAdapter( override fun getItemCount(): Int = 1 - inner class ViewHolder(val binding: ItemAnimeWatchBinding) : + inner class ViewHolder(val binding: ItemMediaSourceBinding) : RecyclerView.ViewHolder(binding.root) { init { displayTimer(media, binding.animeSourceContainer) diff --git a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt index 32b90910..847d8755 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt @@ -31,7 +31,8 @@ import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.FileUrl import ani.dantotsu.R import ani.dantotsu.addons.download.DownloadAddonManager -import ani.dantotsu.databinding.FragmentAnimeWatchBinding +import ani.dantotsu.connections.anilist.api.MediaStreamingEpisode +import ani.dantotsu.databinding.FragmentMediaSourceBinding import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager.Companion.compareName @@ -48,6 +49,7 @@ import ani.dantotsu.media.MediaType import ani.dantotsu.navBarHeight import ani.dantotsu.notifications.subscription.SubscriptionHelper import ani.dantotsu.notifications.subscription.SubscriptionHelper.Companion.saveSubscription +import ani.dantotsu.others.Anify import ani.dantotsu.others.LanguageMapper import ani.dantotsu.parsers.AnimeParser import ani.dantotsu.parsers.AnimeSources @@ -61,6 +63,7 @@ import ani.dantotsu.toast import ani.dantotsu.util.Logger import ani.dantotsu.util.StoragePermissions.Companion.accessAlertDialog import ani.dantotsu.util.StoragePermissions.Companion.hasDirAccess +import ani.dantotsu.util.customAlertDialog import com.anggrayudi.storage.file.extension import com.google.android.material.appbar.AppBarLayout import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource @@ -78,7 +81,7 @@ import kotlin.math.max import kotlin.math.roundToInt class AnimeWatchFragment : Fragment() { - private var _binding: FragmentAnimeWatchBinding? = null + private var _binding: FragmentMediaSourceBinding? = null private val binding get() = _binding!! private val model: MediaDetailsViewModel by activityViewModels() @@ -105,7 +108,7 @@ class AnimeWatchFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View? { - _binding = FragmentAnimeWatchBinding.inflate(inflater, container, false) + _binding = FragmentMediaSourceBinding.inflate(inflater, container, false) return _binding?.root } @@ -126,7 +129,7 @@ class AnimeWatchFragment : Fragment() { ) - binding.animeSourceRecycler.updatePadding(bottom = binding.animeSourceRecycler.paddingBottom + navBarHeight) + binding.mediaSourceRecycler.updatePadding(bottom = binding.mediaSourceRecycler.paddingBottom + navBarHeight) screenWidth = resources.displayMetrics.widthPixels.dp var maxGridSize = (screenWidth / 100f).roundToInt() @@ -150,13 +153,13 @@ class AnimeWatchFragment : Fragment() { } } - binding.animeSourceRecycler.layoutManager = gridLayoutManager + binding.mediaSourceRecycler.layoutManager = gridLayoutManager binding.ScrollTop.setOnClickListener { - binding.animeSourceRecycler.scrollToPosition(10) - binding.animeSourceRecycler.smoothScrollToPosition(0) + binding.mediaSourceRecycler.scrollToPosition(10) + binding.mediaSourceRecycler.smoothScrollToPosition(0) } - binding.animeSourceRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + binding.mediaSourceRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) @@ -170,7 +173,7 @@ class AnimeWatchFragment : Fragment() { } }) model.scrolledToTop.observe(viewLifecycleOwner) { - if (it) binding.animeSourceRecycler.scrollToPosition(0) + if (it) binding.mediaSourceRecycler.scrollToPosition(0) } continueEp = model.continueMedia ?: false @@ -203,7 +206,7 @@ class AnimeWatchFragment : Fragment() { offlineMode = offlineMode ) - binding.animeSourceRecycler.adapter = + binding.mediaSourceRecycler.adapter = ConcatAdapter(headerAdapter, episodeAdapter) lifecycleScope.launch(Dispatchers.IO) { @@ -212,10 +215,11 @@ class AnimeWatchFragment : Fragment() { if (offline) { media.selected!!.sourceIndex = model.watchSources!!.list.lastIndex } else { - awaitAll( - async { model.loadKitsuEpisodes(media) }, - async { model.loadFillerEpisodes(media) } - ) + val kitsuEpisodes = async { model.loadKitsuEpisodes(media) } + val anifyEpisodes = async { model.loadAnifyEpisodes(media.id) } + val fillerEpisodes = async { model.loadFillerEpisodes(media) } + + awaitAll(kitsuEpisodes, anifyEpisodes, fillerEpisodes) } model.loadEpisodes(media, media.selected!!.sourceIndex) } @@ -230,6 +234,18 @@ class AnimeWatchFragment : Fragment() { val episodes = loadedEpisodes[media.selected!!.sourceIndex] if (episodes != null) { episodes.forEach { (i, episode) -> + if (media.anime?.anifyEpisodes != null) { + if (media.anime!!.anifyEpisodes!!.containsKey(i)) { + episode.desc = media.anime!!.anifyEpisodes!![i]?.desc ?: episode.desc + episode.title = if (MediaNameAdapter.removeEpisodeNumberCompletely( + episode.title ?: "" + ).isBlank() + ) media.anime!!.anifyEpisodes!![i]?.title ?: episode.title else episode.title + ?: media.anime!!.anifyEpisodes!![i]?.title ?: episode.title + episode.thumb = media.anime!!.anifyEpisodes!![i]?.thumb ?: episode.thumb + + } + } if (media.anime?.fillerEpisodes != null) { if (media.anime!!.fillerEpisodes!!.containsKey(i)) { episode.title = @@ -239,22 +255,19 @@ class AnimeWatchFragment : Fragment() { } if (media.anime?.kitsuEpisodes != null) { if (media.anime!!.kitsuEpisodes!!.containsKey(i)) { - episode.desc = - media.anime!!.kitsuEpisodes!![i]?.desc ?: episode.desc + episode.desc = media.anime!!.kitsuEpisodes!![i]?.desc ?: episode.desc episode.title = if (MediaNameAdapter.removeEpisodeNumberCompletely( episode.title ?: "" ).isBlank() - ) media.anime!!.kitsuEpisodes!![i]?.title - ?: episode.title else episode.title - ?: media.anime!!.kitsuEpisodes!![i]?.title ?: episode.title - episode.thumb = media.anime!!.kitsuEpisodes!![i]?.thumb - ?: FileUrl[media.cover] + ) media.anime!!.kitsuEpisodes!![i]?.title ?: episode.title else episode.title + ?: media.anime!!.kitsuEpisodes!![i]?.title ?: episode.title + episode.thumb = media.anime!!.kitsuEpisodes!![i]?.thumb ?: episode.thumb } } } media.anime?.episodes = episodes - //CHIP GROUP + // CHIP GROUP val total = episodes.size val divisions = total.toDouble() / 10 start = 0 @@ -295,6 +308,10 @@ class AnimeWatchFragment : Fragment() { if (i != null) media.anime?.fillerEpisodes = i } + model.getAnifyEpisodes().observe(viewLifecycleOwner) { i -> + if (i != null) + media.anime?.anifyEpisodes = i + } } fun onSourceChange(i: Int): AnimeParser { @@ -380,20 +397,18 @@ class AnimeWatchFragment : Fragment() { if (allSettings.size > 1) { val names = allSettings.map { LanguageMapper.getLanguageName(it.lang) }.toTypedArray() - val dialog = AlertDialog.Builder(requireContext(), R.style.MyPopup) - .setTitle("Select a Source") - .setSingleChoiceItems(names, -1) { dialog, which -> + requireContext() + .customAlertDialog() + .apply { + setTitle("Select a Source") + singleChoiceItems(names) { which -> selectedSetting = allSettings[which] itemSelected = true - dialog.dismiss() - - // Move the fragment transaction here requireActivity().runOnUiThread { - val fragment = - AnimeSourcePreferencesFragment().getInstance(selectedSetting.id) { - changeUIVisibility(true) - loadEpisodes(media.selected!!.sourceIndex, true) - } + val fragment = AnimeSourcePreferencesFragment().getInstance(selectedSetting.id) { + changeUIVisibility(true) + loadEpisodes(media.selected!!.sourceIndex, true) + } parentFragmentManager.beginTransaction() .setCustomAnimations(R.anim.slide_up, R.anim.slide_down) .replace(R.id.fragmentExtensionsContainer, fragment) @@ -401,13 +416,13 @@ class AnimeWatchFragment : Fragment() { .commit() } } - .setOnDismissListener { + onDismiss { if (!itemSelected) { changeUIVisibility(true) } } - .show() - dialog.window?.setDimAmount(0.8f) + show() + } } else { // If there's only one setting, proceed with the fragment transaction requireActivity().runOnUiThread { @@ -416,11 +431,12 @@ class AnimeWatchFragment : Fragment() { changeUIVisibility(true) loadEpisodes(media.selected!!.sourceIndex, true) } - parentFragmentManager.beginTransaction() - .setCustomAnimations(R.anim.slide_up, R.anim.slide_down) - .replace(R.id.fragmentExtensionsContainer, fragment) - .addToBackStack(null) - .commit() + parentFragmentManager.beginTransaction().apply { + setCustomAnimations(R.anim.slide_up, R.anim.slide_down) + replace(R.id.fragmentExtensionsContainer, fragment) + addToBackStack(null) + commit() + } } } @@ -619,7 +635,7 @@ class AnimeWatchFragment : Fragment() { private fun reload() { val selected = model.loadSelected(media) - //Find latest episode for subscription + // Find latest episode for subscription selected.latest = media.anime?.episodes?.values?.maxOfOrNull { it.number.toFloatOrNull() ?: 0f } ?: 0f selected.latest = @@ -663,14 +679,14 @@ class AnimeWatchFragment : Fragment() { override fun onResume() { super.onResume() binding.mediaInfoProgressBar.visibility = progress - binding.animeSourceRecycler.layoutManager?.onRestoreInstanceState(state) + binding.mediaSourceRecycler.layoutManager?.onRestoreInstanceState(state) requireActivity().setNavigationTheme() } override fun onPause() { super.onPause() - state = binding.animeSourceRecycler.layoutManager?.onSaveInstanceState() + state = binding.mediaSourceRecycler.layoutManager?.onSaveInstanceState() } companion object { diff --git a/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt b/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt index 14be291a..c0ac4352 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt @@ -23,6 +23,7 @@ import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.MediaType import ani.dantotsu.setAnimation import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.util.customAlertDialog import com.bumptech.glide.Glide import com.bumptech.glide.load.model.GlideUrl import kotlinx.coroutines.delay @@ -106,8 +107,8 @@ class EpisodeAdapter( val thumb = ep.thumb?.let { if (it.url.isNotEmpty()) GlideUrl(it.url) { it.headers } else null } - Glide.with(binding.itemEpisodeImage).load(thumb ?: media.cover).override(400, 0) - .into(binding.itemEpisodeImage) + Glide.with(binding.itemMediaImage).load(thumb ?: media.cover).override(400, 0) + .into(binding.itemMediaImage) binding.itemEpisodeNumber.text = ep.number binding.itemEpisodeTitle.text = if (ep.number == title) "Episode $title" else title @@ -140,9 +141,9 @@ class EpisodeAdapter( } handleProgress( - binding.itemEpisodeProgressCont, - binding.itemEpisodeProgress, - binding.itemEpisodeProgressEmpty, + binding.itemMediaProgressCont, + binding.itemMediaProgress, + binding.itemMediaProgressEmpty, media.id, ep.number ) @@ -154,8 +155,8 @@ class EpisodeAdapter( val thumb = ep.thumb?.let { if (it.url.isNotEmpty()) GlideUrl(it.url) { it.headers } else null } - Glide.with(binding.itemEpisodeImage).load(thumb ?: media.cover).override(400, 0) - .into(binding.itemEpisodeImage) + Glide.with(binding.itemMediaImage).load(thumb ?: media.cover).override(400, 0) + .into(binding.itemMediaImage) binding.itemEpisodeNumber.text = ep.number binding.itemEpisodeTitle.text = title @@ -183,9 +184,9 @@ class EpisodeAdapter( binding.itemEpisodeViewed.visibility = View.GONE } handleProgress( - binding.itemEpisodeProgressCont, - binding.itemEpisodeProgress, - binding.itemEpisodeProgressEmpty, + binding.itemMediaProgressCont, + binding.itemMediaProgress, + binding.itemMediaProgressEmpty, media.id, ep.number ) @@ -208,9 +209,9 @@ class EpisodeAdapter( } } handleProgress( - binding.itemEpisodeProgressCont, - binding.itemEpisodeProgress, - binding.itemEpisodeProgressEmpty, + binding.itemMediaProgressCont, + binding.itemMediaProgress, + binding.itemMediaProgressEmpty, media.id, ep.number ) @@ -318,16 +319,14 @@ class EpisodeAdapter( fragment.onAnimeEpisodeStopDownloadClick(episodeNumber) return@setOnClickListener } else if (downloadedEpisodes.contains(episodeNumber)) { - val builder = AlertDialog.Builder(currContext(), R.style.MyPopup) - builder.setTitle("Delete Episode") - builder.setMessage("Are you sure you want to delete Episode ${episodeNumber}?") - builder.setPositiveButton("Yes") { _, _ -> - fragment.onAnimeEpisodeRemoveDownloadClick(episodeNumber) - } - builder.setNegativeButton("No") { _, _ -> - } - val dialog = builder.show() - dialog.window?.setDimAmount(0.8f) + binding.root.context.customAlertDialog().apply { + setTitle("Delete Episode") + setMessage("Are you sure you want to delete Episode $episodeNumber?") + setPosButton(R.string.yes) { + fragment.onAnimeEpisodeRemoveDownloadClick(episodeNumber) + } + setNegButton(R.string.no) + }.show() return@setOnClickListener } else { fragment.onAnimeEpisodeDownloadClick(episodeNumber) 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 b0bacad5..5fd35024 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt @@ -12,6 +12,7 @@ import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.content.res.Configuration +import android.content.res.Resources import android.graphics.Color import android.graphics.drawable.Animatable import android.hardware.SensorManager @@ -71,9 +72,12 @@ import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player +import androidx.media3.common.text.Cue +import androidx.media3.common.text.CueGroup import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks +import androidx.media3.common.util.Util import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultDataSource @@ -81,6 +85,7 @@ import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -117,6 +122,7 @@ import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.download.video.Helper import ani.dantotsu.dp import ani.dantotsu.getCurrentBrightnessValue +import ani.dantotsu.getLanguageCode import ani.dantotsu.hideSystemBars import ani.dantotsu.hideSystemBarsExtendView import ani.dantotsu.isOnline @@ -135,6 +141,7 @@ import ani.dantotsu.others.getSerialized import ani.dantotsu.parsers.AnimeSources import ani.dantotsu.parsers.HAnimeSources import ani.dantotsu.parsers.Subtitle +import ani.dantotsu.others.Xubtitle import ani.dantotsu.parsers.SubtitleType import ani.dantotsu.parsers.Video import ani.dantotsu.parsers.VideoExtractor @@ -164,6 +171,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import java.util.Calendar import java.util.Locale import java.util.Timer @@ -223,6 +231,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL private lateinit var animeTitle: TextView private lateinit var videoInfo: TextView private lateinit var episodeTitle: Spinner + private lateinit var customSubtitleView: Xubtitle private var orientationListener: OrientationEventListener? = null @@ -318,36 +327,10 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL } private fun setupSubFormatting(playerView: PlayerView) { - val primaryColor = when (PrefManager.getVal(PrefName.PrimaryColor)) { - 0 -> Color.BLACK - 1 -> Color.DKGRAY - 2 -> Color.GRAY - 3 -> Color.LTGRAY - 4 -> Color.WHITE - 5 -> Color.RED - 6 -> Color.YELLOW - 7 -> Color.GREEN - 8 -> Color.CYAN - 9 -> Color.BLUE - 10 -> Color.MAGENTA - 11 -> Color.TRANSPARENT - else -> Color.WHITE - } - val secondaryColor = when (PrefManager.getVal(PrefName.SecondaryColor)) { - 0 -> Color.BLACK - 1 -> Color.DKGRAY - 2 -> Color.GRAY - 3 -> Color.LTGRAY - 4 -> Color.WHITE - 5 -> Color.RED - 6 -> Color.YELLOW - 7 -> Color.GREEN - 8 -> Color.CYAN - 9 -> Color.BLUE - 10 -> Color.MAGENTA - 11 -> Color.TRANSPARENT - else -> Color.BLACK - } + val primaryColor = PrefManager.getVal(PrefName.PrimaryColor) + + val secondaryColor = PrefManager.getVal(PrefName.SecondaryColor) + val outline = when (PrefManager.getVal(PrefName.Outline)) { 0 -> EDGE_TYPE_OUTLINE // Normal 1 -> EDGE_TYPE_DEPRESSED // Shine @@ -355,36 +338,11 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL 3 -> EDGE_TYPE_NONE // No outline else -> EDGE_TYPE_OUTLINE // Normal } - val subBackground = when (PrefManager.getVal(PrefName.SubBackground)) { - 0 -> Color.TRANSPARENT - 1 -> Color.BLACK - 2 -> Color.DKGRAY - 3 -> Color.GRAY - 4 -> Color.LTGRAY - 5 -> Color.WHITE - 6 -> Color.RED - 7 -> Color.YELLOW - 8 -> Color.GREEN - 9 -> Color.CYAN - 10 -> Color.BLUE - 11 -> Color.MAGENTA - else -> Color.TRANSPARENT - } - val subWindow = when (PrefManager.getVal(PrefName.SubWindow)) { - 0 -> Color.TRANSPARENT - 1 -> Color.BLACK - 2 -> Color.DKGRAY - 3 -> Color.GRAY - 4 -> Color.LTGRAY - 5 -> Color.WHITE - 6 -> Color.RED - 7 -> Color.YELLOW - 8 -> Color.GREEN - 9 -> Color.CYAN - 10 -> Color.BLUE - 11 -> Color.MAGENTA - else -> Color.TRANSPARENT - } + + val subBackground = PrefManager.getVal(PrefName.SubBackground) + + val subWindow = PrefManager.getVal(PrefName.SubWindow) + val font = when (PrefManager.getVal(PrefName.Font)) { 0 -> ResourcesCompat.getFont(this, R.font.poppins_semi_bold) 1 -> ResourcesCompat.getFont(this, R.font.poppins_bold) @@ -422,6 +380,53 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL } } + private fun applySubtitleStyles(textView: Xubtitle) { + val primaryColor = PrefManager.getVal(PrefName.PrimaryColor) + + val subBackground = PrefManager.getVal(PrefName.SubBackground) + + val secondaryColor = PrefManager.getVal(PrefName.SecondaryColor) + + val subStroke = PrefManager.getVal(PrefName.SubStroke) + + val fontSize = PrefManager.getVal(PrefName.FontSize).toFloat() + + val font = when (PrefManager.getVal(PrefName.Font)) { + 0 -> ResourcesCompat.getFont(this, R.font.poppins_semi_bold) + 1 -> ResourcesCompat.getFont(this, R.font.poppins_bold) + 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.levenim_mt_bold) + 6 -> ResourcesCompat.getFont(this, R.font.blocky) + else -> ResourcesCompat.getFont(this, R.font.poppins_semi_bold) + } + + textView.setBackgroundColor(subBackground) + textView.setTextColor(primaryColor) + textView.typeface = font + textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize) + + textView.apply { + when (PrefManager.getVal(PrefName.Outline)) { + 0 -> applyOutline(secondaryColor, subStroke) + 1 -> applyShineEffect(secondaryColor) + 2 -> applyDropShadow(secondaryColor, subStroke) + 3 -> {} + else -> applyOutline(secondaryColor, subStroke) + } + } + + textView.alpha = + when (PrefManager.getVal(PrefName.Subtitles)) { + true -> PrefManager.getVal(PrefName.SubAlpha) + false -> 0f + } + + val textElevation = PrefManager.getVal(PrefName.SubBottomMargin) / 50 * resources.displayMetrics.heightPixels + textView.translationY = -textElevation + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -467,6 +472,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL skipTimeButton = playerView.findViewById(R.id.exo_skip_timestamp) skipTimeText = skipTimeButton.findViewById(R.id.exo_skip_timestamp_text) timeStampText = playerView.findViewById(R.id.exo_time_stamp_text) + customSubtitleView = playerView.findViewById(R.id.customSubtitleView) animeTitle = playerView.findViewById(R.id.exo_anime_title) episodeTitle = playerView.findViewById(R.id.exo_ep_sel) @@ -520,7 +526,6 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL it.visibility = View.GONE } } - setupSubFormatting(playerView) if (savedInstanceState != null) { currentWindow = savedInstanceState.getInt(resumeWindow) @@ -1114,60 +1119,6 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL ) initPlayer() preloading = false - val context = this - val offline: Boolean = PrefManager.getVal(PrefName.OfflineMode) - val incognito: Boolean = PrefManager.getVal(PrefName.Incognito) - if ((isOnline(context) && !offline) && Discord.token != null && !incognito) { - lifecycleScope.launch { - val discordMode = PrefManager.getCustomVal("discord_mode", "dantotsu") - val buttons = when (discordMode) { - "nothing" -> mutableListOf( - RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), - ) - - "dantotsu" -> mutableListOf( - RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), - RPC.Link("Watch on Dantotsu", getString(R.string.dantotsu)) - ) - - "anilist" -> { - val userId = PrefManager.getVal(PrefName.AnilistUserId) - val anilistLink = "https://anilist.co/user/$userId/" - mutableListOf( - RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), - RPC.Link("View My AniList", anilistLink) - ) - } - - else -> mutableListOf() - } - val presence = RPC.createPresence( - RPC.Companion.RPCData( - applicationId = Discord.application_Id, - type = RPC.Type.WATCHING, - activityName = media.userPreferredName, - details = ep.title?.takeIf { it.isNotEmpty() } ?: getString( - R.string.episode_num, - ep.number - ), - state = "Episode : ${ep.number}/${media.anime?.totalEpisodes ?: "??"}", - largeImage = media.cover?.let { - RPC.Link( - media.userPreferredName, - it - ) - }, - smallImage = RPC.Link("Dantotsu", Discord.small_Image), - buttons = buttons - ) - ) - val intent = Intent(context, DiscordService::class.java).apply { - putExtra("presence", presence) - } - DiscordServiceRunningSingleton.running = true - startService(intent) - } - } updateProgress() } } @@ -1358,6 +1309,73 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL } + private fun discordRPC(){ + val context = this + val ep = episode + val offline: Boolean = PrefManager.getVal(PrefName.OfflineMode) + val incognito: Boolean = PrefManager.getVal(PrefName.Incognito) + val rpcenabled: Boolean = PrefManager.getVal(PrefName.rpcEnabled) + if ((isOnline(context) && !offline) && Discord.token != null && !incognito && rpcenabled) { + lifecycleScope.launch { + val discordMode = PrefManager.getCustomVal("discord_mode", "dantotsu") + val buttons = when (discordMode) { + "nothing" -> mutableListOf( + RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), + ) + + "dantotsu" -> mutableListOf( + RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), + RPC.Link("Watch on Dantotsu", getString(R.string.dantotsu)) + ) + + "anilist" -> { + val userId = PrefManager.getVal(PrefName.AnilistUserId) + val anilistLink = "https://anilist.co/user/$userId/" + mutableListOf( + RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), + RPC.Link("View My AniList", anilistLink) + ) + } + + else -> mutableListOf() + } + val startTimestamp = Calendar.getInstance() + val durationInSeconds = if (exoPlayer.duration != C.TIME_UNSET) (exoPlayer.duration / 1000).toInt() else 1440 + + val endTimestamp = Calendar.getInstance().apply { + timeInMillis = startTimestamp.timeInMillis + add(Calendar.SECOND, durationInSeconds) + } + val presence = RPC.createPresence( + RPC.Companion.RPCData( + applicationId = Discord.application_Id, + type = RPC.Type.WATCHING, + activityName = media.userPreferredName, + details = ep.title?.takeIf { it.isNotEmpty() } ?: getString( + R.string.episode_num, + ep.number + ), + startTimestamp = startTimestamp.timeInMillis, + stopTimestamp = endTimestamp.timeInMillis, + state = "Episode : ${ep.number}/${media.anime?.totalEpisodes ?: "??"}", + largeImage = media.cover?.let { + RPC.Link( + media.userPreferredName, + it + ) + }, + smallImage = RPC.Link("Dantotsu", Discord.small_Image), + buttons = buttons + ) + ) + val intent = Intent(context, DiscordService::class.java).apply { + putExtra("presence", presence) + } + DiscordServiceRunningSingleton.running = true + startService(intent) + } + } + } private fun initPlayer() { checkNotch() @@ -1383,13 +1401,57 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL val ext = episode.extractors?.find { it.server.name == episode.selectedExtractor } ?: return extractor = ext video = ext.videos.getOrNull(episode.selectedVideo) ?: return + val subLanguages = arrayOf( + "Albanian", + "Arabic", + "Bosnian", + "Bulgarian", + "Chinese", + "Croatian", + "Czech", + "Danish", + "Dutch", + "English", + "Estonian", + "Finnish", + "French", + "Georgian", + "German", + "Greek", + "Hebrew", + "Hindi", + "Indonesian", + "Irish", + "Italian", + "Japanese", + "Korean", + "Lithuanian", + "Luxembourgish", + "Macedonian", + "Mongolian", + "Norwegian", + "Polish", + "Portuguese", + "Punjabi", + "Romanian", + "Russian", + "Serbian", + "Slovak", + "Slovenian", + "Spanish", + "Turkish", + "Ukrainian", + "Urdu", + "Vietnamese", + ) + val lang = subLanguages[PrefManager.getVal(PrefName.SubLanguage)] subtitle = intent.getSerialized("subtitle") ?: when (val subLang: String? = PrefManager.getNullableCustomVal("subLang_${media.id}", null, String::class.java)) { null -> { when (episode.selectedSubtitle) { null -> null - -1 -> ext.subtitles.find { it.language.trim() == "English" || it.language == "en-US" } + -1 -> ext.subtitles.find { it.language.contains( lang, ignoreCase = true ) || it.language.contains( getLanguageCode(lang), ignoreCase = true ) } else -> ext.subtitles.getOrNull(episode.selectedSubtitle!!) } } @@ -1642,7 +1704,18 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL .build() hideSystemBars() - exoPlayer = ExoPlayer.Builder(this) + + val useExtensionDecoder = PrefManager.getVal(PrefName.UseAdditionalCodec) + val decoder = if (useExtensionDecoder) { + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + } else { + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF + } + val renderersFactory = NextRenderersFactory(this) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(decoder) + + exoPlayer = ExoPlayer.Builder(this, renderersFactory) .setMediaSourceFactory(DefaultMediaSourceFactory(cacheFactory)) .setTrackSelector(trackSelector) .setLoadControl(loadControl) @@ -1661,6 +1734,54 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL } playerView.player = exoPlayer + exoPlayer.addListener(object : Player.Listener { + var activeSubtitles = ArrayDeque(3) + var lastSubtitle: String? = null + var lastPosition: Long = 0 + + override fun onCues(cueGroup: CueGroup) { + if (PrefManager.getVal(PrefName.TextviewSubtitles)) { + exoSubtitleView.visibility = View.GONE + customSubtitleView.visibility = View.VISIBLE + val newCues = cueGroup.cues.map { it.text.toString() ?: "" } + + if (newCues.isEmpty()) { + customSubtitleView.text = "" + activeSubtitles.clear() + lastSubtitle = null + lastPosition = 0 + return + } + + val currentPosition = exoPlayer.currentPosition + + if ((lastSubtitle?.length ?: 0) < 20 || (lastPosition != 0L && currentPosition - lastPosition > 1500)) { + activeSubtitles.clear() + } + + for (newCue in newCues) { + if (newCue !in activeSubtitles) { + if (activeSubtitles.size >= 2) { + activeSubtitles.removeLast() + } + activeSubtitles.addFirst(newCue) + lastSubtitle = newCue + lastPosition = currentPosition + } + } + + customSubtitleView.text = activeSubtitles.joinToString("\n") + } else { + customSubtitleView.text = "" + customSubtitleView.visibility = View.GONE + exoSubtitleView.visibility = View.VISIBLE + } + } + }) + + applySubtitleStyles(customSubtitleView) + setupSubFormatting(playerView) + try { val rightNow = Calendar.getInstance() mediaSession = MediaSession.Builder(this, exoPlayer) @@ -1950,7 +2071,10 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL TrackSelectionOverride(trackGroup.mediaTrackGroup, index) ) .build() - if (type == TRACK_TYPE_TEXT) setupSubFormatting(playerView) + if (type == TRACK_TYPE_TEXT) { + setupSubFormatting(playerView) + applySubtitleStyles(customSubtitleView) + } playerView.subtitleView?.alpha = when (isDisabled) { false -> PrefManager.getVal(PrefName.SubAlpha) true -> 0f @@ -2042,6 +2166,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL exoPlayer.play() if (episodeLength == 0f) { episodeLength = exoPlayer.duration.toFloat() + discordRPC() } } isBuffering = playbackState == Player.STATE_BUFFERING 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 e93267e2..febd93ec 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt @@ -444,15 +444,12 @@ class SelectorDialogFragment : BottomSheetDialogFragment() { if (subtitles.isNotEmpty()) { val subtitleNames = subtitles.map { it.language } var subtitleToDownload: Subtitle? = null - val alertDialog = AlertDialog.Builder(context, R.style.MyPopup) - .setTitle(R.string.download_subtitle) - .setSingleChoiceItems( - subtitleNames.toTypedArray(), - -1 - ) { _, which -> + requireActivity().customAlertDialog().apply { + setTitle(R.string.download_subtitle) + singleChoiceItems(subtitleNames.toTypedArray()) {which -> subtitleToDownload = subtitles[which] } - .setPositiveButton(R.string.download) { dialog, _ -> + setPosButton(R.string.download) { scope.launch { if (subtitleToDownload != null) { SubtitleDownloader.downloadSubtitle( @@ -466,13 +463,9 @@ class SelectorDialogFragment : BottomSheetDialogFragment() { ) } } - dialog.dismiss() } - .setNegativeButton(R.string.cancel) { dialog, _ -> - dialog.dismiss() - } - .show() - alertDialog.window?.setDimAmount(0.8f) + setNegButton(R.string.cancel) {} + }.show() } else { snackString(R.string.no_subtitles_available) } @@ -576,65 +569,63 @@ class SelectorDialogFragment : BottomSheetDialogFragment() { if (audioTracks.isNotEmpty()) { val audioNamesArray = audioTracks.toTypedArray() val checkedItems = BooleanArray(audioNamesArray.size) { false } - val alertDialog = AlertDialog.Builder(currContext, R.style.MyPopup) - .setTitle(R.string.download_audio_tracks) - .setMultiChoiceItems(audioNamesArray, checkedItems) { _, which, isChecked -> - val audioPair = Pair(extractor.audioTracks[which].url, extractor.audioTracks[which].lang) - if (isChecked) { - selectedAudioTracks.add(audioPair) - } else { - selectedAudioTracks.remove(audioPair) + + currContext.customAlertDialog().apply{ // ToTest + setTitle(R.string.download_audio_tracks) + multiChoiceItems(audioNamesArray, checkedItems) { + it.forEachIndexed { index, isChecked -> + val audioPair = Pair(extractor.audioTracks[index].url, extractor.audioTracks[index].lang) + if (isChecked) { + selectedAudioTracks.add(audioPair) + } else { + selectedAudioTracks.remove(audioPair) + } } } - .setPositiveButton(R.string.download) { _, _ -> - dialog?.dismiss() + setPosButton(R.string.download) { go() } - .setNegativeButton(R.string.skip) { dialog, _ -> + setNegButton(R.string.skip) { selectedAudioTracks = mutableListOf() go() - dialog.dismiss() } - .setNeutralButton(R.string.cancel) { dialog, _ -> + setNeutralButton(R.string.cancel) { selectedAudioTracks = mutableListOf() - dialog.dismiss() } - .show() - alertDialog.window?.setDimAmount(0.8f) + show() + } } else { go() } } - if (subtitles.isNotEmpty()) { + if (subtitles.isNotEmpty()) { // ToTest val subtitleNamesArray = subtitleNames.toTypedArray() val checkedItems = BooleanArray(subtitleNamesArray.size) { false } - val alertDialog = AlertDialog.Builder(currContext, R.style.MyPopup) - .setTitle(R.string.download_subtitle) - .setMultiChoiceItems(subtitleNamesArray, checkedItems) { _, which, isChecked -> - val subtitlePair = Pair(subtitles[which].file.url, subtitles[which].language) - if (isChecked) { - selectedSubtitles.add(subtitlePair) - } else { - selectedSubtitles.remove(subtitlePair) + currContext.customAlertDialog().apply { + setTitle(R.string.download_subtitle) + multiChoiceItems(subtitleNamesArray, checkedItems) { + it.forEachIndexed { index, isChecked -> + val subtitlePair = Pair(subtitles[index].file.url, subtitles[index].language) + if (isChecked) { + selectedSubtitles.add(subtitlePair) + } else { + selectedSubtitles.remove(subtitlePair) + } } } - .setPositiveButton(R.string.download) { _, _ -> - dialog?.dismiss() + setPosButton(R.string.download) { checkAudioTracks() } - .setNegativeButton(R.string.skip) { dialog, _ -> + setNegButton(R.string.skip) { selectedSubtitles = mutableListOf() checkAudioTracks() - dialog.dismiss() } - .setNeutralButton(R.string.cancel) { dialog, _ -> + setNeutralButton(R.string.cancel) { selectedSubtitles = mutableListOf() - dialog.dismiss() } - .show() - alertDialog.window?.setDimAmount(0.8f) - + show() + } } else { checkAudioTracks() } diff --git a/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt b/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt index 6c552090..0a4bed33 100644 --- a/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt +++ b/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt @@ -21,6 +21,7 @@ import ani.dantotsu.setAnimation import ani.dantotsu.snackString import ani.dantotsu.util.ColorEditor.Companion.adjustColorForContrast import ani.dantotsu.util.ColorEditor.Companion.getContrastRatio +import ani.dantotsu.util.customAlertDialog import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.Section import com.xwray.groupie.viewbinding.BindableItem @@ -385,19 +386,14 @@ class CommentItem( * @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) + commentsFragment.activity.customAlertDialog().apply { + setTitle(title) + setMessage(message) + setPosButton("Yes") { + callback() + } + setNegButton("No") {} + }.show() } private val usernameColors: Array = arrayOf( diff --git a/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt b/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt index ff861dbe..9188698d 100644 --- a/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt @@ -25,6 +25,7 @@ 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.DialogEdittextBinding import ani.dantotsu.databinding.FragmentCommentsBinding import ani.dantotsu.loadImage import ani.dantotsu.media.MediaDetailsActivity @@ -34,6 +35,7 @@ import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.toast import ani.dantotsu.util.Logger +import ani.dantotsu.util.customAlertDialog import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.Section import io.noties.markwon.editor.MarkwonEditor @@ -160,35 +162,48 @@ class CommentsFragment : Fragment() { popup.inflate(R.menu.comments_sort_menu) popup.show() } + binding.openRules.setOnClickListener { + activity.customAlertDialog().apply { + setTitle("Commenting Rules") + .setMessage( + "🚨 BREAK ANY RULE = YOU'RE GONE\n\n" + + "1. NO RACISM, DISCRIMINATION, OR HATE SPEECH\n" + + "2. NO SPAMMING OR SELF-PROMOTION\n" + + "3. ABSOLUTELY NO NSFW CONTENT\n" + + "4. ENGLISH ONLY – NO EXCEPTIONS\n" + + "5. NO IMPERSONATION, HARASSMENT, OR ABUSE\n" + + "6. NO ILLEGAL CONTENT OR EXTREME DISRESPECT TOWARDS ANY FANDOM\n" + + "7. DO NOT REQUEST OR SHARE REPOSITORIES/EXTENSIONS\n" + + "8. SPOILERS ALLOWED ONLY WITH SPOILER TAGS AND A WARNING\n" + + "9. NO SEXUALIZING OR INAPPROPRIATE COMMENTS ABOUT MINOR CHARACTERS\n" + + "10. IF IT'S WRONG, DON'T POST IT!\n\n" + ) + setNegButton("I Understand") {} + 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() + activity.customAlertDialog().apply { + val customView = DialogEdittextBinding.inflate(layoutInflater) + setTitle("Enter a chapter/episode number tag") + setCustomView(customView.root) + setPosButton("OK") { + val text = customView.dialogEditText.text.toString() filterTag = text.toIntOrNull() lifecycleScope.launch { loadAndDisplayComments() } - - dialog.dismiss() } - .setNeutralButton("Clear") { dialog, _ -> + setNeutralButton("Clear") { filterTag = null lifecycleScope.launch { loadAndDisplayComments() } - dialog.dismiss() } - .setNegativeButton("Cancel") { dialog, _ -> - filterTag = null - dialog.dismiss() - } - val dialog = alertDialog.show() - dialog?.window?.setDimAmount(0.8f) + setNegButton("Cancel") { filterTag = null } + show() + } } var isFetching = false @@ -303,13 +318,12 @@ class CommentsFragment : Fragment() { activity.binding.commentLabel.setOnClickListener { //alert dialog to enter a number, with a cancel and ok button - 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() + activity.customAlertDialog().apply { + val customView = DialogEdittextBinding.inflate(layoutInflater) + setTitle("Enter a chapter/episode number tag") + setCustomView(customView.root) + setPosButton("OK") { + val text = customView.dialogEditText.text.toString() tag = text.toIntOrNull() if (tag == null) { activity.binding.commentLabel.background = ResourcesCompat.getDrawable( @@ -324,28 +338,25 @@ class CommentsFragment : Fragment() { null ) } - dialog.dismiss() } - .setNeutralButton("Clear") { dialog, _ -> + setNeutralButton("Clear") { tag = null activity.binding.commentLabel.background = ResourcesCompat.getDrawable( resources, R.drawable.ic_label_off_24, null ) - dialog.dismiss() } - .setNegativeButton("Cancel") { dialog, _ -> + setNegButton("Cancel") { 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) + show() + } } } @@ -363,11 +374,6 @@ class CommentsFragment : Fragment() { } } - @SuppressLint("NotifyDataSetChanged") - override fun onStart() { - super.onStart() - } - @SuppressLint("NotifyDataSetChanged") override fun onResume() { super.onResume() @@ -579,31 +585,28 @@ class CommentsFragment : Fragment() { * Called when the user tries to comment for the first time */ private fun showCommentRulesDialog() { - val alertDialog = AlertDialog.Builder(activity, R.style.MyPopup) - .setTitle("Commenting Rules") + activity.customAlertDialog().apply { + 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" + "🚨 BREAK ANY RULE = YOU'RE GONE\n\n" + + "1. NO RACISM, DISCRIMINATION, OR HATE SPEECH\n" + + "2. NO SPAMMING OR SELF-PROMOTION\n" + + "3. ABSOLUTELY NO NSFW CONTENT\n" + + "4. ENGLISH ONLY – NO EXCEPTIONS\n" + + "5. NO IMPERSONATION, HARASSMENT, OR ABUSE\n" + + "6. NO ILLEGAL CONTENT OR EXTREME DISRESPECT TOWARDS ANY FANDOM\n" + + "7. DO NOT REQUEST OR SHARE REPOSITORIES/EXTENSIONS\n" + + "8. SPOILERS ALLOWED ONLY WITH SPOILER TAGS AND A WARNING\n" + + "9. NO SEXUALIZING OR INAPPROPRIATE COMMENTS ABOUT MINOR CHARACTERS\n" + + "10. IF IT'S WRONG, DON'T POST IT!\n\n" ) - .setPositiveButton("I Understand") { dialog, _ -> - dialog.dismiss() + setPosButton("I Understand") { PrefManager.setVal(PrefName.FirstComment, false) processComment() } - .setNegativeButton("Cancel") { dialog, _ -> - dialog.dismiss() - } - val dialog = alertDialog.show() - dialog?.window?.setDimAmount(0.8f) + setNegButton(R.string.cancel) + show() + } } private fun processComment() { @@ -709,4 +712,4 @@ class CommentsFragment : Fragment() { } } } -} \ 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 177d3684..39aef576 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaReadAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaReadAdapter.kt @@ -1,7 +1,7 @@ package ani.dantotsu.media.manga -import android.app.AlertDialog import android.content.Intent +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -11,6 +11,7 @@ import android.widget.ImageButton import android.widget.LinearLayout import android.widget.NumberPicker import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getString import androidx.core.content.ContextCompat.startActivity import androidx.core.view.isGone import androidx.core.view.isVisible @@ -19,8 +20,9 @@ import androidx.recyclerview.widget.RecyclerView import ani.dantotsu.R import ani.dantotsu.currActivity import ani.dantotsu.currContext +import ani.dantotsu.databinding.CustomDialogLayoutBinding import ani.dantotsu.databinding.DialogLayoutBinding -import ani.dantotsu.databinding.ItemAnimeWatchBinding +import ani.dantotsu.databinding.ItemMediaSourceBinding import ani.dantotsu.databinding.ItemChipBinding import ani.dantotsu.isOnline import ani.dantotsu.loadImage @@ -35,11 +37,14 @@ import ani.dantotsu.others.webview.CookieCatcher import ani.dantotsu.parsers.DynamicMangaParser import ani.dantotsu.parsers.MangaReadSources import ani.dantotsu.parsers.MangaSources +import ani.dantotsu.parsers.OfflineMangaParser import ani.dantotsu.px import ani.dantotsu.settings.FAQActivity import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.snackString import ani.dantotsu.toast +import ani.dantotsu.util.customAlertDialog import com.google.android.material.chip.Chip import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_SUBSCRIPTION_CHECK import eu.kanade.tachiyomi.source.online.HttpSource @@ -55,86 +60,108 @@ class MangaReadAdapter( ) : RecyclerView.Adapter() { var subscribe: MediaDetailsActivity.PopImageButton? = null - private var _binding: ItemAnimeWatchBinding? = null + private var _binding: ItemMediaSourceBinding? = null val hiddenScanlators = mutableListOf() var scanlatorSelectionListener: ScanlatorSelectionListener? = null var options = listOf() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val bind = ItemAnimeWatchBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ViewHolder(bind) + private fun clearCustomValsForMedia(mediaId: String, suffix: String) { + val customVals = PrefManager.getAllCustomValsForMedia("$mediaId$suffix") + customVals.forEach { (key) -> + PrefManager.removeCustomVal(key) + Log.d("PrefManager", "Removed key: $key") + } } - private var nestedDialog: AlertDialog? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val bind = ItemMediaSourceBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(bind) + } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val binding = holder.binding _binding = binding binding.sourceTitle.setText(R.string.chaps) - //Fuck u launch + // Fuck u launch binding.faqbutton.setOnClickListener { val intent = Intent(fragment.requireContext(), FAQActivity::class.java) startActivity(fragment.requireContext(), intent, null) } - //Wrong Title - binding.animeSourceSearch.setOnClickListener { + // Wrong Title + binding.mediaSourceSearch.setOnClickListener { SourceSearchDialogFragment().show( fragment.requireActivity().supportFragmentManager, null ) } val offline = !isOnline(binding.root.context) || PrefManager.getVal(PrefName.OfflineMode) + //for removing saved progress + binding.sourceTitle.setOnLongClickListener{ + fragment.requireContext().customAlertDialog().apply { + setTitle(" Delete Progress for all chapters of ${media.nameRomaji}") + setMessage("This will delete all the locally stored progress for chapters") + setPosButton(R.string.ok){ + clearCustomValsForMedia("${media.id}", "_Chapter") + clearCustomValsForMedia("${media.id}", "_Vol") + snackString("Deleted the progress of Chapters for ${media.nameRomaji}") + } + setNegButton(R.string.no) + show() + } + true + } - binding.animeSourceNameContainer.isGone = offline - binding.animeSourceSettings.isGone = offline - binding.animeSourceSearch.isGone = offline - binding.animeSourceTitle.isGone = offline - //Source Selection + binding.mediaSourceNameContainer.isGone = offline + binding.mediaSourceSettings.isGone = offline + binding.mediaSourceSearch.isGone = offline + binding.mediaSourceTitle.isGone = offline + // Source Selection var source = media.selected!!.sourceIndex.let { if (it >= mangaReadSources.names.size) 0 else it } setLanguageList(media.selected!!.langIndex, source) if (mangaReadSources.names.isNotEmpty() && source in 0 until mangaReadSources.names.size) { - binding.animeSource.setText(mangaReadSources.names[source]) + binding.mediaSource.setText(mangaReadSources.names[source]) mangaReadSources[source].apply { - binding.animeSourceTitle.text = showUserText - showUserTextListener = { MainScope().launch { binding.animeSourceTitle.text = it } } + binding.mediaSourceTitle.text = showUserText + showUserTextListener = { MainScope().launch { binding.mediaSourceTitle.text = it } } } } media.selected?.scanlators?.let { hiddenScanlators.addAll(it) } - binding.animeSource.setAdapter( + binding.mediaSource.setAdapter( ArrayAdapter( fragment.requireContext(), R.layout.item_dropdown, mangaReadSources.names ) ) - binding.animeSourceTitle.isSelected = true - binding.animeSource.setOnItemClickListener { _, _, i, _ -> + binding.mediaSourceTitle.isSelected = true + binding.mediaSource.setOnItemClickListener { _, _, i, _ -> fragment.onSourceChange(i).apply { - binding.animeSourceTitle.text = showUserText - showUserTextListener = { MainScope().launch { binding.animeSourceTitle.text = it } } + binding.mediaSourceTitle.text = showUserText + showUserTextListener = { MainScope().launch { binding.mediaSourceTitle.text = it } } source = i setLanguageList(0, i) } subscribeButton(false) - //invalidate if it's the last source + // Invalidate if it's the last source val invalidate = i == mangaReadSources.names.size - 1 fragment.loadChapters(i, invalidate) } - binding.animeSourceLanguage.setOnItemClickListener { _, _, i, _ -> + binding.mediaSourceLanguage.setOnItemClickListener { _, _, i, _ -> // Check if 'extension' and 'selected' properties exist and are accessible (mangaReadSources[source] as? DynamicMangaParser)?.let { ext -> ext.sourceLanguage = i fragment.onLangChange(i, ext.saveName) fragment.onSourceChange(media.selected!!.sourceIndex).apply { - binding.animeSourceTitle.text = showUserText + binding.mediaSourceTitle.text = showUserText showUserTextListener = - { MainScope().launch { binding.animeSourceTitle.text = it } } + { MainScope().launch { binding.mediaSourceTitle.text = it } } setLanguageList(i, source) } subscribeButton(false) @@ -143,17 +170,17 @@ class MangaReadAdapter( } } - //settings - binding.animeSourceSettings.setOnClickListener { + // Settings + binding.mediaSourceSettings.setOnClickListener { (mangaReadSources[source] as? DynamicMangaParser)?.let { ext -> fragment.openSettings(ext.extension) } } - //Grids + // Grids subscribe = MediaDetailsActivity.PopImageButton( fragment.lifecycleScope, - binding.animeSourceSubscribe, + binding.mediaSourceSubscribe, R.drawable.ic_round_notifications_active_24, R.drawable.ic_round_notifications_none_24, R.color.bg_opp, @@ -161,206 +188,207 @@ class MangaReadAdapter( fragment.subscribed, true ) { - fragment.onNotificationPressed(it, binding.animeSource.text.toString()) + fragment.onNotificationPressed(it, binding.mediaSource.text.toString()) } subscribeButton(false) - binding.animeSourceSubscribe.setOnLongClickListener { + binding.mediaSourceSubscribe.setOnLongClickListener { openSettings(fragment.requireContext(), CHANNEL_SUBSCRIPTION_CHECK) } - binding.animeNestedButton.setOnClickListener { - - val dialogView = - LayoutInflater.from(fragment.requireContext()).inflate(R.layout.dialog_layout, null) - val dialogBinding = DialogLayoutBinding.bind(dialogView) + binding.mediaNestedButton.setOnClickListener { + val dialogBinding = DialogLayoutBinding.inflate(fragment.layoutInflater) var refresh = false var run = false var reversed = media.selected!!.recyclerReversed var style = media.selected!!.recyclerStyle ?: PrefManager.getVal(PrefName.MangaDefaultView) - dialogBinding.animeSourceTop.rotation = if (reversed) -90f else 90f - dialogBinding.sortText.text = if (reversed) "Down to Up" else "Up to Down" - dialogBinding.animeSourceTop.setOnClickListener { - reversed = !reversed - dialogBinding.animeSourceTop.rotation = if (reversed) -90f else 90f - dialogBinding.sortText.text = if (reversed) "Down to Up" else "Up to Down" - run = true - } + dialogBinding.apply { + mediaSourceTop.rotation = if (reversed) -90f else 90f + sortText.text = if (reversed) "Down to Up" else "Up to Down" + mediaSourceTop.setOnClickListener { + reversed = !reversed + mediaSourceTop.rotation = if (reversed) -90f else 90f + sortText.text = if (reversed) "Down to Up" else "Up to Down" + run = true + } - //Grids - dialogBinding.animeSourceGrid.visibility = View.GONE - var selected = when (style) { - 0 -> dialogBinding.animeSourceList - 1 -> dialogBinding.animeSourceCompact - else -> dialogBinding.animeSourceList - } - when (style) { - 0 -> dialogBinding.layoutText.setText(R.string.list) - 1 -> dialogBinding.layoutText.setText(R.string.compact) - else -> dialogBinding.animeSourceList - } - selected.alpha = 1f - fun selected(it: ImageButton) { - selected.alpha = 0.33f - selected = it + // Grids + mediaSourceGrid.visibility = View.GONE + var selected = when (style) { + 0 -> mediaSourceList + 1 -> mediaSourceCompact + else -> mediaSourceList + } + when (style) { + 0 -> layoutText.setText(R.string.list) + 1 -> layoutText.setText(R.string.compact) + else -> mediaSourceList + } selected.alpha = 1f - } - dialogBinding.animeSourceList.setOnClickListener { - selected(it as ImageButton) - style = 0 - dialogBinding.layoutText.setText(R.string.list) - run = true - } - dialogBinding.animeSourceCompact.setOnClickListener { - selected(it as ImageButton) - style = 1 - dialogBinding.layoutText.setText(R.string.compact) - run = true - } - dialogBinding.animeWebviewContainer.setOnClickListener { - if (!WebViewUtil.supportsWebView(fragment.requireContext())) { - toast(R.string.webview_not_installed) + fun selected(it: ImageButton) { + selected.alpha = 0.33f + selected = it + selected.alpha = 1f } - //start CookieCatcher activity - if (mangaReadSources.names.isNotEmpty() && source in 0 until mangaReadSources.names.size) { - val sourceAHH = mangaReadSources[source] as? DynamicMangaParser - val sourceHttp = sourceAHH?.extension?.sources?.firstOrNull() as? HttpSource - val url = sourceHttp?.baseUrl - url?.let { - refresh = true - val intent = Intent(fragment.requireContext(), CookieCatcher::class.java) - .putExtra("url", url) - startActivity(fragment.requireContext(), intent, null) + mediaSourceList.setOnClickListener { + selected(it as ImageButton) + style = 0 + layoutText.setText(R.string.list) + run = true + } + mediaSourceCompact.setOnClickListener { + selected(it as ImageButton) + style = 1 + layoutText.setText(R.string.compact) + run = true + } + mediaWebviewContainer.setOnClickListener { + if (!WebViewUtil.supportsWebView(fragment.requireContext())) { + toast(R.string.webview_not_installed) } - } - } - - //Multi download - dialogBinding.downloadNo.text = "0" - dialogBinding.animeDownloadTop.setOnClickListener { - //Alert dialog asking for the number of chapters to download - val alertDialog = AlertDialog.Builder(currContext(), R.style.MyPopup) - alertDialog.setTitle("Multi Chapter Downloader") - alertDialog.setMessage("Enter the number of chapters to download") - val input = NumberPicker(currContext()) - input.minValue = 1 - input.maxValue = 20 - input.value = 1 - alertDialog.setView(input) - alertDialog.setPositiveButton("OK") { _, _ -> - dialogBinding.downloadNo.text = "${input.value}" - } - alertDialog.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() } - val dialog = alertDialog.show() - dialog.window?.setDimAmount(0.8f) - } - - //Scanlator - dialogBinding.animeScanlatorContainer.isVisible = options.count() > 1 - 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 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 + // Start CookieCatcher activity + if (mangaReadSources.names.isNotEmpty() && source in 0 until mangaReadSources.names.size) { + val sourceAHH = mangaReadSources[source] as? DynamicMangaParser + val sourceHttp = sourceAHH?.extension?.sources?.firstOrNull() as? HttpSource + val url = sourceHttp?.baseUrl + url?.let { + refresh = true + val intent = + Intent(fragment.requireContext(), CookieCatcher::class.java) + .putExtra("url", url) + startActivity(fragment.requireContext(), intent, null) } } - 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)) + // Multi download + downloadNo.text = "0" + mediaDownloadTop.setOnClickListener { + // Alert dialog asking for the number of chapters to download + fragment.requireContext().customAlertDialog().apply { + setTitle("Multi Chapter Downloader") + setMessage("Enter the number of chapters to download") + val input = NumberPicker(currContext()) + input.minValue = 1 + input.maxValue = 20 + input.value = 1 + setCustomView(input) + setPosButton(R.string.ok) { + downloadNo.text = "${input.value}" } + setNegButton(R.string.cancel) + show() } - - // Set checked if its already selected - if (media.selected!!.scanlators != null) { - checkBox.isChecked = media.selected!!.scanlators?.contains(option) != true - scanlatorSelectionListener?.onScanlatorsSelected() - } else { - checkBox.isChecked = true - } - checkboxContainer.addView(checkBox) } + resetProgress.setOnClickListener { + fragment.requireContext().customAlertDialog().apply { + setTitle(" Delete Progress for all chapters of ${media.nameRomaji}") + setMessage("This will delete all the locally stored progress for chapters") + setPosButton(R.string.ok){ +// Usage + clearCustomValsForMedia("${media.id}", "_Chapter") + clearCustomValsForMedia("${media.id}", "_Vol") - // Create AlertDialog - val dialog = AlertDialog.Builder(currContext(), R.style.MyPopup) - .setView(dialogView2) - .setPositiveButton("OK") { _, _ -> - hiddenScanlators.clear() - for (i in 0 until checkboxContainer.childCount) { - val checkBox = checkboxContainer.getChildAt(i) as CheckBox + snackString("Deleted the progress of Chapters for ${media.nameRomaji}") + } + setNegButton(R.string.no) + show() + } + } + resetProgressDef.text = getString(currContext()!!,R.string.clear_stored_chapter) + + // Scanlator + mangaScanlatorContainer.isVisible = options.count() > 1 + scanlatorNo.text = "${options.count()}" + mangaScanlatorTop.setOnClickListener { + CustomDialogLayoutBinding.inflate(fragment.layoutInflater) + val dialogView = CustomDialogLayoutBinding.inflate(fragment.layoutInflater) + val checkboxContainer = dialogView.checkboxContainer + val tickAllButton = dialogView.toggleButton + + 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) { - hiddenScanlators.add(checkBox.text.toString()) + allChecked = false + } else { + allUnchecked = false } } - fragment.onScanlatorChange(hiddenScanlators) - scanlatorSelectionListener?.onScanlatorsSelected() - } - .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 + return when { + allChecked -> R.drawable.untick_all_boxes + allUnchecked -> R.drawable.tick_all_boxes + else -> R.drawable.invert_all_boxes + } } - // Update image resource + options.forEach { option -> + val checkBox = CheckBox(currContext()).apply { + text = option + setOnCheckedChangeListener { _, _ -> + tickAllButton.setImageResource(getToggleImageResource(checkboxContainer)) + } + } + + if (media.selected!!.scanlators != null) { + checkBox.isChecked = media.selected!!.scanlators?.contains(option) != true + scanlatorSelectionListener?.onScanlatorsSelected() + } else { + checkBox.isChecked = true + } + checkboxContainer.addView(checkBox) + } + + fragment.requireContext().customAlertDialog().apply { + setCustomView(dialogView.root) + setPosButton("OK") { + hiddenScanlators.clear() + for (i in 0 until checkboxContainer.childCount) { + val checkBox = checkboxContainer.getChildAt(i) as CheckBox + if (!checkBox.isChecked) { + hiddenScanlators.add(checkBox.text.toString()) + } + } + fragment.onScanlatorChange(hiddenScanlators) + scanlatorSelectionListener?.onScanlatorsSelected() + } + setNegButton("Cancel") + }.show() + tickAllButton.setImageResource(getToggleImageResource(checkboxContainer)) + + tickAllButton.setOnClickListener { + for (i in 0 until checkboxContainer.childCount) { + val checkBox = checkboxContainer.getChildAt(i) as CheckBox + checkBox.isChecked = !checkBox.isChecked + } + tickAllButton.setImageResource(getToggleImageResource(checkboxContainer)) + } + } + + fragment.requireContext().customAlertDialog().apply { + setTitle("Options") + setCustomView(root) + setPosButton("OK") { + if (run) fragment.onIconPressed(style, reversed) + if (downloadNo.text != "0") { + fragment.multiDownload(downloadNo.text.toString().toInt()) + } + if (refresh) fragment.loadChapters(source, true) + } + setNegButton("Cancel") { + if (refresh) fragment.loadChapters(source, true) + } + show() } } - - nestedDialog = AlertDialog.Builder(fragment.requireContext(), R.style.MyPopup) - .setTitle("Options") - .setView(dialogView) - .setPositiveButton("OK") { _, _ -> - if (run) fragment.onIconPressed(style, reversed) - if (dialogBinding.downloadNo.text != "0") { - fragment.multiDownload(dialogBinding.downloadNo.text.toString().toInt()) - } - if (refresh) fragment.loadChapters(source, true) - } - .setNegativeButton("Cancel") { _, _ -> - if (refresh) fragment.loadChapters(source, true) - } - .setOnCancelListener { - if (refresh) fragment.loadChapters(source, true) - } - .create() - nestedDialog?.show() } - //Chapter Handling + // Chapter Handling handleChapters() } @@ -368,7 +396,7 @@ class MangaReadAdapter( subscribe?.enabled(enabled) } - //Chips + // Chips fun updateChips(limit: Int, names: Array, arr: Array, selected: Int = 0) { val binding = _binding if (binding != null) { @@ -379,13 +407,13 @@ class MangaReadAdapter( val chip = ItemChipBinding.inflate( LayoutInflater.from(fragment.context), - binding.animeSourceChipGroup, + binding.mediaSourceChipGroup, false ).root chip.isCheckable = true fun selected() { chip.isChecked = true - binding.animeWatchChipScroll.smoothScrollTo( + binding.mediaWatchChipScroll.smoothScrollTo( (chip.left - screenWidth / 2) + (chip.width / 2), 0 ) @@ -403,7 +431,7 @@ class MangaReadAdapter( } else { names[last - 1] } - //chip.text = "${names[limit * (position)]} - ${names[last - 1]}" + // chip.text = "${names[limit * (position)]} - ${names[last - 1]}" val chipText = "$startChapterString - $endChapterString" chip.text = chipText chip.setTextColor( @@ -417,14 +445,14 @@ class MangaReadAdapter( selected() fragment.onChipClicked(position, limit * (position), last - 1) } - binding.animeSourceChipGroup.addView(chip) + binding.mediaSourceChipGroup.addView(chip) if (selected == position) { selected() select = chip } } if (select != null) - binding.animeWatchChipScroll.apply { + binding.mediaWatchChipScroll.apply { post { scrollTo( (select.left - screenWidth / 2) + (select.width / 2), @@ -436,7 +464,7 @@ class MangaReadAdapter( } fun clearChips() { - _binding?.animeSourceChipGroup?.removeAllViews() + _binding?.mediaSourceChipGroup?.removeAllViews() } fun handleChapters() { @@ -462,70 +490,86 @@ class MangaReadAdapter( } if (formattedChapters.contains(continueEp)) { continueEp = chapters[formattedChapters.indexOf(continueEp)] - binding.animeSourceContinue.visibility = View.VISIBLE + binding.sourceContinue.visibility = View.VISIBLE handleProgress( - binding.itemEpisodeProgressCont, - binding.itemEpisodeProgress, - binding.itemEpisodeProgressEmpty, + binding.itemMediaProgressCont, + binding.itemMediaProgress, + binding.itemMediaProgressEmpty, media.id, continueEp ) - if ((binding.itemEpisodeProgress.layoutParams as LinearLayout.LayoutParams).weight > 0.8f) { + if ((binding.itemMediaProgress.layoutParams as LinearLayout.LayoutParams).weight > 0.8f) { val e = chapters.indexOf(continueEp) if (e != -1 && e + 1 < chapters.size) { continueEp = chapters[e + 1] } } val ep = media.manga.chapters!![continueEp]!! - binding.itemEpisodeImage.loadImage(media.banner ?: media.cover) - binding.animeSourceContinueText.text = + binding.itemMediaImage.loadImage(media.banner ?: media.cover) + binding.mediaSourceContinueText.text = currActivity()!!.getString( R.string.continue_chapter, ep.number, if (!ep.title.isNullOrEmpty()) ep.title else "" ) - binding.animeSourceContinue.setOnClickListener { + binding.sourceContinue.setOnClickListener { fragment.onMangaChapterClick(continueEp) } if (fragment.continueEp) { - if ((binding.itemEpisodeProgress.layoutParams as LinearLayout.LayoutParams).weight < 0.8f) { - binding.animeSourceContinue.performClick() + if ((binding.itemMediaProgress.layoutParams as LinearLayout.LayoutParams).weight < 0.8f) { + binding.sourceContinue.performClick() fragment.continueEp = false } } } else { - binding.animeSourceContinue.visibility = View.GONE + binding.sourceContinue.visibility = View.GONE } - binding.animeSourceProgressBar.visibility = View.GONE - val sourceFound = media.manga.chapters!!.isNotEmpty() - binding.animeSourceNotFound.isGone = sourceFound + + binding.sourceProgressBar.visibility = View.GONE + + val sourceFound = filteredChapters.isNotEmpty() + val isDownloadedSource = mangaReadSources[media.selected!!.sourceIndex] is OfflineMangaParser + + if (isDownloadedSource) { + binding.sourceNotFound.text = if (sourceFound) { + currActivity()!!.getString(R.string.source_not_found) + } else { + currActivity()!!.getString(R.string.download_not_found) + } + } else { + binding.sourceNotFound.text = currActivity()!!.getString(R.string.source_not_found) + } + + binding.sourceNotFound.isGone = sourceFound binding.faqbutton.isGone = sourceFound + + if (!sourceFound && PrefManager.getVal(PrefName.SearchSources)) { - if (binding.animeSource.adapter.count > media.selected!!.sourceIndex + 1) { + if (binding.mediaSource.adapter.count > media.selected!!.sourceIndex + 1) { val nextIndex = media.selected!!.sourceIndex + 1 - binding.animeSource.setText( - binding.animeSource.adapter + binding.mediaSource.setText( + binding.mediaSource.adapter .getItem(nextIndex).toString(), false ) fragment.onSourceChange(nextIndex).apply { - binding.animeSourceTitle.text = showUserText + binding.mediaSourceTitle.text = showUserText showUserTextListener = - { MainScope().launch { binding.animeSourceTitle.text = it } } + { MainScope().launch { binding.mediaSourceTitle.text = it } } setLanguageList(0, nextIndex) } subscribeButton(false) - // invalidate if it's the last source + // Invalidate if it's the last source val invalidate = nextIndex == mangaReadSources.names.size - 1 fragment.loadChapters(nextIndex, invalidate) } } } else { - binding.animeSourceContinue.visibility = View.GONE - binding.animeSourceNotFound.visibility = View.GONE + binding.sourceContinue.visibility = View.GONE + binding.sourceNotFound.visibility = View.GONE binding.faqbutton.visibility = View.GONE clearChips() - binding.animeSourceProgressBar.visibility = View.VISIBLE + binding.sourceProgressBar.visibility = View.VISIBLE } } } @@ -539,9 +583,9 @@ class MangaReadAdapter( ext.sourceLanguage = lang } try { - binding?.animeSourceLanguage?.setText(parser.extension.sources[lang].lang) + binding?.mediaSourceLanguage?.setText(parser.extension.sources[lang].lang) } catch (e: IndexOutOfBoundsException) { - binding?.animeSourceLanguage?.setText( + binding?.mediaSourceLanguage?.setText( parser.extension.sources.firstOrNull()?.lang ?: "Unknown" ) } @@ -551,9 +595,9 @@ class MangaReadAdapter( parser.extension.sources.map { LanguageMapper.getLanguageName(it.lang) } ) val items = adapter.count - binding?.animeSourceLanguageContainer?.isVisible = items > 1 + binding?.mediaSourceLanguageContainer?.isVisible = items > 1 - binding?.animeSourceLanguage?.setAdapter(adapter) + binding?.mediaSourceLanguage?.setAdapter(adapter) } } @@ -561,7 +605,7 @@ class MangaReadAdapter( override fun getItemCount(): Int = 1 - inner class ViewHolder(val binding: ItemAnimeWatchBinding) : + inner class ViewHolder(val binding: ItemMediaSourceBinding) : RecyclerView.ViewHolder(binding.root) } 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 bce5f312..be836ad5 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt @@ -2,7 +2,6 @@ package ani.dantotsu.media.manga import android.Manifest import android.annotation.SuppressLint -import android.app.AlertDialog import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -31,7 +30,7 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.R -import ani.dantotsu.databinding.FragmentAnimeWatchBinding +import ani.dantotsu.databinding.FragmentMediaSourceBinding import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager.Companion.compareName @@ -60,6 +59,7 @@ import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.util.StoragePermissions.Companion.accessAlertDialog import ani.dantotsu.util.StoragePermissions.Companion.hasDirAccess +import ani.dantotsu.util.customAlertDialog import com.google.android.material.appbar.AppBarLayout import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.source.ConfigurableSource @@ -74,7 +74,7 @@ import kotlin.math.max import kotlin.math.roundToInt open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { - private var _binding: FragmentAnimeWatchBinding? = null + private var _binding: FragmentMediaSourceBinding? = null private val binding get() = _binding!! private val model: MediaDetailsViewModel by activityViewModels() @@ -101,7 +101,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { container: ViewGroup?, savedInstanceState: Bundle? ): View? { - _binding = FragmentAnimeWatchBinding.inflate(inflater, container, false) + _binding = FragmentMediaSourceBinding.inflate(inflater, container, false) return _binding?.root } @@ -121,7 +121,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { ContextCompat.RECEIVER_EXPORTED ) - binding.animeSourceRecycler.updatePadding(bottom = binding.animeSourceRecycler.paddingBottom + navBarHeight) + binding.mediaSourceRecycler.updatePadding(bottom = binding.mediaSourceRecycler.paddingBottom + navBarHeight) screenWidth = resources.displayMetrics.widthPixels.dp var maxGridSize = (screenWidth / 100f).roundToInt() @@ -144,13 +144,13 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { } } - binding.animeSourceRecycler.layoutManager = gridLayoutManager + binding.mediaSourceRecycler.layoutManager = gridLayoutManager binding.ScrollTop.setOnClickListener { - binding.animeSourceRecycler.scrollToPosition(10) - binding.animeSourceRecycler.smoothScrollToPosition(0) + binding.mediaSourceRecycler.scrollToPosition(10) + binding.mediaSourceRecycler.smoothScrollToPosition(0) } - binding.animeSourceRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + binding.mediaSourceRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) @@ -164,7 +164,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { } }) model.scrolledToTop.observe(viewLifecycleOwner) { - if (it) binding.animeSourceRecycler.scrollToPosition(0) + if (it) binding.mediaSourceRecycler.scrollToPosition(0) } continueEp = model.continueMedia ?: false @@ -199,7 +199,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { } } - binding.animeSourceRecycler.adapter = + binding.mediaSourceRecycler.adapter = ConcatAdapter(headerAdapter, chapterAdapter) lifecycleScope.launch(Dispatchers.IO) { @@ -214,8 +214,8 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { reload() } } else { - binding.animeNotSupported.visibility = View.VISIBLE - binding.animeNotSupported.text = + binding.mediaNotSupported.visibility = View.VISIBLE + binding.mediaNotSupported.text = getString(R.string.not_supported, media.format ?: "") } } @@ -231,10 +231,10 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { } fun multiDownload(n: Int) { - //get last viewed chapter + // Get last viewed chapter val selected = media.userProgress val chapters = media.manga?.chapters?.values?.toList() - //filter by selected language + // Filter by selected language val progressChapterIndex = (chapters?.indexOfFirst { MediaNameAdapter.findChapterNumber(it.number)?.toInt() == selected } ?: 0) + 1 @@ -244,7 +244,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { // Calculate the end index val endIndex = minOf(progressChapterIndex + n, chapters.size) - //make sure there are enough chapters + // Make sure there are enough chapters val chaptersToDownload = chapters.subList(progressChapterIndex, endIndex) @@ -386,32 +386,30 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { if (allSettings.size > 1) { val names = allSettings.map { LanguageMapper.getLanguageName(it.lang) }.toTypedArray() - val dialog = AlertDialog.Builder(requireContext(), R.style.MyPopup) - .setTitle("Select a Source") - .setSingleChoiceItems(names, -1) { dialog, which -> + requireContext().customAlertDialog().apply { + setTitle("Select a Source") + singleChoiceItems(names) { which -> selectedSetting = allSettings[which] itemSelected = true - dialog.dismiss() - // Move the fragment transaction here - val fragment = - MangaSourcePreferencesFragment().getInstance(selectedSetting.id) { - changeUIVisibility(true) - loadChapters(media.selected!!.sourceIndex, true) - } + val fragment = MangaSourcePreferencesFragment().getInstance(selectedSetting.id) { + changeUIVisibility(true) + loadChapters(media.selected!!.sourceIndex, true) + } parentFragmentManager.beginTransaction() .setCustomAnimations(R.anim.slide_up, R.anim.slide_down) .replace(R.id.fragmentExtensionsContainer, fragment) .addToBackStack(null) .commit() } - .setOnDismissListener { + onDismiss{ if (!itemSelected) { changeUIVisibility(true) } } - .show() - dialog.window?.setDimAmount(0.8f) + show() + + } } else { // If there's only one setting, proceed with the fragment transaction val fragment = MangaSourcePreferencesFragment().getInstance(selectedSetting.id) { @@ -584,7 +582,7 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { private fun reload() { val selected = model.loadSelected(media) - //Find latest chapter for subscription + // Find latest chapter for subscription selected.latest = media.manga?.chapters?.values?.maxOfOrNull { it.number.toFloatOrNull() ?: 0f } ?: 0f selected.latest = @@ -618,14 +616,14 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { override fun onResume() { super.onResume() binding.mediaInfoProgressBar.visibility = progress - binding.animeSourceRecycler.layoutManager?.onRestoreInstanceState(state) + binding.mediaSourceRecycler.layoutManager?.onRestoreInstanceState(state) requireActivity().setNavigationTheme() } override fun onPause() { super.onPause() - state = binding.animeSourceRecycler.layoutManager?.onSaveInstanceState() + state = binding.mediaSourceRecycler.layoutManager?.onSaveInstanceState() } companion object { 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 86085f58..689ab6af 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 @@ -58,6 +58,8 @@ import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsViewModel import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.MediaSingleton +import ani.dantotsu.media.anime.ExoplayerView +import ani.dantotsu.media.anime.ExoplayerView.Companion import ani.dantotsu.media.manga.MangaCache import ani.dantotsu.media.manga.MangaChapter import ani.dantotsu.others.ImageViewDialog @@ -83,6 +85,7 @@ import ani.dantotsu.showSystemBarsRetractView import ani.dantotsu.snackString import ani.dantotsu.themes.ThemeManager import ani.dantotsu.tryWith +import ani.dantotsu.util.customAlertDialog import com.alexvasilkov.gestures.views.GestureFrameLayout import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView @@ -184,6 +187,8 @@ class MangaReaderActivity : AppCompatActivity() { onBackPressedDispatcher.onBackPressed() } + + defaultSettings = loadReaderSettings("reader_settings") ?: defaultSettings onBackPressedDispatcher.addCallback(this) { @@ -258,7 +263,16 @@ class MangaReaderActivity : AppCompatActivity() { } else model.getMedia().value ?: return model.setMedia(media) + @Suppress("UNCHECKED_CAST") + val list = (PrefManager.getNullableCustomVal( + "continueMangaList", + listOf(), + List::class.java + ) as List).toMutableList() + if (list.contains(media.id)) list.remove(media.id) + list.add(media.id) + PrefManager.setCustomVal("continueMangaList", list) if (PrefManager.getVal(PrefName.AutoDetectWebtoon) && media.countryOfOrigin != "JP") applyWebtoon( defaultSettings ) @@ -400,7 +414,8 @@ class MangaReaderActivity : AppCompatActivity() { val context = this val offline: Boolean = PrefManager.getVal(PrefName.OfflineMode) val incognito: Boolean = PrefManager.getVal(PrefName.Incognito) - if ((isOnline(context) && !offline) && Discord.token != null && !incognito) { + val rpcenabled: Boolean = PrefManager.getVal(PrefName.rpcEnabled) + if ((isOnline(context) && !offline) && Discord.token != null && !incognito && rpcenabled) { lifecycleScope.launch { val discordMode = PrefManager.getCustomVal("discord_mode", "dantotsu") val buttons = when (discordMode) { @@ -1013,28 +1028,27 @@ class MangaReaderActivity : AppCompatActivity() { PrefManager.setCustomVal("${media.id}_progressDialog", !isChecked) showProgressDialog = !isChecked } - AlertDialog.Builder(this, R.style.MyPopup) - .setTitle(getString(R.string.title_update_progress)) - .setView(dialogView) - .setCancelable(false) - .setPositiveButton(getString(R.string.yes)) { dialog, _ -> + customAlertDialog().apply { + setTitle(R.string.title_update_progress) + setCustomView(dialogView) + setCancelable(false) + setPosButton(R.string.yes) { PrefManager.setCustomVal("${media.id}_save_progress", true) updateProgress( media, MediaNameAdapter.findChapterNumber(media.manga!!.selectedChapter!!) .toString() ) - dialog.dismiss() runnable.run() } - .setNegativeButton(getString(R.string.no)) { dialog, _ -> + setNegButton(R.string.no) { PrefManager.setCustomVal("${media.id}_save_progress", false) - dialog.dismiss() runnable.run() } - .setOnCancelListener { hideSystemBars() } - .create() - .show() + setOnCancelListener { hideSystemBars() } + show() + + } } else { if (!incognito && PrefManager.getCustomVal( "${media.id}_save_progress", diff --git a/app/src/main/java/ani/dantotsu/media/manga/mangareader/Swipy.kt b/app/src/main/java/ani/dantotsu/media/manga/mangareader/Swipy.kt index 97407805..07eb30e2 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/mangareader/Swipy.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/mangareader/Swipy.kt @@ -8,6 +8,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.widget.FrameLayout +import kotlin.math.abs class Swipy @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null @@ -16,7 +17,6 @@ class Swipy @JvmOverloads constructor( var dragDivider: Int = 5 var vertical = true - //public, in case a different sub child needs to be considered var child: View? = getChildAt(0) var topBeingSwiped: ((Float) -> Unit) = {} @@ -29,49 +29,47 @@ class Swipy @JvmOverloads constructor( var rightBeingSwiped: ((Float) -> Unit) = {} companion object { - private const val DRAG_RATE = .5f + private const val DRAG_RATE = 0.5f private const val INVALID_POINTER = -1 } - private var touchSlop = ViewConfiguration.get(context).scaledTouchSlop - + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop private var activePointerId = INVALID_POINTER private var isBeingDragged = false private var initialDown = 0f private var initialMotion = 0f - enum class VerticalPosition { - Top, - None, - Bottom - } - - enum class HorizontalPosition { - Left, - None, - Right - } + private enum class VerticalPosition { Top, None, Bottom } + private enum class HorizontalPosition { Left, None, Right } private var horizontalPos = HorizontalPosition.None private var verticalPos = VerticalPosition.None private fun setChildPosition() { - child?.apply { + child?.let { if (vertical) { - verticalPos = VerticalPosition.None - if (!canScrollVertically(1)) { - verticalPos = VerticalPosition.Bottom - } - if (!canScrollVertically(-1)) { - verticalPos = VerticalPosition.Top + verticalPos = when { + !it.canScrollVertically(1) && !it.canScrollVertically(-1) -> { + if (initialDown > (Resources.getSystem().displayMetrics.heightPixels / 2)) + VerticalPosition.Bottom + else + VerticalPosition.Top + } + !it.canScrollVertically(1) -> VerticalPosition.Bottom + !it.canScrollVertically(-1) -> VerticalPosition.Top + else -> VerticalPosition.None } } else { - horizontalPos = HorizontalPosition.None - if (!canScrollHorizontally(1)) { - horizontalPos = HorizontalPosition.Right - } - if (!canScrollHorizontally(-1)) { - horizontalPos = HorizontalPosition.Left + horizontalPos = when { + !it.canScrollHorizontally(1) && !it.canScrollHorizontally(-1) -> { + if (initialDown > (Resources.getSystem().displayMetrics.widthPixels / 2)) + HorizontalPosition.Right + else + HorizontalPosition.Left + } + !it.canScrollHorizontally(1) -> HorizontalPosition.Right + !it.canScrollHorizontally(-1) -> HorizontalPosition.Left + else -> HorizontalPosition.None } } } @@ -85,44 +83,26 @@ class Swipy @JvmOverloads constructor( private fun onSecondaryPointerUp(ev: MotionEvent) { val pointerIndex = ev.actionIndex - val pointerId = ev.getPointerId(pointerIndex) - if (pointerId == activePointerId) { - val newPointerIndex = if (pointerIndex == 0) 1 else 0 - activePointerId = ev.getPointerId(newPointerIndex) + if (ev.getPointerId(pointerIndex) == activePointerId) { + activePointerId = ev.getPointerId(if (pointerIndex == 0) 1 else 0) } } override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { - val action = ev.actionMasked - val pointerIndex: Int - if (!isEnabled || canChildScroll()) { - return false - } + if (!isEnabled || canChildScroll()) return false - when (action) { + when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { activePointerId = ev.getPointerId(0) + initialDown = if (vertical) ev.getY(0) else ev.getX(0) isBeingDragged = false - pointerIndex = ev.findPointerIndex(activePointerId) - if (pointerIndex < 0) { - return false - } - initialDown = if (vertical) ev.getY(pointerIndex) else ev.getX(pointerIndex) } - MotionEvent.ACTION_MOVE -> { - if (activePointerId == INVALID_POINTER) { - //("Got ACTION_MOVE event but don't have an active pointer id.") - return false + val pointerIndex = ev.findPointerIndex(activePointerId) + if (pointerIndex >= 0) { + startDragging(if (vertical) ev.getY(pointerIndex) else ev.getX(pointerIndex)) } - pointerIndex = ev.findPointerIndex(activePointerId) - if (pointerIndex < 0) { - return false - } - val pos = if (vertical) ev.getY(pointerIndex) else ev.getX(pointerIndex) - startDragging(pos) } - MotionEvent.ACTION_POINTER_UP -> onSecondaryPointerUp(ev) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { isBeingDragged = false @@ -134,127 +114,97 @@ class Swipy @JvmOverloads constructor( @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(ev: MotionEvent): Boolean { - val action = ev.actionMasked + if (!isEnabled || canChildScroll()) return false + val pointerIndex: Int - if (!isEnabled || canChildScroll()) { - return false - } - when (action) { + when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { activePointerId = ev.getPointerId(0) isBeingDragged = false } - MotionEvent.ACTION_MOVE -> { pointerIndex = ev.findPointerIndex(activePointerId) - if (pointerIndex < 0) { - //("Got ACTION_MOVE event but have an invalid active pointer id.") - return false - } - val pos = if (vertical) ev.getY(pointerIndex) else ev.getX(pointerIndex) - startDragging(pos) - if (isBeingDragged) { - val overscroll = ( - if (vertical) - if (verticalPos == VerticalPosition.Top) pos - initialMotion else initialMotion - pos - else - if (horizontalPos == HorizontalPosition.Left) pos - initialMotion else initialMotion - pos - ) * DRAG_RATE - - if (overscroll > 0) { - parent.requestDisallowInterceptTouchEvent(true) - if (vertical) { - val totalDragDistance = - Resources.getSystem().displayMetrics.heightPixels / dragDivider - if (verticalPos == VerticalPosition.Top) - topBeingSwiped.invoke(overscroll * 2 / totalDragDistance) - else - bottomBeingSwiped.invoke(overscroll * 2 / totalDragDistance) - } else { - val totalDragDistance = - Resources.getSystem().displayMetrics.widthPixels / dragDivider - if (horizontalPos == HorizontalPosition.Left) - leftBeingSwiped.invoke(overscroll / totalDragDistance) - else - rightBeingSwiped.invoke(overscroll / totalDragDistance) - } - } else { - return false - } + if (pointerIndex >= 0) { + val pos = if (vertical) ev.getY(pointerIndex) else ev.getX(pointerIndex) + startDragging(pos) + if (isBeingDragged) handleDrag(pos) } } - MotionEvent.ACTION_POINTER_DOWN -> { pointerIndex = ev.actionIndex - if (pointerIndex < 0) { - //("Got ACTION_POINTER_DOWN event but have an invalid action index.") - return false - } - activePointerId = ev.getPointerId(pointerIndex) + if (pointerIndex >= 0) activePointerId = ev.getPointerId(pointerIndex) } - MotionEvent.ACTION_POINTER_UP -> onSecondaryPointerUp(ev) MotionEvent.ACTION_UP -> { - if (vertical) { - topBeingSwiped.invoke(0f) - bottomBeingSwiped.invoke(0f) - } else { - rightBeingSwiped.invoke(0f) - leftBeingSwiped.invoke(0f) - } + resetSwipes() pointerIndex = ev.findPointerIndex(activePointerId) - if (pointerIndex < 0) { - //("Got ACTION_UP event but don't have an active pointer id.") - return false - } - if (isBeingDragged) { - val pos = if (vertical) ev.getY(pointerIndex) else ev.getX(pointerIndex) - val overscroll = ( - if (vertical) - if (verticalPos == VerticalPosition.Top) pos - initialMotion else initialMotion - pos - else - if (horizontalPos == HorizontalPosition.Left) pos - initialMotion else initialMotion - pos - ) * DRAG_RATE - isBeingDragged = false - finishSpinner(overscroll) - } + if (pointerIndex >= 0) finishSpinner(if (vertical) ev.getY(pointerIndex) else ev.getX(pointerIndex)) activePointerId = INVALID_POINTER return false } - MotionEvent.ACTION_CANCEL -> return false } return true } private fun startDragging(pos: Float) { - val posDiff = - if ((vertical && verticalPos == VerticalPosition.Top) || (!vertical && horizontalPos == HorizontalPosition.Left)) - pos - initialDown - else - initialDown - pos + val posDiff = if ((vertical && verticalPos == VerticalPosition.Top) || (!vertical && horizontalPos == HorizontalPosition.Left)) + pos - initialDown + else + initialDown - pos if (posDiff > touchSlop && !isBeingDragged) { initialMotion = initialDown + touchSlop isBeingDragged = true } } - private fun finishSpinner(overscrollDistance: Float) { - + private fun handleDrag(pos: Float) { + val overscroll = abs((pos - initialMotion) * DRAG_RATE) + parent.requestDisallowInterceptTouchEvent(true) if (vertical) { val totalDragDistance = Resources.getSystem().displayMetrics.heightPixels / dragDivider - if (overscrollDistance * 2 > totalDragDistance) + if (verticalPos == VerticalPosition.Top) + topBeingSwiped.invoke(overscroll * 2 / totalDragDistance) + else + bottomBeingSwiped.invoke(overscroll * 2 / totalDragDistance) + } else { + val totalDragDistance = Resources.getSystem().displayMetrics.widthPixels / dragDivider + if (horizontalPos == HorizontalPosition.Left) + leftBeingSwiped.invoke(overscroll / totalDragDistance) + else + rightBeingSwiped.invoke(overscroll / totalDragDistance) + } + } + + private fun resetSwipes() { + if (vertical) { + topBeingSwiped.invoke(0f) + bottomBeingSwiped.invoke(0f) + } else { + rightBeingSwiped.invoke(0f) + leftBeingSwiped.invoke(0f) + } + } + + private fun finishSpinner(overscrollDistance: Float) { + if (vertical) { + val totalDragDistance = Resources.getSystem().displayMetrics.heightPixels / dragDivider + val swipeDistance = abs(overscrollDistance - initialMotion) + if (swipeDistance > totalDragDistance) { if (verticalPos == VerticalPosition.Top) onTopSwiped.invoke() else onBottomSwiped.invoke() + } } else { val totalDragDistance = Resources.getSystem().displayMetrics.widthPixels / dragDivider - if (overscrollDistance > totalDragDistance) + val swipeDistance = abs(overscrollDistance - initialMotion) + if (swipeDistance > totalDragDistance) { if (horizontalPos == HorizontalPosition.Left) onLeftSwiped.invoke() else onRightSwiped.invoke() + } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/ani/dantotsu/media/novel/NovelReadAdapter.kt b/app/src/main/java/ani/dantotsu/media/novel/NovelReadAdapter.kt index 7f9d34f4..cfaf298f 100644 --- a/app/src/main/java/ani/dantotsu/media/novel/NovelReadAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/novel/NovelReadAdapter.kt @@ -50,16 +50,16 @@ class NovelReadAdapter( val source = media.selected!!.sourceIndex.let { if (it >= novelReadSources.names.size) 0 else it } if (novelReadSources.names.isNotEmpty() && source in 0 until novelReadSources.names.size) { - binding.animeSource.setText(novelReadSources.names[source], false) + binding.mediaSource.setText(novelReadSources.names[source], false) } - binding.animeSource.setAdapter( + binding.mediaSource.setAdapter( ArrayAdapter( fragment.requireContext(), R.layout.item_dropdown, novelReadSources.names ) ) - binding.animeSource.setOnItemClickListener { _, _, i, _ -> + binding.mediaSource.setOnItemClickListener { _, _, i, _ -> fragment.onSourceChange(i) search() } 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 c95328f6..5cd1f8ad 100644 --- a/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt @@ -20,7 +20,7 @@ import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import ani.dantotsu.R import ani.dantotsu.currContext -import ani.dantotsu.databinding.FragmentAnimeWatchBinding +import ani.dantotsu.databinding.FragmentMediaSourceBinding import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.novel.NovelDownloaderService @@ -47,7 +47,7 @@ class NovelReadFragment : Fragment(), DownloadTriggerCallback, DownloadedCheckCallback { - private var _binding: FragmentAnimeWatchBinding? = null + private var _binding: FragmentMediaSourceBinding? = null private val binding get() = _binding!! private val model: MediaDetailsViewModel by activityViewModels() @@ -214,11 +214,11 @@ class NovelReadFragment : Fragment(), ContextCompat.RECEIVER_EXPORTED ) - binding.animeSourceRecycler.updatePadding(bottom = binding.animeSourceRecycler.paddingBottom + navBarHeight) + binding.mediaSourceRecycler.updatePadding(bottom = binding.mediaSourceRecycler.paddingBottom + navBarHeight) - binding.animeSourceRecycler.layoutManager = LinearLayoutManager(requireContext()) + binding.mediaSourceRecycler.layoutManager = LinearLayoutManager(requireContext()) model.scrolledToTop.observe(viewLifecycleOwner) { - if (it) binding.animeSourceRecycler.scrollToPosition(0) + if (it) binding.mediaSourceRecycler.scrollToPosition(0) } continueEp = model.continueMedia ?: false @@ -237,7 +237,7 @@ class NovelReadFragment : Fragment(), this, this ) // probably a better way to do this but it works - binding.animeSourceRecycler.adapter = + binding.mediaSourceRecycler.adapter = ConcatAdapter(headerAdapter, novelResponseAdapter) loaded = true Handler(Looper.getMainLooper()).postDelayed({ @@ -290,7 +290,7 @@ class NovelReadFragment : Fragment(), container: ViewGroup?, savedInstanceState: Bundle? ): View? { - _binding = FragmentAnimeWatchBinding.inflate(inflater, container, false) + _binding = FragmentMediaSourceBinding.inflate(inflater, container, false) return _binding?.root } @@ -304,12 +304,12 @@ class NovelReadFragment : Fragment(), override fun onResume() { super.onResume() binding.mediaInfoProgressBar.visibility = progress - binding.animeSourceRecycler.layoutManager?.onRestoreInstanceState(state) + binding.mediaSourceRecycler.layoutManager?.onRestoreInstanceState(state) } override fun onPause() { super.onPause() - state = binding.animeSourceRecycler.layoutManager?.onSaveInstanceState() + state = binding.mediaSourceRecycler.layoutManager?.onSaveInstanceState() } companion object { 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 92ddc739..7bc304cd 100644 --- a/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/novel/NovelResponseAdapter.kt @@ -13,6 +13,7 @@ import ani.dantotsu.parsers.ShowResponse import ani.dantotsu.setAnimation import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import ani.dantotsu.util.customAlertDialog import com.bumptech.glide.Glide import com.bumptech.glide.load.model.GlideUrl @@ -38,7 +39,7 @@ class NovelResponseAdapter( val binding = holder.binding val novel = list[position] setAnimation(fragment.requireContext(), holder.binding.root) - binding.itemEpisodeImage.loadImage(novel.coverUrl, 400, 0) + binding.itemMediaImage.loadImage(novel.coverUrl, 400, 0) val color =fragment.requireContext().getThemeColor(com.google.android.material.R.attr.colorOnBackground) binding.itemEpisodeTitle.text = novel.name @@ -93,27 +94,22 @@ class NovelResponseAdapter( } binding.root.setOnLongClickListener { - val builder = androidx.appcompat.app.AlertDialog.Builder( - fragment.requireContext(), - R.style.MyPopup - ) - builder.setTitle("Delete ${novel.name}?") - builder.setMessage("Are you sure you want to delete ${novel.name}?") - builder.setPositiveButton("Yes") { _, _ -> - downloadedCheckCallback.deleteDownload(novel) - deleteDownload(novel.link) - snackString("Deleted ${novel.name}") - if (binding.itemEpisodeFiller.text.toString() - .contains("Download", ignoreCase = true) - ) { - binding.itemEpisodeFiller.text = "" + it.context.customAlertDialog().apply { + setTitle("Delete ${novel.name}?") + setMessage("Are you sure you want to delete ${novel.name}?") + setPosButton(R.string.yes) { + downloadedCheckCallback.deleteDownload(novel) + deleteDownload(novel.link) + snackString("Deleted ${novel.name}") + if (binding.itemEpisodeFiller.text.toString() + .contains("Download", ignoreCase = true) + ) { + binding.itemEpisodeFiller.text = "" + } } + setNegButton(R.string.no) + show() } - builder.setNegativeButton("No") { _, _ -> - // Do nothing - } - val dialog = builder.show() - dialog.window?.setDimAmount(0.8f) true } } 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 170980bc..3c314312 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 @@ -2,6 +2,7 @@ package ani.dantotsu.media.novel.novelreader import android.animation.ObjectAnimator import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo @@ -45,12 +46,17 @@ import ani.dantotsu.tryWith import com.google.android.material.slider.Slider import com.vipulog.ebookreader.Book import com.vipulog.ebookreader.EbookReaderEventListener +import com.vipulog.ebookreader.EbookReaderView import com.vipulog.ebookreader.ReaderError import com.vipulog.ebookreader.ReaderFlow import com.vipulog.ebookreader.ReaderTheme import com.vipulog.ebookreader.RelocationInfo import com.vipulog.ebookreader.TocItem +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -190,6 +196,8 @@ class NovelReaderActivity : AppCompatActivity(), EbookReaderEventListener { @SuppressLint("ClickableViewAccessibility") private fun setupViews() { + binding.bookReader.useSafeScope(this) + scope.launch { binding.bookReader.openBook(intent.data!!) } binding.bookReader.setEbookReaderListener(this) @@ -540,4 +548,42 @@ class NovelReaderActivity : AppCompatActivity(), EbookReaderEventListener { hideSystemBars() } } -} \ No newline at end of file +} + + +/** + * ⚠️ TEMPORARY HOTFIX ⚠️ + * + * This is a hacky workaround to handle crashes in the deprecated ebookreader library. + * + * Current implementation: + * - Uses reflection to access the private `scope` field in `EbookReaderView`. + * - Replaces the existing `CoroutineScope` with a new one that includes a + * `CoroutineExceptionHandler`. + * - Ensures that uncaught exceptions in coroutines are handled gracefully by showing a snackbar + * with error details. + * + * TODO: + * - This is NOT a long-term solution + * - The underlying library is archived and unmaintained + * - Schedule migration to an actively maintained library + * - Consider alternatives like https://github.com/readium/kotlin-toolkit + */ +fun EbookReaderView.useSafeScope(activity: Activity) { + runCatching { + val scopeField = javaClass.getDeclaredField("scope").apply { isAccessible = true } + val currentScope = scopeField.get(this) as CoroutineScope + val safeScope = CoroutineScope( + SupervisorJob() + + currentScope.coroutineContext.minusKey(Job) + + scopeExceptionHandler(activity) + ) + scopeField.set(this, safeScope) + }.onFailure { e -> + snackString(e.localizedMessage, activity, e.stackTraceToString()) + } +} + +private fun scopeExceptionHandler(activity: Activity) = CoroutineExceptionHandler { _, e -> + snackString(e.localizedMessage, activity, e.stackTraceToString()) +} 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 994a0c78..08109594 100644 --- a/app/src/main/java/ani/dantotsu/media/user/ListActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/user/ListActivity.kt @@ -47,6 +47,7 @@ class ListActivity : AppCompatActivity() { window.statusBarColor = primaryColor window.navigationBarColor = primaryColor + binding.listed.visibility = View.GONE binding.listTabLayout.setBackgroundColor(primaryColor) binding.listAppBar.setBackgroundColor(primaryColor) binding.listTitle.setTextColor(primaryTextColor) diff --git a/app/src/main/java/ani/dantotsu/notifications/AlarmManagerScheduler.kt b/app/src/main/java/ani/dantotsu/notifications/AlarmManagerScheduler.kt index f5ed216c..8a1cc10f 100644 --- a/app/src/main/java/ani/dantotsu/notifications/AlarmManagerScheduler.kt +++ b/app/src/main/java/ani/dantotsu/notifications/AlarmManagerScheduler.kt @@ -20,21 +20,18 @@ class AlarmManagerScheduler(private val context: Context) : TaskScheduler { return } 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 - ) + val intent = when { + taskType == TaskType.COMMENT_NOTIFICATION && PrefManager.getVal(PrefName.CommentsEnabled) == 1 -> + Intent(context, CommentNotificationReceiver::class.java) - TaskType.SUBSCRIPTION_NOTIFICATION -> Intent( - context, - SubscriptionNotificationReceiver::class.java - ) + taskType == TaskType.ANILIST_NOTIFICATION -> + Intent(context, AnilistNotificationReceiver::class.java) + + taskType == TaskType.SUBSCRIPTION_NOTIFICATION -> + Intent(context, SubscriptionNotificationReceiver::class.java) + + else -> return } val pendingIntent = PendingIntent.getBroadcast( @@ -64,21 +61,18 @@ class AlarmManagerScheduler(private val context: Context) : TaskScheduler { 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 - ) + val intent = when { + taskType == TaskType.COMMENT_NOTIFICATION && PrefManager.getVal(PrefName.CommentsEnabled) == 1 -> + Intent(context, CommentNotificationReceiver::class.java) - TaskType.SUBSCRIPTION_NOTIFICATION -> Intent( - context, - SubscriptionNotificationReceiver::class.java - ) + taskType == TaskType.ANILIST_NOTIFICATION -> + Intent(context, AnilistNotificationReceiver::class.java) + + taskType == TaskType.SUBSCRIPTION_NOTIFICATION -> + Intent(context, SubscriptionNotificationReceiver::class.java) + + else -> return } val pendingIntent = PendingIntent.getBroadcast( diff --git a/app/src/main/java/ani/dantotsu/others/AlignTagHandler.kt b/app/src/main/java/ani/dantotsu/others/AlignTagHandler.kt new file mode 100644 index 00000000..1708ed05 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/others/AlignTagHandler.kt @@ -0,0 +1,32 @@ +package ani.dantotsu.others + +import android.text.Layout +import android.text.style.AlignmentSpan +import io.noties.markwon.MarkwonConfiguration +import io.noties.markwon.RenderProps +import io.noties.markwon.html.HtmlTag +import io.noties.markwon.html.tag.SimpleTagHandler + + +class AlignTagHandler : SimpleTagHandler() { + + override fun getSpans( + configuration: MarkwonConfiguration, + renderProps: RenderProps, + tag: HtmlTag + ): Any { + val alignment: Layout.Alignment = if (tag.attributes().containsKey("center")) { + Layout.Alignment.ALIGN_CENTER + } else if (tag.attributes().containsKey("end")) { + Layout.Alignment.ALIGN_OPPOSITE + } else { + Layout.Alignment.ALIGN_NORMAL + } + + return AlignmentSpan.Standard(alignment) + } + + override fun supportedTags(): Collection { + return setOf("align") + } +} diff --git a/app/src/main/java/ani/dantotsu/others/Anify.kt b/app/src/main/java/ani/dantotsu/others/Anify.kt new file mode 100644 index 00000000..da864f63 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/others/Anify.kt @@ -0,0 +1,46 @@ +package ani.dantotsu.others + +import ani.dantotsu.FileUrl +import ani.dantotsu.Mapper +import ani.dantotsu.client +import ani.dantotsu.media.anime.Episode +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.decodeFromJsonElement + +object Anify { + suspend fun fetchAndParseMetadata(id :Int): Map { + val response = client.get("https://anify.eltik.cc/content-metadata/$id") + .parsed().map { + Mapper.json.decodeFromJsonElement(it) + } + return response.firstOrNull()?.data?.associate { + it.number.toString() to Episode( + number = it.number.toString(), + title = it.title, + desc = it.description, + thumb = FileUrl[it.img], + ) + } ?: emptyMap() + } + @Serializable + data class AnifyElement ( + @SerialName("providerId") + val providerID: String? = null, + val data: List? = null + ) + + @Serializable + data class Datum ( + val id: String? = null, + val description: String? = null, + val hasDub: Boolean? = null, + val img: String? = null, + val isFiller: Boolean? = null, + val number: Long? = null, + val title: String? = null, + val updatedAt: Long? = null, + val rating: Double? = null + ) +} diff --git a/app/src/main/java/ani/dantotsu/others/CrashActivity.kt b/app/src/main/java/ani/dantotsu/others/CrashActivity.kt index d3b5e754..c410d687 100644 --- a/app/src/main/java/ani/dantotsu/others/CrashActivity.kt +++ b/app/src/main/java/ani/dantotsu/others/CrashActivity.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.os.Bundle import android.view.View import android.view.ViewGroup +import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider import androidx.core.view.updateLayoutParams @@ -24,7 +25,10 @@ class CrashActivity : AppCompatActivity() { ThemeManager(this).applyTheme() initActivity(this) binding = ActivityCrashBinding.inflate(layoutInflater) - + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) setContentView(binding.root) binding.root.updateLayoutParams { topMargin = statusBarHeight diff --git a/app/src/main/java/ani/dantotsu/others/Kitsu.kt b/app/src/main/java/ani/dantotsu/others/Kitsu.kt index 958c1b46..663352a5 100644 --- a/app/src/main/java/ani/dantotsu/others/Kitsu.kt +++ b/app/src/main/java/ani/dantotsu/others/Kitsu.kt @@ -18,14 +18,6 @@ object Kitsu { val headers = mapOf( "Content-Type" to "application/json", "Accept" to "application/json", - "Accept-Encoding" to "gzip, deflate", - "Accept-Language" to "en-US,en;q=0.5", - "Host" to "kitsu.io", - "Connection" to "keep-alive", - "Origin" to "https://kitsu.io", - "Sec-Fetch-Dest" to "empty", - "Sec-Fetch-Mode" to "cors", - "Sec-Fetch-Site" to "cross-site", ) val response = tryWithSuspend { val res = client.post( @@ -152,4 +144,4 @@ query { } -} \ No newline at end of file +} diff --git a/app/src/main/java/ani/dantotsu/others/Xubtitle.kt b/app/src/main/java/ani/dantotsu/others/Xubtitle.kt new file mode 100644 index 00000000..4fbf7bfd --- /dev/null +++ b/app/src/main/java/ani/dantotsu/others/Xubtitle.kt @@ -0,0 +1,144 @@ +package ani.dantotsu.others + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Shader +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView + +class Xubtitle + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : AppCompatTextView(context, attrs, defStyleAttr) { + private var outlineThickness: Float = 0f + private var effectColor: Int = currentTextColor + private var currentEffect: Effect = Effect.NONE + + private val shadowPaint = Paint().apply { isAntiAlias = true } + private val outlinePaint = Paint().apply { isAntiAlias = true } + private var shineShader: Shader? = null + + enum class Effect { + NONE, + OUTLINE, + SHINE, + DROP_SHADOW, + } + + override fun onDraw(canvas: Canvas) { + val text = text.toString() + val textPaint = + TextPaint(paint).apply { + color = currentTextColor + } + val staticLayout = + StaticLayout.Builder + .obtain(text, 0, text.length, textPaint, width) + .setAlignment(Layout.Alignment.ALIGN_CENTER) + .setLineSpacing(0f, 1f) + .build() + + when (currentEffect) { + Effect.OUTLINE -> { + textPaint.style = Paint.Style.STROKE + textPaint.strokeWidth = outlineThickness + textPaint.color = effectColor + + staticLayout.draw(canvas) + + textPaint.style = Paint.Style.FILL + textPaint.color = currentTextColor + staticLayout.draw(canvas) + } + + Effect.DROP_SHADOW -> { + setLayerType(LAYER_TYPE_SOFTWARE, null) + textPaint.setShadowLayer(outlineThickness, 4f, 4f, effectColor) + + staticLayout.draw(canvas) + + textPaint.clearShadowLayer() + } + + Effect.SHINE -> { + val shadowShader = + LinearGradient( + 0f, + 0f, + width.toFloat(), + height.toFloat(), + intArrayOf(Color.WHITE, effectColor, Color.BLACK), + null, + Shader.TileMode.CLAMP, + ) + + val shadowPaint = + Paint().apply { + isAntiAlias = true + style = Paint.Style.FILL + textSize = textPaint.textSize + typeface = textPaint.typeface + shader = shadowShader + } + + canvas.drawText( + text, + x + 4f, // Shadow offset + y + 4f, + shadowPaint, + ) + + val shader = + LinearGradient( + 0f, + 0f, + width.toFloat(), + height.toFloat(), + intArrayOf(effectColor, Color.WHITE, Color.WHITE), + null, + Shader.TileMode.CLAMP, + ) + textPaint.shader = shader + staticLayout.draw(canvas) + textPaint.shader = null + } + + Effect.NONE -> { + staticLayout.draw(canvas) + } + } + } + + fun applyOutline( + color: Int, + outlineThickness: Float, + ) { + this.effectColor = color + this.outlineThickness = outlineThickness + currentEffect = Effect.OUTLINE + } + + // Too hard for me to figure it out + fun applyShineEffect(color: Int) { + this.effectColor = color + currentEffect = Effect.SHINE + } + + fun applyDropShadow( + color: Int, + outlineThickness: Float, + ) { + this.effectColor = color + this.outlineThickness = outlineThickness + currentEffect = Effect.DROP_SHADOW + } + } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/others/calc/BiometricPromptUtils.kt b/app/src/main/java/ani/dantotsu/others/calc/BiometricPromptUtils.kt new file mode 100644 index 00000000..6e91ed07 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/others/calc/BiometricPromptUtils.kt @@ -0,0 +1,57 @@ +package ani.dantotsu.others.calc + +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import ani.dantotsu.R +import ani.dantotsu.util.Logger + +object BiometricPromptUtils { + private const val TAG = "BiometricPromptUtils" + + /** + * Create a BiometricPrompt instance + * @param activity: AppCompatActivity + * @param processSuccess: success callback + */ + fun createBiometricPrompt( + activity: AppCompatActivity, + processSuccess: (BiometricPrompt.AuthenticationResult) -> Unit + ): BiometricPrompt { + val executor = ContextCompat.getMainExecutor(activity) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + + override fun onAuthenticationError(errCode: Int, errString: CharSequence) { + super.onAuthenticationError(errCode, errString) + Logger.log("$TAG errCode is $errCode and errString is: $errString") + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + Logger.log("$TAG User biometric rejected.") + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + Log.d(TAG, "Authentication was successful") + processSuccess(result) + } + } + return BiometricPrompt(activity, executor, callback) + } + + /** + * Create a BiometricPrompt.PromptInfo instance + * @param activity: AppCompatActivity + * @return BiometricPrompt.PromptInfo: instance + */ + fun createPromptInfo(activity: AppCompatActivity): BiometricPrompt.PromptInfo = + BiometricPrompt.PromptInfo.Builder().apply { + setTitle(activity.getString(R.string.bio_prompt_info_title)) + setDescription(activity.getString(R.string.bio_prompt_info_desc)) + setConfirmationRequired(false) + setNegativeButtonText(activity.getString(R.string.cancel)) + }.build() +} diff --git a/app/src/main/java/ani/dantotsu/others/calc/CalcActivity.kt b/app/src/main/java/ani/dantotsu/others/calc/CalcActivity.kt index 442ab396..a5d762d1 100644 --- a/app/src/main/java/ani/dantotsu/others/calc/CalcActivity.kt +++ b/app/src/main/java/ani/dantotsu/others/calc/CalcActivity.kt @@ -1,10 +1,14 @@ package ani.dantotsu.others.calc +import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan +import android.view.MotionEvent import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat @@ -16,6 +20,8 @@ import ani.dantotsu.databinding.ActivityCalcBinding import ani.dantotsu.getThemeColor 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 ani.dantotsu.util.NumberConverter.Companion.toBinary @@ -24,7 +30,13 @@ import ani.dantotsu.util.NumberConverter.Companion.toHex class CalcActivity : AppCompatActivity() { private lateinit var binding: ActivityCalcBinding private lateinit var code: String + private val handler = Handler(Looper.getMainLooper()) + private val runnable = Runnable { + success() + } private val stack = CalcStack() + + @SuppressLint("ClickableViewAccessibility") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ThemeManager(this).applyTheme() @@ -73,6 +85,29 @@ class CalcActivity : AppCompatActivity() { binding.displayHex.text = "" binding.display.text = "0" } + if (PrefManager.getVal(PrefName.OverridePassword, false)) { + buttonClear.setOnTouchListener { v, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + handler.postDelayed(runnable, 10000) + true + } + + MotionEvent.ACTION_UP -> { + v.performClick() + handler.removeCallbacks(runnable) + true + } + + MotionEvent.ACTION_CANCEL -> { + handler.removeCallbacks(runnable) + true + } + + else -> false + } + } + } buttonBackspace.setOnClickListener { stack.remove() updateDisplay() @@ -81,6 +116,20 @@ class CalcActivity : AppCompatActivity() { } } + override fun onResume() { + super.onResume() + if (hasPermission) { + success() + } + if (PrefManager.getVal(PrefName.BiometricToken, "").isNotEmpty()) { + val bioMetricPrompt = BiometricPromptUtils.createBiometricPrompt(this) { + success() + } + val promptInfo = BiometricPromptUtils.createPromptInfo(this) + bioMetricPrompt.authenticate(promptInfo) + } + } + private fun success() { hasPermission = true ContextCompat.startActivity( diff --git a/app/src/main/java/ani/dantotsu/parsers/AnimeSources.kt b/app/src/main/java/ani/dantotsu/parsers/AnimeSources.kt index 119f4d2f..f343d70b 100644 --- a/app/src/main/java/ani/dantotsu/parsers/AnimeSources.kt +++ b/app/src/main/java/ani/dantotsu/parsers/AnimeSources.kt @@ -39,7 +39,7 @@ object AnimeSources : WatchSources() { } fun performReorderAnimeSources() { - //remove the downloaded source from the list to avoid duplicates + // Remove the downloaded source from the list to avoid duplicates list = list.filter { it.name != "Downloaded" } list = sortPinnedAnimeSources(list, pinnedAnimeSources) + Lazier( { OfflineAnimeParser() }, diff --git a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt index df97b2ec..0db36d74 100644 --- a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt +++ b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt @@ -348,9 +348,6 @@ class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { val res = source.getChapterList(sManga) val reversedRes = res.reversed() val chapterList = reversedRes.map { sChapterToMangaChapter(it) } - Logger.log("chapterList size: ${chapterList.size}") - Logger.log("chapterList: ${chapterList[1].title}") - Logger.log("chapterList: ${chapterList[1].description}") chapterList } catch (e: Exception) { Logger.log("loadChapters Exception: $e") diff --git a/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt b/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt index 85d1af90..f8ae2e54 100644 --- a/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt @@ -55,13 +55,11 @@ class OfflineAnimeParser : AnimeParser() { episodes.add(episode) } } - //episodes.sortBy { MediaNameAdapter.findEpisodeNumber(it.number) } - episodes.addAll(loadEpisodesCompat(animeLink, extra, sAnime)) - //filter those with the same name - return episodes.distinctBy { it.number } - .sortedBy { MediaNameAdapter.findEpisodeNumber(it.number) } } - return emptyList() + episodes.addAll(loadEpisodesCompat(animeLink, extra, sAnime)) + //filter those with the same name + return episodes.distinctBy { it.number } + .sortedBy { MediaNameAdapter.findEpisodeNumber(it.number) } } override suspend fun loadVideoServers( diff --git a/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt b/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt index f0c1237f..ec3ca338 100644 --- a/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt @@ -43,11 +43,10 @@ class OfflineMangaParser : MangaParser() { chapters.add(chapter) } } - chapters.addAll(loadChaptersCompat(mangaLink, extra, sManga)) - return chapters.distinctBy { it.number } - .sortedBy { MediaNameAdapter.findChapterNumber(it.number) } } - return emptyList() + chapters.addAll(loadChaptersCompat(mangaLink, extra, sManga)) + return chapters.distinctBy { it.number } + .sortedBy { MediaNameAdapter.findChapterNumber(it.number) } } override suspend fun loadImages(chapterLink: String, sChapter: SChapter): List { @@ -66,17 +65,16 @@ class OfflineMangaParser : MangaParser() { for (image in images) { Logger.log("imageNumber: ${image.url.url}") } - return if (images.isNotEmpty()) { - images.sortBy { image -> - val matchResult = imageNumberRegex.find(image.url.url) - matchResult?.groups?.get(1)?.value?.toIntOrNull() ?: Int.MAX_VALUE - } - images - } else { - loadImagesCompat(chapterLink, sChapter) - } } - return emptyList() + return if (images.isNotEmpty()) { + images.sortBy { image -> + val matchResult = imageNumberRegex.find(image.url.url) + matchResult?.groups?.get(1)?.value?.toIntOrNull() ?: Int.MAX_VALUE + } + images + } else { + loadImagesCompat(chapterLink, sChapter) + } } override suspend fun search(query: String): List { 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 8ea2f620..aab307b4 100644 --- a/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt +++ b/app/src/main/java/ani/dantotsu/parsers/novel/NovelExtensionManager.kt @@ -159,7 +159,7 @@ class NovelExtensionManager(private val context: Context) { * * @param pkgName The package name of the application to uninstall. */ - fun uninstallExtension(pkgName: String, context: Context) { + fun uninstallExtension(pkgName: String) { installer.uninstallApk(pkgName) } diff --git a/app/src/main/java/ani/dantotsu/profile/ProfileActivity.kt b/app/src/main/java/ani/dantotsu/profile/ProfileActivity.kt index 5433df6a..88b814af 100644 --- a/app/src/main/java/ani/dantotsu/profile/ProfileActivity.kt +++ b/app/src/main/java/ani/dantotsu/profile/ProfileActivity.kt @@ -22,6 +22,7 @@ import ani.dantotsu.R import ani.dantotsu.blurImage import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.connections.anilist.api.Query +import ani.dantotsu.copyToClipboard import ani.dantotsu.databinding.ActivityProfileBinding import ani.dantotsu.databinding.ItemProfileAppBarBinding import ani.dantotsu.initActivity @@ -30,15 +31,14 @@ import ani.dantotsu.media.user.ListActivity import ani.dantotsu.navBarHeight import ani.dantotsu.openImage import ani.dantotsu.openLinkInBrowser -import ani.dantotsu.others.ImageViewDialog -import ani.dantotsu.profile.activity.FeedFragment +import ani.dantotsu.profile.activity.ActivityFragment +import ani.dantotsu.profile.activity.ActivityFragment.Companion.ActivityType 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 ani.dantotsu.util.MarkdownCreatorActivity import com.google.android.material.appbar.AppBarLayout import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -136,7 +136,7 @@ class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListene followButton.setOnClickListener { lifecycleScope.launch(Dispatchers.IO) { - val res = Anilist.query.toggleFollow(user.id) + val res = Anilist.mutation.toggleFollow(user.id) if (res?.data?.toggleFollow != null) { withContext(Dispatchers.Main) { snackString(R.string.success) @@ -153,16 +153,18 @@ class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListene popup.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.action_view_on_anilist -> { - openLinkInBrowser("https://anilist.co/user/${user.name}") + openLinkInBrowser(getString(R.string.anilist_link, user.name)) true } - R.id.action_create_new_activity -> { - ContextCompat.startActivity( - context, - Intent(context, MarkdownCreatorActivity::class.java) - .putExtra("type", "activity"), - null - ) + R.id.action_share_profile -> { + val shareIntent = Intent(Intent.ACTION_SEND) + shareIntent.type = "text/plain" + shareIntent.putExtra(Intent.EXTRA_TEXT, getString(R.string.anilist_link, user.name)) + startActivity(Intent.createChooser(shareIntent, "Share Profile")) + true + } + R.id.action_copy_user_id -> { + copyToClipboard(user.id.toString(), true) true } else -> false @@ -177,7 +179,11 @@ class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListene user.avatar?.medium ?: "" ) profileUserName.text = user.name - val bannerAnimations: ImageView= if (PrefManager.getVal(PrefName.BannerAnimations)) profileBannerImage else profileBannerImageNoKen + profileUserName.setOnClickListener { + copyToClipboard(profileUserName.text.toString(), true) + } + val bannerAnimations: ImageView = + if (PrefManager.getVal(PrefName.BannerAnimations)) profileBannerImage else profileBannerImageNoKen blurImage( bannerAnimations, @@ -199,7 +205,8 @@ class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListene profileAppBar.addOnOffsetChangedListener(context) - profileFollowerCount.text = (respond.data.followerPage?.pageInfo?.total ?: 0).toString() + profileFollowerCount.text = + (respond.data.followerPage?.pageInfo?.total ?: 0).toString() profileFollowerCountContainer.setOnClickListener { ContextCompat.startActivity( context, @@ -209,7 +216,8 @@ class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListene null ) } - profileFollowingCount.text = (respond.data.followingPage?.pageInfo?.total ?: 0).toString() + profileFollowingCount.text = + (respond.data.followingPage?.pageInfo?.total ?: 0).toString() profileFollowingCountContainer.setOnClickListener { ContextCompat.startActivity( context, @@ -320,7 +328,7 @@ class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListene override fun getItemCount(): Int = 3 override fun createFragment(position: Int): Fragment = when (position) { 0 -> ProfileFragment.newInstance(user) - 1 -> FeedFragment.newInstance(user.id, false, -1) + 1 -> ActivityFragment.newInstance(ActivityType.OTHER_USER, user.id) 2 -> StatsFragment.newInstance(user) else -> ProfileFragment.newInstance(user) } diff --git a/app/src/main/java/ani/dantotsu/profile/StatsFragment.kt b/app/src/main/java/ani/dantotsu/profile/StatsFragment.kt index 52ba668b..c37e0c14 100644 --- a/app/src/main/java/ani/dantotsu/profile/StatsFragment.kt +++ b/app/src/main/java/ani/dantotsu/profile/StatsFragment.kt @@ -252,14 +252,14 @@ class StatsFragment : stat?.statistics?.anime?.scores?.map { convertScore( it.score, - stat.mediaListOptions.scoreFormat + stat.mediaListOptions.scoreFormat.toString() ) } ?: emptyList() } else { stat?.statistics?.manga?.scores?.map { convertScore( it.score, - stat.mediaListOptions.scoreFormat + stat.mediaListOptions.scoreFormat.toString() ) } ?: emptyList() } diff --git a/app/src/main/java/ani/dantotsu/profile/activity/ActivityFragment.kt b/app/src/main/java/ani/dantotsu/profile/activity/ActivityFragment.kt new file mode 100644 index 00000000..8b1500bd --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/activity/ActivityFragment.kt @@ -0,0 +1,173 @@ +package ani.dantotsu.profile.activity + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import ani.dantotsu.R +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.Activity +import ani.dantotsu.databinding.FragmentFeedBinding +import ani.dantotsu.media.MediaDetailsActivity +import ani.dantotsu.navBarHeight +import ani.dantotsu.profile.ProfileActivity +import ani.dantotsu.util.ActivityMarkdownCreator +import com.xwray.groupie.GroupieAdapter +import eu.kanade.tachiyomi.util.system.getSerializableCompat +import kotlinx.coroutines.launch + +class ActivityFragment : Fragment() { + private lateinit var type: ActivityType + private var userId: Int? = null + private var activityId: Int? = null + private lateinit var binding: FragmentFeedBinding + private var adapter: GroupieAdapter = GroupieAdapter() + private var page: 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) + arguments?.let { + type = it.getSerializableCompat("type") as ActivityType + userId = it.getInt("userId") + activityId = it.getInt("activityId") + } + binding.titleBar.visibility = + if (type == ActivityType.OTHER_USER) View.VISIBLE else View.GONE + binding.titleText.text = + if (userId == Anilist.userid) getString(R.string.create_new_activity) else getString(R.string.write_a_message) + binding.titleImage.setOnClickListener { handleTitleImageClick() } + binding.listRecyclerView.adapter = adapter + binding.listRecyclerView.layoutManager = LinearLayoutManager(context) + binding.listProgressBar.isVisible = true + + binding.feedRefresh.updateLayoutParams { + bottomMargin = navBarHeight + } + binding.emptyTextView.text = getString(R.string.nothing_here) + lifecycleScope.launch { + getList() + if (adapter.itemCount == 0) { + binding.emptyTextView.isVisible = true + } + binding.listProgressBar.isVisible = false + } + binding.feedSwipeRefresh.setOnRefreshListener { + lifecycleScope.launch { + adapter.clear() + page = 1 + getList() + binding.feedSwipeRefresh.isRefreshing = false + } + } + binding.listRecyclerView.addOnScrollListener(object : + RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (shouldLoadMore()) { + lifecycleScope.launch { + binding.feedRefresh.isVisible = true + getList() + binding.feedRefresh.isVisible = false + } + } + } + }) + } + + private fun handleTitleImageClick() { + val intent = Intent(context, ActivityMarkdownCreator::class.java).apply { + putExtra("type", if (userId == Anilist.userid) "activity" else "message") + putExtra("userId", userId) + } + ContextCompat.startActivity(requireContext(), intent, null) + } + + private suspend fun getList() { + val list = when (type) { + ActivityType.GLOBAL -> getActivities(global = true) + ActivityType.USER -> getActivities(filter = true) + ActivityType.OTHER_USER -> getActivities(userId = userId) + ActivityType.ONE -> getActivities(activityId = activityId) + } + adapter.addAll(list.map { ActivityItem(it, adapter, ::onActivityClick) }) + } + + private suspend fun getActivities( + global: Boolean = false, + userId: Int? = null, + activityId: Int? = null, + filter: Boolean = false + ): List { + val res = Anilist.query.getFeed(userId, global, page, activityId)?.data?.page?.activities + page += 1 + return res + ?.filter { if (Anilist.adult) true else it.media?.isAdult != true } + ?.filterNot { it.recipient?.id != null && it.recipient.id != Anilist.userid && filter } + ?: emptyList() + } + + private fun shouldLoadMore(): Boolean { + val layoutManager = + (binding.listRecyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + val adapter = binding.listRecyclerView.adapter + return !binding.listRecyclerView.canScrollVertically(1) && + !binding.feedRefresh.isVisible && adapter?.itemCount != 0 && + layoutManager == (adapter!!.itemCount - 1) + + } + + private fun onActivityClick(id: Int, type: String) { + val intent = when (type) { + "USER" -> Intent(requireContext(), ProfileActivity::class.java).putExtra("userId", id) + "MEDIA" -> Intent( + requireContext(), + MediaDetailsActivity::class.java + ).putExtra("mediaId", id) + + else -> return + } + ContextCompat.startActivity(requireContext(), intent, null) + } + + override fun onResume() { + super.onResume() + if (this::binding.isInitialized) { + binding.root.requestLayout() + } + } + + companion object { + enum class ActivityType { GLOBAL, USER, OTHER_USER, ONE } + + fun newInstance( + type: ActivityType, + userId: Int? = null, + activityId: Int? = null + ): ActivityFragment { + return ActivityFragment().apply { + arguments = Bundle().apply { + putSerializable("type", type) + userId?.let { putInt("userId", it) } + activityId?.let { putInt("activityId", it) } + } + } + } + } +} \ 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 index f027b276..bca86098 100644 --- a/app/src/main/java/ani/dantotsu/profile/activity/ActivityItem.kt +++ b/app/src/main/java/ani/dantotsu/profile/activity/ActivityItem.kt @@ -5,7 +5,6 @@ import android.view.View import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity -import androidx.recyclerview.widget.LinearLayoutManager import ani.dantotsu.R import ani.dantotsu.blurImage import ani.dantotsu.buildMarkwon @@ -18,7 +17,7 @@ import ani.dantotsu.profile.UsersDialogFragment import ani.dantotsu.setAnimation import ani.dantotsu.snackString import ani.dantotsu.util.AniMarkdown.Companion.getBasicAniHTML -import ani.dantotsu.util.MarkdownCreatorActivity +import ani.dantotsu.util.ActivityMarkdownCreator import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.viewbinding.BindableItem import kotlinx.coroutines.CoroutineScope @@ -29,23 +28,16 @@ import kotlinx.coroutines.withContext class ActivityItem( private val activity: Activity, + private val parentAdapter: GroupieAdapter, val clickCallback: (Int, type: String) -> Unit, - private val fragActivity: FragmentActivity ) : BindableItem() { private lateinit var binding: ItemActivityBinding - private lateinit var repliesAdapter: GroupieAdapter override fun bind(viewBinding: ItemActivityBinding, position: Int) { binding = viewBinding + val context = binding.root.context + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 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 @@ -54,66 +46,29 @@ class ActivityItem( 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.commentTotalReplies.isVisible = activity.replyCount > 0 - binding.dot.isVisible = activity.replyCount > 0 - binding.commentTotalReplies.setOnClickListener { - when (binding.activityReplies.visibility) { - View.GONE -> { - val replyItems = activity.replies?.map { - ActivityReplyItem(it,fragActivity) { id, type -> - clickCallback( - id, - type - ) - } - } ?: emptyList() - repliesAdapter.addAll(replyItems) - binding.activityReplies.visibility = View.VISIBLE - binding.commentTotalReplies.setText(R.string.hide_replies) - } - - else -> { - repliesAdapter.clear() - binding.activityReplies.visibility = View.GONE - binding.commentTotalReplies.setText(R.string.view_replies) - - } - } - } - if (activity.isLocked != true) { - binding.commentReply.setOnClickListener { - val context = binding.root.context - ContextCompat.startActivity( - context, - Intent(context, MarkdownCreatorActivity::class.java) - .putExtra("type", "replyActivity") - .putExtra("parentId", activity.id), - null - ) - } - } else { - binding.commentReply.visibility = View.GONE - binding.dot.visibility = View.GONE - } val userList = arrayListOf() activity.likes?.forEach { i -> userList.add(User(i.id, i.name.toString(), i.avatar?.medium, i.bannerImage)) } + binding.activityRepliesContainer.setOnClickListener { + RepliesBottomDialog.newInstance(activity.id) + .show((context as FragmentActivity).supportFragmentManager, "replies") + } + binding.replyCount.text = activity.replyCount.toString() + binding.activityReplies.setColorFilter(ContextCompat.getColor(binding.root.context, R.color.bg_opp)) binding.activityLikeContainer.setOnLongClickListener { UsersDialogFragment().apply { userList(userList) - show(fragActivity.supportFragmentManager, "dialog") + show((context as FragmentActivity).supportFragmentManager, "dialog") } true } binding.activityLikeCount.text = (activity.likeCount ?: 0).toString() binding.activityLikeContainer.setOnClickListener { - val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope.launch { - val res = Anilist.query.toggleLike(activity.id, "ACTIVITY") + val res = Anilist.mutation.toggleLike(activity.id, "ACTIVITY") withContext(Dispatchers.Main) { if (res != null) { - if (activity.isLiked == true) { activity.likeCount = activity.likeCount?.minus(1) } else { @@ -129,13 +84,27 @@ class ActivityItem( } } } - val context = binding.root.context + binding.activityDelete.isVisible = activity.userId == Anilist.userid || activity.messenger?.id == Anilist.userid + binding.activityDelete.setOnClickListener { + scope.launch { + val res = Anilist.mutation.deleteActivity(activity.id) + withContext(Dispatchers.Main) { + if (res) { + snackString("Deleted activity") + parentAdapter.remove(this@ActivityItem) + } else { + snackString("Failed to delete activity") + } + } + } + } 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.activityPrivate.visibility = View.GONE binding.activityMediaName.text = activity.media?.title?.userPreferred val activityText = "${activity.user!!.name} ${activity.status} ${ activity.progress @@ -156,11 +125,13 @@ class ActivityItem( binding.activityMediaName.setOnClickListener { clickCallback(activity.media?.id ?: -1, "MEDIA") } + binding.activityEdit.isVisible = false } "TextActivity" -> { binding.activityBannerContainer.visibility = View.GONE binding.activityContent.visibility = View.VISIBLE + binding.activityPrivate.visibility = View.GONE if (!(context as android.app.Activity).isDestroyed) { val markwon = buildMarkwon(context, false) markwon.setMarkdown( @@ -174,11 +145,23 @@ class ActivityItem( binding.activityUserName.setOnClickListener { clickCallback(activity.userId ?: -1, "USER") } + binding.activityEdit.isVisible = activity.userId == Anilist.userid + binding.activityEdit.setOnClickListener { + ContextCompat.startActivity( + context, + Intent(context, ActivityMarkdownCreator::class.java) + .putExtra("type", "activity") + .putExtra("other", activity.text) + .putExtra("edit", activity.id), + null + ) + } } "MessageActivity" -> { binding.activityBannerContainer.visibility = View.GONE binding.activityContent.visibility = View.VISIBLE + binding.activityPrivate.visibility = if (activity.isPrivate == true) View.VISIBLE else View.GONE if (!(context as android.app.Activity).isDestroyed) { val markwon = buildMarkwon(context, false) markwon.setMarkdown( @@ -192,6 +175,19 @@ class ActivityItem( binding.activityUserName.setOnClickListener { clickCallback(activity.messengerId ?: -1, "USER") } + binding.activityEdit.isVisible = false + binding.activityEdit.isVisible = activity.messenger?.id == Anilist.userid + binding.activityEdit.setOnClickListener { + ContextCompat.startActivity( + context, + Intent(context, ActivityMarkdownCreator::class.java) + .putExtra("type", "message") + .putExtra("other", activity.message) + .putExtra("edit", activity.id) + .putExtra("userId", activity.recipientId), + null + ) + } } } } diff --git a/app/src/main/java/ani/dantotsu/profile/activity/ActivityReplyItem.kt b/app/src/main/java/ani/dantotsu/profile/activity/ActivityReplyItem.kt index 21befe05..073ada94 100644 --- a/app/src/main/java/ani/dantotsu/profile/activity/ActivityReplyItem.kt +++ b/app/src/main/java/ani/dantotsu/profile/activity/ActivityReplyItem.kt @@ -1,7 +1,9 @@ package ani.dantotsu.profile.activity +import android.content.Intent import android.view.View import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import ani.dantotsu.R import ani.dantotsu.buildMarkwon @@ -13,6 +15,8 @@ import ani.dantotsu.profile.User import ani.dantotsu.profile.UsersDialogFragment import ani.dantotsu.snackString import ani.dantotsu.util.AniMarkdown.Companion.getBasicAniHTML +import ani.dantotsu.util.ActivityMarkdownCreator +import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.viewbinding.BindableItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,23 +26,27 @@ import kotlinx.coroutines.withContext class ActivityReplyItem( private val reply: ActivityReply, + private val parentId : Int, private val fragActivity: FragmentActivity, + private val parentAdapter: GroupieAdapter, private val clickCallback: (Int, type: String) -> Unit, ) : BindableItem() { private lateinit var binding: ItemActivityReplyBinding override fun bind(viewBinding: ItemActivityReplyBinding, position: Int) { binding = viewBinding - + val context = binding.root.context + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 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) + val likeColor = ContextCompat.getColor(context, R.color.yt_red) + val notLikeColor = ContextCompat.getColor(context, R.color.bg_opp) binding.activityLike.setColorFilter(if (reply.isLiked) likeColor else notLikeColor) - val markwon = buildMarkwon(binding.root.context) + val markwon = buildMarkwon(context) markwon.setMarkdown(binding.activityContent, getBasicAniHTML(reply.text)) + val userList = arrayListOf() reply.likes?.forEach { i -> userList.add(User(i.id, i.name.toString(), i.avatar?.medium, i.bannerImage)) @@ -51,9 +59,8 @@ class ActivityReplyItem( true } binding.activityLikeContainer.setOnClickListener { - val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope.launch { - val res = Anilist.query.toggleLike(reply.id, "ACTIVITY_REPLY") + val res = Anilist.mutation.toggleLike(reply.id, "ACTIVITY_REPLY") withContext(Dispatchers.Main) { if (res != null) { if (reply.isLiked) { @@ -71,6 +78,42 @@ class ActivityReplyItem( } } } + binding.activityReply.setOnClickListener { + ContextCompat.startActivity( + context, + Intent(context, ActivityMarkdownCreator::class.java) + .putExtra("type", "replyActivity") + .putExtra("parentId", parentId) + .putExtra("other", "@${reply.user.name} "), + null + ) + } + binding.activityEdit.isVisible = reply.userId == Anilist.userid + binding.activityEdit.setOnClickListener { + ContextCompat.startActivity( + context, + Intent(context, ActivityMarkdownCreator::class.java) + .putExtra("type", "replyActivity") + .putExtra("parentId", parentId) + .putExtra("other", reply.text) + .putExtra("edit", reply.id), + null + ) + } + binding.activityDelete.isVisible = reply.userId == Anilist.userid + binding.activityDelete.setOnClickListener { + scope.launch { + val res = Anilist.mutation.deleteActivityReply(reply.id) + withContext(Dispatchers.Main) { + if (res) { + snackString("Deleted") + parentAdapter.remove(this@ActivityReplyItem) + } else { + snackString("Failed to delete") + } + } + } + } binding.activityAvatarContainer.setOnClickListener { clickCallback(reply.userId, "USER") diff --git a/app/src/main/java/ani/dantotsu/profile/activity/FeedActivity.kt b/app/src/main/java/ani/dantotsu/profile/activity/FeedActivity.kt index df86fe92..40cb8f96 100644 --- a/app/src/main/java/ani/dantotsu/profile/activity/FeedActivity.kt +++ b/app/src/main/java/ani/dantotsu/profile/activity/FeedActivity.kt @@ -2,6 +2,7 @@ package ani.dantotsu.profile.activity import android.content.res.Configuration import android.os.Bundle +import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.updateLayoutParams @@ -10,16 +11,20 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.R import ani.dantotsu.databinding.ActivityFeedBinding +import ani.dantotsu.databinding.ActivityNotificationBinding import ani.dantotsu.initActivity import ani.dantotsu.navBarHeight import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.profile.activity.ActivityFragment.Companion.ActivityType +import ani.dantotsu.profile.notification.NotificationActivity import nl.joery.animatedbottombar.AnimatedBottomBar class FeedActivity : AppCompatActivity() { - private lateinit var binding: ActivityFeedBinding + private lateinit var binding: ActivityNotificationBinding private var selected: Int = 0 lateinit var navBar: AnimatedBottomBar @@ -27,28 +32,29 @@ class FeedActivity : AppCompatActivity() { super.onCreate(savedInstanceState) ThemeManager(this).applyTheme() initActivity(this) - binding = ActivityFeedBinding.inflate(layoutInflater) + binding = ActivityNotificationBinding.inflate(layoutInflater) setContentView(binding.root) - navBar = binding.feedNavBar - val navBarMargin = if (resources.configuration.orientation == - Configuration.ORIENTATION_LANDSCAPE - ) 0 else navBarHeight - navBar.updateLayoutParams { bottomMargin = navBarMargin } - 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 = navBarMargin - topMargin += statusBarHeight + binding.notificationTitle.text = getString(R.string.activities) + binding.notificationToolbar.updateLayoutParams { + 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 = binding.notificationNavBar + binding.root.updateLayoutParams { + bottomMargin = navBarHeight + } + val tabs = listOf( + Pair(R.drawable.ic_round_person_24, "Following"), + Pair(R.drawable.ic_globe_24, "Global"), + ) + tabs.forEach { (icon, title) -> navBar.addTab(navBar.createTab(icon, title)) } + + binding.notificationBack.setOnClickListener { onBackPressedDispatcher.onBackPressed() } + val getOne = intent.getIntExtra("activityId", -1) + if (getOne != -1) { navBar.visibility = View.GONE } + binding.notificationViewPager.isUserInputEnabled = false + binding.notificationViewPager.adapter = ViewPagerAdapter(supportFragmentManager, lifecycle, getOne) + binding.notificationViewPager.setOffscreenPageLimit(4) + binding.notificationViewPager.setCurrentItem(selected, false) navBar.selectTabAt(selected) navBar.setOnTabSelectListener(object : AnimatedBottomBar.OnTabSelectListener { override fun onTabSelected( @@ -58,24 +64,9 @@ class FeedActivity : AppCompatActivity() { newTab: AnimatedBottomBar.Tab ) { selected = newIndex - binding.feedViewPager.setCurrentItem(selected, true) + binding.notificationViewPager.setCurrentItem(selected, false) } }) - binding.listBack.setOnClickListener { - onBackPressedDispatcher.onBackPressed() - } - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - val margin = - if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) 0 else navBarHeight - val params: ViewGroup.MarginLayoutParams = - binding.feedViewPager.layoutParams as ViewGroup.MarginLayoutParams - val paramsNav: ViewGroup.MarginLayoutParams = - navBar.layoutParams as ViewGroup.MarginLayoutParams - params.updateMargins(bottom = margin) - paramsNav.updateMargins(bottom = margin) } override fun onResume() { @@ -88,12 +79,12 @@ class FeedActivity : AppCompatActivity() { lifecycle: Lifecycle, private val activityId: Int ) : FragmentStateAdapter(fragmentManager, lifecycle) { - override fun getItemCount(): Int = 2 + override fun getItemCount(): Int = if (activityId != -1) 1 else 2 override fun createFragment(position: Int): Fragment { return when (position) { - 0 -> FeedFragment.newInstance(null, false, activityId) - else -> FeedFragment.newInstance(null, true, -1) + 0 -> ActivityFragment.newInstance(if (activityId != -1) ActivityType.ONE else ActivityType.USER, activityId = activityId) + else -> ActivityFragment.newInstance(ActivityType.GLOBAL) } } } diff --git a/app/src/main/java/ani/dantotsu/profile/activity/FeedFragment.kt b/app/src/main/java/ani/dantotsu/profile/activity/FeedFragment.kt deleted file mode 100644 index 102caea1..00000000 --- a/app/src/main/java/ani/dantotsu/profile/activity/FeedFragment.kt +++ /dev/null @@ -1,188 +0,0 @@ -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.setBaseline -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() - - userId = arguments?.getInt("userId", -1) - activityId = arguments?.getInt("activityId", -1) ?: -1 - if (userId == -1) userId = null - global = arguments?.getBoolean("global", false) ?: false - - val navBar = if (userId != null) { - (activity as ProfileActivity).navBar - } else { - (activity as FeedActivity).navBar - } - binding.listRecyclerView.setBaseline(navBar) - binding.listRecyclerView.adapter = adapter - binding.listRecyclerView.layoutManager = - LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) - binding.listProgressBar.visibility = ViewGroup.VISIBLE - } - - @SuppressLint("ClickableViewAccessibility") - override fun onResume() { - super.onResume() - if (this::binding.isInitialized) { - binding.root.requestLayout() - val navBar = if (userId != null) { - (activity as ProfileActivity).navBar - } else { - (activity as FeedActivity).navBar - } - binding.listRecyclerView.setBaseline(navBar) - 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 - .filter { if (Anilist.adult) true else it.media?.isAdult == false } - .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 deleted file mode 100644 index cf03c59c..00000000 --- a/app/src/main/java/ani/dantotsu/profile/activity/NotificationActivity.kt +++ /dev/null @@ -1,309 +0,0 @@ -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.ViewGroup -import android.widget.CheckBox -import android.widget.ImageButton -import android.widget.LinearLayout -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -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.R -import ani.dantotsu.connections.anilist.Anilist -import ani.dantotsu.connections.anilist.api.Notification -import ani.dantotsu.connections.anilist.api.NotificationType -import ani.dantotsu.connections.anilist.api.NotificationType.Companion.fromFormattedString -import ani.dantotsu.currContext -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.notifications.subscription.SubscriptionStore -import ani.dantotsu.profile.ProfileActivity -import ani.dantotsu.settings.saving.PrefManager -import ani.dantotsu.settings.saving.PrefName -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 lateinit var commentStore: List - private lateinit var subscriptionStore: List - private var adapter: GroupieAdapter = GroupieAdapter() - private var notificationList: List = emptyList() - val filters = ArrayList() - private var currentPage: Int = 1 - private var hasNextPage: Boolean = true - - @SuppressLint("ClickableViewAccessibility") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - ThemeManager(this).applyTheme() - initActivity(this) - binding = ActivityFollowBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.listTitle.text = getString(R.string.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 { - onBackPressedDispatcher.onBackPressed() - } - binding.listProgressBar.visibility = ViewGroup.VISIBLE - commentStore = PrefManager.getNullableVal>( - PrefName.CommentNotificationStore, - null - ) ?: listOf() - subscriptionStore = PrefManager.getNullableVal>( - PrefName.SubscriptionNotificationStore, - null - ) ?: listOf() - - binding.followFilterButton.setOnClickListener { - val dialogView = LayoutInflater.from(currContext()).inflate(R.layout.custom_dialog_layout, null) - val checkboxContainer = dialogView.findViewById(R.id.checkboxContainer) - val tickAllButton = dialogView.findViewById(R.id.toggleButton) - val title = dialogView.findViewById(R.id.scantitle) - title.visibility = ViewGroup.GONE - 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 - } - } - NotificationType.entries.forEach { notificationType -> - val checkBox = CheckBox(currContext()) - checkBox.text = notificationType.toFormattedString() - checkBox.isChecked = !filters.contains(notificationType.value.fromFormattedString()) - checkBox.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - filters.remove(notificationType.value.fromFormattedString()) - } else { - filters.add(notificationType.value.fromFormattedString()) - } - tickAllButton.setImageResource(getToggleImageResource(checkboxContainer)) - } - checkboxContainer.addView(checkBox) - } - tickAllButton.setImageResource(getToggleImageResource(checkboxContainer)) - tickAllButton.setOnClickListener { - for (i in 0 until checkboxContainer.childCount) { - val checkBox = checkboxContainer.getChildAt(i) as CheckBox - checkBox.isChecked = !checkBox.isChecked - } - - tickAllButton.setImageResource(getToggleImageResource(checkboxContainer)) - } - val alertD = AlertDialog.Builder(this, R.style.MyPopup) - alertD.setTitle("Filter") - alertD.setView(dialogView) - alertD.setPositiveButton("OK") { _, _ -> - currentPage = 1 - hasNextPage = true - adapter.clear() - adapter.addAll(notificationList.filter { notification -> - !filters.contains(notification.notificationType) - }.map { - NotificationItem( - it, - ::onNotificationClick - ) - }) - loadPage(-1) { - binding.followRefresh.visibility = ViewGroup.GONE - } - } - alertD.setNegativeButton("Cancel") { _, _ -> } - val dialog = alertD.show() - dialog.window?.setDimAmount(0.8f) - } - - 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) { - val furthestTime = newNotifications.minOfOrNull { it.createdAt } ?: 0 - commentStore.forEach { - if ((it.time > furthestTime * 1000L || !hasNextPage) && notificationList.none { notification -> - notification.commentId == it.commentId && notification.createdAt == (it.time / 1000L).toInt() - }) { - val notification = Notification( - it.type.toString(), - System.currentTimeMillis().toInt(), - commentId = it.commentId, - notificationType = it.type.toString(), - mediaId = it.mediaId, - context = it.title + "\n" + it.content, - createdAt = (it.time / 1000L).toInt(), - ) - newNotifications += notification - } - } - subscriptionStore.forEach { - if ((it.time > furthestTime * 1000L || !hasNextPage) && notificationList.none { notification -> - notification.mediaId == it.mediaId && notification.createdAt == (it.time / 1000L).toInt() - }) { - val notification = Notification( - it.type, - System.currentTimeMillis().toInt(), - commentId = it.mediaId, - mediaId = it.mediaId, - notificationType = it.type, - context = it.title + ": " + it.content, - createdAt = (it.time / 1000L).toInt(), - image = it.image, - banner = it.banner ?: it.image - ) - newNotifications += notification - } - } - newNotifications.sortByDescending { it.createdAt } - } - - notificationList += newNotifications - adapter.addAll(newNotifications.filter { notification -> - !filters.contains(notification.notificationType) - }.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/home/status/RepliesBottomDialog.kt b/app/src/main/java/ani/dantotsu/profile/activity/RepliesBottomDialog.kt similarity index 73% rename from app/src/main/java/ani/dantotsu/home/status/RepliesBottomDialog.kt rename to app/src/main/java/ani/dantotsu/profile/activity/RepliesBottomDialog.kt index 1927384a..184c7af9 100644 --- a/app/src/main/java/ani/dantotsu/home/status/RepliesBottomDialog.kt +++ b/app/src/main/java/ani/dantotsu/profile/activity/RepliesBottomDialog.kt @@ -1,4 +1,4 @@ -package ani.dantotsu.home.status +package ani.dantotsu.profile.activity import android.content.Intent import android.os.Bundle @@ -14,9 +14,8 @@ import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.connections.anilist.api.ActivityReply import ani.dantotsu.databinding.BottomSheetRecyclerBinding import ani.dantotsu.profile.ProfileActivity -import ani.dantotsu.profile.activity.ActivityReplyItem import ani.dantotsu.snackString -import ani.dantotsu.util.MarkdownCreatorActivity +import ani.dantotsu.util.ActivityMarkdownCreator import com.xwray.groupie.GroupieAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -49,7 +48,7 @@ class RepliesBottomDialog : BottomSheetDialogFragment() { binding.replyButton.setOnClickListener { ContextCompat.startActivity( context, - Intent(context, MarkdownCreatorActivity::class.java) + Intent(context, ActivityMarkdownCreator::class.java) .putExtra("type", "replyActivity") .putExtra("parentId", activityId), null @@ -58,29 +57,30 @@ class RepliesBottomDialog : BottomSheetDialogFragment() { activityId = requireArguments().getInt("activityId") loading(true) lifecycleScope.launch(Dispatchers.IO) { - val response = Anilist.query.getReplies(activityId) - withContext(Dispatchers.Main) { - loading(false) - if (response != null) { - replies.clear() - replies.addAll(response.data.page.activityReplies) - adapter.update( - replies.map { - ActivityReplyItem( - it, - requireActivity(), - clickCallback = { int, _ -> - onClick(int) - } - ) + loadData() + } + } + + private suspend fun loadData() { + val response = Anilist.query.getReplies(activityId) + withContext(Dispatchers.Main) { + loading(false) + if (response != null) { + replies.clear() + replies.addAll(response.data.page.activityReplies) + adapter.update( + replies.map { + ActivityReplyItem( + it, activityId, requireActivity(), adapter, + ) { i, _ -> + onClick(i) } - ) - } else { - snackString("Failed to load replies") - } + } + ) + } else { + snackString("Failed to load replies") } } - } private fun onClick(int: Int) { @@ -101,6 +101,14 @@ class RepliesBottomDialog : BottomSheetDialogFragment() { super.onDestroyView() } + override fun onResume() { + super.onResume() + loading(true) + lifecycleScope.launch(Dispatchers.IO) { + loadData() + } + } + companion object { fun newInstance(activityId: Int): RepliesBottomDialog { return RepliesBottomDialog().apply { diff --git a/app/src/main/java/ani/dantotsu/profile/notification/NotificationActivity.kt b/app/src/main/java/ani/dantotsu/profile/notification/NotificationActivity.kt new file mode 100644 index 00000000..087c94b5 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/notification/NotificationActivity.kt @@ -0,0 +1,100 @@ +package ani.dantotsu.profile.notification + +import android.os.Bundle +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +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 androidx.viewpager2.widget.ViewPager2 +import ani.dantotsu.R +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.databinding.ActivityNotificationBinding +import ani.dantotsu.initActivity +import ani.dantotsu.navBarHeight +import ani.dantotsu.statusBarHeight +import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.profile.notification.NotificationFragment.Companion.NotificationType.* +import ani.dantotsu.profile.notification.NotificationFragment.Companion.newInstance +import nl.joery.animatedbottombar.AnimatedBottomBar + +class NotificationActivity : AppCompatActivity() { + lateinit var binding: ActivityNotificationBinding + private var selected: Int = 0 + lateinit var navBar: AnimatedBottomBar + private val CommentsEnabled = PrefManager.getVal(PrefName.CommentsEnabled) == 1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + binding = ActivityNotificationBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.notificationTitle.text = getString(R.string.notifications) + binding.notificationToolbar.updateLayoutParams { + topMargin = statusBarHeight + } + navBar = binding.notificationNavBar + binding.root.updateLayoutParams { + bottomMargin = navBarHeight + } + + val tabs = mutableListOf( + Pair(R.drawable.ic_round_person_24, "User"), + Pair(R.drawable.ic_round_movie_filter_24, "Media"), + Pair(R.drawable.ic_round_notifications_active_24, "Subs") + ) + if (CommentsEnabled) { + tabs.add(Pair(R.drawable.ic_round_comment_24, "Comments")) + } + + tabs.forEach { (icon, title) -> navBar.addTab(navBar.createTab(icon, title)) } + + binding.notificationBack.setOnClickListener { onBackPressedDispatcher.onBackPressed() } + val getOne = intent.getIntExtra("activityId", -1) + if (getOne != -1) navBar.isVisible = false + binding.notificationViewPager.isUserInputEnabled = false + binding.notificationViewPager.adapter = ViewPagerAdapter(supportFragmentManager, lifecycle, getOne, CommentsEnabled) + binding.notificationViewPager.setCurrentItem(selected, 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.notificationViewPager.setCurrentItem(selected, false) + } + }) + } + + override fun onResume() { + super.onResume() + if (this::navBar.isInitialized) { + navBar.selectTabAt(selected) + } + } + + private class ViewPagerAdapter( + fragmentManager: FragmentManager, + lifecycle: Lifecycle, + val id: Int = -1, + val commentsEnabled: Boolean + ) : FragmentStateAdapter(fragmentManager, lifecycle) { + override fun getItemCount(): Int = if (id != -1) 1 else if (commentsEnabled) 4 else 3 + + override fun createFragment(position: Int): Fragment = when (position) { + 0 -> newInstance(if (id != -1) ONE else USER, id) + 1 -> newInstance(MEDIA) + 2 -> newInstance(SUBSCRIPTION) + 3 -> newInstance(COMMENT) + else -> newInstance(MEDIA) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/notification/NotificationFragment.kt b/app/src/main/java/ani/dantotsu/profile/notification/NotificationFragment.kt new file mode 100644 index 00000000..c38ec0fb --- /dev/null +++ b/app/src/main/java/ani/dantotsu/profile/notification/NotificationFragment.kt @@ -0,0 +1,233 @@ +package ani.dantotsu.profile.notification + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +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 androidx.recyclerview.widget.RecyclerView +import ani.dantotsu.R +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.Notification +import ani.dantotsu.databinding.FragmentNotificationsBinding +import ani.dantotsu.media.MediaDetailsActivity +import ani.dantotsu.notifications.comment.CommentStore +import ani.dantotsu.notifications.subscription.SubscriptionStore +import ani.dantotsu.profile.ProfileActivity +import ani.dantotsu.profile.activity.FeedActivity +import ani.dantotsu.profile.notification.NotificationFragment.Companion.NotificationType.COMMENT +import ani.dantotsu.profile.notification.NotificationFragment.Companion.NotificationType.MEDIA +import ani.dantotsu.profile.notification.NotificationFragment.Companion.NotificationType.ONE +import ani.dantotsu.profile.notification.NotificationFragment.Companion.NotificationType.SUBSCRIPTION +import ani.dantotsu.profile.notification.NotificationFragment.Companion.NotificationType.USER +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName +import com.xwray.groupie.GroupieAdapter +import eu.kanade.tachiyomi.util.system.getSerializableCompat +import kotlinx.coroutines.launch + + +class NotificationFragment : Fragment() { + private lateinit var type: NotificationType + private var getID: Int = -1 + private lateinit var binding: FragmentNotificationsBinding + private var adapter: GroupieAdapter = GroupieAdapter() + private var currentPage = 1 + private var hasNextPage = false + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentNotificationsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + arguments?.let { + getID = it.getInt("id") + type = it.getSerializableCompat("type") as NotificationType + } + binding.notificationRecyclerView.adapter = adapter + binding.notificationRecyclerView.layoutManager = LinearLayoutManager(context) + binding.notificationProgressBar.isVisible = true + binding.emptyTextView.text = getString(R.string.nothing_here) + lifecycleScope.launch { + getList() + + binding.notificationProgressBar.isVisible = false + } + binding.notificationSwipeRefresh.setOnRefreshListener { + lifecycleScope.launch { + adapter.clear() + currentPage = 1 + getList() + binding.notificationSwipeRefresh.isRefreshing = false + } + } + binding.notificationRecyclerView.addOnScrollListener(object : + RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (shouldLoadMore()) { + lifecycleScope.launch { + binding.notificationRefresh.isVisible = true + getList() + binding.notificationRefresh.isVisible = false + } + } + } + }) + + } + + private suspend fun getList() { + val list = when (type) { + ONE -> getNotificationsFiltered(false) { it.id == getID } + MEDIA -> getNotificationsFiltered(type = true) { it.media != null } + USER -> getNotificationsFiltered { it.media == null } + SUBSCRIPTION -> getSubscriptions() + COMMENT -> getComments() + } + adapter.addAll(list.map { NotificationItem(it, type, adapter, ::onClick) }) + if (adapter.itemCount == 0) { + binding.emptyTextView.isVisible = true + } + } + + private suspend fun getNotificationsFiltered( + reset: Boolean = true, + type: Boolean? = null, + filter: (Notification) -> Boolean + ): List { + val userId = + Anilist.userid ?: PrefManager.getVal(PrefName.AnilistUserId).toIntOrNull() ?: 0 + val res = Anilist.query.getNotifications(userId, currentPage, reset, type)?.data?.page + currentPage = res?.pageInfo?.currentPage?.plus(1) ?: 1 + hasNextPage = res?.pageInfo?.hasNextPage ?: false + return res?.notifications?.filter(filter) ?: listOf() + } + + private fun getSubscriptions(): List { + val list = PrefManager.getNullableVal>( + PrefName.SubscriptionNotificationStore, + null + ) ?: listOf() + + return list + .sortedByDescending { (it.time / 1000L).toInt() } + .filter { it.image != null } // to remove old data + .map { + Notification( + it.type, + System.currentTimeMillis().toInt(), + commentId = it.mediaId, + mediaId = it.mediaId, + notificationType = it.type, + context = it.title + ": " + it.content, + createdAt = (it.time / 1000L).toInt(), + image = it.image, + banner = it.banner ?: it.image + ) + } + } + + private fun getComments(): List { + val list = PrefManager.getNullableVal>( + PrefName.CommentNotificationStore, + null + ) ?: listOf() + return list + .sortedByDescending { (it.time / 1000L).toInt() } + .map { + Notification( + it.type.toString(), + System.currentTimeMillis().toInt(), + commentId = it.commentId, + notificationType = it.type.toString(), + mediaId = it.mediaId, + context = it.title + "\n" + it.content, + createdAt = (it.time / 1000L).toInt(), + ) + } + } + + private fun shouldLoadMore(): Boolean { + val layoutManager = + (binding.notificationRecyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + val adapter = binding.notificationRecyclerView.adapter + + return hasNextPage && !binding.notificationRefresh.isVisible && adapter?.itemCount != 0 && + layoutManager == (adapter!!.itemCount - 1) && + !binding.notificationRecyclerView.canScrollVertically(1) + } + + fun onClick(id: Int, optional: Int?, type: NotificationClickType) { + val intent = when (type) { + NotificationClickType.USER -> Intent( + requireContext(), + ProfileActivity::class.java + ).apply { + putExtra("userId", id) + } + + NotificationClickType.MEDIA -> Intent( + requireContext(), + MediaDetailsActivity::class.java + ).apply { + putExtra("mediaId", id) + } + + NotificationClickType.ACTIVITY -> Intent( + requireContext(), + FeedActivity::class.java + ).apply { + putExtra("activityId", id) + } + + NotificationClickType.COMMENT -> Intent( + requireContext(), + MediaDetailsActivity::class.java + ).apply { + putExtra("FRAGMENT_TO_LOAD", "COMMENTS") + putExtra("mediaId", id) + putExtra("commentId", optional ?: -1) + } + + NotificationClickType.UNDEFINED -> null + } + + intent?.let { + ContextCompat.startActivity(requireContext(), it, null) + } + } + + + override fun onResume() { + super.onResume() + if (this::binding.isInitialized) { + binding.root.requestLayout() + } + } + + companion object { + enum class NotificationClickType { USER, MEDIA, ACTIVITY, COMMENT, UNDEFINED } + enum class NotificationType { MEDIA, USER, SUBSCRIPTION, COMMENT, ONE } + + fun newInstance(type: NotificationType, id: Int = -1): NotificationFragment { + return NotificationFragment().apply { + arguments = Bundle().apply { + putSerializable("type", type) + putInt("id", id) + } + } + } + } + +} \ 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/notification/NotificationItem.kt similarity index 82% rename from app/src/main/java/ani/dantotsu/profile/activity/NotificationItem.kt rename to app/src/main/java/ani/dantotsu/profile/notification/NotificationItem.kt index d0e932d9..61273374 100644 --- a/app/src/main/java/ani/dantotsu/profile/activity/NotificationItem.kt +++ b/app/src/main/java/ani/dantotsu/profile/notification/NotificationItem.kt @@ -1,4 +1,4 @@ -package ani.dantotsu.profile.activity +package ani.dantotsu.profile.notification import android.view.View import android.view.ViewGroup @@ -8,15 +8,27 @@ 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.notifications.comment.CommentStore +import ani.dantotsu.notifications.subscription.SubscriptionStore +import ani.dantotsu.profile.activity.ActivityItemBuilder +import ani.dantotsu.profile.notification.NotificationFragment.Companion.NotificationClickType +import ani.dantotsu.profile.notification.NotificationFragment.Companion.NotificationType.COMMENT +import ani.dantotsu.profile.notification.NotificationFragment.Companion.NotificationType.SUBSCRIPTION import ani.dantotsu.setAnimation +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.toPx +import ani.dantotsu.util.customAlertDialog +import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.viewbinding.BindableItem class NotificationItem( private val notification: Notification, - val clickCallback: (Int, Int?, NotificationClickType) -> Unit -) : BindableItem() { + val type: NotificationFragment.Companion.NotificationType, + val parentAdapter: GroupieAdapter, + val clickCallback: (Int, Int?, NotificationClickType) -> Unit, + + ) : BindableItem() { private lateinit var binding: ItemNotificationBinding override fun bind(viewBinding: ItemNotificationBinding, position: Int) { binding = viewBinding @@ -24,6 +36,48 @@ class NotificationItem( setBinding() } + fun dialog() { + when (type) { + COMMENT, SUBSCRIPTION -> { + binding.root.context.customAlertDialog().apply { + setTitle(R.string.delete) + setMessage(ActivityItemBuilder.getContent(notification)) + setPosButton(R.string.yes) { + when (type) { + COMMENT -> { + val list = PrefManager.getNullableVal>( + PrefName.CommentNotificationStore, + null + ) ?: listOf() + val newList = list.filter { it.commentId != notification.commentId } + PrefManager.setVal(PrefName.CommentNotificationStore, newList) + parentAdapter.remove(this@NotificationItem) + + } + + SUBSCRIPTION -> { + val list = PrefManager.getNullableVal>( + PrefName.SubscriptionNotificationStore, + null + ) ?: listOf() + val newList = list.filter { (it.time / 1000L).toInt() != notification.createdAt} + PrefManager.setVal(PrefName.SubscriptionNotificationStore, newList) + parentAdapter.remove(this@NotificationItem) + } + + else -> {} + } + } + setNegButton(R.string.no) + show() + } + } + + else -> {} + } + + } + override fun getLayout(): Int { return R.layout.item_notification } @@ -32,7 +86,11 @@ class NotificationItem( return ItemNotificationBinding.bind(view) } - private fun image(user: Boolean = false, commentNotification: Boolean = false, newRelease: Boolean = false) { + private fun image( + user: Boolean = false, + commentNotification: Boolean = false, + newRelease: Boolean = false + ) { val cover = if (user) notification.user?.bannerImage ?: notification.user?.avatar?.medium else notification.media?.bannerImage @@ -347,6 +405,14 @@ class NotificationItem( } } } + binding.notificationCoverUser.setOnLongClickListener { + dialog() + true + } + binding.notificationBannerImage.setOnLongClickListener { + dialog() + true + } } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/AddRepositoryBottomSheet.kt b/app/src/main/java/ani/dantotsu/settings/AddRepositoryBottomSheet.kt new file mode 100644 index 00000000..4581f8cb --- /dev/null +++ b/app/src/main/java/ani/dantotsu/settings/AddRepositoryBottomSheet.kt @@ -0,0 +1,159 @@ +package ani.dantotsu.settings + +import android.content.Context +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.BottomSheetDialogFragment +import ani.dantotsu.R +import ani.dantotsu.databinding.BottomSheetAddRepositoryBinding +import ani.dantotsu.databinding.ItemRepoBinding +import ani.dantotsu.media.MediaType +import ani.dantotsu.util.customAlertDialog +import com.xwray.groupie.GroupieAdapter +import com.xwray.groupie.viewbinding.BindableItem + +class RepoItem( + val url: String, + val onRemove: (String) -> Unit +) :BindableItem() { + override fun getLayout() = R.layout.item_repo + + override fun bind(viewBinding: ItemRepoBinding, position: Int) { + viewBinding.repoNameTextView.text = url + viewBinding.repoDeleteImageView.setOnClickListener { + onRemove(url) + } + } + + override fun initializeViewBinding(view: View): ItemRepoBinding { + return ItemRepoBinding.bind(view) + } +} + +class AddRepositoryBottomSheet : BottomSheetDialogFragment() { + private var _binding: BottomSheetAddRepositoryBinding? = null + private val binding get() = _binding!! + private var mediaType: MediaType = MediaType.ANIME + private var onRepositoryAdded: ((String, MediaType) -> Unit)? = null + private var repositories: MutableList = mutableListOf() + private var onRepositoryRemoved: ((String) -> Unit)? = null + private var adapter: GroupieAdapter = GroupieAdapter() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetAddRepositoryBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.repositoriesRecyclerView.adapter = adapter + binding.repositoriesRecyclerView.layoutManager = LinearLayoutManager( + context, + LinearLayoutManager.VERTICAL, + false + ) + adapter.addAll(repositories.map { RepoItem(it, ::onRepositoryRemoved) }) + + binding.repositoryInput.hint = when(mediaType) { + MediaType.ANIME -> getString(R.string.anime_add_repository) + MediaType.MANGA -> getString(R.string.manga_add_repository) + else -> "" + } + + binding.addButton.setOnClickListener { + val input = binding.repositoryInput.text.toString() + val error = isValidUrl(input) + if (error == null) { + context?.let { context -> + addRepoWarning(context) { + onRepositoryAdded?.invoke(input, mediaType) + dismiss() + } + } + } else { + binding.repositoryInput.error = error + } + } + + binding.cancelButton.setOnClickListener { + dismiss() + } + + binding.repositoryInput.setOnEditorActionListener { textView, action, keyEvent -> + if (action == EditorInfo.IME_ACTION_DONE || + (keyEvent?.action == KeyEvent.ACTION_UP && keyEvent.keyCode == KeyEvent.KEYCODE_ENTER)) { + val url = textView.text.toString() + if (url.isNotBlank()) { + val error = isValidUrl(url) + if (error == null) { + context?.let { context -> + addRepoWarning(context) { + onRepositoryAdded?.invoke(url, mediaType) + dismiss() + } + } + return@setOnEditorActionListener true + } else { + binding.repositoryInput.error = error + } + } + } + false + } + } + + private fun onRepositoryRemoved(url: String) { + onRepositoryRemoved?.invoke(url) + repositories.remove(url) + adapter.update(repositories.map { RepoItem(it, ::onRepositoryRemoved) }) + } + + private fun isValidUrl(url: String): String? { + if (!url.startsWith("https://") && !url.startsWith("http://")) + return "URL must start with http:// or https://" + if (!url.removeSuffix("/").endsWith("index.min.json")) + return "URL must end with index.min.json" + return null + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + fun addRepoWarning(context: Context, onRepositoryAdded: () -> Unit) { + context.customAlertDialog() + .setTitle(R.string.warning) + .setMessage(R.string.add_repository_warning) + .setPosButton(R.string.ok) { + onRepositoryAdded.invoke() + } + .setNegButton(R.string.cancel) { } + .show() + } + fun newInstance( + mediaType: MediaType, + repositories: List, + onRepositoryAdded: (String, MediaType) -> Unit, + onRepositoryRemoved: (String) -> Unit + ): AddRepositoryBottomSheet { + return AddRepositoryBottomSheet().apply { + this.mediaType = mediaType + this.repositories.addAll(repositories) + this.onRepositoryAdded = onRepositoryAdded + this.onRepositoryRemoved = onRepositoryRemoved + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/AnilistSettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/AnilistSettingsActivity.kt new file mode 100644 index 00000000..ca9741c5 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/settings/AnilistSettingsActivity.kt @@ -0,0 +1,329 @@ +package ani.dantotsu.settings + +import android.os.Bundle +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.children +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.R +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.Anilist.activityMergeTimeMap +import ani.dantotsu.connections.anilist.Anilist.rowOrderMap +import ani.dantotsu.connections.anilist.Anilist.scoreFormats +import ani.dantotsu.connections.anilist.Anilist.staffNameLang +import ani.dantotsu.connections.anilist.Anilist.titleLang +import ani.dantotsu.connections.anilist.AnilistMutations +import ani.dantotsu.connections.anilist.api.ScoreFormat +import ani.dantotsu.connections.anilist.api.UserStaffNameLanguage +import ani.dantotsu.connections.anilist.api.UserTitleLanguage +import ani.dantotsu.databinding.ActivitySettingsAnilistBinding +import ani.dantotsu.initActivity +import ani.dantotsu.navBarHeight +import ani.dantotsu.restartApp +import ani.dantotsu.statusBarHeight +import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.toast +import ani.dantotsu.util.customAlertDialog +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import kotlinx.coroutines.launch + +class AnilistSettingsActivity : AppCompatActivity() { + private lateinit var binding: ActivitySettingsAnilistBinding + private lateinit var anilistMutations: AnilistMutations + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + val context = this + binding = ActivitySettingsAnilistBinding.inflate(layoutInflater) + setContentView(binding.root) + + anilistMutations = AnilistMutations() + + binding.apply { + settingsAnilistLayout.updateLayoutParams { + topMargin = statusBarHeight + bottomMargin = navBarHeight + } + binding.anilistSettingsBack.setOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + + val currentTitleLang = Anilist.titleLanguage + val titleFormat = UserTitleLanguage.entries.firstOrNull { it.name == currentTitleLang } ?: UserTitleLanguage.ENGLISH + + settingsAnilistTitleLanguage.setText(titleLang[titleFormat.ordinal]) + settingsAnilistTitleLanguage.setAdapter( + ArrayAdapter(context, R.layout.item_dropdown, titleLang) + ) + settingsAnilistTitleLanguage.setOnItemClickListener { _, _, i, _ -> + val selectedLanguage = when (i) { + 0 -> "ENGLISH" + 1 -> "ROMAJI" + 2 -> "NATIVE" + else -> "ENGLISH" + } + lifecycleScope.launch { + anilistMutations.updateSettings(titleLanguage = selectedLanguage) + Anilist.titleLanguage = selectedLanguage + restartApp() + } + settingsAnilistTitleLanguage.clearFocus() + } + + val currentStaffNameLang = Anilist.staffNameLanguage + val staffNameFormat = UserStaffNameLanguage.entries.firstOrNull { it.name == currentStaffNameLang } ?: UserStaffNameLanguage.ROMAJI_WESTERN + + settingsAnilistStaffLanguage.setText(staffNameLang[staffNameFormat.ordinal]) + settingsAnilistStaffLanguage.setAdapter( + ArrayAdapter(context, R.layout.item_dropdown, staffNameLang) + ) + settingsAnilistStaffLanguage.setOnItemClickListener { _, _, i, _ -> + val selectedLanguage = when (i) { + 0 -> "ROMAJI_WESTERN" + 1 -> "ROMAJI" + 2 -> "NATIVE" + else -> "ROMAJI_WESTERN" + } + lifecycleScope.launch { + anilistMutations.updateSettings(staffNameLanguage = selectedLanguage) + Anilist.staffNameLanguage = selectedLanguage + restartApp() + } + settingsAnilistStaffLanguage.clearFocus() + } + + val currentMergeTimeDisplay = activityMergeTimeMap.entries.firstOrNull { it.value == Anilist.activityMergeTime }?.key + ?: "${Anilist.activityMergeTime} mins" + settingsAnilistActivityMergeTime.setText(currentMergeTimeDisplay) + settingsAnilistActivityMergeTime.setAdapter( + ArrayAdapter(context, R.layout.item_dropdown, activityMergeTimeMap.keys.toList()) + ) + settingsAnilistActivityMergeTime.setOnItemClickListener { _, _, i, _ -> + val selectedDisplayTime = activityMergeTimeMap.keys.toList()[i] + val selectedApiTime = activityMergeTimeMap[selectedDisplayTime] ?: 0 + lifecycleScope.launch { + anilistMutations.updateSettings(activityMergeTime = selectedApiTime) + Anilist.activityMergeTime = selectedApiTime + restartApp() + } + settingsAnilistActivityMergeTime.clearFocus() + } + + val currentScoreFormat = Anilist.scoreFormat + val scoreFormat = ScoreFormat.entries.firstOrNull{ it.name == currentScoreFormat } ?: ScoreFormat.POINT_100 + settingsAnilistScoreFormat.setText(scoreFormats[scoreFormat.ordinal]) + settingsAnilistScoreFormat.setAdapter( + ArrayAdapter(context, R.layout.item_dropdown, scoreFormats) + ) + settingsAnilistScoreFormat.setOnItemClickListener { _, _, i, _ -> + val selectedFormat = when (i) { + 0 -> "POINT_100" + 1 -> "POINT_10_DECIMAL" + 2 -> "POINT_10" + 3 -> "POINT_5" + 4 -> "POINT_3" + else -> "POINT_100" + } + lifecycleScope.launch { + anilistMutations.updateSettings(scoreFormat = selectedFormat) + Anilist.scoreFormat = selectedFormat + restartApp() + } + settingsAnilistScoreFormat.clearFocus() + } + + val currentRowOrder = rowOrderMap.entries.firstOrNull { it.value == Anilist.rowOrder }?.key ?: "Score" + settingsAnilistRowOrder.setText(currentRowOrder) + settingsAnilistRowOrder.setAdapter( + ArrayAdapter(context, R.layout.item_dropdown, rowOrderMap.keys.toList()) + ) + settingsAnilistRowOrder.setOnItemClickListener { _, _, i, _ -> + val selectedDisplayOrder = rowOrderMap.keys.toList()[i] + val selectedApiOrder = rowOrderMap[selectedDisplayOrder] ?: "score" + lifecycleScope.launch { + anilistMutations.updateSettings(rowOrder = selectedApiOrder) + Anilist.rowOrder = selectedApiOrder + restartApp() + } + settingsAnilistRowOrder.clearFocus() + } + + val containers = listOf(binding.animeCustomListsContainer, binding.mangaCustomListsContainer) + val customLists = listOf(Anilist.animeCustomLists, Anilist.mangaCustomLists) + val buttons = listOf(binding.addAnimeListButton, binding.addMangaListButton) + + containers.forEachIndexed { index, container -> + customLists[index]?.forEach { listName -> + addCustomListItem(listName, container, index == 0) + } + } + + buttons.forEachIndexed { index, button -> + button.setOnClickListener { + addCustomListItem("", containers[index], index == 0) + } + } + + binding.SettingsAnilistCustomListSave.setOnClickListener { + saveCustomLists() + } + + val currentTimezone = Anilist.timezone?.let { Anilist.getDisplayTimezone(it, context) } ?: context.getString(R.string.selected_no_time_zone) + settingsAnilistTimezone.setText(currentTimezone) + settingsAnilistTimezone.setAdapter( + ArrayAdapter(context, R.layout.item_dropdown, Anilist.timeZone) + ) + settingsAnilistTimezone.setOnItemClickListener { _, _, i, _ -> + val selectedTimezone = Anilist.timeZone[i] + val apiTimezone = Anilist.getApiTimezone(selectedTimezone) + lifecycleScope.launch { + anilistMutations.updateSettings(timezone = apiTimezone) + Anilist.timezone = apiTimezone + restartApp() + } + settingsAnilistTimezone.clearFocus() + } + + val displayAdultContent = Anilist.adult + val airingNotifications = Anilist.airingNotifications + + binding.settingsRecyclerView1.adapter = SettingsAdapter( + arrayListOf( + Settings( + type = 2, + name = getString(R.string.airing_notifications), + desc = getString(R.string.airing_notifications_desc), + icon = R.drawable.ic_round_notifications_active_24, + isChecked = airingNotifications, + switch = { isChecked, _ -> + lifecycleScope.launch { + anilistMutations.updateSettings(airingNotifications = isChecked) + Anilist.airingNotifications = isChecked + restartApp() + } + } + ), + Settings( + type = 2, + name = getString(R.string.display_adult_content), + desc = getString(R.string.display_adult_content_desc), + icon = R.drawable.ic_round_nsfw_24, + isChecked = displayAdultContent, + switch = { isChecked, _ -> + lifecycleScope.launch { + anilistMutations.updateSettings(displayAdultContent = isChecked) + Anilist.adult = isChecked + restartApp() + } + } + ), + ) + ) + binding.settingsRecyclerView1.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + + } + + binding.settingsRecyclerView2.adapter = SettingsAdapter( + arrayListOf( + Settings( + type = 2, + name = getString(R.string.restrict_messages), + desc = getString(R.string.restrict_messages_desc), + icon = R.drawable.ic_round_lock_open_24, + isChecked = Anilist.restrictMessagesToFollowing, + switch = { isChecked, _ -> + lifecycleScope.launch { + anilistMutations.updateSettings(restrictMessagesToFollowing = isChecked) + Anilist.restrictMessagesToFollowing = isChecked + restartApp() + } + } + ), + ) + ) + binding.settingsRecyclerView2.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + + } + + private fun addCustomListItem(listName: String, container: LinearLayout, isAnime: Boolean) { + val customListItemView = layoutInflater.inflate(R.layout.item_custom_list, container, false) + val textInputLayout = customListItemView.findViewById(R.id.customListItem) + val editText = textInputLayout.editText as? TextInputEditText + editText?.setText(listName) + textInputLayout.setEndIconOnClickListener { + val name = editText?.text.toString() + if (name.isNotEmpty()) { + val listExists = if (isAnime) { + Anilist.animeCustomLists?.contains(name) ?: false + } else { + Anilist.mangaCustomLists?.contains(name) ?: false + } + + if (listExists) { + customAlertDialog().apply { + setTitle(getString(R.string.delete_custom_list)) + setMessage(getString(R.string.delete_custom_list_confirm, name)) + setPosButton(getString(R.string.delete)) { + deleteCustomList(name, isAnime) + container.removeView(customListItemView) + } + setNegButton(getString(R.string.cancel)) + }.show() + } else { + container.removeView(customListItemView) + } + } else { + container.removeView(customListItemView) + } + } + container.addView(customListItemView) + } + + private fun deleteCustomList(name: String, isAnime: Boolean) { + lifecycleScope.launch { + val type = if (isAnime) "ANIME" else "MANGA" + val success = anilistMutations.deleteCustomList(name, type) + if (success) { + if (isAnime) { + Anilist.animeCustomLists = Anilist.animeCustomLists?.filter { it != name } + } else { + Anilist.mangaCustomLists = Anilist.mangaCustomLists?.filter { it != name } + } + toast("Custom list deleted") + } else { + toast("Failed to delete custom list") + } + } + } + + private fun saveCustomLists() { + val animeCustomLists = binding.animeCustomListsContainer.children + .mapNotNull { (it.findViewById(R.id.customListItem).editText as? TextInputEditText)?.text?.toString() } + .filter { it.isNotEmpty() } + .toList() + val mangaCustomLists = binding.mangaCustomListsContainer.children + .mapNotNull { (it.findViewById(R.id.customListItem).editText as? TextInputEditText)?.text?.toString() } + .filter { it.isNotEmpty() } + .toList() + + lifecycleScope.launch { + val success = anilistMutations.updateCustomLists(animeCustomLists, mangaCustomLists) + if (success) { + Anilist.animeCustomLists = animeCustomLists + Anilist.mangaCustomLists = mangaCustomLists + toast("Custom lists saved") + } else { + toast("Failed to save custom lists") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt b/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt index 0fa65612..dd11df3a 100644 --- a/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/ExtensionsActivity.kt @@ -21,7 +21,6 @@ import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.R import ani.dantotsu.copyToClipboard -import ani.dantotsu.currContext import ani.dantotsu.databinding.ActivityExtensionsBinding import ani.dantotsu.databinding.DialogRepositoriesBinding import ani.dantotsu.databinding.ItemRepositoryBinding @@ -35,6 +34,7 @@ import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.util.customAlertDialog import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager @@ -43,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import uy.kohesive.injekt.injectLazy +import java.util.Locale class ExtensionsActivity : AppCompatActivity() { lateinit var binding: ActivityExtensionsBinding @@ -173,26 +174,24 @@ class ExtensionsActivity : AppCompatActivity() { initActivity(this) binding.languageselect.setOnClickListener { val languageOptions = - LanguageMapper.Companion.Language.entries.map { it.name }.toTypedArray() - val builder = AlertDialog.Builder(currContext(), R.style.MyPopup) + LanguageMapper.Companion.Language.entries.map { entry -> + entry.name.lowercase().replace("_", " ") + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() } + }.toTypedArray() val listOrder: String = PrefManager.getVal(PrefName.LangSort) val index = LanguageMapper.Companion.Language.entries.toTypedArray() .indexOfFirst { it.code == listOrder } - builder.setTitle("Language") - builder.setSingleChoiceItems(languageOptions, index) { dialog, i -> - PrefManager.setVal( - PrefName.LangSort, - LanguageMapper.Companion.Language.entries[i].code - ) - val currentFragment = - supportFragmentManager.findFragmentByTag("f${viewPager.currentItem}") - if (currentFragment is SearchQueryHandler) { - currentFragment.notifyDataChanged() + customAlertDialog().apply { + setTitle("Language") + singleChoiceItems(languageOptions, index) { selected -> + PrefManager.setVal(PrefName.LangSort, LanguageMapper.Companion.Language.entries[selected].code) + val currentFragment = supportFragmentManager.findFragmentByTag("f${viewPager.currentItem}") + if (currentFragment is SearchQueryHandler) { + currentFragment.notifyDataChanged() + } } - dialog.dismiss() + show() } - val dialog = builder.show() - dialog.window?.setDimAmount(0.8f) } binding.settingsContainer.updateLayoutParams { topMargin = statusBarHeight @@ -243,10 +242,10 @@ class ExtensionsActivity : AppCompatActivity() { ) view.repositoryItem.text = item.removePrefix("https://raw.githubusercontent.com") view.repositoryItem.setOnClickListener { - AlertDialog.Builder(this@ExtensionsActivity, R.style.MyPopup) - .setTitle(R.string.rem_repository) - .setMessage(item) - .setPositiveButton(getString(R.string.ok)) { dialog, _ -> + customAlertDialog().apply { + setTitle(R.string.rem_repository) + setMessage(item) + setPosButton(R.string.ok) { val repos = PrefManager.getVal>(prefName).minus(item) PrefManager.setVal(prefName, repos) repoInventory.removeView(view.root) @@ -263,13 +262,10 @@ class ExtensionsActivity : AppCompatActivity() { else -> {} } } - dialog.dismiss() } - .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - } - .create() - .show() + setNegButton(R.string.cancel) + show() + } } view.repositoryItem.setOnLongClickListener { it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) @@ -320,20 +316,18 @@ class ExtensionsActivity : AppCompatActivity() { dialogView.repoInventory.apply { getSavedRepositories(this, type) } - val alertDialog = AlertDialog.Builder(this@ExtensionsActivity, R.style.MyPopup) - .setTitle(R.string.edit_repositories) - .setView(dialogView.root) - .setPositiveButton(getString(R.string.add)) { _, _ -> - if (!dialogView.repositoryTextBox.text.isNullOrBlank()) - processUserInput(dialogView.repositoryTextBox.text.toString(), type) - } - .setNegativeButton(getString(R.string.close)) { dialog, _ -> - dialog.dismiss() - } - .create() processEditorAction(dialogView.repositoryTextBox, type) - alertDialog.show() - alertDialog.window?.setDimAmount(0.8f) + customAlertDialog().apply { + setTitle(R.string.edit_repositories) + setCustomView(dialogView.root) + setPosButton(R.string.add_list) { + if (!dialogView.repositoryTextBox.text.isNullOrBlank()) { + processUserInput(dialogView.repositoryTextBox.text.toString(), type) + } + } + setNegButton(R.string.close) + show() + } } } } diff --git a/app/src/main/java/ani/dantotsu/settings/FAQActivity.kt b/app/src/main/java/ani/dantotsu/settings/FAQActivity.kt index 49656f55..98a248e2 100644 --- a/app/src/main/java/ani/dantotsu/settings/FAQActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/FAQActivity.kt @@ -50,6 +50,11 @@ class FAQActivity : AppCompatActivity() { currContext()?.getString(R.string.question_5) ?: "", currContext()?.getString(R.string.answer_5) ?: "" ), + Triple( + R.drawable.ic_anilist, + currContext()?.getString(R.string.question_18) ?: "", + currContext()?.getString(R.string.answer_18) ?: "" + ), Triple( R.drawable.ic_anilist, currContext()?.getString(R.string.question_6) ?: "", @@ -60,6 +65,11 @@ class FAQActivity : AppCompatActivity() { currContext()?.getString(R.string.question_7) ?: "", currContext()?.getString(R.string.answer_7) ?: "" ), + Triple( + R.drawable.ic_round_magnet_24, + currContext()?.getString(R.string.question_19) ?: "", + currContext()?.getString(R.string.answer_19) ?: "" + ), Triple( R.drawable.ic_round_lock_open_24, currContext()?.getString(R.string.question_9) ?: "", diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt index 2e6d650c..6ed8d0c6 100644 --- a/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt @@ -1,6 +1,5 @@ package ani.dantotsu.settings -import android.app.AlertDialog import android.app.NotificationManager import android.content.Context import android.os.Bundle @@ -25,13 +24,15 @@ import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.databinding.FragmentExtensionsBinding -import ani.dantotsu.others.LanguageMapper +import ani.dantotsu.others.LanguageMapper.Companion.getLanguageName import ani.dantotsu.parsers.AnimeSources import ani.dantotsu.settings.extensionprefs.AnimeSourcePreferencesFragment import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import ani.dantotsu.util.customAlertDialog + import com.google.android.material.tabs.TabLayout import com.google.android.material.textfield.TextInputLayout import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource @@ -42,13 +43,10 @@ import kotlinx.coroutines.launch import rx.android.schedulers.AndroidSchedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.Collections import java.util.Locale class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { - - private var _binding: FragmentExtensionsBinding? = null private val binding get() = _binding!! private lateinit var extensionsRecyclerView: RecyclerView @@ -72,16 +70,15 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { if (allSettings.isNotEmpty()) { var selectedSetting = allSettings[0] if (allSettings.size > 1) { - val names = allSettings.map { LanguageMapper.getLanguageName(it.lang) } + val names = allSettings.map { getLanguageName(it.lang) } .toTypedArray() var selectedIndex = 0 - val dialog = AlertDialog.Builder(requireContext(), R.style.MyPopup) - .setTitle("Select a Source") - .setSingleChoiceItems(names, selectedIndex) { dialog, which -> + requireContext().customAlertDialog().apply { + setTitle("Select a Source") + singleChoiceItems(names, selectedIndex) { which -> itemSelected = true selectedIndex = which selectedSetting = allSettings[selectedIndex] - dialog.dismiss() val fragment = AnimeSourcePreferencesFragment().getInstance(selectedSetting.id) { @@ -93,13 +90,13 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { .addToBackStack(null) .commit() } - .setOnDismissListener { + onDismiss { if (!itemSelected) { changeUIVisibility(true) } } - .show() - dialog.window?.setDimAmount(0.8f) + show() + } } else { // If there's only one setting, proceed with the fragment transaction val fragment = @@ -121,15 +118,20 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { .show() } }, - { pkg, forceDelete -> - if (isAdded) { // Check if the fragment is currently added to its activity - val context = requireContext() // Store context in a variable + { pkg -> + if (isAdded) { + animeExtensionManager.uninstallExtension(pkg.pkgName) + snackString("Extension uninstalled") + } + }, { pkg -> + if (isAdded) { + val context = requireContext() val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Initialize NotificationManager once + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (pkg.hasUpdate && !forceDelete) { + if (pkg.hasUpdate) { animeExtensionManager.updateExtension(pkg) - .observeOn(AndroidSchedulers.mainThread()) // Observe on main thread + .observeOn(AndroidSchedulers.mainThread()) .subscribe( { installStep -> val builder = NotificationCompat.Builder( @@ -144,7 +146,7 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { }, { error -> Injekt.get().logException(error) - Logger.log(error) // Log the error + Logger.log(error) val builder = NotificationCompat.Builder( context, Notifications.CHANNEL_DOWNLOADER_ERROR @@ -170,14 +172,13 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { } ) } else { - animeExtensionManager.uninstallExtension(pkg.pkgName) - snackString("Extension uninstalled") + snackString("No update available") } + } }, skipIcons ) - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -197,17 +198,10 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { - val newList = extensionsAdapter.currentList.toMutableList() val fromPosition = viewHolder.absoluteAdapterPosition val toPosition = target.absoluteAdapterPosition - if (fromPosition < toPosition) { //probably need to switch to a recyclerview adapter - for (i in fromPosition until toPosition) { - Collections.swap(newList, i, i + 1) - } - } else { - for (i in fromPosition downTo toPosition + 1) { - Collections.swap(newList, i, i - 1) - } + val newList = extensionsAdapter.currentList.toMutableList().apply { + add(toPosition, removeAt(fromPosition)) } extensionsAdapter.submitList(newList) return true @@ -269,7 +263,8 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { private class AnimeExtensionsAdapter( private val onSettingsClicked: (AnimeExtension.Installed) -> Unit, - private val onUninstallClicked: (AnimeExtension.Installed, Boolean) -> Unit, + private val onUninstallClicked: (AnimeExtension.Installed) -> Unit, + private val onUpdateClicked: (AnimeExtension.Installed) -> Unit, val skipIcons: Boolean ) : ListAdapter( DIFF_CALLBACK_INSTALLED @@ -295,7 +290,7 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { override fun onBindViewHolder(holder: ViewHolder, position: Int) { val extension = getItem(position) val nsfw = if (extension.isNsfw) "(18+)" else "" - val lang = LanguageMapper.getLanguageName(extension.lang) + val lang = getLanguageName(extension.lang) holder.extensionNameTextView.text = extension.name val versionText = "$lang ${extension.versionName} $nsfw" holder.extensionVersionTextView.text = versionText @@ -303,20 +298,19 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { holder.extensionIconImageView.setImageDrawable(extension.icon) } if (extension.hasUpdate) { - holder.closeTextView.setImageResource(R.drawable.ic_round_sync_24) + holder.updateView.isVisible = true } else { - holder.closeTextView.setImageResource(R.drawable.ic_round_delete_24) + holder.updateView.isVisible = false } - holder.closeTextView.setOnClickListener { - onUninstallClicked(extension, false) + holder.deleteView.setOnClickListener { + onUninstallClicked(extension) + } + holder.updateView.setOnClickListener { + onUpdateClicked(extension) } holder.settingsImageView.setOnClickListener { onSettingsClicked(extension) } - holder.closeTextView.setOnLongClickListener { - onUninstallClicked(extension, true) - true - } } fun filter(query: String, currentList: List) { @@ -336,7 +330,8 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { view.findViewById(R.id.extensionVersionTextView) val settingsImageView: ImageView = view.findViewById(R.id.settingsImageView) val extensionIconImageView: ImageView = view.findViewById(R.id.extensionIconImageView) - val closeTextView: ImageView = view.findViewById(R.id.closeTextView) + val deleteView: ImageView = view.findViewById(R.id.deleteTextView) + val updateView: ImageView = view.findViewById(R.id.updateTextView) } companion object { diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt index 0a66c648..37bb198c 100644 --- a/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt @@ -1,8 +1,6 @@ package ani.dantotsu.settings -import android.annotation.SuppressLint -import android.app.AlertDialog import android.app.NotificationManager import android.content.Context import android.os.Bundle @@ -28,12 +26,14 @@ import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.databinding.FragmentExtensionsBinding import ani.dantotsu.others.LanguageMapper +import ani.dantotsu.others.LanguageMapper.Companion.getLanguageName 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.snackString import ani.dantotsu.util.Logger +import ani.dantotsu.util.customAlertDialog import com.google.android.material.tabs.TabLayout import com.google.android.material.textfield.TextInputLayout import eu.kanade.tachiyomi.data.notification.Notifications @@ -44,7 +44,6 @@ import kotlinx.coroutines.launch import rx.android.schedulers.AndroidSchedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.Collections import java.util.Locale class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { @@ -74,13 +73,12 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { val names = allSettings.map { LanguageMapper.getLanguageName(it.lang) } .toTypedArray() var selectedIndex = 0 - val dialog = AlertDialog.Builder(requireContext(), R.style.MyPopup) - .setTitle("Select a Source") - .setSingleChoiceItems(names, selectedIndex) { dialog, which -> + requireContext().customAlertDialog().apply { + setTitle("Select a Source") + singleChoiceItems(names, selectedIndex) { which -> itemSelected = true selectedIndex = which selectedSetting = allSettings[selectedIndex] - dialog.dismiss() // Move the fragment transaction here val fragment = @@ -93,13 +91,13 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { .addToBackStack(null) .commit() } - .setOnDismissListener { + onDismiss { if (!itemSelected) { changeUIVisibility(true) } } - .show() - dialog.window?.setDimAmount(0.8f) + show() + } } else { // If there's only one setting, proceed with the fragment transaction val fragment = @@ -120,15 +118,20 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { .show() } }, - { pkg: MangaExtension.Installed, forceDelete: Boolean -> - if (isAdded) { // Check if the fragment is currently added to its activity - val context = requireContext() // Store context in a variable + { pkg: MangaExtension.Installed -> + if (isAdded) { + mangaExtensionManager.uninstallExtension(pkg.pkgName) + snackString("Extension uninstalled") + } + }, { pkg -> + if (isAdded) { + val context = requireContext() val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Initialize NotificationManager once + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (pkg.hasUpdate && !forceDelete) { + if (pkg.hasUpdate) { mangaExtensionManager.updateExtension(pkg) - .observeOn(AndroidSchedulers.mainThread()) // Observe on main thread + .observeOn(AndroidSchedulers.mainThread()) .subscribe( { installStep -> val builder = NotificationCompat.Builder( @@ -143,7 +146,7 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { }, { error -> Injekt.get().logException(error) - Logger.log(error) // Log the error + Logger.log(error) val builder = NotificationCompat.Builder( context, Notifications.CHANNEL_DOWNLOADER_ERROR @@ -160,7 +163,7 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { context, Notifications.CHANNEL_DOWNLOADER_PROGRESS ) - .setSmallIcon(R.drawable.ic_check) + .setSmallIcon(R.drawable.ic_circle_check) .setContentTitle("Update complete") .setContentText("The extension has been successfully updated.") .setPriority(NotificationCompat.PRIORITY_LOW) @@ -169,9 +172,9 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { } ) } else { - mangaExtensionManager.uninstallExtension(pkg.pkgName) - snackString("Extension uninstalled") + snackString("No update available") } + } }, skipIcons ) @@ -195,17 +198,10 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { - val newList = extensionsAdapter.currentList.toMutableList() val fromPosition = viewHolder.absoluteAdapterPosition val toPosition = target.absoluteAdapterPosition - if (fromPosition < toPosition) { //probably need to switch to a recyclerview adapter - for (i in fromPosition until toPosition) { - Collections.swap(newList, i, i + 1) - } - } else { - for (i in fromPosition downTo toPosition + 1) { - Collections.swap(newList, i, i - 1) - } + val newList = extensionsAdapter.currentList.toMutableList().apply { + add(toPosition, removeAt(fromPosition)) } extensionsAdapter.submitList(newList) return true @@ -266,7 +262,8 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { private class MangaExtensionsAdapter( private val onSettingsClicked: (MangaExtension.Installed) -> Unit, - private val onUninstallClicked: (MangaExtension.Installed, Boolean) -> Unit, + private val onUninstallClicked: (MangaExtension.Installed) -> Unit, + private val onUpdateClicked: (MangaExtension.Installed) -> Unit, val skipIcons: Boolean ) : ListAdapter( DIFF_CALLBACK_INSTALLED @@ -276,24 +273,23 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { submitList(newExtensions) } + fun updatePref() { + val map = currentList.map { it.name } + PrefManager.setVal(PrefName.MangaSourcesOrder, map) + MangaSources.pinnedMangaSources = map + MangaSources.performReorderMangaSources() + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_extension, parent, false) return ViewHolder(view) } - fun updatePref() { - val map = currentList.map { it.name }.toList() - PrefManager.setVal(PrefName.MangaSourcesOrder, map) - MangaSources.pinnedMangaSources = map - MangaSources.performReorderMangaSources() - } - - @SuppressLint("ClickableViewAccessibility") override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val extension = getItem(position) // Use getItem() from ListAdapter + val extension = getItem(position) val nsfw = if (extension.isNsfw) "(18+)" else "" - val lang = LanguageMapper.getLanguageName(extension.lang) + val lang = getLanguageName(extension.lang) holder.extensionNameTextView.text = extension.name val versionText = "$lang ${extension.versionName} $nsfw" holder.extensionVersionTextView.text = versionText @@ -301,12 +297,15 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { holder.extensionIconImageView.setImageDrawable(extension.icon) } if (extension.hasUpdate) { - holder.closeTextView.setImageResource(R.drawable.ic_round_sync_24) + holder.updateView.isVisible = true } else { - holder.closeTextView.setImageResource(R.drawable.ic_round_delete_24) + holder.updateView.isVisible = false } - holder.closeTextView.setOnClickListener { - onUninstallClicked(extension, false) + holder.deleteView.setOnClickListener { + onUninstallClicked(extension) + } + holder.updateView.setOnClickListener { + onUpdateClicked(extension) } holder.settingsImageView.setOnClickListener { onSettingsClicked(extension) @@ -330,7 +329,8 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { view.findViewById(R.id.extensionVersionTextView) val settingsImageView: ImageView = view.findViewById(R.id.settingsImageView) val extensionIconImageView: ImageView = view.findViewById(R.id.extensionIconImageView) - val closeTextView: ImageView = view.findViewById(R.id.closeTextView) + val deleteView: ImageView = view.findViewById(R.id.deleteTextView) + val updateView: ImageView = view.findViewById(R.id.updateTextView) } companion object { diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt index 298924b6..5c282e4e 100644 --- a/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/InstalledNovelExtensionsFragment.kt @@ -10,6 +10,7 @@ import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.core.app.NotificationCompat +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil @@ -19,7 +20,6 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface -import ani.dantotsu.currContext import ani.dantotsu.databinding.FragmentNovelExtensionsBinding import ani.dantotsu.others.LanguageMapper import ani.dantotsu.parsers.NovelSources @@ -34,7 +34,6 @@ import kotlinx.coroutines.launch import rx.android.schedulers.AndroidSchedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.Collections import java.util.Locale class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { @@ -48,15 +47,21 @@ class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { Toast.makeText(requireContext(), "Source is not configurable", Toast.LENGTH_SHORT) .show() }, - { pkg, forceDelete -> - if (isAdded) { // Check if the fragment is currently added to its activity - val context = requireContext() // Store context in a variable - val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Initialize NotificationManager once + { pkg -> + if (isAdded) { + novelExtensionManager.uninstallExtension(pkg.pkgName) + snackString("Extension uninstalled") - if (pkg.hasUpdate && !forceDelete) { + } + }, + { pkg -> + if (isAdded) { + val context = requireContext() + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (pkg.hasUpdate) { novelExtensionManager.updateExtension(pkg) - .observeOn(AndroidSchedulers.mainThread()) // Observe on main thread + .observeOn(AndroidSchedulers.mainThread()) .subscribe( { installStep -> val builder = NotificationCompat.Builder( @@ -71,7 +76,7 @@ class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { }, { error -> Injekt.get().logException(error) - Logger.log(error) // Log the error + Logger.log(error) val builder = NotificationCompat.Builder( context, Notifications.CHANNEL_DOWNLOADER_ERROR @@ -97,8 +102,7 @@ class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { } ) } else { - novelExtensionManager.uninstallExtension(pkg.pkgName, currContext() ?: context) - snackString("Extension uninstalled") + snackString("No update available") } } }, skipIcons @@ -123,17 +127,10 @@ class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { - val newList = extensionsAdapter.currentList.toMutableList() val fromPosition = viewHolder.absoluteAdapterPosition val toPosition = target.absoluteAdapterPosition - if (fromPosition < toPosition) { //probably need to switch to a recyclerview adapter - for (i in fromPosition until toPosition) { - Collections.swap(newList, i, i + 1) - } - } else { - for (i in fromPosition downTo toPosition + 1) { - Collections.swap(newList, i, i - 1) - } + val newList = extensionsAdapter.currentList.toMutableList().apply { + add(toPosition, removeAt(fromPosition)) } extensionsAdapter.submitList(newList) return true @@ -195,7 +192,8 @@ class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { private class NovelExtensionsAdapter( private val onSettingsClicked: (NovelExtension.Installed) -> Unit, - private val onUninstallClicked: (NovelExtension.Installed, Boolean) -> Unit, + private val onUninstallClicked: (NovelExtension.Installed) -> Unit, + private val onUpdateClicked: (NovelExtension.Installed) -> Unit, val skipIcons: Boolean ) : ListAdapter( DIFF_CALLBACK_INSTALLED @@ -230,20 +228,19 @@ class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { holder.extensionIconImageView.setImageDrawable(extension.icon) } if (extension.hasUpdate) { - holder.closeTextView.setImageResource(R.drawable.ic_round_sync_24) + holder.updateView.isVisible = true } else { - holder.closeTextView.setImageResource(R.drawable.ic_round_delete_24) + holder.updateView.isVisible = false } - holder.closeTextView.setOnClickListener { - onUninstallClicked(extension, false) + holder.deleteView.setOnClickListener { + onUninstallClicked(extension) + } + holder.updateView.setOnClickListener { + onUpdateClicked(extension) } holder.settingsImageView.setOnClickListener { onSettingsClicked(extension) } - holder.closeTextView.setOnLongClickListener { - onUninstallClicked(extension, true) - true - } } fun filter(query: String, currentList: List) { @@ -263,7 +260,8 @@ class InstalledNovelExtensionsFragment : Fragment(), SearchQueryHandler { view.findViewById(R.id.extensionVersionTextView) val settingsImageView: ImageView = view.findViewById(R.id.settingsImageView) val extensionIconImageView: ImageView = view.findViewById(R.id.extensionIconImageView) - val closeTextView: ImageView = view.findViewById(R.id.closeTextView) + val deleteView: ImageView = view.findViewById(R.id.deleteTextView) + val updateView: ImageView = view.findViewById(R.id.updateTextView) } companion object { diff --git a/app/src/main/java/ani/dantotsu/settings/InstallerSteps.kt b/app/src/main/java/ani/dantotsu/settings/InstallerSteps.kt index 39b91b3e..e66d39c4 100644 --- a/app/src/main/java/ani/dantotsu/settings/InstallerSteps.kt +++ b/app/src/main/java/ani/dantotsu/settings/InstallerSteps.kt @@ -6,6 +6,7 @@ import androidx.core.app.NotificationCompat import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.snackString +import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.extension.InstallStep import uy.kohesive.injekt.Injekt @@ -30,6 +31,7 @@ class InstallerSteps( fun onError(error: Throwable, extra: () -> Unit) { Injekt.get().logException(error) + Logger.log(error) val builder = NotificationCompat.Builder( context, Notifications.CHANNEL_DOWNLOADER_ERROR diff --git a/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt index 9006228a..9181f24b 100644 --- a/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt @@ -1,8 +1,6 @@ package ani.dantotsu.settings -import android.app.AlertDialog import android.content.res.Resources -import android.graphics.Color import android.os.Build import android.os.Bundle import android.util.TypedValue @@ -28,11 +26,21 @@ import ani.dantotsu.snackString import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager import ani.dantotsu.toast +import ani.dantotsu.util.customAlertDialog import com.google.android.material.slider.Slider.OnChangeListener +import eltos.simpledialogfragment.SimpleDialog +import eltos.simpledialogfragment.color.SimpleColorWheelDialog import kotlin.math.roundToInt +class PlayerSettingsActivity : + AppCompatActivity(), + SimpleDialog.OnDialogResultListener { + interface ColorPickerCallback { + fun onColorSelected(color: Int) + } + + private var colorPickerCallback: ColorPickerCallback? = null -class PlayerSettingsActivity : AppCompatActivity() { lateinit var binding: ActivityPlayerSettingsBinding private val player = "player_settings" @@ -40,9 +48,12 @@ class PlayerSettingsActivity : AppCompatActivity() { var subtitle: Subtitle? = null private val Int.toSP - get() = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, this.toFloat(), Resources.getSystem().displayMetrics - ) + get() = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -69,12 +80,11 @@ class PlayerSettingsActivity : AppCompatActivity() { bottomMargin = navBarHeight } - binding.playerSettingsBack.setOnClickListener { onBackPressedDispatcher.onBackPressed() } - //Video + // Video val speeds = arrayOf( @@ -90,7 +100,7 @@ class PlayerSettingsActivity : AppCompatActivity() { 1.5f, 1.66f, 1.75f, - 2f + 2f, ) val cursedSpeeds = arrayOf(1f, 1.25f, 1.5f, 1.75f, 2f, 2.5f, 3f, 4f, 5f, 10f, 25f, 50f) var curSpeedArr = if (PrefManager.getVal(PrefName.CursedSpeeds)) cursedSpeeds else speeds @@ -98,22 +108,21 @@ class PlayerSettingsActivity : AppCompatActivity() { binding.playerSettingsSpeed.text = getString( R.string.default_playback_speed, - speedsName[PrefManager.getVal(PrefName.DefaultSpeed)] + speedsName[PrefManager.getVal(PrefName.DefaultSpeed)], ) - val speedDialog = AlertDialog.Builder(this, R.style.MyPopup) - .setTitle(getString(R.string.default_speed)) binding.playerSettingsSpeed.setOnClickListener { - val dialog = - speedDialog.setSingleChoiceItems( + customAlertDialog().apply { + setTitle(getString(R.string.default_speed)) + singleChoiceItems( speedsName, - PrefManager.getVal(PrefName.DefaultSpeed) - ) { dialog, i -> + PrefManager.getVal(PrefName.DefaultSpeed), + ) { i -> PrefManager.setVal(PrefName.DefaultSpeed, i) binding.playerSettingsSpeed.text = getString(R.string.default_playback_speed, speedsName[i]) - dialog.dismiss() - }.show() - dialog.window?.setDimAmount(0.8f) + } + show() + } } binding.playerSettingsCursedSpeeds.isChecked = PrefManager.getVal(PrefName.CursedSpeeds) @@ -126,11 +135,10 @@ class PlayerSettingsActivity : AppCompatActivity() { binding.playerSettingsSpeed.text = getString( R.string.default_playback_speed, - speedsName[PrefManager.getVal(PrefName.DefaultSpeed)] + speedsName[PrefManager.getVal(PrefName.DefaultSpeed)], ) } - // Time Stamp binding.playerSettingsTimeStamps.isChecked = PrefManager.getVal(PrefName.TimeStampsEnabled) binding.playerSettingsTimeStamps.setOnCheckedChangeListener { _, isChecked -> @@ -182,7 +190,7 @@ class PlayerSettingsActivity : AppCompatActivity() { PrefManager.setVal(PrefName.AutoSkipFiller, isChecked) } - //Update Progress + // Update Progress binding.playerSettingsAskUpdateProgress.isChecked = PrefManager.getVal(PrefName.AskIndividualPlayer) binding.playerSettingsAskUpdateProgress.setOnCheckedChangeListener { _, isChecked -> @@ -208,7 +216,7 @@ class PlayerSettingsActivity : AppCompatActivity() { PrefManager.setVal(PrefName.WatchPercentage, value / 100) } - //Behaviour + // Behaviour binding.playerSettingsAlwaysContinue.isChecked = PrefManager.getVal(PrefName.AlwaysContinue) binding.playerSettingsAlwaysContinue.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.AlwaysContinue, isChecked) @@ -245,13 +253,16 @@ class PlayerSettingsActivity : AppCompatActivity() { false } binding.exoSkipTime.addTextChangedListener { - val time = binding.exoSkipTime.text.toString().toIntOrNull() + val time = + binding.exoSkipTime.text + .toString() + .toIntOrNull() if (time != null) { PrefManager.setVal(PrefName.SkipTime, time) } } - //Other + // Other binding.playerSettingsPiP.apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { visibility = View.VISIBLE @@ -259,7 +270,9 @@ class PlayerSettingsActivity : AppCompatActivity() { setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.Pip, isChecked) } - } else visibility = View.GONE + } else { + visibility = View.GONE + } } binding.playerSettingsCast.isChecked = PrefManager.getVal(PrefName.Cast) @@ -267,28 +280,33 @@ class PlayerSettingsActivity : AppCompatActivity() { PrefManager.setVal(PrefName.Cast, isChecked) } - binding.playerSettingsInternalCast.isChecked = PrefManager.getVal(PrefName.UseInternalCast) - binding.playerSettingsInternalCast.setOnCheckedChangeListener { _, isChecked -> - PrefManager.setVal(PrefName.UseInternalCast, isChecked) - } - binding.playerSettingsRotate.isChecked = PrefManager.getVal(PrefName.RotationPlayer) binding.playerSettingsRotate.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.RotationPlayer, isChecked) } + binding.playerSettingsInternalCast.isChecked = PrefManager.getVal(PrefName.UseInternalCast) + binding.playerSettingsInternalCast.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.UseInternalCast, isChecked) + } + + binding.playerSettingsAdditionalCodec.isChecked = PrefManager.getVal(PrefName.UseAdditionalCodec) + binding.playerSettingsAdditionalCodec.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.UseAdditionalCodec, isChecked) + } + val resizeModes = arrayOf("Original", "Zoom", "Stretch") - val resizeDialog = AlertDialog.Builder(this, R.style.MyPopup) - .setTitle(getString(R.string.default_resize_mode)) binding.playerResizeMode.setOnClickListener { - val dialog = resizeDialog.setSingleChoiceItems( - resizeModes, - PrefManager.getVal(PrefName.Resize) - ) { dialog, count -> - PrefManager.setVal(PrefName.Resize, count) - dialog.dismiss() - }.show() - dialog.window?.setDimAmount(0.8f) + customAlertDialog().apply { + setTitle(getString(R.string.default_resize_mode)) + singleChoiceItems( + resizeModes, + PrefManager.getVal(PrefName.Resize), + ) { count -> + PrefManager.setVal(PrefName.Resize, count) + } + show() + } } fun toggleSubOptions(isChecked: Boolean) { @@ -301,176 +319,236 @@ class PlayerSettingsActivity : AppCompatActivity() { binding.videoSubColorWindow, binding.videoSubFont, binding.videoSubAlpha, + binding.videoSubStroke, binding.subtitleFontSizeText, - binding.subtitleFontSize + binding.subtitleFontSize, + binding.videoSubLanguage, + binding.subTextSwitch, ).forEach { it.isEnabled = isChecked - it.isClickable = isChecked - it.alpha = when (isChecked) { - true -> 1f - false -> 0.5f - } + it.alpha = + when (isChecked) { + true -> 1f + false -> 0.5f + } } } + + fun toggleExpSubOptions(isChecked: Boolean) { + arrayOf( + binding.videoSubStrokeButton, + binding.videoSubStroke, + binding.videoSubBottomMarginButton, + binding.videoSubBottomMargin, + ).forEach { + it.isEnabled = isChecked + it.alpha = + when (isChecked) { + true -> 1f + false -> 0.5f + } + } + } + binding.subSwitch.isChecked = PrefManager.getVal(PrefName.Subtitles) binding.subSwitch.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.Subtitles, isChecked) toggleSubOptions(isChecked) + toggleExpSubOptions(binding.subTextSwitch.isChecked && isChecked) } toggleSubOptions(binding.subSwitch.isChecked) - val colorsPrimary = + + binding.subTextSwitch.isChecked = PrefManager.getVal(PrefName.TextviewSubtitles) + binding.subTextSwitch.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.TextviewSubtitles, isChecked) + toggleExpSubOptions(isChecked) + } + toggleExpSubOptions(binding.subTextSwitch.isChecked) + + val subLanguages = arrayOf( - "Black", - "Dark Gray", - "Gray", - "Light Gray", - "White", - "Red", - "Yellow", - "Green", - "Cyan", - "Blue", - "Magenta" + "Albanian", + "Arabic", + "Bosnian", + "Bulgarian", + "Chinese", + "Croatian", + "Czech", + "Danish", + "Dutch", + "English", + "Estonian", + "Finnish", + "French", + "Georgian", + "German", + "Greek", + "Hebrew", + "Hindi", + "Indonesian", + "Irish", + "Italian", + "Japanese", + "Korean", + "Lithuanian", + "Luxembourgish", + "Macedonian", + "Mongolian", + "Norwegian", + "Polish", + "Portuguese", + "Punjabi", + "Romanian", + "Russian", + "Serbian", + "Slovak", + "Slovenian", + "Spanish", + "Turkish", + "Ukrainian", + "Urdu", + "Vietnamese", + ) + binding.videoSubLanguage.setOnClickListener { + customAlertDialog().apply { + setTitle(getString(R.string.subtitle_langauge)) + singleChoiceItems( + subLanguages, + PrefManager.getVal(PrefName.SubLanguage), + ) { count -> + PrefManager.setVal(PrefName.SubLanguage, count) + } + show() + } + } + + binding.videoSubColorPrimary.setOnClickListener { + val color = PrefManager.getVal(PrefName.PrimaryColor) + val title = getString(R.string.primary_sub_color) + showColorPicker( + color, + title, + object : ColorPickerCallback { + override fun onColorSelected(color: Int) { + PrefManager.setVal(PrefName.PrimaryColor, color) + updateSubPreview() + } + }, + ) + } + + binding.videoSubColorSecondary.setOnClickListener { + val color = PrefManager.getVal(PrefName.SecondaryColor) + val title = getString(R.string.outline_sub_color) + showColorPicker( + color, + title, + object : ColorPickerCallback { + override fun onColorSelected(color: Int) { + PrefManager.setVal(PrefName.SecondaryColor, color) + updateSubPreview() + } + }, + ) + } + + val typesOutline = arrayOf("Outline", "Shine", "Drop Shadow", "None") + binding.videoSubOutline.setOnClickListener { + customAlertDialog().apply { + setTitle(getString(R.string.outline_type)) + singleChoiceItems( + typesOutline, + PrefManager.getVal(PrefName.Outline), + ) { count -> + PrefManager.setVal(PrefName.Outline, count) + updateSubPreview() + } + show() + } + } + + binding.videoSubColorBackground.setOnClickListener { + val color = PrefManager.getVal(PrefName.SubBackground) + val title = getString(R.string.sub_background_color_select) + showColorPicker( + color, + title, + object : ColorPickerCallback { + override fun onColorSelected(color: Int) { + PrefManager.setVal(PrefName.SubBackground, color) + updateSubPreview() + } + }, ) - val primaryColorDialog = AlertDialog.Builder(this, R.style.MyPopup) - .setTitle(getString(R.string.primary_sub_color)) - binding.videoSubColorPrimary.setOnClickListener { - val dialog = primaryColorDialog.setSingleChoiceItems( - colorsPrimary, - PrefManager.getVal(PrefName.PrimaryColor) - ) { dialog, count -> - PrefManager.setVal(PrefName.PrimaryColor, count) - dialog.dismiss() - updateSubPreview() - }.show() - dialog.window?.setDimAmount(0.8f) - } - val colorsSecondary = arrayOf( - "Black", - "Dark Gray", - "Gray", - "Light Gray", - "White", - "Red", - "Yellow", - "Green", - "Cyan", - "Blue", - "Magenta", - "Transparent" - ) - val secondaryColorDialog = AlertDialog.Builder(this, R.style.MyPopup) - .setTitle(getString(R.string.outline_sub_color)) - binding.videoSubColorSecondary.setOnClickListener { - val dialog = secondaryColorDialog.setSingleChoiceItems( - colorsSecondary, - PrefManager.getVal(PrefName.SecondaryColor) - ) { dialog, count -> - PrefManager.setVal(PrefName.SecondaryColor, count) - dialog.dismiss() - updateSubPreview() - }.show() - dialog.window?.setDimAmount(0.8f) - } - val typesOutline = arrayOf("Outline", "Shine", "Drop Shadow", "None") - val outlineDialog = AlertDialog.Builder(this, R.style.MyPopup) - .setTitle(getString(R.string.outline_type)) - binding.videoSubOutline.setOnClickListener { - val dialog = outlineDialog.setSingleChoiceItems( - typesOutline, - PrefManager.getVal(PrefName.Outline) - ) { dialog, count -> - PrefManager.setVal(PrefName.Outline, count) - dialog.dismiss() - updateSubPreview() - }.show() - dialog.window?.setDimAmount(0.8f) - } - val colorsSubBackground = arrayOf( - "Transparent", - "Black", - "Dark Gray", - "Gray", - "Light Gray", - "White", - "Red", - "Yellow", - "Green", - "Cyan", - "Blue", - "Magenta" - ) - val subBackgroundDialog = AlertDialog.Builder(this, R.style.MyPopup) - .setTitle(getString(R.string.sub_background_color_select)) - binding.videoSubColorBackground.setOnClickListener { - val dialog = subBackgroundDialog.setSingleChoiceItems( - colorsSubBackground, - PrefManager.getVal(PrefName.SubBackground) - ) { dialog, count -> - PrefManager.setVal(PrefName.SubBackground, count) - dialog.dismiss() - updateSubPreview() - }.show() - dialog.window?.setDimAmount(0.8f) } - val colorsSubWindow = arrayOf( - "Transparent", - "Black", - "Dark Gray", - "Gray", - "Light Gray", - "White", - "Red", - "Yellow", - "Green", - "Cyan", - "Blue", - "Magenta" - ) - val subWindowDialog = AlertDialog.Builder(this, R.style.MyPopup) - .setTitle(getString(R.string.sub_window_color_select)) binding.videoSubColorWindow.setOnClickListener { - val dialog = subWindowDialog.setSingleChoiceItems( - colorsSubWindow, - PrefManager.getVal(PrefName.SubWindow) - ) { dialog, count -> - PrefManager.setVal(PrefName.SubWindow, count) - dialog.dismiss() - updateSubPreview() - }.show() - dialog.window?.setDimAmount(0.8f) + val color = PrefManager.getVal(PrefName.SubWindow) + val title = getString(R.string.sub_window_color_select) + showColorPicker( + color, + title, + object : ColorPickerCallback { + override fun onColorSelected(color: Int) { + PrefManager.setVal(PrefName.SubWindow, color) + updateSubPreview() + } + }, + ) } binding.videoSubAlpha.value = PrefManager.getVal(PrefName.SubAlpha) - binding.videoSubAlpha.addOnChangeListener(OnChangeListener { _, value, fromUser -> - if (fromUser) { - PrefManager.setVal(PrefName.SubAlpha, value) - updateSubPreview() - } - }) - - val fonts = arrayOf( - "Poppins Semi Bold", - "Poppins Bold", - "Poppins", - "Poppins Thin", - "Century Gothic", - "Levenim MT Bold", - "Blocky" + binding.videoSubAlpha.addOnChangeListener( + OnChangeListener { _, value, fromUser -> + if (fromUser) { + PrefManager.setVal(PrefName.SubAlpha, value) + updateSubPreview() + } + }, ) - val fontDialog = AlertDialog.Builder(this, R.style.MyPopup) - .setTitle(getString(R.string.subtitle_font)) + + binding.videoSubStroke.value = PrefManager.getVal(PrefName.SubStroke) + binding.videoSubStroke.addOnChangeListener( + OnChangeListener { _, value, fromUser -> + if (fromUser) { + PrefManager.setVal(PrefName.SubStroke, value) + updateSubPreview() + } + }, + ) + + binding.videoSubBottomMargin.value = PrefManager.getVal(PrefName.SubBottomMargin) + binding.videoSubBottomMargin.addOnChangeListener( + OnChangeListener { _, value, fromUser -> + if (fromUser) { + PrefManager.setVal(PrefName.SubBottomMargin, value) + updateSubPreview() + } + }, + ) + + val fonts = + arrayOf( + "Poppins Semi Bold", + "Poppins Bold", + "Poppins", + "Poppins Thin", + "Century Gothic", + "Levenim MT Bold", + "Blocky", + ) binding.videoSubFont.setOnClickListener { - val dialog = fontDialog.setSingleChoiceItems( - fonts, - PrefManager.getVal(PrefName.Font) - ) { dialog, count -> - PrefManager.setVal(PrefName.Font, count) - dialog.dismiss() - updateSubPreview() - }.show() - dialog.window?.setDimAmount(0.8f) + customAlertDialog().apply { + setTitle(getString(R.string.subtitle_font)) + singleChoiceItems( + fonts, + PrefManager.getVal(PrefName.Font), + ) { count -> + PrefManager.setVal(PrefName.Font, count) + updateSubPreview() + } + show() + } } binding.subtitleFontSize.setText(PrefManager.getVal(PrefName.FontSize).toString()) binding.subtitleFontSize.setOnEditorActionListener { _, actionId, _ -> @@ -480,89 +558,80 @@ class PlayerSettingsActivity : AppCompatActivity() { false } binding.subtitleFontSize.addTextChangedListener { - val size = binding.subtitleFontSize.text.toString().toIntOrNull() + val size = + binding.subtitleFontSize.text + .toString() + .toIntOrNull() if (size != null) { PrefManager.setVal(PrefName.FontSize, size) updateSubPreview() } } - binding.subtitleTest.addOnChangeListener(object : Xpandable.OnChangeListener { - override fun onExpand() { - updateSubPreview() - } + binding.subtitleTest.addOnChangeListener( + object : Xpandable.OnChangeListener { + override fun onExpand() { + updateSubPreview() + } - override fun onRetract() {} - }) + override fun onRetract() {} + }, + ) updateSubPreview() } + private fun showColorPicker( + originalColor: Int, + title: String, + callback: ColorPickerCallback, + ) { + colorPickerCallback = callback + + SimpleColorWheelDialog() + .title(title) + .color(originalColor) + .alpha(true) + .neg() + .theme(R.style.MyPopup) + .show(this, "colorPicker") + } + + override fun onResult( + dialogTag: String, + which: Int, + extras: Bundle, + ): Boolean { + if (dialogTag == "colorPicker" && which == SimpleDialog.OnDialogResultListener.BUTTON_POSITIVE) { + val color = extras.getInt(SimpleColorWheelDialog.COLOR) + colorPickerCallback?.onColorSelected(color) + + return true + } + return false + } + private fun updateSubPreview() { binding.subtitleTestWindow.run { alpha = PrefManager.getVal(PrefName.SubAlpha) - setBackgroundColor( - when (PrefManager.getVal(PrefName.SubWindow)) { - 0 -> Color.TRANSPARENT - 1 -> Color.BLACK - 2 -> Color.DKGRAY - 3 -> Color.GRAY - 4 -> Color.LTGRAY - 5 -> Color.WHITE - 6 -> Color.RED - 7 -> Color.YELLOW - 8 -> Color.GREEN - 9 -> Color.CYAN - 10 -> Color.BLUE - 11 -> Color.MAGENTA - else -> Color.TRANSPARENT - } - ) + setBackgroundColor(PrefManager.getVal(PrefName.SubWindow)) } + binding.subtitleTestText.run { textSize = PrefManager.getVal(PrefName.FontSize).toSP - typeface = when (PrefManager.getVal(PrefName.Font)) { - 0 -> ResourcesCompat.getFont(this.context, R.font.poppins_semi_bold) - 1 -> ResourcesCompat.getFont(this.context, R.font.poppins_bold) - 2 -> ResourcesCompat.getFont(this.context, R.font.poppins) - 3 -> ResourcesCompat.getFont(this.context, R.font.poppins_thin) - 4 -> ResourcesCompat.getFont(this.context, R.font.century_gothic_regular) - 5 -> ResourcesCompat.getFont(this.context, R.font.levenim_mt_bold) - 6 -> ResourcesCompat.getFont(this.context, R.font.blocky) - else -> ResourcesCompat.getFont(this.context, R.font.poppins_semi_bold) - } - setTextColor( - when (PrefManager.getVal(PrefName.PrimaryColor)) { - 0 -> Color.BLACK - 1 -> Color.DKGRAY - 2 -> Color.GRAY - 3 -> Color.LTGRAY - 4 -> Color.WHITE - 5 -> Color.RED - 6 -> Color.YELLOW - 7 -> Color.GREEN - 8 -> Color.CYAN - 9 -> Color.BLUE - 10 -> Color.MAGENTA - 11 -> Color.TRANSPARENT - else -> Color.WHITE + typeface = + when (PrefManager.getVal(PrefName.Font)) { + 0 -> ResourcesCompat.getFont(this.context, R.font.poppins_semi_bold) + 1 -> ResourcesCompat.getFont(this.context, R.font.poppins_bold) + 2 -> ResourcesCompat.getFont(this.context, R.font.poppins) + 3 -> ResourcesCompat.getFont(this.context, R.font.poppins_thin) + 4 -> ResourcesCompat.getFont(this.context, R.font.century_gothic_regular) + 5 -> ResourcesCompat.getFont(this.context, R.font.levenim_mt_bold) + 6 -> ResourcesCompat.getFont(this.context, R.font.blocky) + else -> ResourcesCompat.getFont(this.context, R.font.poppins_semi_bold) } - ) - setBackgroundColor( - when (PrefManager.getVal(PrefName.SubBackground)) { - 0 -> Color.TRANSPARENT - 1 -> Color.BLACK - 2 -> Color.DKGRAY - 3 -> Color.GRAY - 4 -> Color.LTGRAY - 5 -> Color.WHITE - 6 -> Color.RED - 7 -> Color.YELLOW - 8 -> Color.GREEN - 9 -> Color.CYAN - 10 -> Color.BLUE - 11 -> Color.MAGENTA - else -> Color.TRANSPARENT - } - ) + + setTextColor(PrefManager.getVal(PrefName.PrimaryColor)) + + setBackgroundColor(PrefManager.getVal(PrefName.SubBackground)) } } } diff --git a/app/src/main/java/ani/dantotsu/settings/ProxyDialogFragment.kt b/app/src/main/java/ani/dantotsu/settings/ProxyDialogFragment.kt new file mode 100644 index 00000000..14700753 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/settings/ProxyDialogFragment.kt @@ -0,0 +1,84 @@ +package ani.dantotsu.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import ani.dantotsu.BottomSheetDialogFragment +import ani.dantotsu.databinding.BottomSheetProxyBinding +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.restartApp + +class ProxyDialogFragment : BottomSheetDialogFragment() { + private var _binding: BottomSheetProxyBinding? = null + private val binding get() = _binding!! + + private var proxyHost: String? = PrefManager.getVal(PrefName.Socks5ProxyHost).orEmpty() + private var proxyPort: String? = PrefManager.getVal(PrefName.Socks5ProxyPort).orEmpty() + private var proxyUsername: String? = PrefManager.getVal(PrefName.Socks5ProxyUsername).orEmpty() + private var proxyPassword: String? = PrefManager.getVal(PrefName.Socks5ProxyPassword).orEmpty() + private var authEnabled: Boolean = PrefManager.getVal(PrefName.ProxyAuthEnabled) + private val proxyEnabled: Boolean = PrefManager.getVal(PrefName.EnableSocks5Proxy) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetProxyBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.proxyHost.setText(proxyHost) + binding.proxyPort.setText(proxyPort) + binding.proxyUsername.setText(proxyUsername) + binding.proxyPassword.setText(proxyPassword) + binding.proxyAuthentication.isChecked = authEnabled + + toggleAuthentication(authEnabled) + + binding.proxySave.setOnClickListener { + proxyHost = binding.proxyHost.text?.toString().orEmpty() + proxyPort = binding.proxyPort.text?.toString().orEmpty() + proxyUsername = binding.proxyUsername.text?.toString().orEmpty() + proxyPassword = binding.proxyPassword.text?.toString().orEmpty() + + PrefManager.setVal(PrefName.Socks5ProxyHost, proxyHost) + PrefManager.setVal(PrefName.Socks5ProxyPort, proxyPort) + PrefManager.setVal(PrefName.Socks5ProxyUsername, proxyUsername) + PrefManager.setVal(PrefName.Socks5ProxyPassword, proxyPassword) + + dismiss() + if (proxyEnabled) activity?.restartApp() + } + + binding.proxyAuthentication.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.ProxyAuthEnabled, isChecked) + toggleAuthentication(isChecked) + } + } + + private fun toggleAuthentication(isChecked: Boolean) { + arrayOf( + binding.proxyUsername, + binding.proxyPassword, + binding.proxyUsernameLayout, + binding.proxyPasswordLayout + ).forEach { + it.isEnabled = isChecked + it.alpha = when (isChecked) { + true -> 1f + false -> 0.5f + } + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsAboutActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsAboutActivity.kt index 53ec0dbb..831730cd 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsAboutActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsAboutActivity.kt @@ -10,6 +10,8 @@ import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.LinearLayoutManager import ani.dantotsu.BuildConfig import ani.dantotsu.R +import ani.dantotsu.buildMarkwon +import ani.dantotsu.client import ani.dantotsu.databinding.ActivitySettingsAboutBinding import ani.dantotsu.initActivity import ani.dantotsu.navBarHeight @@ -20,6 +22,9 @@ import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager import ani.dantotsu.util.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class SettingsAboutActivity : AppCompatActivity() { private lateinit var binding: ActivitySettingsAboutBinding @@ -130,6 +135,48 @@ class SettingsAboutActivity : AppCompatActivity() { } } ), + Settings( + type = 1, + name = getString(R.string.privacy_policy), + desc = getString(R.string.privacy_policy_desc), + icon = R.drawable.ic_incognito_24, + onClick = { + val text = TextView(context) + val pPLink = "https://raw.githubusercontent.com/rebelonion/Dantotsu/main/privacy_policy.md" + val backup = "https://gcore.jsdelivr.net/gh/rebelonion/dantotsu/privacy_policy.md" + text.text = getString(R.string.loading) + val markWon = try { + buildMarkwon(this@SettingsAboutActivity, false) + } catch (e: IllegalArgumentException) { + return@Settings + } + CoroutineScope(Dispatchers.IO).launch { + val res = try { + val out = client.get(pPLink) + if (out.code != 200) { + client.get(backup) + } else { + out + }.text + } catch (e: Exception) { + getString(R.string.failed_to_load) + } + runOnUiThread { + markWon.setMarkdown(text, res) + } + } + + CustomBottomDialog.newInstance().apply { + setTitleText(context.getString(R.string.privacy_policy)) + addView(text) + setNegativeButton(context.getString(R.string.close)) { + dismiss() + } + show(supportFragmentManager, "dialog") + } + } + ), + ) ) binding.settingsRecyclerView.layoutManager = diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsAccountActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsAccountActivity.kt index 7bebb68f..7a532400 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsAccountActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsAccountActivity.kt @@ -1,5 +1,6 @@ package ani.dantotsu.settings +import android.content.Intent import android.os.Bundle import android.view.HapticFeedbackConstants import android.view.View @@ -9,6 +10,8 @@ import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager import ani.dantotsu.R import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.connections.discord.Discord @@ -21,11 +24,13 @@ import ani.dantotsu.openLinkInBrowser import ani.dantotsu.others.CustomBottomDialog import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.snackString import ani.dantotsu.startMainActivity import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager import io.noties.markwon.Markwon import io.noties.markwon.SoftBreakAddsNewLinePlugin +import kotlinx.coroutines.launch class SettingsAccountActivity : AppCompatActivity() { private lateinit var binding: ActivitySettingsAccountsBinding @@ -111,6 +116,7 @@ class SettingsAccountActivity : AppCompatActivity() { } else { settingsAnilistAvatar.setImageResource(R.drawable.ic_round_person_24) settingsAnilistUsername.visibility = View.GONE + settingsRecyclerView.visibility = View.GONE settingsAnilistLogin.setText(R.string.login) settingsAnilistLogin.setOnClickListener { Anilist.loginIntent(context) @@ -142,7 +148,7 @@ class SettingsAccountActivity : AppCompatActivity() { reload() } - settingsImageSwitcher.visibility = View.VISIBLE + settingsPresenceSwitcher.visibility = View.VISIBLE var initialStatus = when (PrefManager.getVal(PrefName.DiscordStatus)) { "online" -> R.drawable.discord_status_online "idle" -> R.drawable.discord_status_idle @@ -150,11 +156,11 @@ class SettingsAccountActivity : AppCompatActivity() { "invisible" -> R.drawable.discord_status_invisible else -> R.drawable.discord_status_online } - settingsImageSwitcher.setImageResource(initialStatus) + settingsPresenceSwitcher.setImageResource(initialStatus) val zoomInAnimation = AnimationUtils.loadAnimation(context, R.anim.bounce_zoom) - settingsImageSwitcher.setOnClickListener { + settingsPresenceSwitcher.setOnClickListener { var status = "online" initialStatus = when (initialStatus) { R.drawable.discord_status_online -> { @@ -181,16 +187,16 @@ class SettingsAccountActivity : AppCompatActivity() { } PrefManager.setVal(PrefName.DiscordStatus, status) - settingsImageSwitcher.setImageResource(initialStatus) - settingsImageSwitcher.startAnimation(zoomInAnimation) + settingsPresenceSwitcher.setImageResource(initialStatus) + settingsPresenceSwitcher.startAnimation(zoomInAnimation) } - settingsImageSwitcher.setOnLongClickListener { + settingsPresenceSwitcher.setOnLongClickListener { it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) DiscordDialogFragment().show(supportFragmentManager, "dialog") true } } else { - settingsImageSwitcher.visibility = View.GONE + settingsPresenceSwitcher.visibility = View.GONE settingsDiscordAvatar.setImageResource(R.drawable.ic_round_person_24) settingsDiscordUsername.visibility = View.GONE settingsDiscordLogin.setText(R.string.login) @@ -202,6 +208,57 @@ class SettingsAccountActivity : AppCompatActivity() { } reload() } - } + binding.settingsRecyclerView.adapter = SettingsAdapter( + arrayListOf( + Settings( + type = 2, + name = getString(R.string.enable_rpc), + desc = getString(R.string.enable_rpc_desc), + icon = R.drawable.interests_24, + isChecked = PrefManager.getVal(PrefName.rpcEnabled), + switch = { isChecked, _ -> + PrefManager.setVal(PrefName.rpcEnabled, isChecked) + }, + isVisible = Discord.token != null + ), + Settings( + type = 1, + name = getString(R.string.anilist_settings), + desc = getString(R.string.alsettings_desc), + icon = R.drawable.ic_anilist, + onClick = { + lifecycleScope.launch { + Anilist.query.getUserData() + startActivity(Intent(context, AnilistSettingsActivity::class.java)) + } + }, + isActivity = true + ), + Settings( + type = 2, + name = getString(R.string.comments_button), + desc = getString(R.string.comments_button_desc), + icon = R.drawable.ic_round_comment_24, + isChecked = PrefManager.getVal(PrefName.CommentsEnabled) == 1, + switch = { isChecked, _ -> + PrefManager.setVal(PrefName.CommentsEnabled, if (isChecked) 1 else 2 ) + reload() + }, + isVisible = Anilist.token != null + ), + ) + ) + binding.settingsRecyclerView.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + } + fun reload() { + snackString(getString(R.string.restart_app_extra)) + //snackString(R.string.restart_app_extra) + //?.setDuration(Snackbar.LENGTH_LONG) + //?.setAction(R.string.do_it) { + //startMainActivity(this@SettingsAccountActivity) + //} Disabled for now. Doesn't update the ADDRESS even after this + } } + diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt index 2022fac1..7e58e870 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt @@ -23,12 +23,12 @@ import ani.dantotsu.databinding.ActivitySettingsBinding import ani.dantotsu.initActivity import ani.dantotsu.navBarHeight import ani.dantotsu.openLinkInBrowser -import ani.dantotsu.openLinkInYouTube import ani.dantotsu.others.AppUpdater import ani.dantotsu.others.CustomBottomDialog import ani.dantotsu.pop import ani.dantotsu.setSafeOnClickListener import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.startMainActivity import ani.dantotsu.statusBarHeight @@ -217,10 +217,14 @@ class SettingsActivity : AppCompatActivity() { settingsLogo.setSafeOnClickListener { cursedCounter++ (settingsLogo.drawable as Animatable).start() - if (cursedCounter % 7 == 0) { - toast(R.string.you_cursed) - openLinkInYouTube(getString(R.string.cursed_yt)) - //PrefManager.setVal(PrefName.ImageUrl, !PrefManager.getVal(PrefName.ImageUrl, false)) + if (cursedCounter % 16 == 0) { + val oldVal: Boolean = PrefManager.getVal(PrefName.OC) + if (!oldVal) { + toast(R.string.omega_cursed) + } else { + toast(R.string.omega_freed) + } + PrefManager.setVal(PrefName.OC, !oldVal) } else { snackString(array[(Math.random() * array.size).toInt()], context) } diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsAddonActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsAddonActivity.kt index 664a8b05..55dacb07 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsAddonActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsAddonActivity.kt @@ -53,9 +53,9 @@ class SettingsAddonActivity : AppCompatActivity() { bottomMargin = navBarHeight } - binding.addonSettingsBack.setOnClickListener { onBackPressedDispatcher.onBackPressed() } + addonSettingsBack.setOnClickListener { onBackPressedDispatcher.onBackPressed() } - binding.settingsRecyclerView.adapter = SettingsAdapter( + settingsRecyclerView.adapter = SettingsAdapter( arrayListOf( Settings( type = 1, @@ -207,7 +207,7 @@ class SettingsAddonActivity : AppCompatActivity() { ) ) ) - binding.settingsRecyclerView.layoutManager = + settingsRecyclerView.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) } diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsCommonActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsCommonActivity.kt index bd590873..dea8666c 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsCommonActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsCommonActivity.kt @@ -8,19 +8,24 @@ import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.ArrayAdapter +import android.widget.CheckBox import android.widget.EditText import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.documentfile.provider.DocumentFile import androidx.recyclerview.widget.LinearLayoutManager import ani.dantotsu.R import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.databinding.ActivitySettingsCommonBinding +import ani.dantotsu.databinding.DialogSetPasswordBinding import ani.dantotsu.databinding.DialogUserAgentBinding import ani.dantotsu.download.DownloadsManager import ani.dantotsu.initActivity import ani.dantotsu.navBarHeight +import ani.dantotsu.others.calc.BiometricPromptUtils import ani.dantotsu.restartApp import ani.dantotsu.savePrefsToDownloads import ani.dantotsu.settings.saving.PrefManager @@ -33,12 +38,14 @@ import ani.dantotsu.themes.ThemeManager import ani.dantotsu.toast import ani.dantotsu.util.LauncherWrapper import ani.dantotsu.util.StoragePermissions +import ani.dantotsu.util.customAlertDialog import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.util.UUID class SettingsCommonActivity : AppCompatActivity() { @@ -155,18 +162,16 @@ class SettingsCommonActivity : AppCompatActivity() { icon = R.drawable.ic_download_24, onClick = { val managers = arrayOf("Default", "1DM", "ADM") - val downloadManagerDialog = - AlertDialog.Builder(context, R.style.MyPopup) - .setTitle(R.string.download_manager) - var downloadManager: Int = PrefManager.getVal(PrefName.DownloadManager) - val dialog = downloadManagerDialog.setSingleChoiceItems( - managers, downloadManager - ) { dialog, count -> - downloadManager = count - PrefManager.setVal(PrefName.DownloadManager, downloadManager) - dialog.dismiss() - }.show() - dialog.window?.setDimAmount(0.8f) + customAlertDialog().apply { + setTitle(getString(R.string.download_manager)) + singleChoiceItems( + managers, + PrefManager.getVal(PrefName.DownloadManager) + ) { count -> + PrefManager.setVal(PrefName.DownloadManager, count) + } + show() + } } ), Settings( @@ -175,42 +180,67 @@ class SettingsCommonActivity : AppCompatActivity() { desc = getString(R.string.app_lock_desc), icon = R.drawable.ic_round_lock_open_24, onClick = { - val passwordDialog = AlertDialog.Builder(context, R.style.MyPopup) - .setTitle(R.string.app_lock) - .setView(R.layout.dialog_set_password) - .setPositiveButton(R.string.ok) { dialog, _ -> - val passwordInput = - (dialog as AlertDialog).findViewById(R.id.passwordInput) - val confirmPasswordInput = - dialog.findViewById(R.id.confirmPasswordInput) - - val password = passwordInput?.text.toString() - val confirmPassword = confirmPasswordInput?.text.toString() - + customAlertDialog().apply { + val view = DialogSetPasswordBinding.inflate(layoutInflater) + setTitle(R.string.app_lock) + setCustomView(view.root) + setPosButton(R.string.ok) { + if (view.forgotPasswordCheckbox.isChecked) { + PrefManager.setVal(PrefName.OverridePassword, true) + } + val password = view.passwordInput.text.toString() + val confirmPassword = view.confirmPasswordInput.text.toString() if (password == confirmPassword && password.isNotEmpty()) { PrefManager.setVal(PrefName.AppPassword, password) - toast(R.string.success) - dialog.dismiss() + if (view.biometricCheckbox.isChecked) { + val canBiometricPrompt = + BiometricManager.from(applicationContext) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS + + if (canBiometricPrompt) { + val biometricPrompt = + BiometricPromptUtils.createBiometricPrompt(this@SettingsCommonActivity) { _ -> + val token = UUID.randomUUID().toString() + PrefManager.setVal( + PrefName.BiometricToken, + token + ) + toast(R.string.success) + } + val promptInfo = + BiometricPromptUtils.createPromptInfo(this@SettingsCommonActivity) + biometricPrompt.authenticate(promptInfo) + } + + } else { + PrefManager.setVal(PrefName.BiometricToken, "") + toast(R.string.success) + } } else { toast(R.string.password_mismatch) } } - .setNegativeButton(R.string.cancel) { dialog, _ -> - dialog.dismiss() - } - .setNeutralButton(R.string.remove) { dialog, _ -> + setNegButton(R.string.cancel) + setNeutralButton(R.string.remove){ PrefManager.setVal(PrefName.AppPassword, "") + PrefManager.setVal(PrefName.BiometricToken, "") + PrefManager.setVal(PrefName.OverridePassword, false) toast(R.string.success) - dialog.dismiss() } - .create() - - passwordDialog.window?.setDimAmount(0.8f) - passwordDialog.setOnShowListener { - passwordDialog.findViewById(R.id.passwordInput) - ?.requestFocus() + setOnShowListener { + view.passwordInput.requestFocus() + val canAuthenticate = + BiometricManager.from(applicationContext).canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_WEAK + ) == BiometricManager.BIOMETRIC_SUCCESS + view.biometricCheckbox.isVisible = canAuthenticate + view.biometricCheckbox.isChecked = + PrefManager.getVal(PrefName.BiometricToken, "").isNotEmpty() + view.forgotPasswordCheckbox.isChecked = + PrefManager.getVal(PrefName.OverridePassword) + } + show() } - passwordDialog.show() } ), @@ -275,10 +305,10 @@ class SettingsCommonActivity : AppCompatActivity() { desc = getString(R.string.change_download_location_desc), icon = R.drawable.ic_round_source_24, onClick = { - val dialog = AlertDialog.Builder(context, R.style.MyPopup) - .setTitle(R.string.change_download_location) - .setMessage(R.string.download_location_msg) - .setPositiveButton(R.string.ok) { dialog, _ -> + context.customAlertDialog().apply{ + setTitle(R.string.change_download_location) + setMessage(R.string.download_location_msg) + setPosButton(R.string.ok){ val oldUri = PrefManager.getVal(PrefName.DownloadsDir) launcher.registerForCallback { success -> if (success) { @@ -301,12 +331,10 @@ class SettingsCommonActivity : AppCompatActivity() { } } launcher.launch() - dialog.dismiss() - }.setNeutralButton(R.string.cancel) { dialog, _ -> - dialog.dismiss() - }.create() - dialog.window?.setDimAmount(0.8f) - dialog.show() + } + setNegButton(R.string.cancel) + show() + } } ), Settings( @@ -319,6 +347,17 @@ class SettingsCommonActivity : AppCompatActivity() { PrefManager.setVal(PrefName.ContinueMedia, isChecked) } ), + Settings( + type = 2, + name = getString(R.string.hide_private), + desc = getString(R.string.hide_private_desc), + icon = R.drawable.ic_round_remove_red_eye_24, + isChecked = PrefManager.getVal(PrefName.HidePrivate), + switch = { isChecked, _ -> + PrefManager.setVal(PrefName.HidePrivate, isChecked) + restartApp() + } + ), Settings( type = 2, name = getString(R.string.search_source_list), diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsDialogFragment.kt b/app/src/main/java/ani/dantotsu/settings/SettingsDialogFragment.kt index 70da38d4..1072d742 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsDialogFragment.kt @@ -1,6 +1,5 @@ package ani.dantotsu.settings -import android.app.AlertDialog import android.content.Intent import android.graphics.Color import android.os.Bundle @@ -16,7 +15,6 @@ import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.databinding.BottomSheetSettingsBinding import ani.dantotsu.download.anime.OfflineAnimeFragment import ani.dantotsu.download.manga.OfflineMangaFragment -import ani.dantotsu.getAppString import ani.dantotsu.getThemeColor import ani.dantotsu.home.AnimeFragment import ani.dantotsu.home.HomeFragment @@ -28,7 +26,7 @@ import ani.dantotsu.loadImage import ani.dantotsu.offline.OfflineFragment import ani.dantotsu.profile.ProfileActivity import ani.dantotsu.profile.activity.FeedActivity -import ani.dantotsu.profile.activity.NotificationActivity +import ani.dantotsu.profile.notification.NotificationActivity import ani.dantotsu.setSafeOnClickListener import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsExtensionsActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsExtensionsActivity.kt index abf742ba..59add578 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsExtensionsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsExtensionsActivity.kt @@ -23,6 +23,7 @@ import ani.dantotsu.initActivity import ani.dantotsu.media.MediaType import ani.dantotsu.navBarHeight import ani.dantotsu.parsers.ParserTestActivity +import ani.dantotsu.restartApp import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.statusBarHeight @@ -81,11 +82,11 @@ class SettingsExtensionsActivity : AppCompatActivity() { view.repositoryItem.text = item.removePrefix("https://raw.githubusercontent.com/") view.repositoryItem.setOnClickListener { - AlertDialog.Builder(context, R.style.MyPopup) - .setTitle(R.string.rem_repository).setMessage(item) - .setPositiveButton(getString(R.string.ok)) { dialog, _ -> - val repos = - PrefManager.getVal>(repoList).minus(item) + context.customAlertDialog().apply { + setTitle(R.string.rem_repository) + setMessage(item) + setPosButton(R.string.ok) { + val repos = PrefManager.getVal>(repoList).minus(item) PrefManager.setVal(repoList, repos) setExtensionOutput(repoInventory, type) CoroutineScope(Dispatchers.IO).launch { @@ -93,18 +94,16 @@ class SettingsExtensionsActivity : AppCompatActivity() { MediaType.ANIME -> { animeExtensionManager.findAvailableExtensions() } - MediaType.MANGA -> { mangaExtensionManager.findAvailableExtensions() } - else -> {} } } - dialog.dismiss() - }.setNegativeButton(getString(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - }.create().show() + } + setNegButton(R.string.cancel) + show() + } } view.repositoryItem.setOnLongClickListener { it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) @@ -117,14 +116,13 @@ class SettingsExtensionsActivity : AppCompatActivity() { } fun processUserInput(input: String, mediaType: MediaType, view: ViewGroup) { - val entry = - if (input.endsWith("/") || input.endsWith("index.min.json")) input.substring( - 0, - input.lastIndexOf("/") - ) else input + val validLink = if (input.contains("github.com") && input.contains("blob")) { + input.replace("github.com", "raw.githubusercontent.com") + .replace("/blob/", "/") + } else input if (mediaType == MediaType.ANIME) { val anime = - PrefManager.getVal>(PrefName.AnimeExtensionRepos).plus(entry) + PrefManager.getVal>(PrefName.AnimeExtensionRepos).plus(validLink) PrefManager.setVal(PrefName.AnimeExtensionRepos, anime) CoroutineScope(Dispatchers.IO).launch { animeExtensionManager.findAvailableExtensions() @@ -133,7 +131,7 @@ class SettingsExtensionsActivity : AppCompatActivity() { } if (mediaType == MediaType.MANGA) { val manga = - PrefManager.getVal>(PrefName.MangaExtensionRepos).plus(entry) + PrefManager.getVal>(PrefName.MangaExtensionRepos).plus(validLink) PrefManager.setVal(PrefName.MangaExtensionRepos, manga) CoroutineScope(Dispatchers.IO).launch { mangaExtensionManager.findAvailableExtensions() @@ -142,25 +140,6 @@ class SettingsExtensionsActivity : AppCompatActivity() { } } - fun processEditorAction( - dialog: AlertDialog, - editText: EditText, - mediaType: MediaType, - view: ViewGroup - ) { - editText.setOnEditorActionListener { textView, action, keyEvent -> - if (action == EditorInfo.IME_ACTION_SEARCH || action == EditorInfo.IME_ACTION_DONE || (keyEvent?.action == KeyEvent.ACTION_UP && keyEvent.keyCode == KeyEvent.KEYCODE_ENTER)) { - return@setOnEditorActionListener if (textView.text.isNullOrBlank()) { - false - } else { - processUserInput(textView.text.toString(), mediaType, view) - dialog.dismiss() - true - } - } - false - } - } settingsRecyclerView.adapter = SettingsAdapter( arrayListOf( Settings( @@ -169,31 +148,19 @@ class SettingsExtensionsActivity : AppCompatActivity() { desc = getString(R.string.anime_add_repository_desc), icon = R.drawable.ic_github, onClick = { - val dialogView = DialogUserAgentBinding.inflate(layoutInflater) - val editText = dialogView.userAgentTextBox.apply { - hint = getString(R.string.anime_add_repository) - } - context.customAlertDialog().apply { - setTitle(R.string.anime_add_repository) - setCustomView(dialogView.root) - setPosButton(getString(R.string.ok)) { - if (!editText.text.isNullOrBlank()) processUserInput( - editText.text.toString(), - MediaType.ANIME, - it.attachView - ) + val animeRepos = PrefManager.getVal>(PrefName.AnimeExtensionRepos) + AddRepositoryBottomSheet.newInstance( + MediaType.ANIME, + animeRepos.toList(), + onRepositoryAdded = { input, mediaType -> + processUserInput(input, mediaType, it.attachView) + }, + onRepositoryRemoved = { item -> + val repos = PrefManager.getVal>(PrefName.AnimeExtensionRepos).minus(item) + PrefManager.setVal(PrefName.AnimeExtensionRepos, repos) + setExtensionOutput(it.attachView, MediaType.ANIME) } - setNegButton(getString(R.string.cancel)) - attach { dialog -> - processEditorAction( - dialog, - editText, - MediaType.ANIME, - it.attachView - ) - } - show() - } + ).show(supportFragmentManager, "add_repo") }, attach = { setExtensionOutput(it.attachView, MediaType.ANIME) @@ -205,31 +172,19 @@ class SettingsExtensionsActivity : AppCompatActivity() { desc = getString(R.string.manga_add_repository_desc), icon = R.drawable.ic_github, onClick = { - val dialogView = DialogUserAgentBinding.inflate(layoutInflater) - val editText = dialogView.userAgentTextBox.apply { - hint = getString(R.string.manga_add_repository) - } - val alertDialog = AlertDialog.Builder(context, R.style.MyPopup) - .setTitle(R.string.manga_add_repository).setView(dialogView.root) - .setPositiveButton(getString(R.string.ok)) { dialog, _ -> - if (!editText.text.isNullOrBlank()) processUserInput( - editText.text.toString(), - MediaType.MANGA, - it.attachView - ) - dialog.dismiss() - }.setNegativeButton(getString(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - }.create() - - processEditorAction( - alertDialog, - editText, + val mangaRepos = PrefManager.getVal>(PrefName.MangaExtensionRepos) + AddRepositoryBottomSheet.newInstance( MediaType.MANGA, - it.attachView - ) - alertDialog.show() - alertDialog.window?.setDimAmount(0.8f) + mangaRepos.toList(), + onRepositoryAdded = { input, mediaType -> + processUserInput(input, mediaType, it.attachView) + }, + onRepositoryRemoved = { item -> + val repos = PrefManager.getVal>(PrefName.MangaExtensionRepos).minus(item) + PrefManager.setVal(PrefName.MangaExtensionRepos, repos) + setExtensionOutput(it.attachView, MediaType.MANGA) + } + ).show(supportFragmentManager, "add_repo") }, attach = { setExtensionOutput(it.attachView, MediaType.MANGA) @@ -258,27 +213,41 @@ class SettingsExtensionsActivity : AppCompatActivity() { val dialogView = DialogUserAgentBinding.inflate(layoutInflater) val editText = dialogView.userAgentTextBox editText.setText(PrefManager.getVal(PrefName.DefaultUserAgent)) - val alertDialog = AlertDialog.Builder(context, R.style.MyPopup) - .setTitle(R.string.user_agent).setView(dialogView.root) - .setPositiveButton(getString(R.string.ok)) { dialog, _ -> - PrefManager.setVal( - PrefName.DefaultUserAgent, - editText.text.toString() - ) - dialog.dismiss() - }.setNeutralButton(getString(R.string.reset)) { dialog, _ -> + context.customAlertDialog().apply { + setTitle(R.string.user_agent) + setCustomView(dialogView.root) + setPosButton(R.string.ok) { + PrefManager.setVal(PrefName.DefaultUserAgent, editText.text.toString()) + } + setNeutralButton(R.string.reset) { PrefManager.removeVal(PrefName.DefaultUserAgent) editText.setText("") - dialog.dismiss() - }.setNegativeButton(getString(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - }.create() - - alertDialog.show() - alertDialog.window?.setDimAmount(0.8f) + } + setNegButton(R.string.cancel) + }.show() } ), Settings( + type = 2, + name = getString(R.string.proxy), + desc = getString(R.string.proxy_desc), + icon = R.drawable.swap_horizontal_circle_24, + isChecked = PrefManager.getVal(PrefName.EnableSocks5Proxy), + switch = { isChecked, _ -> + PrefManager.setVal(PrefName.EnableSocks5Proxy, isChecked) + restartApp() + } + ), + Settings( + type = 1, + name = getString(R.string.proxy_setup), + desc = getString(R.string.proxy_setup_desc), + icon = R.drawable.lan_24, + onClick = { + ProxyDialogFragment().show(supportFragmentManager, "dialog") + } + ), + Settings( type = 2, name = getString(R.string.force_legacy_installer), desc = getString(R.string.force_legacy_installer_desc), diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsNotificationActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsNotificationActivity.kt index 2b417df0..c7a9b1e6 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsNotificationActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsNotificationActivity.kt @@ -25,6 +25,8 @@ import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.util.customAlertDialog +import java.util.Locale class SettingsNotificationActivity : AppCompatActivity() { private lateinit var binding: ActivitySettingsNotificationsBinding @@ -76,26 +78,16 @@ class SettingsNotificationActivity : AppCompatActivity() { desc = getString(R.string.subscriptions_info), icon = R.drawable.ic_round_notifications_none_24, onClick = { - val speedDialog = AlertDialog.Builder(context, R.style.MyPopup) - .setTitle(R.string.subscriptions_checking_time) - val dialog = - speedDialog.setSingleChoiceItems(timeNames, curTime) { dialog, i -> + context.customAlertDialog().apply { + setTitle(R.string.subscriptions_checking_time) + singleChoiceItems(timeNames, curTime) { i -> curTime = i - it.settingsTitle.text = - getString( - R.string.subscriptions_checking_time_s, - timeNames[i] - ) - PrefManager.setVal( - PrefName.SubscriptionNotificationInterval, - curTime - ) - dialog.dismiss() - TaskScheduler.create( - context, PrefManager.getVal(PrefName.UseAlarmManager) - ).scheduleAllTasks(context) - }.show() - dialog.window?.setDimAmount(0.8f) + it.settingsTitle.text = getString(R.string.subscriptions_checking_time_s, timeNames[i]) + PrefManager.setVal(PrefName.SubscriptionNotificationInterval, curTime) + TaskScheduler.create(context, PrefManager.getVal(PrefName.UseAlarmManager)).scheduleAllTasks(context) + } + show() + } }, onLongClick = { TaskScheduler.create( @@ -130,7 +122,10 @@ class SettingsNotificationActivity : AppCompatActivity() { val dialog = AlertDialog.Builder(context, R.style.MyPopup) .setTitle(R.string.anilist_notification_filters) .setMultiChoiceItems( - types.toTypedArray(), + types.map { name -> + name.replace("_", " ").lowercase().replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() + } }.toTypedArray(), selected ) { _, which, isChecked -> val type = types[which] diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsThemeActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsThemeActivity.kt index 05ab6e41..c315305b 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsThemeActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsThemeActivity.kt @@ -4,6 +4,8 @@ import android.content.ComponentName import android.content.Intent import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter @@ -16,7 +18,6 @@ import ani.dantotsu.databinding.ActivitySettingsThemeBinding import ani.dantotsu.initActivity import ani.dantotsu.navBarHeight import ani.dantotsu.reloadActivity -import ani.dantotsu.restartApp import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.statusBarHeight @@ -73,7 +74,7 @@ class SettingsThemeActivity : AppCompatActivity(), SimpleDialog.OnDialogResultLi previous = current current.alpha = 1f PrefManager.setVal(PrefName.DarkMode, mode) - reloadActivity() + reload() } settingsUiAuto.setOnClickListener { @@ -210,7 +211,9 @@ class SettingsThemeActivity : AppCompatActivity(), SimpleDialog.OnDialogResultLi fun reload() { PrefManager.setCustomVal("reload", true) - restartApp() + Handler(Looper.getMainLooper()).postDelayed({ + reloadActivity() + finishAndRemoveTask() + }, 100) } - } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/SubscriptionItem.kt b/app/src/main/java/ani/dantotsu/settings/SubscriptionItem.kt index bb8c0396..bb60cd4e 100644 --- a/app/src/main/java/ani/dantotsu/settings/SubscriptionItem.kt +++ b/app/src/main/java/ani/dantotsu/settings/SubscriptionItem.kt @@ -9,31 +9,25 @@ import ani.dantotsu.loadImage import ani.dantotsu.media.MediaDetailsActivity import ani.dantotsu.notifications.subscription.SubscriptionHelper import com.xwray.groupie.GroupieAdapter -import com.xwray.groupie.Item import com.xwray.groupie.viewbinding.BindableItem class SubscriptionItem( val id: Int, private val media: SubscriptionHelper.Companion.SubscribeMedia, - private val adapter: GroupieAdapter + private val adapter: GroupieAdapter, + private val onItemRemoved: (Int) -> Unit ) : BindableItem() { private lateinit var binding: ItemSubscriptionBinding - override fun bind(p0: ItemSubscriptionBinding, p1: Int) { - val context = p0.root.context - binding = p0 - val parserName = if (media.isAnime) - SubscriptionHelper.getAnimeParser(media.id).name - else - SubscriptionHelper.getMangaParser(media.id).name - val mediaName = media.name - val showName = "$mediaName ($parserName)" - binding.subscriptionName.text = showName + + override fun bind(viewBinding: ItemSubscriptionBinding, position: Int) { + binding = viewBinding + val context = binding.root.context + + binding.subscriptionName.text = media.name binding.root.setOnClickListener { ContextCompat.startActivity( context, - Intent(context, MediaDetailsActivity::class.java).putExtra( - "mediaId", media.id - ), + Intent(context, MediaDetailsActivity::class.java).putExtra("mediaId", media.id), null ) } @@ -41,14 +35,11 @@ class SubscriptionItem( binding.deleteSubscription.setOnClickListener { SubscriptionHelper.deleteSubscription(id, true) adapter.remove(this) + onItemRemoved(id) } } - override fun getLayout(): Int { - return R.layout.item_subscription - } + override fun getLayout(): Int = R.layout.item_subscription - override fun initializeViewBinding(p0: View): ItemSubscriptionBinding { - return ItemSubscriptionBinding.bind(p0) - } + override fun initializeViewBinding(view: View): ItemSubscriptionBinding = ItemSubscriptionBinding.bind(view) } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/SubscriptionSource.kt b/app/src/main/java/ani/dantotsu/settings/SubscriptionSource.kt new file mode 100644 index 00000000..aa9bf5d3 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/settings/SubscriptionSource.kt @@ -0,0 +1,113 @@ +package ani.dantotsu.settings + +import android.app.AlertDialog +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.View +import android.view.ViewGroup +import ani.dantotsu.R +import ani.dantotsu.databinding.ItemExtensionBinding +import ani.dantotsu.notifications.subscription.SubscriptionHelper +import ani.dantotsu.util.customAlertDialog +import com.xwray.groupie.GroupieAdapter +import com.xwray.groupie.viewbinding.BindableItem + +class SubscriptionSource( + private val parserName: String, + private val subscriptions: MutableList, + private val adapter: GroupieAdapter, + private var parserIcon: Drawable? = null, + private val onGroupRemoved: (SubscriptionSource) -> Unit +) : BindableItem() { + private lateinit var binding: ItemExtensionBinding + private var isExpanded = false + + override fun bind(viewBinding: ItemExtensionBinding, position: Int) { + binding = viewBinding + binding.extensionNameTextView.text = parserName + updateSubscriptionCount() + binding.extensionSubscriptions.visibility = View.VISIBLE + binding.root.setOnClickListener { + isExpanded = !isExpanded + toggleSubscriptions() + } + binding.root.setOnLongClickListener { + showRemoveAllSubscriptionsDialog(it.context) + true + } + binding.extensionIconImageView.visibility = View.VISIBLE + val layoutParams = binding.extensionIconImageView.layoutParams as ViewGroup.MarginLayoutParams + layoutParams.leftMargin = 28 + binding.extensionIconImageView.layoutParams = layoutParams + + parserIcon?.let { + binding.extensionIconImageView.setImageDrawable(it) + } ?: run { + binding.extensionIconImageView.setImageResource(R.drawable.control_background_40dp) + } + + binding.extensionPinImageView.visibility = View.GONE + binding.extensionVersionTextView.visibility = View.GONE + binding.deleteTextView.visibility = View.GONE + binding.updateTextView.visibility = View.GONE + binding.settingsImageView.visibility = View.GONE + } + + private fun updateSubscriptionCount() { + binding.subscriptionCount.text = subscriptions.size.toString() + binding.subscriptionCount.visibility = if (subscriptions.isEmpty()) View.GONE else View.VISIBLE + } + + private fun showRemoveAllSubscriptionsDialog(context: Context) { + context.customAlertDialog().apply{ + setTitle(R.string.remove_all_subscriptions) + setMessage(R.string.remove_all_subscriptions_desc, parserName) + setPosButton(R.string.ok) { removeAllSubscriptions() } + setNegButton(R.string.cancel) + show() + } + } + + private fun removeAllSubscriptions() { + subscriptions.forEach { subscription -> + SubscriptionHelper.deleteSubscription(subscription.id, false) + } + if (isExpanded) { + val startPosition = adapter.getAdapterPosition(this) + 1 + repeat(subscriptions.size) { + adapter.removeGroupAtAdapterPosition(startPosition) + } + } + subscriptions.clear() + onGroupRemoved(this) + } + + private fun removeSubscription(id: Any?) { + subscriptions.removeAll { it.id == id } + updateSubscriptionCount() + if (subscriptions.isEmpty()) { + onGroupRemoved(this) + } else { + adapter.notifyItemChanged(adapter.getAdapterPosition(this)) + } + } + + private fun toggleSubscriptions() { + val startPosition = adapter.getAdapterPosition(this) + 1 + if (isExpanded) { + subscriptions.forEachIndexed { index, subscribeMedia -> + adapter.add(startPosition + index, SubscriptionItem(subscribeMedia.id, subscribeMedia, adapter) { removedId -> + removeSubscription(removedId) + }) + } + } else { + repeat(subscriptions.size) { + adapter.removeGroupAtAdapterPosition(startPosition) + } + } + } + + override fun getLayout(): Int = R.layout.item_extension + + override fun initializeViewBinding(view: View): ItemExtensionBinding = ItemExtensionBinding.bind(view) +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/SubscriptionsBottomDialog.kt b/app/src/main/java/ani/dantotsu/settings/SubscriptionsBottomDialog.kt index 2b833114..93fd58db 100644 --- a/app/src/main/java/ani/dantotsu/settings/SubscriptionsBottomDialog.kt +++ b/app/src/main/java/ani/dantotsu/settings/SubscriptionsBottomDialog.kt @@ -1,5 +1,6 @@ package ani.dantotsu.settings +import android.graphics.drawable.Drawable import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -9,13 +10,21 @@ import ani.dantotsu.BottomSheetDialogFragment import ani.dantotsu.R import ani.dantotsu.databinding.BottomSheetRecyclerBinding import ani.dantotsu.notifications.subscription.SubscriptionHelper +import ani.dantotsu.parsers.novel.NovelExtensionManager import com.xwray.groupie.GroupieAdapter +import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager +import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get class SubscriptionsBottomDialog : BottomSheetDialogFragment() { private var _binding: BottomSheetRecyclerBinding? = null private val binding get() = _binding!! private val adapter: GroupieAdapter = GroupieAdapter() private var subscriptions: Map = mapOf() + private val animeExtension: AnimeExtensionManager = Injekt.get() + private val mangaExtensions: MangaExtensionManager = Injekt.get() + private val novelExtensions: NovelExtensionManager = Injekt.get() override fun onCreateView( inflater: LayoutInflater, @@ -36,8 +45,33 @@ class SubscriptionsBottomDialog : BottomSheetDialogFragment() { val context = requireContext() binding.title.text = context.getString(R.string.subscriptions) binding.replyButton.visibility = View.GONE - subscriptions.forEach { (id, media) -> - adapter.add(SubscriptionItem(id, media, adapter)) + + val groupedSubscriptions = subscriptions.values.groupBy { + if (it.isAnime) SubscriptionHelper.getAnimeParser(it.id).name + else SubscriptionHelper.getMangaParser(it.id).name + } + + groupedSubscriptions.forEach { (parserName, mediaList) -> + adapter.add(SubscriptionSource( + parserName, + mediaList.toMutableList(), + adapter, + getParserIcon(parserName) + ) { group -> + adapter.remove(group) + }) + } + } + + private fun getParserIcon(parserName: String): Drawable? { + return when { + animeExtension.installedExtensionsFlow.value.any { it.name == parserName } -> + animeExtension.installedExtensionsFlow.value.find { it.name == parserName }?.icon + mangaExtensions.installedExtensionsFlow.value.any { it.name == parserName } -> + mangaExtensions.installedExtensionsFlow.value.find { it.name == parserName }?.icon + novelExtensions.installedExtensionsFlow.value.any { it.name == parserName } -> + novelExtensions.installedExtensionsFlow.value.find { it.name == parserName }?.icon + else -> null } } diff --git a/app/src/main/java/ani/dantotsu/settings/UserInterfaceSettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/UserInterfaceSettingsActivity.kt index a33ddb2e..08b6345e 100644 --- a/app/src/main/java/ani/dantotsu/settings/UserInterfaceSettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/UserInterfaceSettingsActivity.kt @@ -14,6 +14,7 @@ import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.util.customAlertDialog class UserInterfaceSettingsActivity : AppCompatActivity() { lateinit var binding: ActivityUserInterfaceSettingsBinding @@ -38,20 +39,23 @@ class UserInterfaceSettingsActivity : AppCompatActivity() { binding.uiSettingsHomeLayout.setOnClickListener { val set = PrefManager.getVal>(PrefName.HomeLayout).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.HomeLayout).toBooleanArray() - ) { _, i, value -> - set[i] = value + customAlertDialog().apply { + setTitle(getString(R.string.home_layout_show)) + multiChoiceItems( + items = views, + checkedItems = PrefManager.getVal>(PrefName.HomeLayout).toBooleanArray() + ) { selectedItems -> + for (i in selectedItems.indices) { + set[i] = selectedItems[i] } - setPositiveButton("Done") { _, _ -> - PrefManager.setVal(PrefName.HomeLayout, set) - restartApp() - } - }.show() - dialog.window?.setDimAmount(0.8f) + } + setPosButton(R.string.ok) { + PrefManager.setVal(PrefName.HomeLayout, set) + restartApp() + } + show() + } + } binding.uiSettingsSmallView.isChecked = PrefManager.getVal(PrefName.SmallView) @@ -65,6 +69,10 @@ class UserInterfaceSettingsActivity : AppCompatActivity() { PrefManager.setVal(PrefName.ImmersiveMode, isChecked) restartApp() } + binding.uiSettingsHideRedDot.isChecked = !PrefManager.getVal(PrefName.ShowNotificationRedDot) + binding.uiSettingsHideRedDot.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.ShowNotificationRedDot, !isChecked) + } binding.uiSettingsBannerAnimation.isChecked = PrefManager.getVal(PrefName.BannerAnimations) binding.uiSettingsBannerAnimation.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.BannerAnimations, isChecked) 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 2a2438d9..c0833ee2 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt @@ -236,6 +236,28 @@ object PrefManager { } } + /** + * Retrieves all SharedPreferences entries with keys starting with the specified prefix. + * + * @param prefix The prefix to filter keys. + * @return A map containing key-value pairs that match the prefix. + */ + fun getAllCustomValsForMedia(prefix: String): Map { + val prefs = irrelevantPreferences ?: return emptyMap() + val allEntries = mutableMapOf() + + prefs.all.forEach { (key, value) -> + if (key.startsWith(prefix)) { + allEntries[key] = value + } + } + + return allEntries + } + + + + @Suppress("UNCHECKED_CAST") fun getLiveVal(prefName: PrefName, default: T): SharedPreferenceLiveData { val pref = getPrefLocation(prefName.data.prefLocation) 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 1d69ebad..659e399b 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt @@ -22,6 +22,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files CheckUpdate(Pref(Location.General, Boolean::class, true)), VerboseLogging(Pref(Location.General, Boolean::class, false)), DohProvider(Pref(Location.General, Int::class, 0)), + HidePrivate(Pref(Location.General, Boolean::class, false)), DefaultUserAgent( Pref( Location.General, @@ -45,6 +46,9 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files IncludeAnimeList(Pref(Location.General, Boolean::class, true)), IncludeMangaList(Pref(Location.General, Boolean::class, true)), AdultOnly(Pref(Location.General, Boolean::class, false)), + CommentsEnabled(Pref(Location.General, Int::class, 0)), + EnableSocks5Proxy(Pref(Location.General, Boolean::class, false)), + ProxyAuthEnabled(Pref(Location.General, Boolean::class, false)), //User Interface UseOLED(Pref(Location.UI, Boolean::class, false)), @@ -82,6 +86,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files MangaListSortOrder(Pref(Location.UI, String::class, "score")), CommentSortOrder(Pref(Location.UI, String::class, "newest")), FollowerLayout(Pref(Location.UI, Int::class, 0)), + ShowNotificationRedDot(Pref(Location.UI, Boolean::class, true)), //Player @@ -89,12 +94,16 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files CursedSpeeds(Pref(Location.Player, Boolean::class, false)), Resize(Pref(Location.Player, Int::class, 0)), Subtitles(Pref(Location.Player, Boolean::class, true)), - PrimaryColor(Pref(Location.Player, Int::class, 4)), - SecondaryColor(Pref(Location.Player, Int::class, 0)), + TextviewSubtitles(Pref(Location.Player, Boolean::class, false)), + SubLanguage(Pref(Location.Player, Int::class, 9)), + PrimaryColor(Pref(Location.Player, Int::class, Color.WHITE)), + SecondaryColor(Pref(Location.Player, Int::class, Color.BLACK)), Outline(Pref(Location.Player, Int::class, 0)), - SubBackground(Pref(Location.Player, Int::class, 0)), - SubWindow(Pref(Location.Player, Int::class, 0)), + SubBackground(Pref(Location.Player, Int::class, Color.TRANSPARENT)), + SubWindow(Pref(Location.Player, Int::class, Color.TRANSPARENT)), SubAlpha(Pref(Location.Player, Float::class, 1f)), + SubStroke(Pref(Location.Player, Float::class, 8f)), + SubBottomMargin(Pref(Location.Player, Float::class, 1f)), Font(Pref(Location.Player, Int::class, 0)), FontSize(Pref(Location.Player, Int::class, 20)), Locale(Pref(Location.Player, Int::class, 2)), @@ -122,6 +131,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files Pip(Pref(Location.Player, Boolean::class, true)), RotationPlayer(Pref(Location.Player, Boolean::class, true)), TorrentEnabled(Pref(Location.Player, Boolean::class, false)), + UseAdditionalCodec(Pref(Location.Player, Boolean::class, false)), //Reader ShowSource(Pref(Location.Reader, Boolean::class, true)), @@ -189,7 +199,9 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files SubscriptionNotificationStore(Pref(Location.Irrelevant, List::class, listOf())), UnreadCommentNotifications(Pref(Location.Irrelevant, Int::class, 0)), DownloadsDir(Pref(Location.Irrelevant, String::class, "")), + OC(Pref(Location.Irrelevant, Boolean::class, false)), RefreshStatus(Pref(Location.Irrelevant, Boolean::class, false)), + rpcEnabled(Pref(Location.Irrelevant, Boolean::class, true)), //Protected DiscordToken(Pref(Location.Protected, String::class, "")), @@ -202,4 +214,10 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files MALCodeChallenge(Pref(Location.Protected, String::class, "")), MALToken(Pref(Location.Protected, MAL.ResponseToken::class, "")), AppPassword(Pref(Location.Protected, String::class, "")), -} \ No newline at end of file + BiometricToken(Pref(Location.Protected, String::class, "")), + OverridePassword(Pref(Location.Protected, Boolean::class, false)), + Socks5ProxyHost(Pref(Location.Protected, String::class, "")), + Socks5ProxyPort(Pref(Location.Protected, String::class, "")), + Socks5ProxyUsername(Pref(Location.Protected, String::class, "")), + Socks5ProxyPassword(Pref(Location.Protected, String::class, "")), +} diff --git a/app/src/main/java/ani/dantotsu/themes/ThemeManager.kt b/app/src/main/java/ani/dantotsu/themes/ThemeManager.kt index 05e1c5de..a92d9535 100644 --- a/app/src/main/java/ani/dantotsu/themes/ThemeManager.kt +++ b/app/src/main/java/ani/dantotsu/themes/ThemeManager.kt @@ -7,6 +7,7 @@ import android.graphics.Bitmap import android.os.Build import android.view.Window import android.view.WindowManager +import android.view.View import ani.dantotsu.R import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName @@ -62,6 +63,7 @@ class ThemeManager(private val context: Activity) { window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) window.statusBarColor = 0x00000000 context.setTheme(themeToApply) + window.decorView.layoutDirection = View.LAYOUT_DIRECTION_LTR } fun setWindowFlag(activity: Activity, bits: Int, on: Boolean) { diff --git a/app/src/main/java/ani/dantotsu/util/ActivityMarkdownCreator.kt b/app/src/main/java/ani/dantotsu/util/ActivityMarkdownCreator.kt new file mode 100644 index 00000000..5548401e --- /dev/null +++ b/app/src/main/java/ani/dantotsu/util/ActivityMarkdownCreator.kt @@ -0,0 +1,286 @@ +package ani.dantotsu.util + +import android.os.Bundle +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.widget.addTextChangedListener +import ani.dantotsu.R +import ani.dantotsu.buildMarkwon +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.databinding.ActivityMarkdownCreatorBinding +import ani.dantotsu.initActivity +import ani.dantotsu.navBarHeight +import ani.dantotsu.openLinkInBrowser +import ani.dantotsu.others.AndroidBug5497Workaround +import ani.dantotsu.statusBarHeight +import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.toast +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import io.noties.markwon.editor.MarkwonEditor +import io.noties.markwon.editor.MarkwonEditorTextWatcher +import kotlinx.coroutines.DelicateCoroutinesApi +import tachiyomi.core.util.lang.launchIO + +class ActivityMarkdownCreator : AppCompatActivity() { + private lateinit var binding: ActivityMarkdownCreatorBinding + private lateinit var type: String + private var text: String = "" + private var ping: String? = null + private var parentId: Int = 0 + private var isPreviewMode: Boolean = false + + enum class MarkdownFormat( + val syntax: String, + val selectionOffset: Int, + val imageViewId: Int + ) { + BOLD("****", 2, R.id.formatBold), + ITALIC("**", 1, R.id.formatItalic), + STRIKETHROUGH("~~~~", 2, R.id.formatStrikethrough), + SPOILER("~!!~", 2, R.id.formatSpoiler), + LINK("[Placeholder](%s)", 0, R.id.formatLink), + IMAGE("img(%s)", 0, R.id.formatImage), + YOUTUBE("youtube(%s)", 0, R.id.formatYoutube), + VIDEO("webm(%s)", 0, R.id.formatVideo), + ORDERED_LIST("1. ", 3, R.id.formatListOrdered), + UNORDERED_LIST("- ", 2, R.id.formatListUnordered), + HEADING("# ", 2, R.id.formatTitle), + CENTERED("~~~~~~", 3, R.id.formatCenter), + QUOTE("> ", 2, R.id.formatQuote), + CODE("``", 1, R.id.formatCode) + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + binding = ActivityMarkdownCreatorBinding.inflate(layoutInflater) + binding.markdownCreatorToolbar.updateLayoutParams { + topMargin = statusBarHeight + } + binding.markdownOptionsContainer.updateLayoutParams { + bottomMargin += navBarHeight + } + setContentView(binding.root) + AndroidBug5497Workaround.assistActivity(this) {} + + val params = binding.createButton.layoutParams as ViewGroup.MarginLayoutParams + params.marginEnd = 16 * resources.displayMetrics.density.toInt() + binding.createButton.layoutParams = params + + if (intent.hasExtra("type")) { + type = intent.getStringExtra("type")!! + } else { + toast("Error: No type") + finish() + return + } + val editId = intent.getIntExtra("edit", -1) + val userId = intent.getIntExtra("userId", -1) + parentId = intent.getIntExtra("parentId", -1) + when (type) { + "replyActivity" -> if (parentId == -1) { + toast("Error: No parent ID") + finish() + return + } + + "message" -> { + if (editId == -1) { + binding.privateCheckbox.visibility = ViewGroup.VISIBLE + } + } + } + var private = false + binding.privateCheckbox.setOnCheckedChangeListener { _, isChecked -> + private = isChecked + } + + ping = intent.getStringExtra("other") + text = ping ?: "" + binding.editText.setText(text) + binding.editText.addTextChangedListener { + if (!isPreviewMode) { + text = it.toString() + } + } + previewMarkdown(false) + + binding.markdownCreatorBack.setOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + + binding.createButton.setOnClickListener { + if (text.isBlank()) { + toast(getString(R.string.cannot_be_empty)) + return@setOnClickListener + } + customAlertDialog().apply { + setTitle(R.string.warning) + setMessage(R.string.post_to_anilist_warning) + setPosButton(R.string.ok) { + launchIO { + val isEdit = editId != -1 + val success = when (type) { + "activity" -> if (isEdit) { + Anilist.mutation.postActivity(text, editId) + } else { + Anilist.mutation.postActivity(text) + } + //"review" -> Anilist.mutation.postReview(text) + "replyActivity" -> if (isEdit) { + Anilist.mutation.postReply(parentId, text, editId) + } else { + Anilist.mutation.postReply(parentId, text) + } + "message" -> if (isEdit) { + Anilist.mutation.postMessage(userId, text, editId) + } else { + Anilist.mutation.postMessage(userId, text, isPrivate = private) + } + + else -> "Error: Unknown type" + } + toast(success) + finish() + } + } + setNeutralButton(R.string.open_rules) { + openLinkInBrowser("https://anilist.co/forum/thread/14") + } + setNegButton(R.string.cancel) + show() + } + } + + binding.previewCheckbox.setOnClickListener { + isPreviewMode = !isPreviewMode + previewMarkdown(isPreviewMode) + if (isPreviewMode) { + toast("Preview enabled") + } else { + toast("Preview disabled") + } + } + binding.editText.requestFocus() + setupMarkdownButtons() + } + + private fun setupMarkdownButtons() { + MarkdownFormat.entries.forEach { format -> + findViewById(format.imageViewId)?.setOnClickListener { + applyMarkdownFormat(format) + } + } + } + + private fun applyMarkdownFormat(format: MarkdownFormat) { + val start = binding.editText.selectionStart + val end = binding.editText.selectionEnd + + if (start != end) { + val selectedText = binding.editText.text?.substring(start, end) ?: "" + val lines = selectedText.split("\n") + + val newText = when (format) { + MarkdownFormat.UNORDERED_LIST -> { + lines.joinToString("\n") { "- $it" } + } + + MarkdownFormat.ORDERED_LIST -> { + lines.mapIndexed { index, line -> "${index + 1}. $line" }.joinToString("\n") + } + + else -> { + if (format.syntax.contains("%s")) { + String.format(format.syntax, selectedText) + } else { + format.syntax.substring(0, format.selectionOffset) + + selectedText + + format.syntax.substring(format.selectionOffset) + } + } + } + + binding.editText.text?.replace(start, end, newText) + binding.editText.setSelection(start + newText.length) + } else { + if (format.syntax.contains("%s")) { + showInputDialog(format, start) + } else { + val newText = format.syntax + binding.editText.text?.insert(start, newText) + binding.editText.setSelection(start + format.selectionOffset) + } + } + } + + + private fun showInputDialog(format: MarkdownFormat, position: Int) { + val inputLayout = TextInputLayout(this).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE + isHintEnabled = true + } + + val inputEditText = TextInputEditText(this).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + } + + inputLayout.addView(inputEditText) + + val container = FrameLayout(this).apply { + addView(inputLayout) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setPadding(0, 0, 0, 0) + } + customAlertDialog().apply { + setTitle("Paste your link here") + setCustomView(container) + setPosButton(getString(R.string.ok)) { + val input = inputEditText.text.toString() + val formattedText = String.format(format.syntax, input) + binding.editText.text?.insert(position, formattedText) + binding.editText.setSelection(position + formattedText.length) + } + setNegButton(getString(R.string.cancel)) + }.show() + + inputEditText.requestFocus() + } + + private fun previewMarkdown(preview: Boolean) { + val markwon = buildMarkwon(this, false, anilist = true) + if (preview) { + binding.editText.isVisible = false + binding.editText.isEnabled = false + binding.markdownPreview.isVisible = true + markwon.setMarkdown(binding.markdownPreview, AniMarkdown.getBasicAniHTML(text)) + } else { + binding.editText.isVisible = true + binding.markdownPreview.isVisible = false + binding.editText.setText(text) + binding.editText.isEnabled = true + val markwonEditor = MarkwonEditor.create(markwon) + binding.editText.addTextChangedListener( + MarkwonEditorTextWatcher.withProcess(markwonEditor) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/util/AlertDialogBuilder.kt b/app/src/main/java/ani/dantotsu/util/AlertDialogBuilder.kt index d2ab6601..7891e7d6 100644 --- a/app/src/main/java/ani/dantotsu/util/AlertDialogBuilder.kt +++ b/app/src/main/java/ani/dantotsu/util/AlertDialogBuilder.kt @@ -1,5 +1,6 @@ package ani.dantotsu.util +import android.app.Activity import android.app.AlertDialog import android.content.Context import android.view.View @@ -20,7 +21,24 @@ class AlertDialogBuilder(private val context: Context) { private var selectedItemIndex: Int = -1 private var onItemSelected: ((Int) -> Unit)? = null private var customView: View? = null + private var onShow: (() -> Unit)? = null private var attach: ((dialog: AlertDialog) -> Unit)? = null + private var onDismiss: (() -> Unit)? = null + private var onCancel: (() -> Unit)? = null + private var cancelable: Boolean = true + fun setCancelable(cancelable: Boolean): AlertDialogBuilder { + this.cancelable = cancelable + return this + } + fun setOnShowListener(onShow: () -> Unit): AlertDialogBuilder { + this.onShow = onShow + return this + } + fun setOnCancelListener(onCancel: () -> Unit): AlertDialogBuilder { + this.onCancel = onCancel + return this + } + fun setTitle(title: String?): AlertDialogBuilder { this.title = title return this @@ -45,6 +63,10 @@ class AlertDialogBuilder(private val context: Context) { this.customView = view return this } + fun setCustomView(layoutResId: Int): AlertDialogBuilder { + this.customView = View.inflate(context, layoutResId, null) + return this + } fun setPosButton(title: String?, onClick: (() -> Unit)? = null): AlertDialogBuilder { this.posButtonTitle = title @@ -52,11 +74,7 @@ class AlertDialogBuilder(private val context: Context) { return this } - fun setPosButton( - int: Int, - formatArgs: Int? = null, - onClick: (() -> Unit)? = null - ): AlertDialogBuilder { + fun setPosButton(int: Int, formatArgs: Int? = null, onClick: (() -> Unit)? = null): AlertDialogBuilder { this.posButtonTitle = context.getString(int, formatArgs) this.onPositiveButtonClick = onClick return this @@ -68,11 +86,7 @@ class AlertDialogBuilder(private val context: Context) { return this } - fun setNegButton( - int: Int, - formatArgs: Int? = null, - onClick: (() -> Unit)? = null - ): AlertDialogBuilder { + fun setNegButton(int: Int, formatArgs: Int? = null, onClick: (() -> Unit)? = null): AlertDialogBuilder { this.negButtonTitle = context.getString(int, formatArgs) this.onNegativeButtonClick = onClick return this @@ -84,11 +98,7 @@ class AlertDialogBuilder(private val context: Context) { return this } - fun setNeutralButton( - int: Int, - formatArgs: Int? = null, - onClick: (() -> Unit)? = null - ): AlertDialogBuilder { + fun setNeutralButton(int: Int, formatArgs: Int? = null, onClick: (() -> Unit)? = null): AlertDialogBuilder { this.neutralButtonTitle = context.getString(int, formatArgs) this.onNeutralButtonClick = onClick return this @@ -99,22 +109,19 @@ class AlertDialogBuilder(private val context: Context) { return this } - fun singleChoiceItems( - items: Array, - selectedItemIndex: Int = -1, - onItemSelected: (Int) -> Unit - ): AlertDialogBuilder { + fun onDismiss(onDismiss: (() -> Unit)? = null): AlertDialogBuilder { + this.onDismiss = onDismiss + return this + } + + fun singleChoiceItems(items: Array, selectedItemIndex: Int = -1, onItemSelected: (Int) -> Unit): AlertDialogBuilder { this.items = items this.selectedItemIndex = selectedItemIndex this.onItemSelected = onItemSelected return this } - fun multiChoiceItems( - items: Array, - checkedItems: BooleanArray? = null, - onItemsSelected: (BooleanArray) -> Unit - ): AlertDialogBuilder { + fun multiChoiceItems(items: Array, checkedItems: BooleanArray? = null, onItemsSelected: (BooleanArray) -> Unit): AlertDialogBuilder { this.items = items this.checkedItems = checkedItems ?: BooleanArray(items.size) { false } this.onItemsSelected = onItemsSelected @@ -122,19 +129,23 @@ class AlertDialogBuilder(private val context: Context) { } fun show() { + if (context is Activity && context.isFinishing) return // Ensure context is valid + val builder = AlertDialog.Builder(context, R.style.MyPopup) if (title != null) builder.setTitle(title) if (message != null) builder.setMessage(message) if (customView != null) builder.setView(customView) if (items != null) { if (onItemSelected != null) { - builder.setSingleChoiceItems(items, selectedItemIndex) { _, which -> + builder.setSingleChoiceItems(items, selectedItemIndex) { dialog, which -> selectedItemIndex = which onItemSelected?.invoke(which) + dialog.dismiss() } } else if (checkedItems != null && onItemsSelected != null) { builder.setMultiChoiceItems(items, checkedItems) { _, which, isChecked -> checkedItems?.set(which, isChecked) + onItemsSelected?.invoke(checkedItems!!) } } } @@ -156,15 +167,28 @@ class AlertDialogBuilder(private val context: Context) { dialog.dismiss() } } - builder.setCancelable(false) + if (onCancel != null) { + builder.setOnCancelListener { + onCancel?.invoke() + } + } + builder.setCancelable(cancelable) val dialog = builder.create() attach?.invoke(dialog) - dialog.window?.setDimAmount(0.8f) + dialog.setOnDismissListener { + onDismiss?.invoke() + } + dialog.setOnShowListener { + onShow?.invoke() + } + dialog.window?.apply { + setDimAmount(0.8f) + attributes.windowAnimations = android.R.style.Animation_Dialog + } dialog.show() } - } fun Context.customAlertDialog(): AlertDialogBuilder { return AlertDialogBuilder(this) -} \ No newline at end of file +} diff --git a/app/src/main/java/ani/dantotsu/util/AniMarkdown.kt b/app/src/main/java/ani/dantotsu/util/AniMarkdown.kt index 0228256c..200b3883 100644 --- a/app/src/main/java/ani/dantotsu/util/AniMarkdown.kt +++ b/app/src/main/java/ani/dantotsu/util/AniMarkdown.kt @@ -1,12 +1,13 @@ package ani.dantotsu.util +import ani.dantotsu.getYoutubeId import ani.dantotsu.util.ColorEditor.Companion.toCssColor class AniMarkdown { //istg anilist has the worst api companion object { - private fun convertNestedImageToHtml(markdown: String): String { + private fun String.convertNestedImageToHtml(): String { val regex = """\[!\[(.*?)]\((.*?)\)]\((.*?)\)""".toRegex() - return regex.replace(markdown) { matchResult -> + return regex.replace(this) { matchResult -> val altText = matchResult.groupValues[1] val imageUrl = matchResult.groupValues[2] val linkUrl = matchResult.groupValues[3] @@ -14,26 +15,49 @@ class AniMarkdown { //istg anilist has the worst api } } - private fun convertImageToHtml(markdown: String): String { + private fun String.convertImageToHtml(): String { val regex = """!\[(.*?)]\((.*?)\)""".toRegex() - return regex.replace(markdown) { matchResult -> + val anilistRegex = """img\(.*?\)""".toRegex() + val markdownImage = regex.replace(this) { matchResult -> val altText = matchResult.groupValues[1] val imageUrl = matchResult.groupValues[2] """$altText""" } + return anilistRegex.replace(markdownImage) { matchResult -> + val imageUrl = matchResult.groupValues[1] + """Image""" + } } - private fun convertLinkToHtml(markdown: String): String { + private fun String.convertLinkToHtml(): String { val regex = """\[(.*?)]\((.*?)\)""".toRegex() - return regex.replace(markdown) { matchResult -> + return regex.replace(this) { matchResult -> val linkText = matchResult.groupValues[1] val linkUrl = matchResult.groupValues[2] """
$linkText""" } } - private fun replaceLeftovers(html: String): String { - return html.replace(" ", " ") + private fun String.convertYoutubeToHtml(): String { + val regex = """
""".toRegex() + return regex.replace(this) { matchResult -> + val url = matchResult.groupValues[1] + val id = getYoutubeId(url) + if (id.isNotEmpty()) { + """
+ $url + + Youtube Link + +
""".trimIndent() + } else { + """Youtube Video""" + } + } + } + + private fun String.replaceLeftovers(): String { + return this.replace(" ", " ") .replace("&", "&") .replace("<", "<") .replace(">", ">") @@ -46,18 +70,29 @@ class AniMarkdown { //istg anilist has the worst api .replace("\n", "
") } - private fun underlineToHtml(html: String): String { - return html.replace("(?s)___(.*?)___".toRegex(), "
$1
") + private fun String.underlineToHtml(): String { + return this.replace("(?s)___(.*?)___".toRegex(), "
$1
") .replace("(?s)__(.*?)__".toRegex(), "
$1
") .replace("(?s)\\s+_([^_]+)_\\s+".toRegex(), "$1") } + private fun String.convertCenterToHtml(): String { + val regex = """~~~(.*?)~~~""".toRegex() + return regex.replace(this) { matchResult -> + val centerText = matchResult.groupValues[1] + """$centerText""" + } + } + fun getBasicAniHTML(html: String): String { - val step0 = convertNestedImageToHtml(html) - val step1 = convertImageToHtml(step0) - val step2 = convertLinkToHtml(step1) - val step3 = replaceLeftovers(step2) - return underlineToHtml(step3) + return html + .convertNestedImageToHtml() + .convertImageToHtml() + .convertLinkToHtml() + .convertYoutubeToHtml() + .convertCenterToHtml() + .replaceLeftovers() + .underlineToHtml() } fun getFullAniHTML(html: String, textColor: Int): String { @@ -90,10 +125,8 @@ class AniMarkdown { //istg anilist has the worst api /* Add responsive design elements for other content as needed */ - - $basicHtml - - +$basicHtml + """.trimIndent() return returnHtml } diff --git a/app/src/main/java/ani/dantotsu/util/AudioHelper.kt b/app/src/main/java/ani/dantotsu/util/AudioHelper.kt new file mode 100644 index 00000000..bfd38be0 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/util/AudioHelper.kt @@ -0,0 +1,58 @@ +package ani.dantotsu.util + +import android.content.Context +import android.media.AudioManager +import android.media.MediaPlayer + +class AudioHelper(private val context: Context) { + + private val audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private var mediaPlayer: MediaPlayer? = null + + fun routeAudioToSpeaker() { + audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + audioManager.isSpeakerphoneOn = true + } + + private val maxVolume: Int + get() = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + private var oldVolume: Int = 0 + fun setVolume(percentage: Int) { + oldVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val volume = (maxVolume * percentage) / 100 + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0) + } + + fun playAudio(audio: Int) { + mediaPlayer?.release() + mediaPlayer = MediaPlayer.create(context, audio) + mediaPlayer?.setOnCompletionListener { + setVolume(oldVolume) + audioManager.abandonAudioFocus(null) + it.release() + } + mediaPlayer?.setOnPreparedListener { + it.start() + } + } + + fun stopAudio() { + mediaPlayer?.let { + if (it.isPlaying) { + it.stop() + } + it.release() + mediaPlayer = null + } + } + + companion object { + fun run(context: Context, audio: Int) { + val audioHelper = AudioHelper(context) + audioHelper.routeAudioToSpeaker() + audioHelper.setVolume(90) + audioHelper.playAudio(audio) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/util/CountUpTimer.kt b/app/src/main/java/ani/dantotsu/util/CountUpTimer.kt deleted file mode 100644 index 725781ff..00000000 --- a/app/src/main/java/ani/dantotsu/util/CountUpTimer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package ani.dantotsu.util - -import android.os.CountDownTimer - -// https://stackoverflow.com/a/40422151/461982 -abstract class CountUpTimer protected constructor( - private val duration: Long -) : CountDownTimer(duration, INTERVAL_MS) { - abstract fun onTick(second: Int) - override fun onTick(msUntilFinished: Long) { - val second = ((duration - msUntilFinished) / 1000).toInt() - onTick(second) - } - - override fun onFinish() { - onTick(duration / 1000) - } - - companion object { - private const val INTERVAL_MS: Long = 1000 - } -} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/util/MarkdownCreatorActivity.kt b/app/src/main/java/ani/dantotsu/util/MarkdownCreatorActivity.kt deleted file mode 100644 index 9772bf63..00000000 --- a/app/src/main/java/ani/dantotsu/util/MarkdownCreatorActivity.kt +++ /dev/null @@ -1,123 +0,0 @@ -package ani.dantotsu.util - -import android.os.Bundle -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.updateLayoutParams -import androidx.core.widget.addTextChangedListener -import ani.dantotsu.R -import ani.dantotsu.buildMarkwon -import ani.dantotsu.connections.anilist.Anilist -import ani.dantotsu.databinding.ActivityMarkdownCreatorBinding -import ani.dantotsu.initActivity -import ani.dantotsu.navBarHeight -import ani.dantotsu.openLinkInBrowser -import ani.dantotsu.statusBarHeight -import ani.dantotsu.themes.ThemeManager -import ani.dantotsu.toast -import io.noties.markwon.editor.MarkwonEditor -import io.noties.markwon.editor.MarkwonEditorTextWatcher -import kotlinx.coroutines.DelicateCoroutinesApi -import tachiyomi.core.util.lang.launchIO - -class MarkdownCreatorActivity : AppCompatActivity() { - private lateinit var binding: ActivityMarkdownCreatorBinding - private lateinit var type: String - private var text: String = "" - private var parentId: Int = 0 - @OptIn(DelicateCoroutinesApi::class) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - ThemeManager(this).applyTheme() - initActivity(this) - binding = ActivityMarkdownCreatorBinding.inflate(layoutInflater) - binding.markdownCreatorToolbar.updateLayoutParams { - topMargin = statusBarHeight - } - binding.buttonContainer.updateLayoutParams { - bottomMargin += navBarHeight - } - setContentView(binding.root) - if (intent.hasExtra("type")) { - type = intent.getStringExtra("type")!! - } else { - toast("Error: No type") - finish() - return - } - binding.markdownCreatorTitle.text = when (type) { - "activity" -> getString(R.string.create_new_activity) - "review" -> getString(R.string.create_new_review) - "replyActivity" -> { - parentId = intent.getIntExtra("parentId", -1) - if (parentId == -1) { - toast("Error: No parent ID") - finish() - return - } - getString(R.string.create_new_reply) - } - else -> "" - } - binding.editText.setText(text) - binding.editText.addTextChangedListener { - if (!binding.markdownCreatorPreviewCheckbox.isChecked) { - text = it.toString() - } - } - previewMarkdown(false) - binding.markdownCreatorPreviewCheckbox.setOnClickListener { - previewMarkdown(binding.markdownCreatorPreviewCheckbox.isChecked) - } - binding.cancelButton.setOnClickListener { - onBackPressedDispatcher.onBackPressed() - } - binding.markdownCreatorBack.setOnClickListener { - onBackPressedDispatcher.onBackPressed() - } - - binding.createButton.setOnClickListener { - if (text.isBlank()) { - toast(getString(R.string.cannot_be_empty)) - return@setOnClickListener - } - AlertDialogBuilder(this).apply { - setTitle(R.string.warning) - setMessage(R.string.post_to_anilist_warning) - setPosButton(R.string.ok) { - launchIO { - val success = when (type) { - "activity" -> Anilist.mutation.postActivity(text) - //"review" -> Anilist.mutation.postReview(text) - "replyActivity" -> Anilist.mutation.postReply(parentId, text) - else -> "Error: Unknown type" - } - toast(success) - } - onBackPressedDispatcher.onBackPressed() - } - setNeutralButton(R.string.open_rules) { - openLinkInBrowser("https://anilist.co/forum/thread/14") - } - setNegButton(R.string.cancel) - }.show() - } - - binding.editText.requestFocus() - } - - private fun previewMarkdown(preview: Boolean) { - val markwon = buildMarkwon(this, false, anilist = true) - if (preview) { - binding.editText.isEnabled = false - markwon.setMarkdown(binding.editText, text) - } else { - binding.editText.setText(text) - binding.editText.isEnabled = true - val markwonEditor = MarkwonEditor.create(markwon) - binding.editText.addTextChangedListener( - MarkwonEditorTextWatcher.withProcess(markwonEditor) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/util/NumberConverter.kt b/app/src/main/java/ani/dantotsu/util/NumberConverter.kt index 49d41de7..61aefe4e 100644 --- a/app/src/main/java/ani/dantotsu/util/NumberConverter.kt +++ b/app/src/main/java/ani/dantotsu/util/NumberConverter.kt @@ -47,5 +47,9 @@ class NumberConverter { val intBits = java.lang.Float.floatToIntBits(number) return Integer.toBinaryString(intBits) } + + fun Int.ofLength(length: Int): String { + return this.toString().padStart(length, '0') + } } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt b/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt index 8291c567..ee2a9aa8 100644 --- a/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt +++ b/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt @@ -71,20 +71,17 @@ class StoragePermissions { complete(true) return } - val builder = AlertDialog.Builder(this, R.style.MyPopup) - builder.setTitle(getString(R.string.dir_access)) - builder.setMessage(getString(R.string.dir_access_msg)) - builder.setPositiveButton(getString(R.string.ok)) { dialog, _ -> - launcher.registerForCallback(complete) - launcher.launch() - dialog.dismiss() - } - builder.setNegativeButton(getString(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - complete(false) - } - val dialog = builder.show() - dialog.window?.setDimAmount(0.8f) + customAlertDialog().apply { + setTitle(getString(R.string.dir_access)) + setMessage(getString(R.string.dir_access_msg)) + setPosButton(getString(R.string.ok)) { + launcher.registerForCallback(complete) + launcher.launch() + } + setNegButton(getString(R.string.cancel)) { + complete(false) + } + }.show() } private fun pathToUri(path: String): Uri { diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index c67eefea..d0a7af33 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -52,7 +52,7 @@ internal class ExtensionGithubApi { sources = it.sources?.toAnimeExtensionSources().orEmpty(), apkName = it.apk, repository = repository, - iconUrl = "${repository}/icon/${it.pkg}.png", + iconUrl = "${repository.removeSuffix("/index.min.json")}/icon/${it.pkg}.png", ) } } @@ -64,10 +64,6 @@ internal class ExtensionGithubApi { val repos = PrefManager.getVal>(PrefName.AnimeExtensionRepos).toMutableList() - if (repos.isEmpty()) { - repos.add("https://raw.githubusercontent.com/aniyomiorg/aniyomi-extensions/repo") - PrefManager.setVal(PrefName.AnimeExtensionRepos, repos.toSet()) - } repos.forEach { try { @@ -93,12 +89,6 @@ internal class ExtensionGithubApi { .toAnimeExtensions(it) } - // Sanity check - a small number of extensions probably means something broke - // with the repo generator - if (repoExtensions.size < 10) { - throw Exception() - } - extensions.addAll(repoExtensions) } catch (e: Throwable) { Logger.log("Failed to get extensions from GitHub") @@ -145,7 +135,7 @@ internal class ExtensionGithubApi { sources = it.sources?.toMangaExtensionSources().orEmpty(), apkName = it.apk, repository = repository, - iconUrl = "${repository}/icon/${it.pkg}.png", + iconUrl = "${repository.removeSuffix("/index.min.json")}/icon/${it.pkg}.png", ) } } @@ -157,19 +147,20 @@ internal class ExtensionGithubApi { val repos = PrefManager.getVal>(PrefName.MangaExtensionRepos).toMutableList() - if (repos.isEmpty()) { - repos.add("https://raw.githubusercontent.com/keiyoushi/extensions/main") - PrefManager.setVal(PrefName.MangaExtensionRepos, repos.toSet()) - } repos.forEach { + val repoUrl = if (it.contains("index.min.json")) { + it + } else { + "$it${if (it.endsWith('/')) "" else "/"}index.min.json" + } try { val githubResponse = try { networkService.client - .newCall(GET("${it}/index.min.json")) + .newCall(GET(repoUrl)) .awaitSuccess() } catch (e: Throwable) { - Logger.log("Failed to get repo: $it") + Logger.log("Failed to get repo: $repoUrl") Logger.log(e) null } @@ -186,12 +177,6 @@ internal class ExtensionGithubApi { .toMangaExtensions(it) } - // Sanity check - a small number of extensions probably means something broke - // with the repo generator - if (repoExtensions.size < 10) { - throw Exception() - } - extensions.addAll(repoExtensions) } catch (e: Throwable) { Logger.log("Failed to get extensions from GitHub") @@ -209,8 +194,11 @@ internal class ExtensionGithubApi { private fun fallbackRepoUrl(repoUrl: String): String? { var fallbackRepoUrl = "https://gcore.jsdelivr.net/gh/" - val strippedRepoUrl = - repoUrl.removePrefix("https://").removePrefix("http://").removeSuffix("/") + val strippedRepoUrl = repoUrl + .removePrefix("https://") + .removePrefix("http://") + .removeSuffix("/") + .removeSuffix("/index.min.json") val repoUrlParts = strippedRepoUrl.split("/") if (repoUrlParts.size < 3) { return null diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt index 32504b89..2f2b81bd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt @@ -153,7 +153,7 @@ abstract class Installer(private val service: Service) { } val nextEntry = queue.first() if (waitingInstall.compareAndSet(null, nextEntry)) { - queue.removeFirst() + queue.removeAt(0) processEntry(nextEntry) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index 268cfa98..fdaa245a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -513,7 +513,7 @@ internal object ExtensionLoader { */ private fun getSignatureHash(pkgInfo: PackageInfo): String? { val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - pkgInfo.signingInfo.signingCertificateHistory + pkgInfo.signingInfo?.signingCertificateHistory else @Suppress("DEPRECATION") pkgInfo.signatures return if (signatures != null && signatures.isNotEmpty()) { 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 53149638..189c5663 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -5,8 +5,10 @@ import android.os.Build import ani.dantotsu.Mapper import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.util.Logger import com.lagradost.nicehttp.Requests import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor +import eu.kanade.tachiyomi.network.interceptor.IgnoreGzipInterceptor import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor import okhttp3.Cache @@ -15,11 +17,50 @@ import okhttp3.brotli.BrotliInterceptor import okhttp3.logging.HttpLoggingInterceptor import java.io.File import java.util.concurrent.TimeUnit +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.Authenticator +import java.net.PasswordAuthentication +import java.util.prefs.Preferences +import okhttp3.Credentials +import okhttp3.Response +import okhttp3.Route class NetworkHelper( context: Context ) { + init { + setupSocks5Proxy() + } + +private fun setupSocks5Proxy() { + val proxyEnabled = PrefManager.getVal(PrefName.EnableSocks5Proxy) + if (proxyEnabled) { + val proxyHost = PrefManager.getVal(PrefName.Socks5ProxyHost) + val proxyPort = PrefManager.getVal(PrefName.Socks5ProxyPort) + + System.setProperty("socksProxyHost", proxyHost) + System.setProperty("socksProxyPort", proxyPort) + + if (PrefManager.getVal(PrefName.ProxyAuthEnabled)) { + val proxyUsername = PrefManager.getVal(PrefName.Socks5ProxyUsername) + val proxyPassword = PrefManager.getVal(PrefName.Socks5ProxyPassword) + + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication(proxyUsername, proxyPassword.toCharArray()) + } + } + ) + } + } else { + System.clearProperty("socksProxyHost") + System.clearProperty("socksProxyPort") + Authenticator.setDefault(null) + } + } + val cookieJar = AndroidCookieJar() val client: OkHttpClient = run { @@ -35,12 +76,19 @@ class NetworkHelper( ), ) .addInterceptor(UncaughtExceptionInterceptor()) - .addInterceptor(BrotliInterceptor) .addInterceptor(UserAgentInterceptor(::defaultUserAgentProvider)) + .addNetworkInterceptor(IgnoreGzipInterceptor()) + .addNetworkInterceptor(BrotliInterceptor) - if (PrefManager.getVal(PrefName.VerboseLogging)) { - val httpLoggingInterceptor = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.HEADERS + class ConsoleLogger : HttpLoggingInterceptor.Logger { + override fun log(message: String) { + Logger.log("OkHttp: $message") + } + } + + if (PrefManager.getVal(PrefName.VerboseLogging)) { + val httpLoggingInterceptor = HttpLoggingInterceptor(ConsoleLogger()).apply { + level = HttpLoggingInterceptor.Level.BASIC } builder.addNetworkInterceptor(httpLoggingInterceptor) } @@ -64,9 +112,8 @@ class NetworkHelper( PREF_DOH_SHECAN -> builder.dohShecan() PREF_DOH_LIBREDNS -> builder.dohLibreDNS() } - - builder.build() - } + builder.build() + } val downloadClient = client.newBuilder().callTimeout(20, TimeUnit.MINUTES).build() @@ -92,4 +139,4 @@ class NetworkHelper( companion object { fun defaultUserAgentProvider() = PrefManager.getVal(PrefName.DefaultUserAgent) } -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/IgnoreGzipInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/IgnoreGzipInterceptor.kt new file mode 100644 index 00000000..f1331a57 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/IgnoreGzipInterceptor.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.network.interceptor + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * To use [okhttp3.brotli.BrotliInterceptor] as a network interceptor, + * add [IgnoreGzipInterceptor] right before it. + * + * This nullifies the transparent gzip of [okhttp3.internal.http.BridgeInterceptor] + * so gzip and Brotli are explicitly handled by the [okhttp3.brotli.BrotliInterceptor]. + */ +class IgnoreGzipInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + if (request.header("Accept-Encoding") == "gzip") { + request = request.newBuilder().removeHeader("Accept-Encoding").build() + } + return chain.proceed(request) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/UncaughtExceptionInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/UncaughtExceptionInterceptor.kt index dccfd469..44fa9a5c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/UncaughtExceptionInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/UncaughtExceptionInterceptor.kt @@ -23,6 +23,7 @@ class UncaughtExceptionInterceptor : Interceptor { Logger.log(e) throw IOException("Request timed out") // there's some odd behavior throwing a SocketTimeoutException } catch (e: Exception) { + Logger.log(e) if (e is IOException) { throw e } else { diff --git a/app/src/main/res/drawable/format_align_center_24.xml b/app/src/main/res/drawable/format_align_center_24.xml new file mode 100644 index 00000000..c174a0ea --- /dev/null +++ b/app/src/main/res/drawable/format_align_center_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_bold_24.xml b/app/src/main/res/drawable/format_bold_24.xml new file mode 100644 index 00000000..c640aaaa --- /dev/null +++ b/app/src/main/res/drawable/format_bold_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_code_24.xml b/app/src/main/res/drawable/format_code_24.xml new file mode 100644 index 00000000..61eaae2d --- /dev/null +++ b/app/src/main/res/drawable/format_code_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_image_24.xml b/app/src/main/res/drawable/format_image_24.xml new file mode 100644 index 00000000..f6cc30a4 --- /dev/null +++ b/app/src/main/res/drawable/format_image_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_italic_24.xml b/app/src/main/res/drawable/format_italic_24.xml new file mode 100644 index 00000000..7f9ea8f9 --- /dev/null +++ b/app/src/main/res/drawable/format_italic_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_link_24.xml b/app/src/main/res/drawable/format_link_24.xml new file mode 100644 index 00000000..c8c0c1b6 --- /dev/null +++ b/app/src/main/res/drawable/format_link_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_list_bulleted_24.xml b/app/src/main/res/drawable/format_list_bulleted_24.xml new file mode 100644 index 00000000..d2558d85 --- /dev/null +++ b/app/src/main/res/drawable/format_list_bulleted_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/format_list_numbered_24.xml b/app/src/main/res/drawable/format_list_numbered_24.xml new file mode 100644 index 00000000..bc344d32 --- /dev/null +++ b/app/src/main/res/drawable/format_list_numbered_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_quote_24.xml b/app/src/main/res/drawable/format_quote_24.xml new file mode 100644 index 00000000..74ecc150 --- /dev/null +++ b/app/src/main/res/drawable/format_quote_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_spoiler_24.xml b/app/src/main/res/drawable/format_spoiler_24.xml new file mode 100644 index 00000000..647711b4 --- /dev/null +++ b/app/src/main/res/drawable/format_spoiler_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_strikethrough_24.xml b/app/src/main/res/drawable/format_strikethrough_24.xml new file mode 100644 index 00000000..a7556219 --- /dev/null +++ b/app/src/main/res/drawable/format_strikethrough_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_title_24.xml b/app/src/main/res/drawable/format_title_24.xml new file mode 100644 index 00000000..4b15683f --- /dev/null +++ b/app/src/main/res/drawable/format_title_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_video_24.xml b/app/src/main/res/drawable/format_video_24.xml new file mode 100644 index 00000000..9813485d --- /dev/null +++ b/app/src/main/res/drawable/format_video_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/format_youtube_24.xml b/app/src/main/res/drawable/format_youtube_24.xml new file mode 100644 index 00000000..909e5a21 --- /dev/null +++ b/app/src/main/res/drawable/format_youtube_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..0b9c4378 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_history_24.xml b/app/src/main/res/drawable/ic_round_history_24.xml new file mode 100644 index 00000000..1d5164d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_history_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_library_books_24.xml b/app/src/main/res/drawable/ic_round_library_books_24.xml new file mode 100644 index 00000000..ee9f6511 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_library_books_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_round_lock_24.xml b/app/src/main/res/drawable/ic_round_lock_24.xml index 68cb9c1f..87281bf0 100644 --- a/app/src/main/res/drawable/ic_round_lock_24.xml +++ b/app/src/main/res/drawable/ic_round_lock_24.xml @@ -1,6 +1,7 @@ + + diff --git a/app/src/main/res/drawable/lan_24.xml b/app/src/main/res/drawable/lan_24.xml new file mode 100644 index 00000000..dc99da69 --- /dev/null +++ b/app/src/main/res/drawable/lan_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/network_node_24.xml b/app/src/main/res/drawable/network_node_24.xml new file mode 100644 index 00000000..267173b5 --- /dev/null +++ b/app/src/main/res/drawable/network_node_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/swap_horizontal_circle_24.xml b/app/src/main/res/drawable/swap_horizontal_circle_24.xml new file mode 100644 index 00000000..15a2fc5b --- /dev/null +++ b/app/src/main/res/drawable/swap_horizontal_circle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/vpn_key_24.xml b/app/src/main/res/drawable/vpn_key_24.xml new file mode 100644 index 00000000..cef95237 --- /dev/null +++ b/app/src/main/res/drawable/vpn_key_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout-land/activity_calc.xml b/app/src/main/res/layout-land/activity_calc.xml new file mode 100644 index 00000000..de7d9d5f --- /dev/null +++ b/app/src/main/res/layout-land/activity_calc.xml @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + +