diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index a1277018..7165aeed 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -75,18 +75,21 @@ jobs: - 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: Upload a Build Artifact + - name: Upload Build Artifacts uses: actions/upload-artifact@v4.3.1 with: - name: Dantotsu - path: "app/build/outputs/apk/google/alpha/app-google-alpha.apk" + name: APKs + path: | + app/build/outputs/apk/google/alpha/*/*.apk + app/build/outputs/apk/google/alpha/*/*/*.apk + app/build/outputs/apk/google/alpha/*/*/*/*.apk - name: Upload APK to Discord and Telegram if: ${{ github.repository == 'rebelonion/Dantotsu' }} @@ -99,14 +102,34 @@ jobs: if [ ${#commit_messages} -gt $max_length ]; then commit_messages="${commit_messages:0:$max_length}... (truncated)" fi - contentbody=$( jq -nc --arg msg "Alpha-Build: <@714249925248024617> **$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 }} + 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-universal-alpha.apk" ${{ secrets.DISCORD_WEBHOOK }} + curl -F "dantotsu_debug=@app/build/outputs/apk/google/alpha/app-google-armeabi-v7a-alpha.apk" ${{ secrets.DISCORD_WEBHOOK }} + curl -F "dantotsu_debug=@app/build/outputs/apk/google/alpha/app-google-arm64-v8a-alpha.apk" ${{ secrets.DISCORD_WEBHOOK }} + curl -F "dantotsu_debug=@app/build/outputs/apk/google/alpha/app-google-x86-alpha.apk" ${{ secrets.DISCORD_WEBHOOK }} + curl -F "dantotsu_debug=@app/build/outputs/apk/google/alpha/app-google-x86_64-alpha.apk" ${{ secrets.DISCORD_WEBHOOK }} #Telegram curl -F "chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }}" \ - -F "document=@app/build/outputs/apk/google/alpha/app-google-alpha.apk" \ + -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 + curl -F "chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }}" \ + -F "document=@app/build/outputs/apk/google/alpha/app-google-armeabi-v7a-alpha.apk" \ + -F "caption=armeabi-v7a" \ + https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument + curl -F "chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }}" \ + -F "document=@app/build/outputs/apk/google/alpha/app-google-arm64-v8a-alpha.apk" \ + -F "caption=arm64-v8a" \ + https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument + curl -F "chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }}" \ + -F "document=@app/build/outputs/apk/google/alpha/app-google-x86-alpha.apk" \ + -F "caption=x86" \ + https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument + curl -F "chat_id=${{ secrets.TELEGRAM_CHANNEL_ID }}" \ + -F "document=@app/build/outputs/apk/google/alpha/app-google-x86_64-alpha.apk" \ + -F "caption=x86_64" \ + https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument env: COMMIT_LOG: ${{ env.COMMIT_LOG }} diff --git a/app/build.gradle b/app/build.gradle index b4ab706b..97c05e9b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,6 +6,10 @@ plugins { id 'com.google.devtools.ksp' } +def gitCommitHash = providers.exec { + commandLine("git", "rev-parse", "--verify", "--short", "HEAD") +}.standardOutput.asText.get().trim() + android { compileSdk 34 @@ -17,6 +21,14 @@ android { versionName "3.0.0" versionCode 300000000 signingConfig signingConfigs.debug + splits { + abi { + enable true + reset() + include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + universalApk true + } + } } flavorDimensions += "store" @@ -38,7 +50,7 @@ android { buildTypes { alpha { applicationIdSuffix ".beta" // keep as beta by popular request - versionNameSuffix "-alpha01" + versionNameSuffix "-alpha01-" + gitCommitHash manifestPlaceholders.icon_placeholder = "@mipmap/ic_launcher_alpha" manifestPlaceholders.icon_placeholder_round = "@mipmap/ic_launcher_alpha_round" debuggable System.getenv("CI") == null @@ -95,6 +107,7 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3' implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.webkit:webkit:1.10.0' + implementation "com.anggrayudi:storage:1.5.5" // Glide ext.glide_version = '4.16.0' @@ -145,6 +158,9 @@ dependencies { // String Matching implementation 'me.xdrop:fuzzywuzzy:1.4.0' + implementation group: 'com.arthenica', name: 'ffmpeg-kit-full-gpl', version: '6.0-2.LTS' + //implementation 'com.github.yausername.youtubedl-android:library:0.15.0' + // Aniyomi implementation 'io.reactivex:rxjava:1.3.8' implementation 'io.reactivex:rxandroid:1.2.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fdab5a4c..934bd7b3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -73,12 +73,24 @@ android:resource="@xml/upcoming_widget_info" /> + + + + + + + + + + + + + + - - - - - - - = Build.VERSION_CODES.P && statusBarHeight == 0 && a.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && statusBarHeight == 0 + && a.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { window.decorView.rootWindowInsets?.displayCutout?.apply { if (boundingRects.size > 0) { statusBarHeight = min(boundingRects[0].width(), boundingRects[0].height()) @@ -619,9 +625,14 @@ fun ImageView.loadImage(file: FileUrl?, size: Int = 0) { file?.url = PrefManager.getVal(PrefName.ImageUrl).ifEmpty { file?.url ?: "" } if (file?.url?.isNotEmpty() == true) { tryWith { - val glideUrl = GlideUrl(file.url) { file.headers } - Glide.with(this.context).load(glideUrl).transition(withCrossFade()).override(size) - .into(this) + if (file.url.startsWith("content://")) { + Glide.with(this.context).load(Uri.parse(file.url)).transition(withCrossFade()) + .override(size).into(this) + } else { + val glideUrl = GlideUrl(file.url) { file.headers } + Glide.with(this.context).load(glideUrl).transition(withCrossFade()).override(size) + .into(this) + } } } } @@ -876,31 +887,6 @@ fun savePrefs( } } -fun downloadsPermission(activity: AppCompatActivity): Boolean { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true - val permissions = arrayOf( - Manifest.permission.WRITE_EXTERNAL_STORAGE, - Manifest.permission.READ_EXTERNAL_STORAGE - ) - - val requiredPermissions = permissions.filter { - ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED - }.toTypedArray() - - return if (requiredPermissions.isNotEmpty()) { - ActivityCompat.requestPermissions( - activity, - requiredPermissions, - DOWNLOADS_PERMISSION_REQUEST_CODE - ) - false - } else { - true - } -} - -private const val DOWNLOADS_PERMISSION_REQUEST_CODE = 100 - fun shareImage(title: String, bitmap: Bitmap, context: Context) { val contentUri = FileProvider.getUriForFile( @@ -1004,6 +990,54 @@ fun countDown(media: Media, view: ViewGroup) { } } +fun sinceWhen(media: Media, view: ViewGroup) { + CoroutineScope(Dispatchers.IO).launch { + MangaUpdates().search(media.name ?: media.nameRomaji, media.startDate)?.let { + val latestChapter = it.metadata.series.latestChapter ?: it.record.chapter?.let { chapter -> + if (chapter.contains("-")) + chapter.split("-")[1].trim() + else + chapter + }?.toInt() + 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 + } +} + fun MutableMap.checkId(id: Int): Boolean { this.forEach { if (it.value.id == id) { diff --git a/app/src/main/java/ani/dantotsu/MainActivity.kt b/app/src/main/java/ani/dantotsu/MainActivity.kt index 8a45b6e3..08f6aac6 100644 --- a/app/src/main/java/ani/dantotsu/MainActivity.kt +++ b/app/src/main/java/ani/dantotsu/MainActivity.kt @@ -3,6 +3,7 @@ package ani.dantotsu import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.app.AlertDialog +import android.content.Context import android.content.Intent import android.content.res.Configuration import android.graphics.drawable.Animatable @@ -18,8 +19,8 @@ import android.view.View import android.view.ViewGroup import android.view.animation.AnticipateInterpolator import android.widget.TextView -import android.widget.Toast import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity @@ -54,6 +55,7 @@ import ani.dantotsu.others.CustomBottomDialog import ani.dantotsu.profile.ProfileActivity import ani.dantotsu.profile.activity.FeedActivity import ani.dantotsu.profile.activity.NotificationActivity +import ani.dantotsu.settings.ExtensionsActivity import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefManager.asLiveBool import ani.dantotsu.settings.saving.PrefName @@ -228,17 +230,6 @@ class MainActivity : AppCompatActivity() { } } - val preferences: SourcePreferences = Injekt.get() - if (preferences.animeExtensionUpdatesCount() - .get() > 0 || preferences.mangaExtensionUpdatesCount().get() > 0 - ) { - Toast.makeText( - this, - "You have extension updates available!", - Toast.LENGTH_LONG - ).show() - } - binding.root.isMotionEventSplittingEnabled = false lifecycleScope.launch { @@ -282,6 +273,16 @@ class MainActivity : AppCompatActivity() { binding.root.doOnAttach { initActivity(this) + val preferences: SourcePreferences = Injekt.get() + if (preferences.animeExtensionUpdatesCount() + .get() > 0 || preferences.mangaExtensionUpdatesCount().get() > 0 + ) { + snackString(R.string.extension_updates_available) + ?.setDuration(Snackbar.LENGTH_LONG) + ?.setAction(R.string.review) { + startActivity(Intent(this, ExtensionsActivity::class.java)) + } + } window.navigationBarColor = ContextCompat.getColor(this, android.R.color.transparent) selectedOption = if (fragment != null) { when (fragment) { @@ -448,7 +449,7 @@ class MainActivity : AppCompatActivity() { } } } - lifecycleScope.launch(Dispatchers.IO) { //simple cleanup + /*lifecycleScope.launch(Dispatchers.IO) { //simple cleanup val index = Helper.downloadManager(this@MainActivity).downloadIndex val downloadCursor = index.getDownloads() while (downloadCursor.moveToNext()) { @@ -457,7 +458,7 @@ class MainActivity : AppCompatActivity() { Helper.downloadManager(this@MainActivity).removeDownload(download.request.id) } } - } + }*/ //TODO: remove this } override fun onRestart() { @@ -482,7 +483,7 @@ class MainActivity : AppCompatActivity() { 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" + subtitleTextView?.text = getString(R.string.enter_password_to_decrypt_file) val dialog = AlertDialog.Builder(this, R.style.MyPopup) .setTitle("Enter Password") diff --git a/app/src/main/java/ani/dantotsu/Network.kt b/app/src/main/java/ani/dantotsu/Network.kt index 91840b3e..1c26b3bb 100644 --- a/app/src/main/java/ani/dantotsu/Network.kt +++ b/app/src/main/java/ani/dantotsu/Network.kt @@ -9,6 +9,7 @@ import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ResponseParser import com.lagradost.nicehttp.addGenericDns import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.NetworkHelper.Companion.defaultUserAgentProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -40,7 +41,7 @@ fun initializeNetwork() { defaultHeaders = mapOf( "User-Agent" to - Injekt.get().defaultUserAgentProvider() + defaultUserAgentProvider() .format(Build.VERSION.RELEASE, Build.MODEL) ) 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 804343f6..65fbb3cd 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/Anilist.kt @@ -7,6 +7,7 @@ import android.util.Log import androidx.browser.customtabs.CustomTabsIntent import ani.dantotsu.R import ani.dantotsu.client +import ani.dantotsu.connections.comments.CommentsAPI import ani.dantotsu.currContext import ani.dantotsu.openLinkInBrowser import ani.dantotsu.settings.saving.PrefManager @@ -40,20 +41,54 @@ object Anilist { "SCORE_DESC", "POPULARITY_DESC", "TRENDING_DESC", + "START_DATE_DESC", "TITLE_ENGLISH", "TITLE_ENGLISH_DESC", "SCORE" ) + val source = listOf( + "ORIGINAL", + "MANGA", + "LIGHT NOVEL", + "VISUAL NOVEL", + "VIDEO GAME", + "OTHER", + "NOVEL", + "DOUJINSHI", + "ANIME", + "WEB NOVEL", + "LIVE ACTION", + "GAME", + "COMIC", + "MULTIMEDIA PROJECT", + "PICTURE BOOK" + ) + + val animeStatus = listOf( + "FINISHED", + "RELEASING", + "NOT YET RELEASED", + "CANCELLED" + ) + + val mangaStatus = listOf( + "FINISHED", + "RELEASING", + "NOT YET RELEASED", + "HIATUS", + "CANCELLED" + ) + val seasons = listOf( "WINTER", "SPRING", "SUMMER", "FALL" ) - val anime_formats = listOf( + val animeFormats = listOf( "TV", "TV SHORT", "MOVIE", "SPECIAL", "OVA", "ONA", "MUSIC" ) - val manga_formats = listOf( + val mangaFormats = listOf( "MANGA", "NOVEL", "ONE SHOT" ) @@ -117,6 +152,9 @@ object Anilist { episodesWatched = null chapterRead = null PrefManager.removeVal(PrefName.AnilistToken) + //logout from comments api + CommentsAPI.logout() + } suspend inline fun executeQuery( 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 4e02d228..fa4a3090 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt @@ -72,7 +72,7 @@ class AnilistQueries { media.cameFromContinue = false val query = - """{Media(id:${media.id}){id favourites popularity mediaListEntry{id status score(format:POINT_100)progress private notes repeat customLists updatedAt startedAt{year month day}completedAt{year month day}}isFavourite siteUrl idMal nextAiringEpisode{episode airingAt}source countryOfOrigin format duration season seasonYear startDate{year month day}endDate{year month day}genres studios(isMain:true){nodes{id name siteUrl}}description trailer{site id}synonyms tags{name rank isMediaSpoiler}characters(sort:[ROLE,FAVOURITES_DESC],perPage:25,page:1){edges{role node{id image{medium}name{userPreferred}isFavourite}}}relations{edges{relationType(version:2)node{id idMal mediaListEntry{progress private score(format:POINT_100)status}episodes chapters nextAiringEpisode{episode}popularity meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}staffPreview:staff(perPage:8,sort:[RELEVANCE,ID]){edges{role node{id image{large medium}name{userPreferred}}}}recommendations(sort:RATING_DESC){nodes{mediaRecommendation{id idMal mediaListEntry{progress private score(format:POINT_100)status}episodes chapters nextAiringEpisode{episode}meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}externalLinks{url site}}}""" + """{Media(id:${media.id}){id favourites popularity mediaListEntry{id status score(format:POINT_100)progress private notes repeat customLists updatedAt startedAt{year month day}completedAt{year month day}}isFavourite siteUrl idMal nextAiringEpisode{episode airingAt}source countryOfOrigin format duration season seasonYear startDate{year month day}endDate{year month day}genres studios(isMain:true){nodes{id name siteUrl}}description trailer{site id}synonyms tags{name rank isMediaSpoiler}characters(sort:[ROLE,FAVOURITES_DESC],perPage:25,page:1){edges{role 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}}}""" runBlocking { val anilist = async { var response = executeQuery(query, force = true, show = true) @@ -139,7 +139,15 @@ class AnilistQueries { ?: "SUPPORTING" else -> i.role.toString() - } + }, + voiceActor = i.voiceActors?.map { + Author( + id = it.id, + name = it.name?.userPreferred, + image = it.image?.medium, + role = it.languageV2 + ) + } as ArrayList ) ) } @@ -885,18 +893,23 @@ class AnilistQueries { sort: String? = null, genres: MutableList? = null, tags: MutableList? = null, + status: String? = null, + source: String? = null, format: String? = null, + countryOfOrigin: String? = null, isAdult: Boolean = false, onList: Boolean? = null, excludedGenres: MutableList? = null, excludedTags: MutableList? = null, + startYear: Int? = null, seasonYear: Int? = null, season: String? = null, id: Int? = null, hd: Boolean = false, + adultOnly: Boolean = false ): SearchResults? { val query = """ -query (${"$"}page: Int = 1, ${"$"}id: Int, ${"$"}type: MediaType, ${"$"}isAdult: Boolean = false, ${"$"}search: String, ${"$"}format: [MediaFormat], ${"$"}status: MediaStatus, ${"$"}countryOfOrigin: CountryCode, ${"$"}source: MediaSource, ${"$"}season: MediaSeason, ${"$"}seasonYear: Int, ${"$"}year: String, ${"$"}onList: Boolean, ${"$"}yearLesser: FuzzyDateInt, ${"$"}yearGreater: FuzzyDateInt, ${"$"}episodeLesser: Int, ${"$"}episodeGreater: Int, ${"$"}durationLesser: Int, ${"$"}durationGreater: Int, ${"$"}chapterLesser: Int, ${"$"}chapterGreater: Int, ${"$"}volumeLesser: Int, ${"$"}volumeGreater: Int, ${"$"}licensedBy: [String], ${"$"}isLicensed: Boolean, ${"$"}genres: [String], ${"$"}excludedGenres: [String], ${"$"}tags: [String], ${"$"}excludedTags: [String], ${"$"}minimumTagRank: Int, ${"$"}sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC]) { +query (${"$"}page: Int = 1, ${"$"}id: Int, ${"$"}type: MediaType, ${"$"}isAdult: Boolean = false, ${"$"}search: String, ${"$"}format: [MediaFormat], ${"$"}status: MediaStatus, ${"$"}countryOfOrigin: CountryCode, ${"$"}source: MediaSource, ${"$"}season: MediaSeason, ${"$"}seasonYear: Int, ${"$"}year: String, ${"$"}onList: Boolean, ${"$"}yearLesser: FuzzyDateInt, ${"$"}yearGreater: FuzzyDateInt, ${"$"}episodeLesser: Int, ${"$"}episodeGreater: Int, ${"$"}durationLesser: Int, ${"$"}durationGreater: Int, ${"$"}chapterLesser: Int, ${"$"}chapterGreater: Int, ${"$"}volumeLesser: Int, ${"$"}volumeGreater: Int, ${"$"}licensedBy: [String], ${"$"}isLicensed: Boolean, ${"$"}genres: [String], ${"$"}excludedGenres: [String], ${"$"}tags: [String], ${"$"}excludedTags: [String], ${"$"}minimumTagRank: Int, ${"$"}sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC, START_DATE_DESC]) { Page(page: ${"$"}page, perPage: ${perPage ?: 50}) { pageInfo { total @@ -941,14 +954,19 @@ query (${"$"}page: Int = 1, ${"$"}id: Int, ${"$"}type: MediaType, ${"$"}isAdult: } """.replace("\n", " ").replace(""" """, "") val variables = """{"type":"$type","isAdult":$isAdult + ${if (adultOnly) ""","isAdult":true""" else ""} ${if (onList != null) ""","onList":$onList""" else ""} ${if (page != null) ""","page":"$page"""" else ""} ${if (id != null) ""","id":"$id"""" else ""} - ${if (seasonYear != null) ""","seasonYear":"$seasonYear"""" else ""} + ${if (type == "ANIME" && seasonYear != null) ""","seasonYear":"$seasonYear"""" else ""} + ${if (type == "MANGA" && startYear != null) ""","yearGreater":${startYear}0000,"yearLesser":${startYear + 1}0000""" else ""} ${if (season != null) ""","season":"$season"""" else ""} ${if (search != null) ""","search":"$search"""" else ""} + ${if (source != null) ""","source":"$source"""" else ""} ${if (sort != null) ""","sort":"$sort"""" else ""} + ${if (status != null) ""","status":"$status"""" else ""} ${if (format != null) ""","format":"${format.replace(" ", "_")}"""" else ""} + ${if (countryOfOrigin != null) ""","countryOfOrigin":"$countryOfOrigin"""" else ""} ${if (genres?.isNotEmpty() == true) ""","genres":[${genres.joinToString { "\"$it\"" }}]""" else ""} ${ if (excludedGenres?.isNotEmpty() == true) @@ -980,7 +998,6 @@ query (${"$"}page: Int = 1, ${"$"}id: Int, ${"$"}type: MediaType, ${"$"}isAdult: else "" } }""".replace("\n", " ").replace(""" """, "") - val response = executeQuery(query, variables, true)?.data?.page if (response?.media != null) { val responseArray = arrayListOf() @@ -1012,7 +1029,11 @@ query (${"$"}page: Int = 1, ${"$"}id: Int, ${"$"}type: MediaType, ${"$"}isAdult: excludedGenres = excludedGenres, tags = tags, excludedTags = excludedTags, + status = status, + source = source, format = format, + countryOfOrigin = countryOfOrigin, + startYear = startYear, seasonYear = seasonYear, season = season, results = responseArray, @@ -1022,9 +1043,60 @@ 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 recentAnimeUpdates(): String{ + return """Page(page:1,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(): String{ + return """Page(page:1,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(): String{ + return """Page(page:1,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(): String{ + return """Page(page:1,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(): Query.AnimeList?{ + return executeQuery( + """{ + recentUpdates:${recentAnimeUpdates()} + trendingMovies:${trendingMovies()} + topRated:${topRatedAnime()} + mostFav:${mostFavAnime()} + }""".trimIndent(), force = true + ) + } + private val onListManga = (if(PrefManager.getVal(PrefName.IncludeMangaList)) "" else "onList:false").replace("\"", "") + private fun trendingManga(): String{ + return """Page(page:1,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(): String{ + return """Page(page:1,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(): String{ + return """Page(page:1,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(): String{ + return """Page(page:1,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(): String{ + return """Page(page:1,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(): Query.MangaList?{ + return executeQuery( + """{ + trendingManga:${trendingManga()} + trendingManhwa:${trendingManhwa()} + trendingNovel:${trendingNovel()} + topRated:${topRatedManga()} + mostFav:${mostFavManga()} + }""".trimIndent(), force = true + ) + + } suspend fun recentlyUpdated( - smaller: Boolean = true, greater: Long = 0, lesser: Long = System.currentTimeMillis() / 1000 - 10000 ): MutableList? { @@ -1074,21 +1146,6 @@ Page(page:$page,perPage:50) { }""".replace("\n", " ").replace(""" """, "") return executeQuery(query, force = true)?.data?.page } - if (smaller) { - val response = execute()?.airingSchedules ?: return null - val idArr = mutableListOf() - val listOnly: Boolean = PrefManager.getVal(PrefName.RecentlyListOnly) - return response.mapNotNull { i -> - i.media?.let { - if (!idArr.contains(it.id)) - if (!listOnly && (it.countryOfOrigin == "JP" && (if (!Anilist.adult) it.isAdult == false else true)) || (listOnly && it.mediaListEntry != null)) { - idArr.add(it.id) - Media(it) - } else null - else null - } - }.toMutableList() - } else { var i = 1 val list = mutableListOf() var res: Page? = null @@ -1108,7 +1165,6 @@ Page(page:$page,perPage:50) { i++ } return list.reversed().toMutableList() - } } suspend fun getCharacterDetails(character: Character): Character { @@ -1331,7 +1387,52 @@ Page(page:$page,perPage:50) { author.yearMedia = yearMedia return author } + suspend fun getVoiceActorsDetails(author: Author): Author { + fun query(page: Int = 0) = """ { + Staff(id:${author.id}) { + id + characters(page: $page,sort:FAVOURITES_DESC) { + pageInfo{ + hasNextPage + } + nodes{ + id + name { + first + middle + last + full + native + userPreferred + } + image { + large + medium + } + + } + } + } +}""".replace("\n", " ").replace(""" """, "") + var hasNextPage = true + var page = 0 + val characters = arrayListOf() + while (hasNextPage) { + page++ + hasNextPage = executeQuery( + query(page), + force = true + )?.data?.author?.characters?.let { + it.nodes?.forEach { i -> + characters.add(Character(i.id, i.name?.userPreferred, i.image?.large, i.image?.medium, "", false)) + } + it.pageInfo?.hasNextPage == true + } ?: false + } + author.character = characters + return author + } suspend fun toggleFollow(id: Int): Query.ToggleFollow? { return executeQuery( """mutation{ToggleFollow(userId:$id){id, isFollowing, isFollower}}""" @@ -1394,15 +1495,10 @@ Page(page:$page,perPage:50) { """{ favoriteAnime:${userFavMediaQuery(true, 1, id)} favoriteManga:${userFavMediaQuery(false, 1, id)} - animeMediaList:${bannerImageQuery("ANIME", id)} - mangaMediaList:${bannerImageQuery("MANGA", id)} }""".trimIndent(), force = true ) } - private fun bannerImageQuery(type: String, id: Int?): String { - return """MediaListCollection(userId: ${id}, type: $type, chunk:1,perChunk:25, sort: [SCORE_DESC,UPDATED_TIME_DESC]) { lists { entries{ media { id bannerImage } } } }""" - } suspend fun getNotifications(id: Int, page: Int = 1, resetNotification: Boolean = true): NotificationResponse? { val reset = if (resetNotification) "true" else "false" 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 a4092b91..a802a164 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistViewModel.kt @@ -5,6 +5,8 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.webkit.internal.ApiFeature.P +import androidx.webkit.internal.StartupApiFeature import ani.dantotsu.BuildConfig import ani.dantotsu.R import ani.dantotsu.connections.discord.Discord @@ -58,45 +60,36 @@ class AnilistHomeViewModel : ViewModel() { MutableLiveData>(null) fun getAnimeContinue(): LiveData> = animeContinue - suspend fun setAnimeContinue() = animeContinue.postValue(Anilist.query.continueMedia("ANIME")) private val animeFav: MutableLiveData> = MutableLiveData>(null) fun getAnimeFav(): LiveData> = animeFav - suspend fun setAnimeFav() = animeFav.postValue(Anilist.query.favMedia(true)) private val animePlanned: MutableLiveData> = MutableLiveData>(null) fun getAnimePlanned(): LiveData> = animePlanned - suspend fun setAnimePlanned() = - animePlanned.postValue(Anilist.query.continueMedia("ANIME", true)) private val mangaContinue: MutableLiveData> = MutableLiveData>(null) fun getMangaContinue(): LiveData> = mangaContinue - suspend fun setMangaContinue() = mangaContinue.postValue(Anilist.query.continueMedia("MANGA")) private val mangaFav: MutableLiveData> = MutableLiveData>(null) fun getMangaFav(): LiveData> = mangaFav - suspend fun setMangaFav() = mangaFav.postValue(Anilist.query.favMedia(false)) private val mangaPlanned: MutableLiveData> = MutableLiveData>(null) fun getMangaPlanned(): LiveData> = mangaPlanned - suspend fun setMangaPlanned() = - mangaPlanned.postValue(Anilist.query.continueMedia("MANGA", true)) private val recommendation: MutableLiveData> = MutableLiveData>(null) fun getRecommendation(): LiveData> = recommendation - suspend fun setRecommendation() = recommendation.postValue(Anilist.query.recommendations()) suspend fun initHomePage() { val res = Anilist.query.initHomePage() @@ -144,18 +137,15 @@ class AnilistAnimeViewModel : ViewModel() { sort = Anilist.sortBy[2], season = season, seasonYear = year, - hd = true + hd = true, + adultOnly = PrefManager.getVal(PrefName.AdultOnly) )?.results ) } - private val updated: MutableLiveData> = - MutableLiveData>(null) - - fun getUpdated(): LiveData> = updated - suspend fun loadUpdated() = updated.postValue(Anilist.query.recentlyUpdated()) private val animePopular = MutableLiveData(null) + fun getPopular(): LiveData = animePopular suspend fun loadPopular( type: String, @@ -170,7 +160,8 @@ class AnilistAnimeViewModel : ViewModel() { search = searchVal, onList = if (onList) null else false, sort = sort, - genres = genres + genres = genres, + adultOnly = PrefManager.getVal(PrefName.AdultOnly) ) ) } @@ -185,13 +176,60 @@ class AnilistAnimeViewModel : ViewModel() { r.sort, r.genres, r.tags, + r.status, + r.source, r.format, + r.countryOfOrigin, r.isAdult, - r.onList + r.onList, + adultOnly = PrefManager.getVal(PrefName.AdultOnly), ) ) var loaded: Boolean = false + private val updated: MutableLiveData> = + MutableLiveData>(null) + fun getUpdated(): LiveData> = updated + + private val popularMovies: MutableLiveData> = + MutableLiveData>(null) + fun getMovies(): LiveData> = popularMovies + + private val topRatedAnime: MutableLiveData> = + MutableLiveData>(null) + fun getTopRated(): LiveData> = topRatedAnime + + private val mostFavAnime: MutableLiveData> = + MutableLiveData>(null) + fun getMostFav(): LiveData> = mostFavAnime + suspend fun loadAll() { + val res = Anilist.query.loadAnimeList()?.data + + val listOnly: Boolean = PrefManager.getVal(PrefName.RecentlyListOnly) + val adultOnly: Boolean = PrefManager.getVal(PrefName.AdultOnly) + res?.apply{ + val idArr = mutableListOf() + updated.postValue(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 + } + }?.toMutableList() ?: arrayListOf()) + popularMovies.postValue(trendingMovies?.media?.map { Media(it) }?.toMutableList() ?: arrayListOf()) + topRatedAnime.postValue(topRated?.media?.map { Media(it) }?.toMutableList() ?: arrayListOf()) + mostFavAnime.postValue(mostFav?.media?.map { Media(it) }?.toMutableList() ?: arrayListOf()) + } + } } class AnilistMangaViewModel : ViewModel() { @@ -209,23 +247,11 @@ class AnilistMangaViewModel : ViewModel() { type, perPage = 10, sort = Anilist.sortBy[2], - hd = true + hd = true, + adultOnly = PrefManager.getVal(PrefName.AdultOnly) )?.results ) - private val updated: MutableLiveData> = - MutableLiveData>(null) - - fun getTrendingNovel(): LiveData> = updated - suspend fun loadTrendingNovel() = - updated.postValue( - Anilist.query.search( - type, - perPage = 10, - sort = Anilist.sortBy[2], - format = "NOVEL" - )?.results - ) private val mangaPopular = MutableLiveData(null) fun getPopular(): LiveData = mangaPopular @@ -242,7 +268,8 @@ class AnilistMangaViewModel : ViewModel() { search = searchVal, onList = if (onList) null else false, sort = sort, - genres = genres + genres = genres, + adultOnly = PrefManager.getVal(PrefName.AdultOnly) ) ) } @@ -257,17 +284,53 @@ class AnilistMangaViewModel : ViewModel() { r.sort, r.genres, r.tags, + r.status, + r.source, r.format, + r.countryOfOrigin, r.isAdult, r.onList, r.excludedGenres, r.excludedTags, + r.startYear, r.seasonYear, - r.season + r.season, + adultOnly = PrefManager.getVal(PrefName.AdultOnly) ) ) var loaded: Boolean = false + + private val popularManga: MutableLiveData> = + MutableLiveData>(null) + fun getPopularManga(): LiveData> = popularManga + + private val popularManhwa: MutableLiveData> = + MutableLiveData>(null) + fun getPopularManhwa(): LiveData> = popularManhwa + + private val popularNovel: MutableLiveData> = + MutableLiveData>(null) + fun getPopularNovel(): LiveData> = popularNovel + + private val topRatedManga: MutableLiveData> = + MutableLiveData>(null) + fun getTopRated(): LiveData> = topRatedManga + + private val mostFavManga: MutableLiveData> = + MutableLiveData>(null) + fun getMostFav(): LiveData> = mostFavManga + suspend fun loadAll() { + val response = Anilist.query.loadMangaList()?.data + + response?.apply { + popularManga.postValue(trendingManga?.media?.map { Media(it) }?.toMutableList() ?: arrayListOf()) + popularManhwa.postValue(trendingManhwa?.media?.map { Media(it) }?.toMutableList() ?: arrayListOf()) + popularNovel.postValue(trendingNovel?.media?.map { Media(it) }?.toMutableList() ?: arrayListOf()) + topRatedManga.postValue(topRated?.media?.map { Media(it) }?.toMutableList() ?: arrayListOf()) + mostFavManga.postValue(mostFav?.media?.map { Media(it) }?.toMutableList() ?: arrayListOf()) + } + } } class AnilistSearch : ViewModel() { @@ -286,13 +349,17 @@ class AnilistSearch : ViewModel() { r.sort, r.genres, r.tags, + r.status, + r.source, r.format, + r.countryOfOrigin, r.isAdult, r.onList, r.excludedGenres, r.excludedTags, + r.startYear, r.seasonYear, - r.season + r.season, ) ) @@ -305,11 +372,15 @@ class AnilistSearch : ViewModel() { r.sort, r.genres, r.tags, + r.status, + r.source, r.format, + r.countryOfOrigin, r.isAdult, r.onList, r.excludedGenres, r.excludedTags, + r.startYear, r.seasonYear, r.season ) @@ -347,11 +418,6 @@ class ProfileViewModel : ViewModel() { fun getAnimeFav(): LiveData> = animeFav - private val listImages: MutableLiveData> = - MutableLiveData>(arrayListOf()) - - fun getListImages(): LiveData> = listImages - suspend fun setData(id: Int) { val res = Anilist.query.initProfilePage(id) val mangaList = res?.data?.favoriteManga?.favourites?.manga?.edges?.mapNotNull { @@ -367,30 +433,11 @@ class ProfileViewModel : ViewModel() { } animeFav.postValue(ArrayList(animeList ?: arrayListOf())) - val bannerImages = arrayListOf(null, null) - val animeRandom = res?.data?.animeMediaList?.lists?.mapNotNull { - it.entries?.mapNotNull { entry -> - val imageUrl = entry.media?.bannerImage - if (imageUrl != null && imageUrl != "null") imageUrl - else null - } - }?.flatten()?.randomOrNull() - bannerImages[0] = animeRandom - val mangaRandom = res?.data?.mangaMediaList?.lists?.mapNotNull { - it.entries?.mapNotNull { entry -> - val imageUrl = entry.media?.bannerImage - if (imageUrl != null && imageUrl != "null") imageUrl - else null - } - }?.flatten()?.randomOrNull() - bannerImages[1] = mangaRandom - listImages.postValue(bannerImages) - } fun refresh() { mangaFav.postValue(mangaFav.value) animeFav.postValue(animeFav.value) - listImages.postValue(listImages.value) + } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/SearchResults.kt b/app/src/main/java/ani/dantotsu/connections/anilist/SearchResults.kt index 32bc1757..8458cc7e 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/SearchResults.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/SearchResults.kt @@ -11,13 +11,17 @@ data class SearchResults( var onList: Boolean? = null, var perPage: Int? = null, var search: String? = null, + var countryOfOrigin :String? = null, var sort: String? = null, var genres: MutableList? = null, var excludedGenres: MutableList? = null, var tags: MutableList? = null, var excludedTags: MutableList? = null, + var status: String? = null, + var source: String? = null, var format: String? = null, var seasonYear: Int? = null, + var startYear: Int? = null, var season: String? = null, var page: Int = 1, var results: MutableList, @@ -37,12 +41,24 @@ data class SearchResults( ) ) } + status?.let { + list.add(SearchChip("STATUS", currContext()!!.getString(R.string.filter_status, it))) + } + source?.let { + list.add(SearchChip("SOURCE", currContext()!!.getString(R.string.filter_source, it))) + } format?.let { list.add(SearchChip("FORMAT", currContext()!!.getString(R.string.filter_format, it))) } + countryOfOrigin?.let { + list.add(SearchChip("COUNTRY", currContext()!!.getString(R.string.filter_country, it))) + } season?.let { list.add(SearchChip("SEASON", it)) } + startYear?.let { + list.add(SearchChip("START_YEAR", it.toString())) + } seasonYear?.let { list.add(SearchChip("SEASON_YEAR", it.toString())) } @@ -74,8 +90,12 @@ data class SearchResults( fun removeChip(chip: SearchChip) { when (chip.type) { "SORT" -> sort = null + "STATUS" -> status = null + "SOURCE" -> source = null "FORMAT" -> format = null + "COUNTRY" -> countryOfOrigin = null "SEASON" -> season = null + "START_YEAR" -> startYear = null "SEASON_YEAR" -> seasonYear = null "GENRE" -> genres?.remove(chip.text) "EXCLUDED_GENRE" -> excludedGenres?.remove(chip.text) diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/api/Character.kt b/app/src/main/java/ani/dantotsu/connections/anilist/api/Character.kt index e0539085..ec5a9ef4 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/api/Character.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/api/Character.kt @@ -55,7 +55,7 @@ data class CharacterConnection( @SerialName("nodes") var nodes: List?, // The pagination information - // @SerialName("pageInfo") var pageInfo: PageInfo?, + @SerialName("pageInfo") var pageInfo: PageInfo?, ) : java.io.Serializable @Serializable @@ -72,7 +72,7 @@ data class CharacterEdge( @SerialName("name") var name: String?, // The voice actors of the character - // @SerialName("voiceActors") var voiceActors: List?, + @SerialName("voiceActors") var voiceActors: List?, // The voice actors of the character with role date // @SerialName("voiceActorRoles") var voiceActorRoles: List?, 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 58445406..20f35035 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 @@ -147,12 +147,35 @@ class Query { @Serializable data class Data( @SerialName("favoriteAnime") val favoriteAnime: ani.dantotsu.connections.anilist.api.User?, - @SerialName("favoriteManga") val favoriteManga: ani.dantotsu.connections.anilist.api.User?, - @SerialName("animeMediaList") val animeMediaList: ani.dantotsu.connections.anilist.api.MediaListCollection?, - @SerialName("mangaMediaList") val mangaMediaList: ani.dantotsu.connections.anilist.api.MediaListCollection? + @SerialName("favoriteManga") val favoriteManga: ani.dantotsu.connections.anilist.api.User?) + } + @Serializable + data class AnimeList( + @SerialName("data") + val data: Data? + ) { + @Serializable + data class Data( + @SerialName("recentUpdates") val recentUpdates: ani.dantotsu.connections.anilist.api.Page?, + @SerialName("trendingMovies") val trendingMovies: ani.dantotsu.connections.anilist.api.Page?, + @SerialName("topRated") val topRated: ani.dantotsu.connections.anilist.api.Page?, + @SerialName("mostFav") val mostFav: ani.dantotsu.connections.anilist.api.Page?, + ) + } + @Serializable + data class MangaList( + @SerialName("data") + val data: Data? + ) { + @Serializable + data class Data( + @SerialName("trendingManga") val trendingManga: ani.dantotsu.connections.anilist.api.Page?, + @SerialName("trendingManhwa") val trendingManhwa: ani.dantotsu.connections.anilist.api.Page?, + @SerialName("trendingNovel") val trendingNovel: ani.dantotsu.connections.anilist.api.Page?, + @SerialName("topRated") val topRated: ani.dantotsu.connections.anilist.api.Page?, + @SerialName("mostFav") val mostFav: ani.dantotsu.connections.anilist.api.Page?, ) } - @Serializable data class ToggleFollow( @SerialName("data") diff --git a/app/src/main/java/ani/dantotsu/connections/bakaupdates/MangaUpdates.kt b/app/src/main/java/ani/dantotsu/connections/bakaupdates/MangaUpdates.kt new file mode 100644 index 00000000..7d811ba3 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/connections/bakaupdates/MangaUpdates.kt @@ -0,0 +1,98 @@ +package ani.dantotsu.connections.bakaupdates + +import ani.dantotsu.client +import ani.dantotsu.connections.anilist.api.FuzzyDate +import ani.dantotsu.tryWithSuspend +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 = client.post(apiUrl, json = query).parsed() + res.results?.forEach{ println("MangaUpdates: $it") } + res.results?.first { it.metadata.series.lastUpdated?.timestamp != null } + } + } + + @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 a114c1e5..4dd0e903 100644 --- a/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt +++ b/app/src/main/java/ani/dantotsu/connections/comments/CommentsAPI.kt @@ -24,7 +24,7 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get object CommentsAPI { - val address: String = "https://1224665.xyz:443" + private const val ADDRESS: String = "https://1224665.xyz:443" var authToken: String? = null var userId: String? = null var isBanned: Boolean = false @@ -33,7 +33,7 @@ object CommentsAPI { var totalVotes: Int = 0 suspend fun getCommentsForId(id: Int, page: Int = 1, tag: Int?, sort: String?): CommentResponse? { - var url = "$address/comments/$id/$page" + var url = "$ADDRESS/comments/$id/$page" val request = requestBuilder() tag?.let { url += "?tag=$it" @@ -61,7 +61,7 @@ object CommentsAPI { } suspend fun getRepliesFromId(id: Int, page: Int = 1): CommentResponse? { - val url = "$address/comments/parent/$id/$page" + val url = "$ADDRESS/comments/parent/$id/$page" val request = requestBuilder() val json = try { request.get(url) @@ -83,7 +83,7 @@ object CommentsAPI { } suspend fun getSingleComment(id: Int): Comment? { - val url = "$address/comments/$id" + val url = "$ADDRESS/comments/$id" val request = requestBuilder() val json = try { request.get(url) @@ -105,7 +105,7 @@ object CommentsAPI { } suspend fun vote(commentId: Int, voteType: Int): Boolean { - val url = "$address/comments/vote/$commentId/$voteType" + val url = "$ADDRESS/comments/vote/$commentId/$voteType" val request = requestBuilder() val json = try { request.post(url) @@ -121,7 +121,7 @@ object CommentsAPI { } suspend fun comment(mediaId: Int, parentCommentId: Int?, content: String, tag: Int?): Comment? { - val url = "$address/comments" + val url = "$ADDRESS/comments" val body = FormBody.Builder() .add("user_id", userId ?: return null) .add("media_id", mediaId.toString()) @@ -169,7 +169,7 @@ object CommentsAPI { } suspend fun deleteComment(commentId: Int): Boolean { - val url = "$address/comments/$commentId" + val url = "$ADDRESS/comments/$commentId" val request = requestBuilder() val json = try { request.delete(url) @@ -185,7 +185,7 @@ object CommentsAPI { } suspend fun editComment(commentId: Int, content: String): Boolean { - val url = "$address/comments/$commentId" + val url = "$ADDRESS/comments/$commentId" val body = FormBody.Builder() .add("content", content) .build() @@ -204,7 +204,7 @@ object CommentsAPI { } suspend fun banUser(userId: String): Boolean { - val url = "$address/ban/$userId" + val url = "$ADDRESS/ban/$userId" val request = requestBuilder() val json = try { request.post(url) @@ -225,7 +225,7 @@ object CommentsAPI { mediaTitle: String, reportedId: String ): Boolean { - val url = "$address/report/$commentId" + val url = "$ADDRESS/report/$commentId" val body = FormBody.Builder() .add("username", username) .add("mediaName", mediaTitle) @@ -247,7 +247,7 @@ object CommentsAPI { } suspend fun getNotifications(client: OkHttpClient): NotificationResponse? { - val url = "$address/notification/reply" + val url = "$ADDRESS/notification/reply" val request = requestBuilder(client) val json = try { request.get(url) @@ -268,7 +268,7 @@ object CommentsAPI { } private suspend fun getUserDetails(client: OkHttpClient? = null): User? { - val url = "$address/user" + val url = "$ADDRESS/user" val request = if (client != null) requestBuilder(client) else requestBuilder() val json = try { request.get(url) @@ -310,7 +310,7 @@ object CommentsAPI { } } - val url = "$address/authenticate" + val url = "$ADDRESS/authenticate" val token = PrefManager.getVal(PrefName.AnilistToken, null as String?) ?: return repeat(MAX_RETRIES) { try { @@ -348,6 +348,17 @@ object CommentsAPI { snackString("Failed to login after multiple attempts") } + fun logout() { + PrefManager.removeVal(PrefName.CommentAuthResponse) + PrefManager.removeVal(PrefName.CommentTokenExpiry) + authToken = null + userId = null + isBanned = false + isAdmin = false + isMod = false + totalVotes = 0 + } + private suspend fun authRequest( token: String, url: String, diff --git a/app/src/main/java/ani/dantotsu/connections/github/Contributors.kt b/app/src/main/java/ani/dantotsu/connections/github/Contributors.kt new file mode 100644 index 00000000..f796e38a --- /dev/null +++ b/app/src/main/java/ani/dantotsu/connections/github/Contributors.kt @@ -0,0 +1,84 @@ +package ani.dantotsu.connections.github + +import ani.dantotsu.Mapper +import ani.dantotsu.R +import ani.dantotsu.client +import ani.dantotsu.getAppString +import ani.dantotsu.settings.Developer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.decodeFromJsonElement + +class Contributors { + + fun getContributors(): Array { + var developers = arrayOf() + runBlocking(Dispatchers.IO) { + val repo = getAppString(R.string.repo) + val res = client.get("https://api.github.com/repos/$repo/contributors") + .parsed().map { + Mapper.json.decodeFromJsonElement(it) + } + res.find { it.login == "rebelonion"}?.let { first -> + developers = developers.plus( + Developer( + first.login, + first.avatarUrl, + "Owner and Maintainer", + first.htmlUrl + ) + ).plus(arrayOf( + Developer( + "Wai What", + "https://avatars.githubusercontent.com/u/149729762?v=4", + "Icon Designer", + "https://github.com/WaiWhat" + ), + Developer( + "MarshMeadow", + "https://avatars.githubusercontent.com/u/88599122?v=4", + "Beta Icon Designer", + "https://github.com/MarshMeadow?tab=repositories" + ), + Developer( + "Zaxx69", + "https://avatars.githubusercontent.com/u/138523882?v=4", + "Telegram Admin", + "https://github.com/Zaxx69" + ), + Developer( + "Arif Alam", + "https://avatars.githubusercontent.com/u/70383209?v=4", + "Head Discord Moderator", + "https://youtube.com/watch?v=dQw4w9WgXcQ" + ) + )) + } + res.filter {it.login != "rebelonion"}.forEach { + developers = developers.plus( + Developer( + it.login, + it.avatarUrl, + "Contributor", + it.htmlUrl + ) + ) + } + } + return developers + } + + + @Serializable + data class GithubResponse( + @SerialName("login") + val login: String, + @SerialName("avatar_url") + val avatarUrl: String, + @SerialName("html_url") + val htmlUrl: String + ) +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/github/Forks.kt b/app/src/main/java/ani/dantotsu/connections/github/Forks.kt new file mode 100644 index 00000000..a074eea0 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/connections/github/Forks.kt @@ -0,0 +1,55 @@ +package ani.dantotsu.connections.github + +import ani.dantotsu.Mapper +import ani.dantotsu.R +import ani.dantotsu.client +import ani.dantotsu.getAppString +import ani.dantotsu.settings.Developer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.decodeFromJsonElement + +class Forks { + + fun getForks(): Array { + var forks = arrayOf() + runBlocking(Dispatchers.IO) { + val res = client.get("https://api.github.com/repos/rebelonion/Dantotsu/forks") + .parsed().map { + Mapper.json.decodeFromJsonElement(it) + } + res.forEach { + forks = forks.plus( + Developer( + it.name, + it.owner.avatarUrl, + it.owner.login, + it.htmlUrl + ) + ) + } + } + return forks + } + + + @Serializable + data class GithubResponse( + @SerialName("name") + val name: String, + val owner: Owner, + @SerialName("html_url") + val htmlUrl: String, + ) { + @Serializable + data class Owner( + @SerialName("login") + val login: String, + @SerialName("avatar_url") + val avatarUrl: String + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt index 4eb7f5a3..fa01cc79 100644 --- a/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt +++ b/app/src/main/java/ani/dantotsu/download/DownloadsManager.kt @@ -1,14 +1,25 @@ package ani.dantotsu.download import android.content.Context -import android.os.Environment -import android.widget.Toast +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import ani.dantotsu.download.DownloadsManager.Companion.findValidName import ani.dantotsu.media.MediaType import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName +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.moveFileTo +import com.anggrayudi.storage.file.moveFolderTo import com.google.gson.Gson import com.google.gson.reflect.TypeToken -import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.Serializable class DownloadsManager(private val context: Context) { @@ -42,27 +53,29 @@ class DownloadsManager(private val context: Context) { saveDownloads() } - fun removeDownload(downloadedType: DownloadedType) { + fun removeDownload(downloadedType: DownloadedType, onFinished: () -> Unit) { downloadsList.remove(downloadedType) - removeDirectory(downloadedType) + CoroutineScope(Dispatchers.IO).launch { + removeDirectory(downloadedType) + withContext(Dispatchers.Main) { + onFinished() + } + } saveDownloads() } fun removeMedia(title: String, type: MediaType) { - val subDirectory = type.asText() - val directory = File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$subDirectory/$title" - ) - if (directory.exists()) { - val deleted = directory.deleteRecursively() + val baseDirectory = getBaseDirectory(context, type) + val directory = baseDirectory?.findFolder(title) + if (directory?.exists() == true) { + val deleted = directory.deleteRecursively(context, false) if (deleted) { - Toast.makeText(context, "Successfully deleted", Toast.LENGTH_SHORT).show() + snackString("Successfully deleted") } else { - Toast.makeText(context, "Failed to delete directory", Toast.LENGTH_SHORT).show() + snackString("Failed to delete directory") } } else { - Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() + snackString("Directory does not exist") cleanDownloads() } when (type) { @@ -89,23 +102,17 @@ class DownloadsManager(private val context: Context) { private fun cleanDownload(type: MediaType) { // remove all folders that are not in the downloads list - val subDirectory = type.asText() - val directory = File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$subDirectory" - ) + val directory = getBaseDirectory(context, type) val downloadsSubLists = when (type) { MediaType.MANGA -> mangaDownloadedTypes MediaType.ANIME -> animeDownloadedTypes else -> novelDownloadedTypes } - if (directory.exists()) { + if (directory?.exists() == true && directory.isDirectory) { val files = directory.listFiles() - if (files != null) { - for (file in files) { - if (!downloadsSubLists.any { it.title == file.name }) { - file.deleteRecursively() - } + for (file in files) { + if (!downloadsSubLists.any { it.title == file.name }) { + file.deleteRecursively(context, false) } } } @@ -113,27 +120,57 @@ class DownloadsManager(private val context: Context) { val iterator = downloadsList.iterator() while (iterator.hasNext()) { val download = iterator.next() - val downloadDir = File(directory, download.title) - if ((!downloadDir.exists() && download.type == type) || download.title.isBlank()) { + val downloadDir = directory?.findFolder(download.title) + if ((downloadDir?.exists() == false && download.type == type) || download.title.isBlank()) { iterator.remove() } } } - fun saveDownloadsListToJSONFileInDownloadsFolder(downloadsList: List) //for debugging - { - val jsonString = gson.toJson(downloadsList) - val file = File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/downloads.json" - ) - if (file.parentFile?.exists() == false) { - file.parentFile?.mkdirs() + fun moveDownloadsDir(context: Context, oldUri: Uri, newUri: Uri, finished: (Boolean, String) -> Unit) { + try { + if (oldUri == newUri) { + finished(false, "Source and destination are the same") + return + } + CoroutineScope(Dispatchers.IO).launch { + + val oldBase = + DocumentFile.fromTreeUri(context, oldUri) ?: throw Exception("Old base is null") + val newBase = + DocumentFile.fromTreeUri(context, newUri) ?: throw Exception("New base is null") + val folder = + oldBase.findFolder(BASE_LOCATION) ?: throw Exception("Base folder not found") + folder.moveFolderTo(context, newBase, false, BASE_LOCATION, object: + FolderCallback() { + override fun onFailed(errorCode: ErrorCode) { + when (errorCode) { + ErrorCode.CANCELED -> finished(false, "Move canceled") + ErrorCode.CANNOT_CREATE_FILE_IN_TARGET -> finished(false, "Cannot create file in target") + ErrorCode.INVALID_TARGET_FOLDER -> finished(true, "Invalid target folder") // seems to still work + ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH -> finished(false, "No space left on target path") + ErrorCode.UNKNOWN_IO_ERROR -> finished(false, "Unknown IO error") + ErrorCode.SOURCE_FOLDER_NOT_FOUND -> finished(false, "Source folder not found") + ErrorCode.STORAGE_PERMISSION_DENIED -> finished(false, "Storage permission denied") + ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER -> finished(false, "Target folder cannot have same path with source folder") + else -> finished(false, "Failed to move downloads: $errorCode") + } + Logger.log("Failed to move downloads: $errorCode") + super.onFailed(errorCode) + } + + override fun onCompleted(result: Result) { + finished(true, "Successfully moved downloads") + super.onCompleted(result) + } + }) + } + + } catch (e: Exception) { + snackString("Error: ${e.message}") + finished(false, "Failed to move downloads: ${e.message}") + return } - if (!file.exists()) { - file.createNewFile() - } - file.writeText(jsonString) } fun queryDownload(downloadedType: DownloadedType): Boolean { @@ -149,98 +186,35 @@ class DownloadsManager(private val context: Context) { } private fun removeDirectory(downloadedType: DownloadedType) { - val directory = when (downloadedType.type) { - MediaType.MANGA -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/${downloadedType.title}/${downloadedType.chapter}" - ) - } - MediaType.ANIME -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Anime/${downloadedType.title}/${downloadedType.chapter}" - ) - } - else -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/${downloadedType.title}/${downloadedType.chapter}" - ) - } - } + val baseDirectory = getBaseDirectory(context, downloadedType.type) + val directory = + baseDirectory?.findFolder(downloadedType.title)?.findFolder(downloadedType.chapter) // Check if the directory exists and delete it recursively - if (directory.exists()) { - val deleted = directory.deleteRecursively() + if (directory?.exists() == true) { + val deleted = directory.deleteRecursively(context, false) if (deleted) { - Toast.makeText(context, "Successfully deleted", Toast.LENGTH_SHORT).show() - } else { - Toast.makeText(context, "Failed to delete directory", Toast.LENGTH_SHORT).show() - } - } else { - Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() - } - } + snackString("Successfully deleted") - fun exportDownloads(downloadedType: DownloadedType) { //copies to the downloads folder available to the user - val directory = when (downloadedType.type) { - MediaType.MANGA -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/${downloadedType.title}/${downloadedType.chapter}" - ) - } - MediaType.ANIME -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Anime/${downloadedType.title}/${downloadedType.chapter}" - ) - } - else -> { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/${downloadedType.title}/${downloadedType.chapter}" - ) - } - } - val destination = File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/${downloadedType.title}/${downloadedType.chapter}" - ) - if (directory.exists()) { - val copied = directory.copyRecursively(destination, true) - if (copied) { - Toast.makeText(context, "Successfully copied", Toast.LENGTH_SHORT).show() } else { - Toast.makeText(context, "Failed to copy directory", Toast.LENGTH_SHORT).show() + snackString("Failed to delete directory") } } else { - Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() + snackString("Directory does not exist") } } fun purgeDownloads(type: MediaType) { - val directory = when (type) { - MediaType.MANGA -> { - File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Manga") - } - MediaType.ANIME -> { - File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Anime") - } - else -> { - File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/Novel") - } - } - if (directory.exists()) { - val deleted = directory.deleteRecursively() + val directory = getBaseDirectory(context, type) + if (directory?.exists() == true) { + val deleted = directory.deleteRecursively(context, false) if (deleted) { - Toast.makeText(context, "Successfully deleted", Toast.LENGTH_SHORT).show() + snackString("Successfully deleted") } else { - Toast.makeText(context, "Failed to delete directory", Toast.LENGTH_SHORT).show() + snackString("Failed to delete directory") } } else { - Toast.makeText(context, "Directory does not exist", Toast.LENGTH_SHORT).show() + snackString("Directory does not exist") } downloadsList.removeAll { it.type == type } @@ -248,59 +222,95 @@ class DownloadsManager(private val context: Context) { } companion object { - const val novelLocation = "Dantotsu/Novel" - const val mangaLocation = "Dantotsu/Manga" - const val animeLocation = "Dantotsu/Anime" + private const val BASE_LOCATION = "Dantotsu" + private const val MANGA_SUB_LOCATION = "Manga" + private const val ANIME_SUB_LOCATION = "Anime" + private const val NOVEL_SUB_LOCATION = "Novel" + private const val RESERVED_CHARS = "|\\?*<\":>+[]/'" - fun getDirectory( - context: Context, - type: MediaType, - title: String, - chapter: String? = null - ): File { + fun String?.findValidName(): String { + return this?.filterNot { RESERVED_CHARS.contains(it) } ?: "" + } + + /** + * Get and create a base directory for the given type + * @param context the context + * @param type the type of media + * @return the base directory + */ + + private fun getBaseDirectory(context: Context, type: MediaType): DocumentFile? { + val baseDirectory = Uri.parse(PrefManager.getVal(PrefName.DownloadsDir)) + if (baseDirectory == Uri.EMPTY) return null + var base = DocumentFile.fromTreeUri(context, baseDirectory) ?: return null + base = base.findOrCreateFolder(BASE_LOCATION, false) ?: return null return when (type) { MediaType.MANGA -> { - if (chapter != null) { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$mangaLocation/$title/$chapter" - ) - } else { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$mangaLocation/$title" - ) - } + base.findOrCreateFolder(MANGA_SUB_LOCATION, false) } + MediaType.ANIME -> { - if (chapter != null) { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$animeLocation/$title/$chapter" - ) - } else { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$animeLocation/$title" - ) - } + base.findOrCreateFolder(ANIME_SUB_LOCATION, false) } + else -> { - if (chapter != null) { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$novelLocation/$title/$chapter" - ) - } else { - File( - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "$novelLocation/$title" - ) - } + base.findOrCreateFolder(NOVEL_SUB_LOCATION, false) } } } + + /** + * Get and create a subdirectory for the given type + * @param context the context + * @param type the type of media + * @param title the title of the media + * @param chapter the chapter of the media + * @return the subdirectory + */ + fun getSubDirectory( + context: Context, + type: MediaType, + overwrite: Boolean, + title: String, + chapter: String? = null + ): DocumentFile? { + val baseDirectory = getBaseDirectory(context, type) ?: return null + return if (chapter != null) { + baseDirectory.findOrCreateFolder(title, false) + ?.findOrCreateFolder(chapter, overwrite) + } else { + baseDirectory.findOrCreateFolder(title, overwrite) + } + } + + fun getDirSize(context: Context, type: MediaType, title: String, chapter: String? = null): Long { + val directory = getSubDirectory(context, type, false, title, chapter) ?: return 0 + var size = 0L + directory.listFiles().forEach { + size += it.length() + } + return size + } + + 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()) + } + } + } } -data class DownloadedType(val title: String, val chapter: String, val type: MediaType) : Serializable +data class DownloadedType( + val pTitle: String, val pChapter: String, val type: MediaType +) : Serializable { + val title: String + get() = pTitle.findValidName() + val chapter: String + get() = pChapter.findValidName() +} 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 5d9c7a5b..daaa200e 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt @@ -9,24 +9,21 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build -import android.os.Environment import android.os.IBinder import android.widget.Toast import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.documentfile.provider.DocumentFile import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.offline.DownloadManager -import androidx.media3.exoplayer.offline.DownloadService import ani.dantotsu.FileUrl import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface -import ani.dantotsu.currActivity import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager -import ani.dantotsu.download.video.ExoplayerDownloadService -import ani.dantotsu.download.video.Helper +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory +import ani.dantotsu.download.anime.AnimeDownloaderService.AnimeDownloadTask.Companion.getTaskName import ani.dantotsu.media.Media import ani.dantotsu.media.MediaType import ani.dantotsu.media.SubtitleDownloader @@ -36,6 +33,12 @@ import ani.dantotsu.parsers.Video import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import com.anggrayudi.storage.file.forceDelete +import com.anggrayudi.storage.file.openOutputStream +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFmpegKitConfig +import com.arthenica.ffmpegkit.FFprobeKit +import com.arthenica.ffmpegkit.SessionState import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.animesource.model.SAnime @@ -46,7 +49,6 @@ import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapterImpl import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -56,13 +58,12 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.File -import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue + class AnimeDownloaderService : Service() { private lateinit var notificationManager: NotificationManagerCompat @@ -88,6 +89,7 @@ class AnimeDownloaderService : Service() { setSmallIcon(R.drawable.ic_download_24) priority = NotificationCompat.PRIORITY_DEFAULT setOnlyAlertOnce(true) + setProgress(100, 0, false) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( @@ -156,27 +158,14 @@ class AnimeDownloaderService : Service() { @UnstableApi fun cancelDownload(taskName: String) { - val url = - AnimeServiceDataSingleton.downloadQueue.find { it.getTaskName() == taskName }?.video?.file?.url - ?: currentTasks.find { it.getTaskName() == taskName }?.video?.file?.url ?: "" - if (url.isEmpty()) { - snackString("Failed to cancel download") - return + val sessionIds = + AnimeServiceDataSingleton.downloadQueue.filter { it.getTaskName() == taskName } + .map { it.sessionId }.toMutableList() + sessionIds.addAll(currentTasks.filter { it.getTaskName() == taskName }.map { it.sessionId }) + sessionIds.forEach { + FFmpegKit.cancel(it) } currentTasks.removeAll { it.getTaskName() == taskName } - DownloadService.sendSetStopReason( - this@AnimeDownloaderService, - ExoplayerDownloadService::class.java, - url, - androidx.media3.exoplayer.offline.Download.STATE_STOPPED, - false - ) - DownloadService.sendRemoveDownload( - this@AnimeDownloaderService, - ExoplayerDownloadService::class.java, - url, - false - ) CoroutineScope(Dispatchers.Default).launch { mutex.withLock { downloadJobs[taskName]?.cancel() @@ -209,7 +198,7 @@ class AnimeDownloaderService : Service() { @androidx.annotation.OptIn(UnstableApi::class) suspend fun download(task: AnimeDownloadTask) { try { - val downloadManager = Helper.downloadManager(this@AnimeDownloaderService) + //val downloadManager = Helper.downloadManager(this@AnimeDownloaderService) withContext(Dispatchers.Main) { val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( @@ -220,18 +209,80 @@ class AnimeDownloaderService : Service() { true } - builder.setContentText("Downloading ${task.title} - ${task.episode}") + builder.setContentText("Downloading ${getTaskName(task.title, task.episode)}") if (notifi) { notificationManager.notify(NOTIFICATION_ID, builder.build()) } - currActivity()?.let { - Helper.downloadVideo( - it, - task.video, - task.subtitle - ) + val outputDir = getSubDirectory( + this@AnimeDownloaderService, + MediaType.ANIME, + false, + task.title, + task.episode + ) ?: throw Exception("Failed to create output directory") + + outputDir.findFile("${task.getTaskName()}.mp4")?.delete() + val outputFile = outputDir.createFile("video/mp4", "${task.getTaskName()}.mp4") + ?: throw Exception("Failed to create output file") + + var percent = 0 + var totalLength = 0.0 + val path = FFmpegKitConfig.getSafParameterForWrite( + this@AnimeDownloaderService, + outputFile.uri + ) + val headersStringBuilder = StringBuilder().append(" ") + task.video.file.headers.forEach { + headersStringBuilder.append("\"${it.key}: ${it.value}\"\'\r\n\'") } + headersStringBuilder.append(" ") + FFprobeKit.executeAsync( + "-headers $headersStringBuilder -i ${task.video.file.url} -show_entries format=duration -v quiet -of csv=\"p=0\"", + { + Logger.log("FFprobeKit: $it") + }, { + if (it.message.toDoubleOrNull() != null) { + totalLength = it.message.toDouble() + } + }) + + var request = "-headers" + val headers = headersStringBuilder.toString() + if (task.video.file.headers.isNotEmpty()) { + request += headers + } + request += "-i ${task.video.file.url} -c copy -bsf:a aac_adtstoasc -tls_verify 0 $path -v trace" + println("Request: $request") + val ffTask = + FFmpegKit.executeAsync(request, + { session -> + val state: SessionState = session.state + val returnCode = session.returnCode + // CALLED WHEN SESSION IS EXECUTED + Logger.log( + java.lang.String.format( + "FFmpeg process exited with state %s and rc %s.%s", + state, + returnCode, + session.failStackTrace + ) + ) + + }, { + // CALLED WHEN SESSION PRINTS LOGS + Logger.log(it.message) + }) { + // CALLED WHEN SESSION GENERATES STATISTICS + val timeInMilliseconds = it.time + if (timeInMilliseconds > 0 && totalLength > 0) { + percent = ((it.time / 1000) / totalLength * 100).toInt() + } + Logger.log("Statistics: $it") + } + task.sessionId = ffTask.sessionId + currentTasks.find { it.getTaskName() == task.getTaskName() }?.sessionId = + ffTask.sessionId saveMediaInfo(task) task.subtitle?.let { @@ -245,86 +296,115 @@ class AnimeDownloaderService : Service() { ) ) } - val downloadStarted = - hasDownloadStarted(downloadManager, task, 30000) // 30 seconds timeout - - if (!downloadStarted) { - Logger.log("Download failed to start") - builder.setContentText("${task.title} - ${task.episode} Download failed to start") - notificationManager.notify(NOTIFICATION_ID, builder.build()) - snackString("${task.title} - ${task.episode} Download failed to start") - broadcastDownloadFailed(task.episode) - return@withContext - } - // periodically check if the download is complete - while (downloadManager.downloadIndex.getDownload(task.video.file.url) != null) { - val download = downloadManager.downloadIndex.getDownload(task.video.file.url) - if (download != null) { - if (download.state == androidx.media3.exoplayer.offline.Download.STATE_FAILED) { - Logger.log("Download failed") - builder.setContentText("${task.title} - ${task.episode} Download failed") - notificationManager.notify(NOTIFICATION_ID, builder.build()) - snackString("${task.title} - ${task.episode} Download failed") - Logger.log("Download failed: ${download.failureReason}") - downloadsManager.removeDownload( - DownloadedType( + while (ffTask.state != SessionState.COMPLETED) { + if (ffTask.state == SessionState.FAILED) { + Logger.log("Download failed") + builder.setContentText( + "${ + getTaskName( task.title, - task.episode, - MediaType.ANIME, + task.episode ) - ) - Injekt.get().logException( - Exception( - "Anime Download failed:" + - " ${download.failureReason}" + - " url: ${task.video.file.url}" + - " title: ${task.title}" + - " episode: ${task.episode}" - ) - ) - currentTasks.removeAll { it.getTaskName() == task.getTaskName() } - broadcastDownloadFailed(task.episode) - break - } - if (download.state == androidx.media3.exoplayer.offline.Download.STATE_COMPLETED) { - Logger.log("Download completed") - builder.setContentText("${task.title} - ${task.episode} Download completed") - notificationManager.notify(NOTIFICATION_ID, builder.build()) - snackString("${task.title} - ${task.episode} Download completed") - PrefManager.getAnimeDownloadPreferences().edit().putString( - task.getTaskName(), - task.video.file.url - ).apply() - downloadsManager.addDownload( - DownloadedType( - task.title, - task.episode, - MediaType.ANIME, - ) - ) - currentTasks.removeAll { it.getTaskName() == task.getTaskName() } - broadcastDownloadFinished(task.episode) - break - } - if (download.state == androidx.media3.exoplayer.offline.Download.STATE_STOPPED) { - Logger.log("Download stopped") - builder.setContentText("${task.title} - ${task.episode} Download stopped") - notificationManager.notify(NOTIFICATION_ID, builder.build()) - snackString("${task.title} - ${task.episode} Download stopped") - break - } - broadcastDownloadProgress( - task.episode, - download.percentDownloaded.toInt() + } Download failed" ) - if (notifi) { - notificationManager.notify(NOTIFICATION_ID, builder.build()) - } + notificationManager.notify(NOTIFICATION_ID, builder.build()) + snackString("${getTaskName(task.title, task.episode)} Download failed") + Logger.log("Download failed: ${ffTask.failStackTrace}") + downloadsManager.removeDownload( + DownloadedType( + task.title, + task.episode, + MediaType.ANIME, + ) + ) {} + Injekt.get().logException( + Exception( + "Anime Download failed:" + + " ${getTaskName(task.title, task.episode)}" + + " url: ${task.video.file.url}" + + " title: ${task.title}" + + " episode: ${task.episode}" + ) + ) + currentTasks.removeAll { it.getTaskName() == task.getTaskName() } + broadcastDownloadFailed(task.episode) + break + } + builder.setProgress( + 100, percent.coerceAtMost(99), + false + ) + broadcastDownloadProgress( + task.episode, + percent.coerceAtMost(99) + ) + if (notifi) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) } kotlinx.coroutines.delay(2000) } + if (ffTask.state == SessionState.COMPLETED) { + if (ffTask.returnCode.isValueError) { + Logger.log("Download failed") + builder.setContentText( + "${ + getTaskName( + task.title, + task.episode + ) + } Download failed" + ) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + snackString("${getTaskName(task.title, task.episode)} Download failed") + downloadsManager.removeDownload( + DownloadedType( + task.title, + task.episode, + MediaType.ANIME, + ) + ) {} + Injekt.get().logException( + Exception( + "Anime Download failed:" + + " ${getTaskName(task.title, task.episode)}" + + " url: ${task.video.file.url}" + + " title: ${task.title}" + + " episode: ${task.episode}" + ) + ) + currentTasks.removeAll { it.getTaskName() == task.getTaskName() } + broadcastDownloadFailed(task.episode) + return@withContext + } + Logger.log("Download completed") + builder.setContentText( + "${ + getTaskName( + task.title, + task.episode + ) + } Download completed" + ) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + snackString("${getTaskName(task.title, task.episode)} Download completed") + PrefManager.getAnimeDownloadPreferences().edit().putString( + task.getTaskName(), + task.video.file.url + ).apply() + downloadsManager.addDownload( + DownloadedType( + task.title, + task.episode, + MediaType.ANIME, + ) + ) + + currentTasks.removeAll { it.getTaskName() == task.getTaskName() } + broadcastDownloadFinished(task.episode) + } else throw Exception("Download failed") + } } catch (e: Exception) { if (e.message?.contains("Coroutine was cancelled") == false) { //wut @@ -337,35 +417,24 @@ class AnimeDownloaderService : Service() { } } - @androidx.annotation.OptIn(UnstableApi::class) - suspend fun hasDownloadStarted( - downloadManager: DownloadManager, - task: AnimeDownloadTask, - timeout: Long - ): Boolean { - val startTime = System.currentTimeMillis() - while (System.currentTimeMillis() - startTime < timeout) { - val download = downloadManager.downloadIndex.getDownload(task.video.file.url) - if (download != null) { - return true - } - // Delay between each poll - kotlinx.coroutines.delay(500) - } - return false - } - - @OptIn(DelicateCoroutinesApi::class) private fun saveMediaInfo(task: AnimeDownloadTask) { CoroutineScope(Dispatchers.IO).launch { - val directory = File( - getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "${DownloadsManager.animeLocation}/${task.title}" - ) - val episodeDirectory = File(directory, task.episode) - if (!episodeDirectory.exists()) episodeDirectory.mkdirs() + 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") + val episodeDirectory = + getSubDirectory( + this@AnimeDownloaderService, + MediaType.ANIME, + false, + task.title, + task.episode + ) + ?: throw Exception("Directory not found") - val file = File(directory, "media.json") val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl @@ -399,14 +468,25 @@ class AnimeDownloaderService : Service() { val jsonString = gson.toJson(media) withContext(Dispatchers.Main) { - file.writeText(jsonString) + try { + file.openOutputStream(this@AnimeDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") + output.write(jsonString.toByteArray()) + } + } catch (e: android.system.ErrnoException) { + e.printStackTrace() + Toast.makeText( + this@AnimeDownloaderService, + "Error while saving: ${e.localizedMessage}", + Toast.LENGTH_LONG + ).show() + } } } } } - - private suspend fun downloadImage(url: String, directory: File, name: String): String? = + private suspend fun downloadImage(url: String, directory: DocumentFile, name: String): String? = withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null println("Downloading url $url") @@ -417,13 +497,16 @@ class AnimeDownloaderService : Service() { throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") } - val file = File(directory, name) - FileOutputStream(file).use { output -> + directory.findFile(name)?.forceDelete(this@AnimeDownloaderService) + val file = + directory.createFile("image/jpeg", name) ?: throw Exception("File not created") + file.openOutputStream(this@AnimeDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") connection.inputStream.use { input -> input.copyTo(output) } } - return@withContext file.absolutePath + return@withContext file.uri.toString() } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { @@ -490,14 +573,15 @@ class AnimeDownloaderService : Service() { val episodeImage: String? = null, val retries: Int = 2, val simultaneousDownloads: Int = 2, + var sessionId: Long = -1 ) { fun getTaskName(): String { - return "$title - $episode" + return "${title.replace("/", "")}/${episode.replace("/", "")}" } companion object { fun getTaskName(title: String, episode: String): String { - return "$title - $episode" + return "${title.replace("/", "")}/${episode.replace("/", "")}" } } } @@ -511,7 +595,6 @@ class AnimeDownloaderService : Service() { object AnimeServiceDataSingleton { var video: Video? = null - var sourceMedia: Media? = null var downloadQueue: Queue = ConcurrentLinkedQueue() @Volatile 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 64329759..6c21cb86 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/OfflineAnimeFragment.kt @@ -4,7 +4,6 @@ package ani.dantotsu.download.anime import android.content.Intent import android.net.Uri import android.os.Bundle -import android.os.Environment import android.text.Editable import android.text.TextWatcher import android.util.TypedValue @@ -25,6 +24,7 @@ import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.view.marginBottom import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import ani.dantotsu.R import ani.dantotsu.bottomBar @@ -33,6 +33,7 @@ import ani.dantotsu.currActivity import ani.dantotsu.currContext import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.findValidName import ani.dantotsu.initActivity import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsActivity @@ -44,6 +45,7 @@ import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import com.anggrayudi.storage.file.openInputStream import com.google.android.material.card.MaterialCardView import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.textfield.TextInputLayout @@ -55,9 +57,13 @@ import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.model.SEpisodeImpl import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapterImpl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.File class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { @@ -66,6 +72,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { private lateinit var gridView: GridView private lateinit var adapter: OfflineAnimeAdapter private lateinit var total: TextView + private var downloadsJob: Job = Job() override fun onCreateView( inflater: LayoutInflater, @@ -112,10 +119,10 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { }) var style: Int = PrefManager.getVal(PrefName.OfflineView) val layoutList = view.findViewById(R.id.downloadedList) - val layoutcompact = view.findViewById(R.id.downloadedGrid) + val layoutCompact = view.findViewById(R.id.downloadedGrid) var selected = when (style) { 0 -> layoutList - 1 -> layoutcompact + 1 -> layoutCompact else -> layoutList } selected.alpha = 1f @@ -136,7 +143,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { grid() } - layoutcompact.setOnClickListener { + layoutCompact.setOnClickListener { selected(it as ImageView) style = 1 PrefManager.setVal(PrefName.OfflineView, style) @@ -156,11 +163,11 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { @OptIn(UnstableApi::class) private fun grid() { gridView.visibility = View.VISIBLE - getDownloads() val fadeIn = AlphaAnimation(0f, 1f) fadeIn.duration = 300 // animations pog gridView.layoutAnimation = LayoutAnimationController(fadeIn) adapter = OfflineAnimeAdapter(requireContext(), downloads, this) + getDownloads() gridView.adapter = adapter gridView.scheduleLayoutAnimation() total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List" @@ -168,20 +175,22 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { // Get the OfflineAnimeModel that was clicked val item = adapter.getItem(position) as OfflineAnimeModel val media = - downloadManager.animeDownloadedTypes.firstOrNull { it.title == item.title } + downloadManager.animeDownloadedTypes.firstOrNull { it.title == item.title.findValidName() } media?.let { - val mediaModel = getMedia(it) - if (mediaModel == null) { - snackString("Error loading media.json") - return@let + lifecycleScope.launch { + val mediaModel = getMedia(it) + if (mediaModel == null) { + snackString("Error loading media.json") + return@launch + } + MediaDetailsActivity.mediaSingleton = mediaModel + ContextCompat.startActivity( + requireActivity(), + Intent(requireContext(), MediaDetailsActivity::class.java) + .putExtra("download", true), + null + ) } - MediaDetailsActivity.mediaSingleton = mediaModel - ContextCompat.startActivity( - requireActivity(), - Intent(requireContext(), MediaDetailsActivity::class.java) - .putExtra("download", true), - null - ) } ?: run { snackString("no media found") } @@ -204,13 +213,7 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { if (mediaIds.isEmpty()) { snackString("No media found") // if this happens, terrible things have happened } - for (mediaId in mediaIds) { - ani.dantotsu.download.video.Helper.downloadManager(requireContext()) - .removeDownload(mediaId.toString()) - } getDownloads() - adapter.setItems(downloads) - total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List" } builder.setNegativeButton("No") { _, _ -> // Do nothing @@ -238,7 +241,6 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { gridView.setOnScrollListener(object : AbsListView.OnScrollListener { override fun onScrollStateChanged(view: AbsListView, scrollState: Int) { - // Implement behavior for different scroll states if needed } override fun onScroll( @@ -261,7 +263,6 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { override fun onResume() { super.onResume() getDownloads() - adapter.notifyDataSetChanged() } override fun onPause() { @@ -281,25 +282,39 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { private fun getDownloads() { downloads = listOf() - val animeTitles = downloadManager.animeDownloadedTypes.map { it.title }.distinct() - val newAnimeDownloads = mutableListOf() - for (title in animeTitles) { - val tDownloads = downloadManager.animeDownloadedTypes.filter { it.title == title } - val download = tDownloads.first() - val offlineAnimeModel = loadOfflineAnimeModel(download) - newAnimeDownloads += offlineAnimeModel + if (downloadsJob.isActive) { + downloadsJob.cancel() + } + downloadsJob = Job() + CoroutineScope(Dispatchers.IO + downloadsJob).launch { + val animeTitles = downloadManager.animeDownloadedTypes.map { it.title }.distinct() + val newAnimeDownloads = mutableListOf() + for (title in animeTitles) { + val tDownloads = downloadManager.animeDownloadedTypes.filter { it.title == title } + val download = tDownloads.first() + val offlineAnimeModel = loadOfflineAnimeModel(download) + newAnimeDownloads += offlineAnimeModel + } + downloads = newAnimeDownloads + withContext(Dispatchers.Main) { + adapter.setItems(downloads) + total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List" + adapter.notifyDataSetChanged() + } } - downloads = newAnimeDownloads } - private fun getMedia(downloadedType: DownloadedType): Media? { - val type = downloadedType.type.asText() - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$type/${downloadedType.title}" - ) - //load media.json and convert to media class with gson + /** + * Load media.json file from the directory and convert it to Media class + * @param downloadedType DownloadedType object + * @return Media object + */ + private suspend fun getMedia(downloadedType: DownloadedType): Media? { return try { + val directory = DownloadsManager.getSubDirectory( + context ?: currContext()!!, downloadedType.type, + false, downloadedType.title + ) val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl @@ -311,8 +326,13 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { SEpisodeImpl() // Provide an instance of SEpisodeImpl }) .create() - val media = File(directory, "media.json") - val mediaJson = media.readText() + val media = directory?.findFile("media.json") + ?: return null + val mediaJson = + media.openInputStream(context ?: currContext()!!)?.bufferedReader().use { + it?.readText() + } + ?: return null gson.fromJson(mediaJson, Media::class.java) } catch (e: Exception) { Logger.log("Error loading media.json: ${e.message}") @@ -322,22 +342,26 @@ class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { } } - private fun loadOfflineAnimeModel(downloadedType: DownloadedType): OfflineAnimeModel { + /** + * Load OfflineAnimeModel from the directory + * @param downloadedType DownloadedType object + * @return OfflineAnimeModel object + */ + private suspend fun loadOfflineAnimeModel(downloadedType: DownloadedType): OfflineAnimeModel { val type = downloadedType.type.asText() - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$type/${downloadedType.title}" - ) - //load media.json and convert to media class with gson try { + val directory = DownloadsManager.getSubDirectory( + context ?: currContext()!!, downloadedType.type, + false, downloadedType.title + ) val mediaModel = getMedia(downloadedType)!! - val cover = File(directory, "cover.jpg") - val coverUri: Uri? = if (cover.exists()) { - Uri.fromFile(cover) + val cover = directory?.findFile("cover.jpg") + val coverUri: Uri? = if (cover?.exists() == true) { + cover.uri } else null - val banner = File(directory, "banner.jpg") - val bannerUri: Uri? = if (banner.exists()) { - Uri.fromFile(banner) + val banner = directory?.findFile("banner.jpg") + val bannerUri: Uri? = if (banner?.exists() == true) { + banner.uri } else null val title = mediaModel.mainName() val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore 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 92e811bd..08ddb3a7 100644 --- a/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/manga/MangaDownloaderService.kt @@ -10,17 +10,18 @@ import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.graphics.Bitmap import android.os.Build -import android.os.Environment import android.os.IBinder import android.widget.Toast import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.documentfile.provider.DocumentFile import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.media.Media import ani.dantotsu.media.MediaType import ani.dantotsu.media.manga.ImageData @@ -31,6 +32,9 @@ 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 com.anggrayudi.storage.file.deleteRecursively +import com.anggrayudi.storage.file.forceDelete +import com.anggrayudi.storage.file.openOutputStream import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.data.notification.Notifications.CHANNEL_DOWNLOADER_PROGRESS @@ -51,8 +55,6 @@ import kotlinx.coroutines.withContext import tachiyomi.core.util.lang.launchIO import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.File -import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL import java.util.Queue @@ -189,13 +191,20 @@ class MangaDownloaderService : Service() { true } - //val deferredList = mutableListOf>() val deferredMap = mutableMapOf>() builder.setContentText("Downloading ${task.title} - ${task.chapter}") if (notifi) { notificationManager.notify(NOTIFICATION_ID, builder.build()) } + getSubDirectory( + this@MangaDownloaderService, + MediaType.MANGA, + false, + task.title, + task.chapter + )?.deleteRecursively(this@MangaDownloaderService) + // Loop through each ImageData object from the task var farthest = 0 for ((index, image) in task.imageData.withIndex()) { @@ -263,24 +272,18 @@ class MangaDownloaderService : Service() { private fun saveToDisk(fileName: String, bitmap: Bitmap, title: String, chapter: String) { try { // Define the directory within the private external storage space - val directory = File( - this.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/$title/$chapter" - ) - - if (!directory.exists()) { - directory.mkdirs() - } - - // Create a file reference within that directory for your image - val file = File(directory, fileName) + 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 - FileOutputStream(file).use { outputStream -> + file.openOutputStream(this, false).use { outputStream -> + if (outputStream == null) throw Exception("Output stream is null") bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) } - - } catch (e: Exception) { println("Exception while saving image: ${e.message}") snackString("Exception while saving image: ${e.message}") @@ -291,13 +294,12 @@ class MangaDownloaderService : Service() { @OptIn(DelicateCoroutinesApi::class) private fun saveMediaInfo(task: DownloadTask) { launchIO { - val directory = File( - getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/${task.title}" - ) - if (!directory.exists()) directory.mkdirs() - - val file = File(directory, "media.json") + 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") val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl @@ -312,7 +314,10 @@ class MangaDownloaderService : Service() { val jsonString = gson.toJson(media) withContext(Dispatchers.Main) { try { - file.writeText(jsonString) + file.openOutputStream(this@MangaDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") + output.write(jsonString.toByteArray()) + } } catch (e: android.system.ErrnoException) { e.printStackTrace() Toast.makeText( @@ -327,7 +332,7 @@ class MangaDownloaderService : Service() { } - private suspend fun downloadImage(url: String, directory: File, name: String): String? = + private suspend fun downloadImage(url: String, directory: DocumentFile, name: String): String? = withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null println("Downloading url $url") @@ -337,14 +342,16 @@ class MangaDownloaderService : Service() { if (connection.responseCode != HttpURLConnection.HTTP_OK) { throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") } - - val file = File(directory, name) - FileOutputStream(file).use { output -> + directory.findFile(name)?.forceDelete(this@MangaDownloaderService) + val file = + directory.createFile("image/jpeg", name) ?: throw Exception("File not created") + file.openOutputStream(this@MangaDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") connection.inputStream.use { input -> input.copyTo(output) } } - return@withContext file.absolutePath + return@withContext file.uri.toString() } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { 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 99250edf..1d912887 100644 --- a/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt +++ b/app/src/main/java/ani/dantotsu/download/manga/OfflineMangaFragment.kt @@ -3,7 +3,6 @@ package ani.dantotsu.download.manga import android.content.Intent import android.net.Uri import android.os.Bundle -import android.os.Environment import android.text.Editable import android.text.TextWatcher import android.util.TypedValue @@ -23,6 +22,7 @@ import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.view.marginBottom import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import ani.dantotsu.R import ani.dantotsu.bottomBar import ani.dantotsu.connections.crashlytics.CrashlyticsInterface @@ -30,6 +30,7 @@ import ani.dantotsu.currActivity import ani.dantotsu.currContext import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.initActivity import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsActivity @@ -41,6 +42,7 @@ import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import com.anggrayudi.storage.file.openInputStream import com.google.android.material.card.MaterialCardView import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.textfield.TextInputLayout @@ -48,9 +50,13 @@ import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapterImpl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.File class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { @@ -59,6 +65,7 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { private lateinit var gridView: GridView private lateinit var adapter: OfflineMangaAdapter private lateinit var total: TextView + private var downloadsJob: Job = Job() override fun onCreateView( inflater: LayoutInflater, @@ -148,11 +155,11 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { private fun grid() { gridView.visibility = View.VISIBLE - getDownloads() val fadeIn = AlphaAnimation(0f, 1f) fadeIn.duration = 300 // animations pog gridView.layoutAnimation = LayoutAnimationController(fadeIn) adapter = OfflineMangaAdapter(requireContext(), downloads, this) + getDownloads() gridView.adapter = adapter gridView.scheduleLayoutAnimation() total.text = @@ -164,14 +171,15 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { downloadManager.mangaDownloadedTypes.firstOrNull { it.title == item.title } ?: downloadManager.novelDownloadedTypes.firstOrNull { it.title == item.title } media?.let { - - ContextCompat.startActivity( - requireActivity(), - Intent(requireContext(), MediaDetailsActivity::class.java) - .putExtra("media", getMedia(it)) - .putExtra("download", true), - null - ) + lifecycleScope.launch { + ContextCompat.startActivity( + requireActivity(), + Intent(requireContext(), MediaDetailsActivity::class.java) + .putExtra("media", getMedia(it)) + .putExtra("download", true), + null + ) + } } ?: run { snackString("no media found") } @@ -194,9 +202,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { builder.setPositiveButton("Yes") { _, _ -> downloadManager.removeMedia(item.title, type) getDownloads() - adapter.setItems(downloads) - total.text = - if (gridView.count > 0) "Manga and Novels (${gridView.count})" else "Empty List" } builder.setNegativeButton("No") { _, _ -> // Do nothing @@ -225,7 +230,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { gridView.setOnScrollListener(object : AbsListView.OnScrollListener { override fun onScrollStateChanged(view: AbsListView, scrollState: Int) { - // Implement behavior for different scroll states if needed } override fun onScroll( @@ -248,7 +252,6 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { override fun onResume() { super.onResume() getDownloads() - adapter.notifyDataSetChanged() } override fun onPause() { @@ -268,42 +271,62 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { private fun getDownloads() { downloads = listOf() - val mangaTitles = downloadManager.mangaDownloadedTypes.map { it.title }.distinct() - val newMangaDownloads = mutableListOf() - for (title in mangaTitles) { - val tDownloads = downloadManager.mangaDownloadedTypes.filter { it.title == title } - val download = tDownloads.first() - val offlineMangaModel = loadOfflineMangaModel(download) - newMangaDownloads += offlineMangaModel + if (downloadsJob.isActive) { + downloadsJob.cancel() } - downloads = newMangaDownloads - val novelTitles = downloadManager.novelDownloadedTypes.map { it.title }.distinct() - val newNovelDownloads = mutableListOf() - for (title in novelTitles) { - val tDownloads = downloadManager.novelDownloadedTypes.filter { it.title == title } - val download = tDownloads.first() - val offlineMangaModel = loadOfflineMangaModel(download) - newNovelDownloads += offlineMangaModel + downloads = listOf() + downloadsJob = Job() + CoroutineScope(Dispatchers.IO + downloadsJob).launch { + val mangaTitles = downloadManager.mangaDownloadedTypes.map { it.title }.distinct() + val newMangaDownloads = mutableListOf() + for (title in mangaTitles) { + val tDownloads = downloadManager.mangaDownloadedTypes.filter { it.title == title } + val download = tDownloads.first() + val offlineMangaModel = loadOfflineMangaModel(download) + newMangaDownloads += offlineMangaModel + } + downloads = newMangaDownloads + val novelTitles = downloadManager.novelDownloadedTypes.map { it.title }.distinct() + val newNovelDownloads = mutableListOf() + for (title in novelTitles) { + val tDownloads = downloadManager.novelDownloadedTypes.filter { it.title == title } + val download = tDownloads.first() + val offlineMangaModel = loadOfflineMangaModel(download) + newNovelDownloads += offlineMangaModel + } + downloads += newNovelDownloads + withContext(Dispatchers.Main) { + adapter.setItems(downloads) + total.text = + if (gridView.count > 0) "Manga and Novels (${gridView.count})" else "Empty List" + adapter.notifyDataSetChanged() + } } - downloads += newNovelDownloads } - private fun getMedia(downloadedType: DownloadedType): Media? { - val type = downloadedType.type.asText() - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$type/${downloadedType.title}" - ) - //load media.json and convert to media class with gson + /** + * Load media.json file from the directory and convert it to Media class + * @param downloadedType DownloadedType object + * @return Media object + */ + private suspend fun getMedia(downloadedType: DownloadedType): Media? { return try { + val directory = getSubDirectory( + context ?: currContext()!!, downloadedType.type, + false, downloadedType.title + ) val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl }) .create() - val media = File(directory, "media.json") - val mediaJson = media.readText() + val media = directory?.findFile("media.json") + ?: return null + val mediaJson = + media.openInputStream(context ?: currContext()!!)?.bufferedReader().use { + it?.readText() + } gson.fromJson(mediaJson, Media::class.java) } catch (e: Exception) { Logger.log("Error loading media.json: ${e.message}") @@ -313,22 +336,22 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { } } - private fun loadOfflineMangaModel(downloadedType: DownloadedType): OfflineMangaModel { + private suspend fun loadOfflineMangaModel(downloadedType: DownloadedType): OfflineMangaModel { val type = downloadedType.type.asText() - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/$type/${downloadedType.title}" - ) //load media.json and convert to media class with gson try { + val directory = getSubDirectory( + context ?: currContext()!!, downloadedType.type, + false, downloadedType.title + ) val mediaModel = getMedia(downloadedType)!! - val cover = File(directory, "cover.jpg") - val coverUri: Uri? = if (cover.exists()) { - Uri.fromFile(cover) + val cover = directory?.findFile("cover.jpg") + val coverUri: Uri? = if (cover?.exists() == true) { + cover.uri } else null - val banner = File(directory, "banner.jpg") - val bannerUri: Uri? = if (banner.exists()) { - Uri.fromFile(banner) + val banner = directory?.findFile("banner.jpg") + val bannerUri: Uri? = if (banner?.exists() == true) { + banner.uri } else null val title = mediaModel.mainName() val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore @@ -336,14 +359,14 @@ class OfflineMangaFragment : Fragment(), OfflineMangaSearchListener { val isOngoing = mediaModel.status == currActivity()!!.getString(R.string.status_releasing) val isUserScored = mediaModel.userScore != 0 - val readchapter = (mediaModel.userProgress ?: "~").toString() - val totalchapter = "${mediaModel.manga?.totalChapters ?: "??"}" + val readChapter = (mediaModel.userProgress ?: "~").toString() + val totalChapter = "${mediaModel.manga?.totalChapters ?: "??"}" val chapters = " Chapters" return OfflineMangaModel( title, score, - totalchapter, - readchapter, + totalChapter, + readChapter, type, chapters, isOngoing, 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 fc432313..123a54c1 100644 --- a/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/novel/NovelDownloaderService.kt @@ -16,15 +16,19 @@ import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.documentfile.provider.DocumentFile import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.media.Media import ani.dantotsu.media.MediaType import ani.dantotsu.media.novel.NovelReadFragment import ani.dantotsu.snackString import ani.dantotsu.util.Logger +import com.anggrayudi.storage.file.forceDelete +import com.anggrayudi.storage.file.openOutputStream import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.data.notification.Notifications @@ -250,24 +254,25 @@ class NovelDownloaderService : Service() { if (!response.isSuccessful) { throw IOException("Failed to download file: ${response.message}") } + val directory = getSubDirectory( + this@NovelDownloaderService, + MediaType.NOVEL, + false, + task.title, + task.chapter + ) ?: throw Exception("Directory not found") + directory.findFile("0.epub")?.forceDelete(this@NovelDownloaderService) - val file = File( - this@NovelDownloaderService.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/${task.title}/${task.chapter}/0.epub" - ) - - // Create directories if they don't exist - file.parentFile?.takeIf { !it.exists() }?.mkdirs() - - // Overwrite existing file - if (file.exists()) file.delete() + val file = directory.createFile("application/epub+zip", "0.epub") + ?: throw Exception("File not created") //download cover task.coverUrl?.let { file.parentFile?.let { it1 -> downloadImage(it, it1, "cover.jpg") } } + val outputStream = this@NovelDownloaderService.contentResolver.openOutputStream(file.uri) ?: throw Exception("Could not open OutputStream") - val sink = file.sink().buffer() + val sink = outputStream.sink().buffer() val responseBody = response.body val totalBytes = responseBody.contentLength() var downloadedBytes = 0L @@ -352,13 +357,16 @@ class NovelDownloaderService : Service() { @OptIn(DelicateCoroutinesApi::class) private fun saveMediaInfo(task: DownloadTask) { launchIO { - val directory = File( - getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/${task.title}" - ) - if (!directory.exists()) directory.mkdirs() - - val file = File(directory, "media.json") + val directory = + DownloadsManager.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") val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl @@ -372,33 +380,47 @@ class NovelDownloaderService : Service() { val jsonString = gson.toJson(media) withContext(Dispatchers.Main) { - file.writeText(jsonString) + try { + file.openOutputStream(this@NovelDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") + output.write(jsonString.toByteArray()) + } + } catch (e: android.system.ErrnoException) { + e.printStackTrace() + Toast.makeText( + this@NovelDownloaderService, + "Error while saving: ${e.localizedMessage}", + Toast.LENGTH_LONG + ).show() + } } } } } - private suspend fun downloadImage(url: String, directory: File, name: String): String? = + private suspend fun downloadImage(url: String, directory: DocumentFile, name: String): String? = withContext( Dispatchers.IO ) { var connection: HttpURLConnection? = null - println("Downloading url $url") + Logger.log("Downloading url $url") try { connection = URL(url).openConnection() as HttpURLConnection connection.connect() if (connection.responseCode != HttpURLConnection.HTTP_OK) { throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") } - - val file = File(directory, name) - FileOutputStream(file).use { output -> + directory.findFile(name)?.forceDelete(this@NovelDownloaderService) + val file = + directory.createFile("image/jpeg", name) ?: throw Exception("File not created") + file.openOutputStream(this@NovelDownloaderService, false).use { output -> + if (output == null) throw Exception("Output stream is null") connection.inputStream.use { input -> input.copyTo(output) } } - return@withContext file.absolutePath + return@withContext file.uri.toString() } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { diff --git a/app/src/main/java/ani/dantotsu/download/video/ExoplayerDownloadService.kt b/app/src/main/java/ani/dantotsu/download/video/ExoplayerDownloadService.kt deleted file mode 100644 index 4add998c..00000000 --- a/app/src/main/java/ani/dantotsu/download/video/ExoplayerDownloadService.kt +++ /dev/null @@ -1,37 +0,0 @@ -package ani.dantotsu.download.video - -import android.app.Notification -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.offline.Download -import androidx.media3.exoplayer.offline.DownloadManager -import androidx.media3.exoplayer.offline.DownloadNotificationHelper -import androidx.media3.exoplayer.offline.DownloadService -import androidx.media3.exoplayer.scheduler.PlatformScheduler -import androidx.media3.exoplayer.scheduler.Scheduler -import ani.dantotsu.R - -@UnstableApi -class ExoplayerDownloadService : - DownloadService(1, 2000, "download_service", R.string.downloads, 0) { - companion object { - private const val JOB_ID = 1 - private const val FOREGROUND_NOTIFICATION_ID = 1 - } - - override fun getDownloadManager(): DownloadManager = Helper.downloadManager(this) - - override fun getScheduler(): Scheduler = PlatformScheduler(this, JOB_ID) - - override fun getForegroundNotification( - downloads: MutableList, - notMetRequirements: Int - ): Notification = - DownloadNotificationHelper(this, "download_service").buildProgressNotification( - this, - R.drawable.mono, - null, - null, - downloads, - notMetRequirements - ) -} \ No newline at end of file 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 258c123a..ed146b90 100644 --- a/app/src/main/java/ani/dantotsu/download/video/Helper.kt +++ b/app/src/main/java/ani/dantotsu/download/video/Helper.kt @@ -53,140 +53,6 @@ import java.util.concurrent.Executors @SuppressLint("UnsafeOptInUsageError") object Helper { - - - private var simpleCache: SimpleCache? = null - - fun downloadVideo(context: Context, video: Video, subtitle: Subtitle?) { - val dataSourceFactory = DataSource.Factory { - val dataSource: HttpDataSource = - OkHttpDataSource.Factory(okHttpClient).createDataSource() - defaultHeaders.forEach { - dataSource.setRequestProperty(it.key, it.value) - } - video.file.headers.forEach { - dataSource.setRequestProperty(it.key, it.value) - } - dataSource - } - val mimeType = when (video.format) { - VideoType.M3U8 -> MimeTypes.APPLICATION_M3U8 - VideoType.DASH -> MimeTypes.APPLICATION_MPD - else -> MimeTypes.APPLICATION_MP4 - } - - val builder = MediaItem.Builder().setUri(video.file.url).setMimeType(mimeType) - var sub: MediaItem.SubtitleConfiguration? = null - if (subtitle != null) { - sub = MediaItem.SubtitleConfiguration - .Builder(Uri.parse(subtitle.file.url)) - .setSelectionFlags(C.SELECTION_FLAG_FORCED) - .setMimeType( - when (subtitle.type) { - SubtitleType.VTT -> MimeTypes.TEXT_VTT - SubtitleType.ASS -> MimeTypes.TEXT_SSA - SubtitleType.SRT -> MimeTypes.APPLICATION_SUBRIP - SubtitleType.UNKNOWN -> MimeTypes.TEXT_SSA - } - ) - .build() - } - if (sub != null) builder.setSubtitleConfigurations(mutableListOf(sub)) - val mediaItem = builder.build() - val downloadHelper = DownloadHelper.forMediaItem( - context, - mediaItem, - DefaultRenderersFactory(context), - dataSourceFactory - ) - downloadHelper.prepare(object : DownloadHelper.Callback { - override fun onPrepared(helper: DownloadHelper) { - helper.getDownloadRequest(null).let { - DownloadService.sendAddDownload( - context, - ExoplayerDownloadService::class.java, - it, - false - ) - } - } - - override fun onPrepareError(helper: DownloadHelper, e: IOException) { - logError(e) - } - }) - } - - - private var download: DownloadManager? = null - private const val DOWNLOAD_CONTENT_DIRECTORY = "Anime_Downloads" - - @Synchronized - @UnstableApi - fun downloadManager(context: Context): DownloadManager { - return download ?: let { - val database = Injekt.get() - val dataSourceFactory = DataSource.Factory { - //val dataSource: HttpDataSource = OkHttpDataSource.Factory(okHttpClient).createDataSource() - val networkHelper = Injekt.get() - val okHttpClient = networkHelper.client - val dataSource: HttpDataSource = - OkHttpDataSource.Factory(okHttpClient).createDataSource() - defaultHeaders.forEach { - dataSource.setRequestProperty(it.key, it.value) - } - dataSource - } - val threadPoolSize = Runtime.getRuntime().availableProcessors() - val executorService = Executors.newFixedThreadPool(threadPoolSize) - val downloadManager = DownloadManager( - context, - database, - getSimpleCache(context), - dataSourceFactory, - executorService - ).apply { - requirements = - Requirements(Requirements.NETWORK or Requirements.DEVICE_STORAGE_NOT_LOW) - maxParallelDownloads = 3 - } - downloadManager.addListener( //for testing - object : DownloadManager.Listener { - override fun onDownloadChanged( - downloadManager: DownloadManager, - download: Download, - finalException: Exception? - ) { - when (download.state) { - Download.STATE_COMPLETED -> Logger.log("Download Completed") - Download.STATE_FAILED -> Logger.log("Download Failed") - Download.STATE_STOPPED -> Logger.log("Download Stopped") - Download.STATE_QUEUED -> Logger.log("Download Queued") - Download.STATE_DOWNLOADING -> Logger.log("Download Downloading") - Download.STATE_REMOVING -> Logger.log("Download Removing") - Download.STATE_RESTARTING -> Logger.log("Download Restarting") - } - } - } - ) - - downloadManager - } - } - - private var downloadDirectory: File? = null - - @Synchronized - private fun getDownloadDirectory(context: Context): File { - if (downloadDirectory == null) { - downloadDirectory = context.getExternalFilesDir(null) - if (downloadDirectory == null) { - downloadDirectory = context.filesDir - } - } - return downloadDirectory!! - } - @OptIn(UnstableApi::class) fun startAnimeDownloadService( context: Context, @@ -225,15 +91,6 @@ object Helper { .setTitle("Download Exists") .setMessage("A download for this episode already exists. Do you want to overwrite it?") .setPositiveButton("Yes") { _, _ -> - DownloadService.sendRemoveDownload( - context, - ExoplayerDownloadService::class.java, - PrefManager.getAnimeDownloadPreferences().getString( - animeDownloadTask.getTaskName(), - "" - ) ?: "", - false - ) PrefManager.getAnimeDownloadPreferences().edit() .remove(animeDownloadTask.getTaskName()) .apply() @@ -243,12 +100,13 @@ object Helper { episode, MediaType.ANIME ) - ) - AnimeServiceDataSingleton.downloadQueue.offer(animeDownloadTask) - if (!AnimeServiceDataSingleton.isServiceRunning) { - val intent = Intent(context, AnimeDownloaderService::class.java) - ContextCompat.startForegroundService(context, intent) - AnimeServiceDataSingleton.isServiceRunning = true + ) { + AnimeServiceDataSingleton.downloadQueue.offer(animeDownloadTask) + if (!AnimeServiceDataSingleton.isServiceRunning) { + val intent = Intent(context, AnimeDownloaderService::class.java) + ContextCompat.startForegroundService(context, intent) + AnimeServiceDataSingleton.isServiceRunning = true + } } } .setNegativeButton("No") { _, _ -> } @@ -263,18 +121,6 @@ object Helper { } } - @OptIn(UnstableApi::class) - fun getSimpleCache(context: Context): SimpleCache { - return if (simpleCache == null) { - val downloadDirectory = File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY) - val database = Injekt.get() - simpleCache = SimpleCache(downloadDirectory, NoOpCacheEvictor(), database) - simpleCache!! - } else { - simpleCache!! - } - } - private fun isNotificationPermissionGranted(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return ActivityCompat.checkSelfPermission( diff --git a/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt b/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt index c7434c96..0be7a3e2 100644 --- a/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt +++ b/app/src/main/java/ani/dantotsu/home/AnimeFragment.kt @@ -207,6 +207,21 @@ class AnimeFragment : Fragment() { animePageAdapter.updateRecent(MediaAdaptor(0, it, requireActivity())) } } + model.getMovies().observe(viewLifecycleOwner) { + if (it != null) { + animePageAdapter.updateMovies(MediaAdaptor(0, it, requireActivity())) + } + } + model.getTopRated().observe(viewLifecycleOwner) { + if (it != null) { + animePageAdapter.updateTopRated(MediaAdaptor(0, it, requireActivity())) + } + } + model.getMostFav().observe(viewLifecycleOwner) { + if (it != null) { + animePageAdapter.updateMostFav(MediaAdaptor(0, it, requireActivity())) + } + } if (animePageAdapter.trendingViewPager != null) { animePageAdapter.updateHeight() model.getTrending().observe(viewLifecycleOwner) { @@ -263,7 +278,7 @@ class AnimeFragment : Fragment() { } model.loaded = true model.loadTrending(1) - model.loadUpdated() + model.loadAll() model.loadPopular( "ANIME", sort = Anilist.sortBy[1], onList = PrefManager.getVal( PrefName.PopularAnimeList diff --git a/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt b/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt index 2aacbda4..9861113a 100644 --- a/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt +++ b/app/src/main/java/ani/dantotsu/home/AnimePageAdapter.kt @@ -195,24 +195,67 @@ class AnimePageAdapter : RecyclerView.Adapter 0 binding.homeNotificationCount.text = Anilist.unreadNotificationCount.toString() @@ -137,6 +137,7 @@ class HomeFragment : Fragment() { bottomMargin = navBarHeight } binding.homeUserBg.updateLayoutParams { height += statusBarHeight } + binding.homeUserBgNoKen.updateLayoutParams { height += statusBarHeight } binding.homeTopContainer.updatePadding(top = statusBarHeight) var reached = false diff --git a/app/src/main/java/ani/dantotsu/home/MangaFragment.kt b/app/src/main/java/ani/dantotsu/home/MangaFragment.kt index 24eedc27..17c4dec8 100644 --- a/app/src/main/java/ani/dantotsu/home/MangaFragment.kt +++ b/app/src/main/java/ani/dantotsu/home/MangaFragment.kt @@ -160,11 +160,31 @@ class MangaFragment : Fragment() { }) mangaPageAdapter.ready.observe(viewLifecycleOwner) { i -> if (i == true) { - model.getTrendingNovel().observe(viewLifecycleOwner) { + model.getPopularNovel().observe(viewLifecycleOwner) { if (it != null) { mangaPageAdapter.updateNovel(MediaAdaptor(0, it, requireActivity())) } } + model.getPopularManga().observe(viewLifecycleOwner) { + if (it != null) { + mangaPageAdapter.updateTrendingManga(MediaAdaptor(0, it, requireActivity())) + } + } + model.getPopularManhwa().observe(viewLifecycleOwner) { + if (it != null) { + mangaPageAdapter.updateTrendingManhwa(MediaAdaptor(0, it, requireActivity())) + } + } + model.getTopRated().observe(viewLifecycleOwner) { + if (it != null) { + mangaPageAdapter.updateTopRated(MediaAdaptor(0, it, requireActivity())) + } + } + model.getMostFav().observe(viewLifecycleOwner) { + if (it != null) { + mangaPageAdapter.updateMostFav(MediaAdaptor(0, it, requireActivity())) + } + } if (mangaPageAdapter.trendingViewPager != null) { mangaPageAdapter.updateHeight() model.getTrending().observe(viewLifecycleOwner) { @@ -237,7 +257,7 @@ class MangaFragment : Fragment() { } model.loaded = true model.loadTrending() - model.loadTrendingNovel() + model.loadAll() model.loadPopular( "MANGA", sort = Anilist.sortBy[1], onList = PrefManager.getVal( PrefName.PopularMangaList diff --git a/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt b/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt index ac823a5f..27f4c8ea 100644 --- a/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt +++ b/app/src/main/java/ani/dantotsu/home/MangaPageAdapter.kt @@ -178,25 +178,76 @@ class MangaPageAdapter : RecyclerView.Adapter>? = null + var id: Int, + var name: String?, + var image: String?, + var role: String?, + var yearMedia: MutableMap>? = null, + var character: ArrayList? = null ) : Serializable diff --git a/app/src/main/java/ani/dantotsu/media/AuthorActivity.kt b/app/src/main/java/ani/dantotsu/media/AuthorActivity.kt index 22bc54f1..6c5ee146 100644 --- a/app/src/main/java/ani/dantotsu/media/AuthorActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/AuthorActivity.kt @@ -32,7 +32,7 @@ class AuthorActivity : AppCompatActivity() { private val model: OtherDetailsViewModel by viewModels() private var author: Author? = null private var loaded = false - + private var isVoiceArtist: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -55,43 +55,59 @@ class AuthorActivity : AppCompatActivity() { binding.studioClose.setOnClickListener { onBackPressedDispatcher.onBackPressed() } + isVoiceArtist = intent.getBooleanExtra("isVoiceArtist", false) + if (isVoiceArtist) { + model.getVoiceActor().observe(this) { + if (it != null) { + author = it + loaded = true + binding.studioProgressBar.visibility = View.GONE + binding.studioRecycler.visibility = View.VISIBLE + binding.studioRecycler.adapter = CharacterAdapter(author!!.character ?: arrayListOf()) + binding.studioRecycler.layoutManager = GridLayoutManager( + this, + (screenWidth / 120f).toInt() + ) + } + } + }else{ + model.getAuthor().observe(this) { + if (it != null) { + author = it + loaded = true + binding.studioProgressBar.visibility = View.GONE + binding.studioRecycler.visibility = View.VISIBLE - model.getAuthor().observe(this) { - if (it != null) { - author = it - loaded = true - binding.studioProgressBar.visibility = View.GONE - binding.studioRecycler.visibility = View.VISIBLE + val titlePosition = arrayListOf() + val concatAdapter = ConcatAdapter() + val map = author!!.yearMedia ?: return@observe + val keys = map.keys.toTypedArray() + var pos = 0 - val titlePosition = arrayListOf() - val concatAdapter = ConcatAdapter() - val map = author!!.yearMedia ?: return@observe - val keys = map.keys.toTypedArray() - var pos = 0 - - val gridSize = (screenWidth / 124f).toInt() - val gridLayoutManager = GridLayoutManager(this, gridSize) - gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int): Int { - return when (position in titlePosition) { - true -> gridSize - else -> 1 + val gridSize = (screenWidth / 124f).toInt() + val gridLayoutManager = GridLayoutManager(this, gridSize) + gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return when (position in titlePosition) { + true -> gridSize + else -> 1 + } } } - } - for (i in keys.indices) { - val medias = map[keys[i]]!! - val empty = if (medias.size >= 4) medias.size % 4 else 4 - medias.size - titlePosition.add(pos) - pos += (empty + medias.size + 1) + for (i in keys.indices) { + val medias = map[keys[i]]!! + val empty = if (medias.size >= 4) medias.size % 4 else 4 - medias.size + titlePosition.add(pos) + pos += (empty + medias.size + 1) - concatAdapter.addAdapter(TitleAdapter("${keys[i]} (${medias.size})")) - concatAdapter.addAdapter(MediaAdaptor(0, medias, this, true)) - concatAdapter.addAdapter(EmptyAdapter(empty)) - } + concatAdapter.addAdapter(TitleAdapter("${keys[i]} (${medias.size})")) + concatAdapter.addAdapter(MediaAdaptor(0, medias, this, true)) + concatAdapter.addAdapter(EmptyAdapter(empty)) + } - binding.studioRecycler.adapter = concatAdapter - binding.studioRecycler.layoutManager = gridLayoutManager + binding.studioRecycler.adapter = concatAdapter + binding.studioRecycler.layoutManager = gridLayoutManager + } } } val live = Refresh.activity.getOrPut(this.hashCode()) { MutableLiveData(true) } @@ -99,7 +115,7 @@ class AuthorActivity : AppCompatActivity() { if (it) { scope.launch { if (author != null) - withContext(Dispatchers.IO) { model.loadAuthor(author!!) } + withContext(Dispatchers.IO) { if (isVoiceArtist) model.loadVoiceActor(author!!) else model.loadAuthor(author!!)} live.postValue(false) } } diff --git a/app/src/main/java/ani/dantotsu/media/AuthorAdapter.kt b/app/src/main/java/ani/dantotsu/media/AuthorAdapter.kt index 56086b20..c7ac9645 100644 --- a/app/src/main/java/ani/dantotsu/media/AuthorAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/AuthorAdapter.kt @@ -15,7 +15,8 @@ import ani.dantotsu.setAnimation import java.io.Serializable class AuthorAdapter( - private val authorList: ArrayList + private val authorList: ArrayList, + private val isVA: Boolean = false, ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AuthorViewHolder { val binding = @@ -43,7 +44,7 @@ class AuthorAdapter( Intent( itemView.context, AuthorActivity::class.java - ).putExtra("author", author as Serializable), + ).putExtra("author", author as Serializable).putExtra("isVoiceArtist", isVA), ActivityOptionsCompat.makeSceneTransitionAnimation( itemView.context as Activity, Pair.create( diff --git a/app/src/main/java/ani/dantotsu/media/Character.kt b/app/src/main/java/ani/dantotsu/media/Character.kt index e774ed30..48746505 100644 --- a/app/src/main/java/ani/dantotsu/media/Character.kt +++ b/app/src/main/java/ani/dantotsu/media/Character.kt @@ -1,6 +1,8 @@ package ani.dantotsu.media import ani.dantotsu.connections.anilist.api.FuzzyDate +import ani.dantotsu.connections.anilist.api.Query +import org.checkerframework.checker.units.qual.A import java.io.Serializable data class Character( @@ -14,5 +16,6 @@ data class Character( var age: String? = null, var gender: String? = null, var dateOfBirth: FuzzyDate? = null, - var roles: ArrayList? = null + var roles: ArrayList? = null, + val voiceActor: ArrayList? = null, ) : Serializable \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/CharacterAdapter.kt b/app/src/main/java/ani/dantotsu/media/CharacterAdapter.kt index e82036eb..d97e138d 100644 --- a/app/src/main/java/ani/dantotsu/media/CharacterAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/CharacterAdapter.kt @@ -28,6 +28,7 @@ class CharacterAdapter( setAnimation(binding.root.context, holder.binding.root) val character = characterList[position] val whitespace = "${character.role} " + character.voiceActor binding.itemCompactRelation.text = whitespace binding.itemCompactImage.loadImage(character.image) binding.itemCompactTitle.text = character.name diff --git a/app/src/main/java/ani/dantotsu/media/CharacterDetailsAdapter.kt b/app/src/main/java/ani/dantotsu/media/CharacterDetailsAdapter.kt index ec111dc3..d3d5d811 100644 --- a/app/src/main/java/ani/dantotsu/media/CharacterDetailsAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/CharacterDetailsAdapter.kt @@ -2,7 +2,9 @@ package ani.dantotsu.media import android.app.Activity import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import ani.dantotsu.R import ani.dantotsu.currActivity @@ -36,7 +38,13 @@ class CharacterDetailsAdapter(private val character: Character, private val acti val markWon = Markwon.builder(activity).usePlugin(SoftBreakAddsNewLinePlugin.create()) .usePlugin(SpoilerPlugin()).build() markWon.setMarkdown(binding.characterDesc, desc.replace("~!", "||").replace("!~", "||")) - + binding.voiceActorRecycler.adapter = AuthorAdapter(character.voiceActor ?: arrayListOf(), true) + binding.voiceActorRecycler.layoutManager = LinearLayoutManager( + activity, LinearLayoutManager.HORIZONTAL, false + ) + if (binding.voiceActorRecycler.adapter!!.itemCount == 0) { + binding.voiceActorContainer.visibility = View.GONE + } } override fun getItemCount(): Int = 1 diff --git a/app/src/main/java/ani/dantotsu/media/MediaAdaptor.kt b/app/src/main/java/ani/dantotsu/media/MediaAdaptor.kt index 78d264ee..8fa4e1fa 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaAdaptor.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaAdaptor.kt @@ -173,6 +173,7 @@ class MediaAdaptor( val b = (holder as MediaPageViewHolder).binding val media = mediaList?.get(position) if (media != null) { + val bannerAnimations: Boolean = PrefManager.getVal(PrefName.BannerAnimations) b.itemCompactImage.loadImage(media.cover) if (bannerAnimations) @@ -182,7 +183,7 @@ class MediaAdaptor( AccelerateDecelerateInterpolator() ) ) - blurImage(b.itemCompactBanner, media.banner ?: media.cover) + blurImage(if (bannerAnimations) b.itemCompactBanner else b.itemCompactBannerNoKen , media.banner ?: media.cover) b.itemCompactOngoing.isVisible = media.status == currActivity()!!.getString(R.string.status_releasing) b.itemCompactTitle.text = media.userPreferredName @@ -231,7 +232,7 @@ class MediaAdaptor( AccelerateDecelerateInterpolator() ) ) - blurImage(b.itemCompactBanner, media.banner ?: media.cover) + blurImage(if (bannerAnimations) b.itemCompactBanner else b.itemCompactBannerNoKen , media.banner ?: media.cover) b.itemCompactOngoing.isVisible = media.status == currActivity()!!.getString(R.string.status_releasing) b.itemCompactTitle.text = media.userPreferredName diff --git a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt index a7b8858f..4f93cdde 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaDetailsActivity.kt @@ -13,6 +13,7 @@ import android.view.View import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import android.widget.ImageView +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources @@ -53,6 +54,7 @@ import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.snackString import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.util.LauncherWrapper import com.flaviofaria.kenburnsview.RandomTransitionGenerator import com.google.android.material.appbar.AppBarLayout import kotlinx.coroutines.CoroutineScope @@ -66,7 +68,7 @@ import kotlin.math.abs class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListener { - + lateinit var launcher: LauncherWrapper lateinit var binding: ActivityMediaBinding private val scope = lifecycleScope private val model: MediaDetailsViewModel by viewModels() @@ -92,6 +94,9 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi onBackPressedDispatcher.onBackPressed() return } + val contract = ActivityResultContracts.OpenDocumentTree() + launcher = LauncherWrapper(this, contract) + mediaSingleton = null ThemeManager(this).applyTheme(MediaSingleton.bitmap) MediaSingleton.bitmap = null @@ -576,4 +581,4 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi companion object { var mediaSingleton: Media? = null } -} +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt b/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt index 00d3224d..609de856 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt @@ -27,7 +27,6 @@ import ani.dantotsu.R import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.connections.anilist.GenresViewModel import ani.dantotsu.copyToClipboard -import ani.dantotsu.countDown import ani.dantotsu.currActivity import ani.dantotsu.databinding.ActivityGenreBinding import ani.dantotsu.databinding.FragmentMediaInfoBinding @@ -35,8 +34,10 @@ import ani.dantotsu.databinding.ItemChipBinding import ani.dantotsu.databinding.ItemQuelsBinding import ani.dantotsu.databinding.ItemTitleChipgroupBinding import ani.dantotsu.databinding.ItemTitleRecyclerBinding +import ani.dantotsu.databinding.ItemTitleSearchBinding import ani.dantotsu.databinding.ItemTitleTextBinding import ani.dantotsu.databinding.ItemTitleTrailerBinding +import ani.dantotsu.displayTimer import ani.dantotsu.loadImage import ani.dantotsu.navBarHeight import ani.dantotsu.px @@ -91,7 +92,6 @@ class MediaInfoFragment : Fragment() { if (media != null && !loaded) { loaded = true - binding.mediaInfoProgressBar.visibility = View.GONE binding.mediaInfoContainer.visibility = View.VISIBLE val infoName = tripleTab + (media.name ?: media.nameRomaji) @@ -225,8 +225,7 @@ class MediaInfoFragment : Fragment() { .setDuration(400).start() } } - - countDown(media, binding.mediaInfoContainer) + displayTimer(media, binding.mediaInfoContainer) val parent = _binding?.mediaInfoContainer!! val screenWidth = resources.displayMetrics.run { widthPixels / density } @@ -438,113 +437,138 @@ class MediaInfoFragment : Fragment() { if (!media.relations.isNullOrEmpty() && !offline) { if (media.sequel != null || media.prequel != null) { - val bind = ItemQuelsBinding.inflate( + ItemQuelsBinding.inflate( LayoutInflater.from(context), parent, false - ) + ).apply { - if (media.sequel != null) { - bind.mediaInfoSequel.visibility = View.VISIBLE - bind.mediaInfoSequelImage.loadImage( - media.sequel!!.banner ?: media.sequel!!.cover - ) - bind.mediaInfoSequel.setSafeOnClickListener { - ContextCompat.startActivity( - requireContext(), - Intent( - requireContext(), - MediaDetailsActivity::class.java - ).putExtra( - "media", - media.sequel as Serializable - ), null + if (media.sequel != null) { + mediaInfoSequel.visibility = View.VISIBLE + mediaInfoSequelImage.loadImage( + media.sequel!!.banner ?: media.sequel!!.cover ) - } - } - if (media.prequel != null) { - bind.mediaInfoPrequel.visibility = View.VISIBLE - bind.mediaInfoPrequelImage.loadImage( - media.prequel!!.banner ?: media.prequel!!.cover - ) - bind.mediaInfoPrequel.setSafeOnClickListener { - ContextCompat.startActivity( - requireContext(), - Intent( + mediaInfoSequel.setSafeOnClickListener { + ContextCompat.startActivity( requireContext(), - MediaDetailsActivity::class.java - ).putExtra( - "media", - media.prequel as Serializable - ), null - ) + Intent( + requireContext(), + MediaDetailsActivity::class.java + ).putExtra( + "media", + media.sequel as Serializable + ), null + ) + } } + if (media.prequel != null) { + mediaInfoPrequel.visibility = View.VISIBLE + mediaInfoPrequelImage.loadImage( + media.prequel!!.banner ?: media.prequel!!.cover + ) + mediaInfoPrequel.setSafeOnClickListener { + ContextCompat.startActivity( + requireContext(), + Intent( + requireContext(), + MediaDetailsActivity::class.java + ).putExtra( + "media", + media.prequel as Serializable + ), null + ) + } + } + parent.addView(root) + } + + ItemTitleSearchBinding.inflate( + LayoutInflater.from(context), + parent, + false + ).apply { + + titleSearchImage.loadImage(media.banner ?: media.cover) + titleSearchText.text = + getString(R.string.search_title, media.mainName()) + titleSearchCard.setSafeOnClickListener { + val query = Intent(requireContext(), SearchActivity::class.java) + .putExtra("type", "ANIME") + .putExtra("query", media.mainName()) + .putExtra("search", true) + ContextCompat.startActivity(requireContext(), query, null) + } + + parent.addView(root) } - parent.addView(bind.root) } - val bindi = ItemTitleRecyclerBinding.inflate( + ItemTitleRecyclerBinding.inflate( LayoutInflater.from(context), parent, false - ) + ).apply { - bindi.itemRecycler.adapter = - MediaAdaptor(0, media.relations!!, requireActivity()) - bindi.itemRecycler.layoutManager = LinearLayoutManager( - requireContext(), - LinearLayoutManager.HORIZONTAL, - false - ) - parent.addView(bindi.root) + itemRecycler.adapter = + MediaAdaptor(0, media.relations!!, requireActivity()) + itemRecycler.layoutManager = LinearLayoutManager( + requireContext(), + LinearLayoutManager.HORIZONTAL, + false + ) + parent.addView(root) + } } if (!media.characters.isNullOrEmpty() && !offline) { - val bind = ItemTitleRecyclerBinding.inflate( + ItemTitleRecyclerBinding.inflate( LayoutInflater.from(context), parent, false - ) - bind.itemTitle.setText(R.string.characters) - bind.itemRecycler.adapter = - CharacterAdapter(media.characters!!) - bind.itemRecycler.layoutManager = LinearLayoutManager( - requireContext(), - LinearLayoutManager.HORIZONTAL, - false - ) - parent.addView(bind.root) + ).apply { + itemTitle.setText(R.string.characters) + itemRecycler.adapter = + CharacterAdapter(media.characters!!) + itemRecycler.layoutManager = LinearLayoutManager( + requireContext(), + LinearLayoutManager.HORIZONTAL, + false + ) + parent.addView(root) + } } if (!media.staff.isNullOrEmpty() && !offline) { - val bind = ItemTitleRecyclerBinding.inflate( + ItemTitleRecyclerBinding.inflate( LayoutInflater.from(context), parent, false - ) - bind.itemTitle.setText(R.string.staff) - bind.itemRecycler.adapter = - AuthorAdapter(media.staff!!) - bind.itemRecycler.layoutManager = LinearLayoutManager( - requireContext(), - LinearLayoutManager.HORIZONTAL, - false - ) - parent.addView(bind.root) + ).apply { + itemTitle.setText(R.string.staff) + itemRecycler.adapter = + AuthorAdapter(media.staff!!) + itemRecycler.layoutManager = LinearLayoutManager( + requireContext(), + LinearLayoutManager.HORIZONTAL, + false + ) + parent.addView(root) + } } if (!media.recommendations.isNullOrEmpty() && !offline) { - val bind = ItemTitleRecyclerBinding.inflate( + ItemTitleRecyclerBinding.inflate( LayoutInflater.from(context), parent, false - ) - bind.itemTitle.setText(R.string.recommended) - bind.itemRecycler.adapter = - MediaAdaptor(0, media.recommendations!!, requireActivity()) - bind.itemRecycler.layoutManager = LinearLayoutManager( - requireContext(), - LinearLayoutManager.HORIZONTAL, - false - ) - parent.addView(bind.root) + ).apply { + itemTitle.setText(R.string.recommended) + itemRecycler.adapter = + MediaAdaptor(0, media.recommendations!!, requireActivity()) + itemRecycler.layoutManager = LinearLayoutManager( + requireContext(), + LinearLayoutManager.HORIZONTAL, + false + ) + parent.addView(root) + } } } } @@ -569,6 +593,7 @@ class MediaInfoFragment : Fragment() { } } } + super.onViewCreated(view, null) } diff --git a/app/src/main/java/ani/dantotsu/media/MediaNameAdapter.kt b/app/src/main/java/ani/dantotsu/media/MediaNameAdapter.kt new file mode 100644 index 00000000..07d151a0 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/media/MediaNameAdapter.kt @@ -0,0 +1,146 @@ +package ani.dantotsu.media + +import java.util.Locale +import java.util.regex.Matcher +import java.util.regex.Pattern + +object MediaNameAdapter { + + private const val REGEX_ITEM = "[\\s:.\\-]*(\\d+\\.?\\d*)[\\s:.\\-]*" + private const val REGEX_PART_NUMBER = "(? "sub" + SubDubType.DUB -> "dub" + SubDubType.NULL -> "" + } + val toggledCasePreserved = + if (subdub?.get(0)?.isUpperCase() == true || soft?.get(0) + ?.isUpperCase() == true + ) toggled.replaceFirstChar { + if (it.isLowerCase()) it.titlecase( + Locale.ROOT + ) else it.toString() + } else toggled + + subdubMatcher.replaceFirst(toggledCasePreserved + bed) + } else { + null + } + } + + fun getSubDub(text: String): SubDubType { + val subdubPattern: Pattern = Pattern.compile(REGEX_SUBDUB, Pattern.CASE_INSENSITIVE) + val subdubMatcher: Matcher = subdubPattern.matcher(text) + + return if (subdubMatcher.find()) { + val subdub = subdubMatcher.group(2)?.lowercase(Locale.ROOT) + when (subdub) { + "sub" -> SubDubType.SUB + "dub" -> SubDubType.DUB + else -> SubDubType.NULL + } + } else { + SubDubType.NULL + } + } + + enum class SubDubType { + SUB, DUB, NULL + } + + fun findSeasonNumber(text: String): Int? { + val seasonPattern: Pattern = Pattern.compile(REGEX_SEASON, Pattern.CASE_INSENSITIVE) + val seasonMatcher: Matcher = seasonPattern.matcher(text) + + return if (seasonMatcher.find()) { + seasonMatcher.group(2)?.toInt() + } else { + null + } + } + + fun findEpisodeNumber(text: String): Float? { + val episodePattern: Pattern = Pattern.compile(REGEX_EPISODE, Pattern.CASE_INSENSITIVE) + val episodeMatcher: Matcher = episodePattern.matcher(text) + + return if (episodeMatcher.find()) { + if (episodeMatcher.group(2) != null) { + episodeMatcher.group(2)?.toFloat() + } else { + val failedEpisodeNumberPattern: Pattern = + Pattern.compile(REGEX_PART_NUMBER, Pattern.CASE_INSENSITIVE) + val failedEpisodeNumberMatcher: Matcher = + failedEpisodeNumberPattern.matcher(text) + if (failedEpisodeNumberMatcher.find()) { + failedEpisodeNumberMatcher.group(1)?.toFloat() + } else { + null + } + } + } else { + null + } + } + + fun removeEpisodeNumber(text: String): String { + val regexPattern = Regex(REGEX_EPISODE, RegexOption.IGNORE_CASE) + val removedNumber = text.replace(regexPattern, "").ifEmpty { + text + } + val letterPattern = Regex("[a-zA-Z]") + return if (letterPattern.containsMatchIn(removedNumber)) { + removedNumber + } else { + text + } + } + + + fun removeEpisodeNumberCompletely(text: String): String { + val regexPattern = Regex(REGEX_EPISODE, RegexOption.IGNORE_CASE) + val removedNumber = text.replace(regexPattern, "") + return if (removedNumber.equals(text, true)) { // if nothing was removed + val failedEpisodeNumberPattern = + Regex(REGEX_PART_NUMBER, RegexOption.IGNORE_CASE) + failedEpisodeNumberPattern.replace(removedNumber) { mr -> + mr.value.replaceFirst(mr.groupValues[1], "") + } + } else { + removedNumber + } + } + + fun findChapterNumber(text: String): Float? { + val pattern: Pattern = Pattern.compile(REGEX_CHAPTER, Pattern.CASE_INSENSITIVE) + val matcher: Matcher = pattern.matcher(text) + + return if (matcher.find()) { + matcher.group(2)?.toFloat() + } else { + val failedChapterNumberPattern: Pattern = + Pattern.compile(REGEX_PART_NUMBER, Pattern.CASE_INSENSITIVE) + val failedChapterNumberMatcher: Matcher = + failedChapterNumberPattern.matcher(text) + if (failedChapterNumberMatcher.find()) { + failedChapterNumberMatcher.group(1)?.toFloat() + } else { + null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/OtherDetailsViewModel.kt b/app/src/main/java/ani/dantotsu/media/OtherDetailsViewModel.kt index 677d700f..394ddf0a 100644 --- a/app/src/main/java/ani/dantotsu/media/OtherDetailsViewModel.kt +++ b/app/src/main/java/ani/dantotsu/media/OtherDetailsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import ani.dantotsu.connections.anilist.Anilist +import org.checkerframework.checker.units.qual.A import java.text.DateFormat import java.util.Date @@ -25,12 +26,16 @@ class OtherDetailsViewModel : ViewModel() { suspend fun loadAuthor(m: Author) { if (author.value == null) author.postValue(Anilist.query.getAuthorDetails(m)) } - + private val voiceActor: MutableLiveData = MutableLiveData(null) + fun getVoiceActor(): LiveData = voiceActor + suspend fun loadVoiceActor(m: Author) { + if (voiceActor.value == null) voiceActor.postValue(Anilist.query.getVoiceActorsDetails(m)) + } private val calendar: MutableLiveData>> = MutableLiveData(null) fun getCalendar(): LiveData>> = calendar suspend fun loadCalendar() { val curr = System.currentTimeMillis() / 1000 - val res = Anilist.query.recentlyUpdated(false, curr - 86400, curr + (86400 * 6)) + val res = Anilist.query.recentlyUpdated(curr - 86400, curr + (86400 * 6)) val df = DateFormat.getDateInstance(DateFormat.FULL) val map = mutableMapOf>() val idMap = mutableMapOf>() diff --git a/app/src/main/java/ani/dantotsu/media/SearchActivity.kt b/app/src/main/java/ani/dantotsu/media/SearchActivity.kt index d2869be4..fb685fe9 100644 --- a/app/src/main/java/ani/dantotsu/media/SearchActivity.kt +++ b/app/src/main/java/ani/dantotsu/media/SearchActivity.kt @@ -70,11 +70,16 @@ class SearchActivity : AppCompatActivity() { intent.getStringExtra("type") ?: "ANIME", isAdult = if (Anilist.adult) intent.getBooleanExtra("hentai", false) else false, onList = listOnly, + search = intent.getStringExtra("query"), genres = intent.getStringExtra("genre")?.let { mutableListOf(it) }, tags = intent.getStringExtra("tag")?.let { mutableListOf(it) }, sort = intent.getStringExtra("sortBy"), + status = intent.getStringExtra("status"), + source = intent.getStringExtra("source"), + countryOfOrigin = intent.getStringExtra("country"), season = intent.getStringExtra("season"), - seasonYear = intent.getStringExtra("seasonYear")?.toIntOrNull(), + seasonYear = if (intent.getStringExtra("type") == "ANIME") intent.getStringExtra("seasonYear")?.toIntOrNull() else null, + startYear = if (intent.getStringExtra("type") == "MANGA") intent.getStringExtra("seasonYear")?.toIntOrNull() else null, results = mutableListOf(), hasNextPage = false ) @@ -133,8 +138,12 @@ class SearchActivity : AppCompatActivity() { excludedTags = it.excludedTags tags = it.tags season = it.season + startYear = it.startYear seasonYear = it.seasonYear + status = it.status + source = it.source format = it.format + countryOfOrigin = it.countryOfOrigin page = it.page hasNextPage = it.hasNextPage } diff --git a/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt b/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt index cb590e3a..f04cee00 100644 --- a/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/SearchAdapter.kt @@ -13,6 +13,8 @@ import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager +import android.widget.ImageView +import android.widget.PopupMenu import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.recyclerview.widget.LinearLayoutManager @@ -45,6 +47,19 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri private lateinit var searchHistoryAdapter: SearchHistoryAdapter private lateinit var binding: ItemSearchHeaderBinding + private fun updateFilterTextViewDrawable() { + val filterDrawable = when (activity.result.sort) { + Anilist.sortBy[0] -> R.drawable.ic_round_area_chart_24 + Anilist.sortBy[1] -> R.drawable.ic_round_filter_peak_24 + Anilist.sortBy[2] -> R.drawable.ic_round_star_graph_24 + Anilist.sortBy[3] -> R.drawable.ic_round_new_releases_24 + Anilist.sortBy[4] -> R.drawable.ic_round_filter_list_24 + Anilist.sortBy[5] -> R.drawable.ic_round_filter_list_24_reverse + Anilist.sortBy[6] -> R.drawable.ic_round_assist_walker_24 + else -> R.drawable.ic_round_filter_alt_24 + } + binding.filterTextView.setCompoundDrawablesWithIntrinsicBounds(filterDrawable, 0, 0, 0) + } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchHeaderViewHolder { val binding = ItemSearchHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) @@ -93,7 +108,7 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri binding.searchAdultCheck.isChecked = adult binding.searchList.isChecked = listOnly == true - binding.searchChipRecycler.adapter = SearchChipAdapter(activity).also { + binding.searchChipRecycler.adapter = SearchChipAdapter(activity, this).also { activity.updateChips = { it.update() } } @@ -103,6 +118,59 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri binding.searchFilter.setOnClickListener { SearchFilterBottomDialog.newInstance().show(activity.supportFragmentManager, "dialog") } + binding.searchFilter.setOnLongClickListener { + val popupMenu = PopupMenu(activity, binding.searchFilter) + popupMenu.menuInflater.inflate(R.menu.sortby_filter_menu, popupMenu.menu) + popupMenu.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.sort_by_score -> { + activity.result.sort = Anilist.sortBy[0] + activity.updateChips.invoke() + activity.search() + updateFilterTextViewDrawable() + } + R.id.sort_by_popular -> { + activity.result.sort = Anilist.sortBy[1] + activity.updateChips.invoke() + activity.search() + updateFilterTextViewDrawable() + } + R.id.sort_by_trending -> { + activity.result.sort = Anilist.sortBy[2] + activity.updateChips.invoke() + activity.search() + updateFilterTextViewDrawable() + } + R.id.sort_by_recent -> { + activity.result.sort = Anilist.sortBy[3] + activity.updateChips.invoke() + activity.search() + updateFilterTextViewDrawable() + } + R.id.sort_by_a_z -> { + activity.result.sort = Anilist.sortBy[4] + activity.updateChips.invoke() + activity.search() + updateFilterTextViewDrawable() + } + R.id.sort_by_z_a -> { + activity.result.sort = Anilist.sortBy[5] + activity.updateChips.invoke() + activity.search() + updateFilterTextViewDrawable() + } + R.id.sort_by_pure_pain -> { + activity.result.sort = Anilist.sortBy[6] + activity.updateChips.invoke() + activity.search() + updateFilterTextViewDrawable() + } + } + true + } + popupMenu.show() + true + } binding.searchByImage.setOnClickListener { activity.startActivity(Intent(activity, ImageSearchActivity::class.java)) } @@ -257,7 +325,7 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri } - class SearchChipAdapter(val activity: SearchActivity) : + class SearchChipAdapter(val activity: SearchActivity, private val searchAdapter: SearchAdapter) : RecyclerView.Adapter() { private var chips = activity.result.toChipList() @@ -274,11 +342,12 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri override fun onBindViewHolder(holder: SearchChipViewHolder, position: Int) { val chip = chips[position] holder.binding.root.apply { - text = chip.text + text = chip.text.replace("_", " ") setOnClickListener { activity.result.removeChip(chip) update() activity.search() + searchAdapter.updateFilterTextViewDrawable() } } } @@ -287,6 +356,7 @@ class SearchAdapter(private val activity: SearchActivity, private val type: Stri fun update() { chips = activity.result.toChipList() notifyDataSetChanged() + searchAdapter.updateFilterTextViewDrawable() } override fun getItemCount(): Int = chips.size diff --git a/app/src/main/java/ani/dantotsu/media/SearchFilterBottomDialog.kt b/app/src/main/java/ani/dantotsu/media/SearchFilterBottomDialog.kt index 297f8289..53ee263f 100644 --- a/app/src/main/java/ani/dantotsu/media/SearchFilterBottomDialog.kt +++ b/app/src/main/java/ani/dantotsu/media/SearchFilterBottomDialog.kt @@ -1,11 +1,15 @@ package ani.dantotsu.media +import android.animation.ObjectAnimator import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.View.GONE import android.view.ViewGroup +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.AnimationUtils import android.widget.ArrayAdapter +import android.widget.PopupMenu import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -17,6 +21,9 @@ import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.databinding.BottomSheetSearchFilterBinding import ani.dantotsu.databinding.ItemChipBinding import com.google.android.material.chip.Chip +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.util.Calendar class SearchFilterBottomDialog : BottomSheetDialogFragment() { @@ -38,6 +45,54 @@ class SearchFilterBottomDialog : BottomSheetDialogFragment() { private var exGenres = mutableListOf() private var selectedTags = mutableListOf() private var exTags = mutableListOf() + private fun updateChips() { + binding.searchFilterGenres.adapter?.notifyDataSetChanged() + binding.searchFilterTags.adapter?.notifyDataSetChanged() + } + + private fun startBounceZoomAnimation(view: View? = null) { + val targetView = view ?: binding.sortByFilter + val bounceZoomAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.bounce_zoom) + targetView.startAnimation(bounceZoomAnimation) + } + + private fun setSortByFilterImage() { + val filterDrawable = when (activity.result.sort) { + Anilist.sortBy[0] -> R.drawable.ic_round_area_chart_24 + Anilist.sortBy[1] -> R.drawable.ic_round_filter_peak_24 + Anilist.sortBy[2] -> R.drawable.ic_round_star_graph_24 + Anilist.sortBy[3] -> R.drawable.ic_round_new_releases_24 + Anilist.sortBy[4] -> R.drawable.ic_round_filter_list_24 + Anilist.sortBy[5] -> R.drawable.ic_round_filter_list_24_reverse + Anilist.sortBy[6] -> R.drawable.ic_round_assist_walker_24 + else -> R.drawable.ic_round_filter_alt_24 + } + binding.sortByFilter.setImageResource(filterDrawable) + } + + private fun resetSearchFilter() { + activity.result.sort = null + binding.sortByFilter.setImageResource(R.drawable.ic_round_filter_alt_24) + startBounceZoomAnimation(binding.sortByFilter) + activity.result.countryOfOrigin = null + binding.countryFilter.setImageResource(R.drawable.ic_round_globe_search_googlefonts) + startBounceZoomAnimation(binding.countryFilter) + + selectedGenres.clear() + exGenres.clear() + selectedTags.clear() + exTags.clear() + binding.searchStatus.setText("") + binding.searchSource.setText("") + binding.searchFormat.setText("") + binding.searchSeason.setText("") + binding.searchYear.setText("") + binding.searchStatus.clearFocus() + binding.searchFormat.clearFocus() + binding.searchSeason.clearFocus() + binding.searchYear.clearFocus() + updateChips() + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -47,14 +102,149 @@ class SearchFilterBottomDialog : BottomSheetDialogFragment() { exGenres = activity.result.excludedGenres ?: mutableListOf() selectedTags = activity.result.tags ?: mutableListOf() exTags = activity.result.excludedTags ?: mutableListOf() + setSortByFilterImage() + + binding.resetSearchFilter.setOnClickListener { + val rotateAnimation = ObjectAnimator.ofFloat(binding.resetSearchFilter, "rotation", 180f, 540f) + rotateAnimation.duration = 500 + rotateAnimation.interpolator = AccelerateDecelerateInterpolator() + rotateAnimation.start() + resetSearchFilter() + } + + binding.resetSearchFilter.setOnLongClickListener { + val rotateAnimation = ObjectAnimator.ofFloat(binding.resetSearchFilter, "rotation", 180f, 540f) + rotateAnimation.duration = 500 + rotateAnimation.interpolator = AccelerateDecelerateInterpolator() + rotateAnimation.start() + val bounceAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.bounce_zoom) + + binding.resetSearchFilter.startAnimation(bounceAnimation) + binding.resetSearchFilter.postDelayed({ + resetSearchFilter() + + CoroutineScope(Dispatchers.Main).launch { + activity.result.apply { + status = binding.searchStatus.text.toString().replace(" ", "_").ifBlank { null } + source = binding.searchSource.text.toString().replace(" ", "_").ifBlank { null } + format = binding.searchFormat.text.toString().ifBlank { null } + season = binding.searchSeason.text.toString().ifBlank { null } + startYear = binding.searchYear.text.toString().toIntOrNull() + seasonYear = binding.searchYear.text.toString().toIntOrNull() + sort = activity.result.sort + genres = selectedGenres + tags = selectedTags + excludedGenres = exGenres + excludedTags = exTags + } + activity.updateChips.invoke() + activity.search() + dismiss() + } + }, 500) + true + } + + binding.sortByFilter.setOnClickListener { + val popupMenu = PopupMenu(requireContext(), it) + popupMenu.menuInflater.inflate(R.menu.sortby_filter_menu, popupMenu.menu) + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.sort_by_score -> { + activity.result.sort = Anilist.sortBy[0] + binding.sortByFilter.setImageResource(R.drawable.ic_round_area_chart_24) + startBounceZoomAnimation() + } + + R.id.sort_by_popular -> { + activity.result.sort = Anilist.sortBy[1] + binding.sortByFilter.setImageResource(R.drawable.ic_round_filter_peak_24) + startBounceZoomAnimation() + } + + R.id.sort_by_trending -> { + activity.result.sort = Anilist.sortBy[2] + binding.sortByFilter.setImageResource(R.drawable.ic_round_star_graph_24) + startBounceZoomAnimation() + } + + R.id.sort_by_recent -> { + activity.result.sort = Anilist.sortBy[3] + binding.sortByFilter.setImageResource(R.drawable.ic_round_new_releases_24) + startBounceZoomAnimation() + } + + R.id.sort_by_a_z -> { + activity.result.sort = Anilist.sortBy[4] + binding.sortByFilter.setImageResource(R.drawable.ic_round_filter_list_24) + startBounceZoomAnimation() + } + + R.id.sort_by_z_a -> { + activity.result.sort = Anilist.sortBy[5] + binding.sortByFilter.setImageResource(R.drawable.ic_round_filter_list_24_reverse) + startBounceZoomAnimation() + } + + R.id.sort_by_pure_pain -> { + activity.result.sort = Anilist.sortBy[6] + binding.sortByFilter.setImageResource(R.drawable.ic_round_assist_walker_24) + startBounceZoomAnimation() + } + } + true + } + popupMenu.show() + } + + binding.countryFilter.setOnClickListener { + val popupMenu = PopupMenu(requireContext(), it) + popupMenu.menuInflater.inflate(R.menu.country_filter_menu, popupMenu.menu) + popupMenu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.country_global -> { + binding.countryFilter.setImageResource(R.drawable.ic_round_globe_search_googlefonts) + startBounceZoomAnimation(binding.countryFilter) + } + R.id.country_china -> { + activity.result.countryOfOrigin = "CN" + binding.countryFilter.setImageResource(R.drawable.ic_round_globe_china_googlefonts) + startBounceZoomAnimation(binding.countryFilter) + } + R.id.country_south_korea -> { + activity.result.countryOfOrigin = "KR" + binding.countryFilter.setImageResource(R.drawable.ic_round_globe_south_korea_googlefonts) + startBounceZoomAnimation(binding.countryFilter) + } + R.id.country_japan -> { + activity.result.countryOfOrigin = "JP" + binding.countryFilter.setImageResource(R.drawable.ic_round_globe_japan_googlefonts) + startBounceZoomAnimation(binding.countryFilter) + } + R.id.country_taiwan -> { + activity.result.countryOfOrigin = "TW" + binding.countryFilter.setImageResource(R.drawable.ic_round_globe_taiwan_googlefonts) + startBounceZoomAnimation(binding.countryFilter) + } + } + true + } + popupMenu.show() + } binding.searchFilterApply.setOnClickListener { activity.result.apply { + status = binding.searchStatus.text.toString().replace(" ", "_").ifBlank { null } + source = binding.searchSource.text.toString().replace(" ", "_").ifBlank { null } format = binding.searchFormat.text.toString().ifBlank { null } - sort = binding.searchSortBy.text.toString().ifBlank { null } - ?.let { Anilist.sortBy[resources.getStringArray(R.array.sort_by).indexOf(it)] } season = binding.searchSeason.text.toString().ifBlank { null } - seasonYear = binding.searchYear.text.toString().toIntOrNull() + if (activity.result.type == "ANIME") { + seasonYear = binding.searchYear.text.toString().toIntOrNull() + } else { + startYear = binding.searchYear.text.toString().toIntOrNull() + } + sort = activity.result.sort + countryOfOrigin = activity.result.countryOfOrigin genres = selectedGenres tags = selectedTags excludedGenres = exGenres @@ -67,15 +257,22 @@ class SearchFilterBottomDialog : BottomSheetDialogFragment() { binding.searchFilterCancel.setOnClickListener { dismiss() } - - binding.searchSortBy.setText(activity.result.sort?.let { - resources.getStringArray(R.array.sort_by)[Anilist.sortBy.indexOf(it)] - }) - binding.searchSortBy.setAdapter( + val format = if (activity.result.type == "ANIME") Anilist.animeStatus else Anilist.mangaStatus + binding.searchStatus.setText(activity.result.status?.replace("_", " ")) + binding.searchStatus.setAdapter( ArrayAdapter( binding.root.context, R.layout.item_dropdown, - resources.getStringArray(R.array.sort_by) + format + ) + ) + + binding.searchSource.setText(activity.result.source?.replace("_", " ")) + binding.searchSource.setAdapter( + ArrayAdapter( + binding.root.context, + R.layout.item_dropdown, + Anilist.source.toTypedArray() ) ) @@ -84,11 +281,25 @@ class SearchFilterBottomDialog : BottomSheetDialogFragment() { ArrayAdapter( binding.root.context, R.layout.item_dropdown, - (if (activity.result.type == "ANIME") Anilist.anime_formats else Anilist.manga_formats).toTypedArray() + (if (activity.result.type == "ANIME") Anilist.animeFormats else Anilist.mangaFormats).toTypedArray() ) ) - if (activity.result.type == "MANGA") binding.searchSeasonYearCont.visibility = GONE + if (activity.result.type == "ANIME") { + binding.searchYear.setText(activity.result.seasonYear?.toString()) + } else { + binding.searchYear.setText(activity.result.startYear?.toString()) + } + binding.searchYear.setAdapter( + ArrayAdapter( + binding.root.context, + R.layout.item_dropdown, + (1970 until Calendar.getInstance().get(Calendar.YEAR) + 2).map { it.toString() } + .reversed().toTypedArray() + ) + ) + + if (activity.result.type == "MANGA") binding.searchSeasonCont.visibility = GONE else { binding.searchSeason.setText(activity.result.season) binding.searchSeason.setAdapter( @@ -98,16 +309,6 @@ class SearchFilterBottomDialog : BottomSheetDialogFragment() { Anilist.seasons.toTypedArray() ) ) - - binding.searchYear.setText(activity.result.seasonYear?.toString()) - binding.searchYear.setAdapter( - ArrayAdapter( - binding.root.context, - R.layout.item_dropdown, - (1970 until Calendar.getInstance().get(Calendar.YEAR) + 2).map { it.toString() } - .reversed().toTypedArray() - ) - ) } binding.searchFilterGenres.adapter = FilterChipAdapter(Anilist.genres ?: listOf()) { chip -> diff --git a/app/src/main/java/ani/dantotsu/media/SubtitleDownloader.kt b/app/src/main/java/ani/dantotsu/media/SubtitleDownloader.kt index 6672f37d..38affa0d 100644 --- a/app/src/main/java/ani/dantotsu/media/SubtitleDownloader.kt +++ b/app/src/main/java/ani/dantotsu/media/SubtitleDownloader.kt @@ -5,6 +5,7 @@ import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager import ani.dantotsu.parsers.SubtitleType import ani.dantotsu.snackString +import com.anggrayudi.storage.file.openOutputStream import eu.kanade.tachiyomi.network.NetworkHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -51,21 +52,17 @@ class SubtitleDownloader { downloadedType: DownloadedType ) { try { - val directory = DownloadsManager.getDirectory( + val directory = DownloadsManager.getSubDirectory( context, downloadedType.type, + false, downloadedType.title, downloadedType.chapter - ) - if (!directory.exists()) { //just in case - directory.mkdirs() - } + ) ?: throw Exception("Could not create directory") val type = loadSubtitleType(url) - val subtiteFile = File(directory, "subtitle.${type}") - if (subtiteFile.exists()) { - subtiteFile.delete() - } - subtiteFile.createNewFile() + directory.findFile("subtitle.${type}")?.delete() + val subtitleFile = directory.createFile("*/*", "subtitle.${type}") + ?: throw Exception("Could not create subtitle file") val client = Injekt.get().client val request = Request.Builder().url(url).build() @@ -77,7 +74,8 @@ class SubtitleDownloader { } reponse.body.byteStream().use { input -> - subtiteFile.outputStream().use { output -> + subtitleFile.openOutputStream(context, false).use { output -> + if (output == null) throw Exception("Could not open output stream") input.copyTo(output) } } diff --git a/app/src/main/java/ani/dantotsu/media/anime/AnimeNameAdapter.kt b/app/src/main/java/ani/dantotsu/media/anime/AnimeNameAdapter.kt deleted file mode 100644 index 16142902..00000000 --- a/app/src/main/java/ani/dantotsu/media/anime/AnimeNameAdapter.kt +++ /dev/null @@ -1,127 +0,0 @@ -package ani.dantotsu.media.anime - -import java.util.Locale -import java.util.regex.Matcher -import java.util.regex.Pattern - -class AnimeNameAdapter { - companion object { - const val episodeRegex = - "(episode|episodio|ep|e)[\\s:.\\-]*(\\d+\\.?\\d*)[\\s:.\\-]*\\(?\\s*(sub|subbed|dub|dubbed)*\\s*\\)?\\s*" - const val failedEpisodeNumberRegex = - "(? "sub" - SubDubType.DUB -> "dub" - SubDubType.NULL -> "" - } - val toggledCasePreserved = - if (subdub?.get(0)?.isUpperCase() == true || soft?.get(0) - ?.isUpperCase() == true - ) toggled.replaceFirstChar { - if (it.isLowerCase()) it.titlecase( - Locale.ROOT - ) else it.toString() - } else toggled - - subdubMatcher.replaceFirst(toggledCasePreserved + bed) - } else { - null - } - } - - fun getSubDub(text: String): SubDubType { - val subdubPattern: Pattern = Pattern.compile(subdubRegex, Pattern.CASE_INSENSITIVE) - val subdubMatcher: Matcher = subdubPattern.matcher(text) - - return if (subdubMatcher.find()) { - val subdub = subdubMatcher.group(2)?.lowercase(Locale.ROOT) - when (subdub) { - "sub" -> SubDubType.SUB - "dub" -> SubDubType.DUB - else -> SubDubType.NULL - } - } else { - SubDubType.NULL - } - } - - enum class SubDubType { - SUB, DUB, NULL - } - - fun findSeasonNumber(text: String): Int? { - val seasonPattern: Pattern = Pattern.compile(seasonRegex, Pattern.CASE_INSENSITIVE) - val seasonMatcher: Matcher = seasonPattern.matcher(text) - - return if (seasonMatcher.find()) { - seasonMatcher.group(2)?.toInt() - } else { - null - } - } - - fun findEpisodeNumber(text: String): Float? { - val episodePattern: Pattern = Pattern.compile(episodeRegex, Pattern.CASE_INSENSITIVE) - val episodeMatcher: Matcher = episodePattern.matcher(text) - - return if (episodeMatcher.find()) { - if (episodeMatcher.group(2) != null) { - episodeMatcher.group(2)?.toFloat() - } else { - val failedEpisodeNumberPattern: Pattern = - Pattern.compile(failedEpisodeNumberRegex, Pattern.CASE_INSENSITIVE) - val failedEpisodeNumberMatcher: Matcher = - failedEpisodeNumberPattern.matcher(text) - if (failedEpisodeNumberMatcher.find()) { - failedEpisodeNumberMatcher.group(1)?.toFloat() - } else { - null - } - } - } else { - null - } - } - - fun removeEpisodeNumber(text: String): String { - val regexPattern = Regex(episodeRegex, RegexOption.IGNORE_CASE) - val removedNumber = text.replace(regexPattern, "").ifEmpty { - text - } - val letterPattern = Regex("[a-zA-Z]") - return if (letterPattern.containsMatchIn(removedNumber)) { - removedNumber - } else { - text - } - } - - - fun removeEpisodeNumberCompletely(text: String): String { - val regexPattern = Regex(episodeRegex, RegexOption.IGNORE_CASE) - val removedNumber = text.replace(regexPattern, "") - return if (removedNumber.equals(text, true)) { // if nothing was removed - val failedEpisodeNumberPattern = - Regex(failedEpisodeNumberRegex, RegexOption.IGNORE_CASE) - failedEpisodeNumberPattern.replace(removedNumber) { mr -> - mr.value.replaceFirst(mr.groupValues[1], "") - } - } else { - removedNumber - } - } - } -} 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 792a687c..d5d11b9e 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchAdapter.kt @@ -17,15 +17,16 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import ani.dantotsu.FileUrl import ani.dantotsu.R -import ani.dantotsu.countDown import ani.dantotsu.currActivity import ani.dantotsu.databinding.DialogLayoutBinding import ani.dantotsu.databinding.ItemAnimeWatchBinding import ani.dantotsu.databinding.ItemChipBinding +import ani.dantotsu.displayTimer import ani.dantotsu.isOnline import ani.dantotsu.loadImage import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsActivity +import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.SourceSearchDialogFragment import ani.dantotsu.openSettings import ani.dantotsu.others.LanguageMapper @@ -51,7 +52,7 @@ class AnimeWatchAdapter( private val fragment: AnimeWatchFragment, private val watchSources: WatchSources ) : RecyclerView.Adapter() { - + private var autoSelect = true var subscribe: MediaDetailsActivity.PopImageButton? = null private var _binding: ItemAnimeWatchBinding? = null @@ -403,7 +404,7 @@ class AnimeWatchAdapter( } val ep = media.anime.episodes!![continueEp]!! - val cleanedTitle = ep.title?.let { AnimeNameAdapter.removeEpisodeNumber(it) } + val cleanedTitle = ep.title?.let { MediaNameAdapter.removeEpisodeNumber(it) } binding.itemEpisodeImage.loadImage( ep.thumb ?: FileUrl[media.banner ?: media.cover], 0 @@ -436,7 +437,8 @@ class AnimeWatchAdapter( val sourceFound = media.anime.episodes!!.isNotEmpty() binding.animeSourceNotFound.isGone = sourceFound binding.faqbutton.isGone = sourceFound - if (!sourceFound && PrefManager.getVal(PrefName.SearchSources)) { + + if (!sourceFound && PrefManager.getVal(PrefName.SearchSources) && autoSelect) { if (binding.animeSource.adapter.count > media.selected!!.sourceIndex + 1) { val nextIndex = media.selected!!.sourceIndex + 1 binding.animeSource.setText(binding.animeSource.adapter @@ -452,6 +454,7 @@ class AnimeWatchAdapter( fragment.loadEpisodes(nextIndex, false) } } + binding.animeSource.setOnClickListener { autoSelect = false } } else { binding.animeSourceContinue.visibility = View.GONE binding.animeSourceNotFound.visibility = View.GONE @@ -497,8 +500,7 @@ class AnimeWatchAdapter( inner class ViewHolder(val binding: ItemAnimeWatchBinding) : RecyclerView.ViewHolder(binding.root) { init { - //Timer - countDown(media, binding.animeSourceContainer) + 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 f02a556d..2554f283 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt @@ -14,6 +14,7 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.Toast import androidx.annotation.OptIn +import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.math.MathUtils @@ -34,13 +35,14 @@ import ani.dantotsu.R import ani.dantotsu.databinding.FragmentAnimeWatchBinding import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.findValidName import ani.dantotsu.download.anime.AnimeDownloaderService -import ani.dantotsu.download.video.ExoplayerDownloadService import ani.dantotsu.dp import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsActivity import ani.dantotsu.media.MediaDetailsViewModel import ani.dantotsu.media.MediaType +import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.navBarHeight import ani.dantotsu.notifications.subscription.SubscriptionHelper import ani.dantotsu.notifications.subscription.SubscriptionHelper.Companion.saveSubscription @@ -53,6 +55,8 @@ 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.StoragePermissions.Companion.accessAlertDialog +import ani.dantotsu.util.StoragePermissions.Companion.hasDirAccess import com.google.android.material.appbar.AppBarLayout import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension @@ -224,7 +228,7 @@ class AnimeWatchFragment : Fragment() { if (media.anime!!.kitsuEpisodes!!.containsKey(i)) { episode.desc = media.anime!!.kitsuEpisodes!![i]?.desc ?: episode.desc - episode.title = if (AnimeNameAdapter.removeEpisodeNumberCompletely( + episode.title = if (MediaNameAdapter.removeEpisodeNumberCompletely( episode.title ?: "" ).isBlank() ) media.anime!!.kitsuEpisodes!![i]?.title @@ -421,7 +425,19 @@ class AnimeWatchFragment : Fragment() { } fun onAnimeEpisodeDownloadClick(i: String) { - model.onEpisodeClick(media, i, requireActivity().supportFragmentManager, isDownload = true) + activity?.let{ + if (!hasDirAccess(it)) { + (it as MediaDetailsActivity).accessAlertDialog(it.launcher) { success -> + if (success) { + model.onEpisodeClick(media, i, requireActivity().supportFragmentManager, isDownload = true) + } else { + snackString("Permission is required to download") + } + } + } else { + model.onEpisodeClick(media, i, requireActivity().supportFragmentManager, isDownload = true) + } + } } fun onAnimeEpisodeStopDownloadClick(i: String) { @@ -441,8 +457,9 @@ class AnimeWatchFragment : Fragment() { i, MediaType.ANIME ) - ) - episodeAdapter.purgeDownload(i) + ) { + episodeAdapter.purgeDownload(i) + } } @OptIn(UnstableApi::class) @@ -453,20 +470,15 @@ class AnimeWatchFragment : Fragment() { i, MediaType.ANIME ) - ) - val taskName = AnimeDownloaderService.AnimeDownloadTask.getTaskName(media.mainName(), i) - val id = PrefManager.getAnimeDownloadPreferences().getString( - taskName, - "" - ) ?: "" - PrefManager.getAnimeDownloadPreferences().edit().remove(taskName).apply() - DownloadService.sendRemoveDownload( - requireContext(), - ExoplayerDownloadService::class.java, - id, - true - ) - episodeAdapter.deleteDownload(i) + ) { + val taskName = AnimeDownloaderService.AnimeDownloadTask.getTaskName(media.mainName(), i) + val id = PrefManager.getAnimeDownloadPreferences().getString( + taskName, + "" + ) ?: "" + PrefManager.getAnimeDownloadPreferences().edit().remove(taskName).apply() + episodeAdapter.deleteDownload(i) + } } private val downloadStatusReceiver = object : BroadcastReceiver() { @@ -530,7 +542,7 @@ class AnimeWatchFragment : Fragment() { episodeAdapter.updateType(style ?: PrefManager.getVal(PrefName.AnimeDefaultView)) episodeAdapter.notifyItemRangeInserted(0, arr.size) for (download in downloadManager.animeDownloadedTypes) { - if (download.title == media.mainName()) { + if (download.title == media.mainName().findValidName()) { episodeAdapter.stopDownload(download.chapter) } } 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 21f3e439..86e8af61 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/EpisodeAdapters.kt @@ -10,7 +10,6 @@ import androidx.annotation.OptIn import androidx.core.view.isVisible import androidx.lifecycle.coroutineScope import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.offline.DownloadIndex import androidx.recyclerview.widget.RecyclerView import ani.dantotsu.R import ani.dantotsu.connections.updateProgress @@ -18,9 +17,12 @@ import ani.dantotsu.currContext import ani.dantotsu.databinding.ItemEpisodeCompactBinding import ani.dantotsu.databinding.ItemEpisodeGridBinding import ani.dantotsu.databinding.ItemEpisodeListBinding +import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getDirSize import ani.dantotsu.download.anime.AnimeDownloaderService -import ani.dantotsu.download.video.Helper import ani.dantotsu.media.Media +import ani.dantotsu.media.MediaNameAdapter +import ani.dantotsu.media.MediaType import ani.dantotsu.setAnimation import ani.dantotsu.settings.saving.PrefManager import com.bumptech.glide.Glide @@ -55,15 +57,7 @@ class EpisodeAdapter( var arr: List = arrayListOf(), var offlineMode: Boolean ) : RecyclerView.Adapter() { - - private lateinit var index: DownloadIndex - - - init { - if (offlineMode) { - index = Helper.downloadManager(fragment.requireContext()).downloadIndex - } - } + val context = fragment.requireContext() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return (when (viewType) { @@ -102,7 +96,7 @@ class EpisodeAdapter( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val ep = arr[position] val title = if (!ep.title.isNullOrEmpty() && ep.title != "null") { - ep.title?.let { AnimeNameAdapter.removeEpisodeNumber(it) } + ep.title?.let { MediaNameAdapter.removeEpisodeNumber(it) } } else { ep.number } ?: "" @@ -247,17 +241,8 @@ class EpisodeAdapter( // Find the position of the chapter and notify only that item val position = arr.indexOfFirst { it.number == episodeNumber } if (position != -1) { - val taskName = AnimeDownloaderService.AnimeDownloadTask.getTaskName( - media.mainName(), - episodeNumber - ) - val id = PrefManager.getAnimeDownloadPreferences().getString( - taskName, - "" - ) ?: "" val size = try { - val download = index.getDownload(id) - bytesToHuman(download?.bytesDownloaded ?: 0) + bytesToHuman(getDirSize(context, MediaType.ANIME, media.mainName(), episodeNumber)) } catch (e: Exception) { null } 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 259cfb89..8211cb08 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/ExoplayerView.kt @@ -104,7 +104,7 @@ import ani.dantotsu.connections.discord.RPC import ani.dantotsu.connections.updateProgress import ani.dantotsu.databinding.ActivityExoplayerBinding import ani.dantotsu.defaultHeaders -import ani.dantotsu.download.video.Helper +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.dp import ani.dantotsu.getCurrentBrightnessValue import ani.dantotsu.hideSystemBars @@ -113,6 +113,8 @@ import ani.dantotsu.isOnline import ani.dantotsu.logError import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsViewModel +import ani.dantotsu.media.MediaNameAdapter +import ani.dantotsu.media.MediaType import ani.dantotsu.media.SubtitleDownloader import ani.dantotsu.okHttpClient import ani.dantotsu.others.AniSkip @@ -393,7 +395,8 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL isCastApiAvailable = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS try { - castContext = CastContext.getSharedInstance(this, Executors.newSingleThreadExecutor()).result + castContext = + CastContext.getSharedInstance(this, Executors.newSingleThreadExecutor()).result castPlayer = CastPlayer(castContext!!) castPlayer!!.setSessionAvailabilityListener(this) } catch (e: Exception) { @@ -441,41 +444,43 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL }, AUDIO_CONTENT_TYPE_MOVIE, AUDIOFOCUS_GAIN) if (System.getInt(contentResolver, System.ACCELEROMETER_ROTATION, 0) != 1) { - if (PrefManager.getVal(PrefName.RotationPlayer)) { - orientationListener = - object : OrientationEventListener(this, SensorManager.SENSOR_DELAY_UI) { - override fun onOrientationChanged(orientation: Int) { - when (orientation) { - in 45..135 -> { - if (rotation != ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) { - exoRotate.visibility = View.VISIBLE + if (PrefManager.getVal(PrefName.RotationPlayer)) { + orientationListener = + object : OrientationEventListener(this, SensorManager.SENSOR_DELAY_UI) { + override fun onOrientationChanged(orientation: Int) { + when (orientation) { + in 45..135 -> { + if (rotation != ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) { + exoRotate.visibility = View.VISIBLE + } + rotation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + } + + in 225..315 -> { + if (rotation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) { + exoRotate.visibility = View.VISIBLE + } + rotation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + + in 315..360, in 0..45 -> { + if (rotation != ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) { + exoRotate.visibility = View.VISIBLE + } + rotation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } } - rotation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE - } - in 225..315 -> { - if (rotation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) { - exoRotate.visibility = View.VISIBLE - } - rotation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } - in 315..360, in 0..45 -> { - if (rotation != ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) { - exoRotate.visibility = View.VISIBLE - } - rotation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } } - } + orientationListener?.enable() } - orientationListener?.enable() - } - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - exoRotate.setOnClickListener { - requestedOrientation = rotation - it.visibility = View.GONE - } -} + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + exoRotate.setOnClickListener { + requestedOrientation = rotation + it.visibility = View.GONE + } + } setupSubFormatting(playerView) @@ -998,7 +1003,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL episodeTitleArr = arrayListOf() episodes.forEach { val episode = it.value - val cleanedTitle = AnimeNameAdapter.removeEpisodeNumberCompletely(episode.title ?: "") + val cleanedTitle = MediaNameAdapter.removeEpisodeNumberCompletely(episode.title ?: "") episodeTitleArr.add("Episode ${episode.number}${if (episode.filler) " [Filler]" else ""}${if (cleanedTitle.isNotBlank() && cleanedTitle != "null") ": $cleanedTitle" else ""}") } @@ -1083,35 +1088,48 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL val incognito: Boolean = PrefManager.getVal(PrefName.Incognito) if ((isOnline(context) && !offline) && Discord.token != null && !incognito) { lifecycleScope.launch { - val presence = RPC.createPresence(RPC.Companion.RPCData( - applicationId = Discord.application_Id, - type = RPC.Type.WATCHING, - activityName = media.userPreferredName, - details = ep.title?.takeIf { it.isNotEmpty() } ?: getString( - R.string.episode_num, - ep.number - ), - state = "Episode : ${ep.number}/${media.anime?.totalEpisodes ?: "??"}", - largeImage = media.cover?.let { - RPC.Link( - media.userPreferredName, - it - ) - }, - smallImage = RPC.Link( - "Dantotsu", - Discord.small_Image - ), - buttons = mutableListOf( + val discordMode = PrefManager.getCustomVal("discord_mode", "dantotsu") + val buttons = when (discordMode) { + "nothing" -> mutableListOf( RPC.Link(getString(R.string.view_anime), media.shareLink ?: ""), - RPC.Link( - "Stream on Dantotsu", - getString(R.string.github) + ) + + "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) } @@ -1119,7 +1137,6 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL startService(intent) } } - updateProgress() } } @@ -1156,7 +1173,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL if (PrefManager.getVal(PrefName.Cast)) { playerView.findViewById(R.id.exo_cast).apply { visibility = View.VISIBLE - if(PrefManager.getVal(PrefName.UseInternalCast)) { + if (PrefManager.getVal(PrefName.UseInternalCast)) { try { CastButtonFactory.setUpMediaRouteButton(context, this) dialogFactory = CustomCastThemeFactory() @@ -1319,7 +1336,11 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL ) @Suppress("UNCHECKED_CAST") - val list = (PrefManager.getNullableCustomVal("continueAnimeList", listOf(), List::class.java) as List).toMutableList() + val list = (PrefManager.getNullableCustomVal( + "continueAnimeList", + listOf(), + List::class.java + ) as List).toMutableList() if (list.contains(media.id)) list.remove(media.id) list.add(media.id) PrefManager.setCustomVal("continueAnimeList", list) @@ -1413,7 +1434,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL } val dafuckDataSourceFactory = DefaultDataSource.Factory(this) cacheFactory = CacheDataSource.Factory().apply { - setCache(Helper.getSimpleCache(this@ExoplayerView)) + setCache(VideoCache.getInstance(this@ExoplayerView)) if (ext.server.offline) { setUpstreamDataSourceFactory(dafuckDataSourceFactory) } else { @@ -1430,15 +1451,28 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL val downloadedMediaItem = if (ext.server.offline) { val key = ext.server.name - downloadId = PrefManager.getAnimeDownloadPreferences() - .getString(key, null) - if (downloadId != null) { - Helper.downloadManager(this) - .downloadIndex.getDownload(downloadId!!)?.request?.toMediaItem() + val titleName = ext.server.name.split("/").first() + val episodeName = ext.server.name.split("/").last() + + val directory = getSubDirectory(this, MediaType.ANIME, false, titleName, episodeName) + if (directory != null) { + val files = directory.listFiles() + println(files) + val docFile = directory.listFiles().firstOrNull { + it.name?.endsWith(".mp4") == true || it.name?.endsWith(".mkv") == true + } + if (docFile != null) { + val uri = docFile.uri + MediaItem.Builder().setUri(uri).setMimeType(mimeType).build() + } else { + snackString("File not found") + null + } } else { - snackString("Download not found") + snackString("Directory not found") null } + } else null mediaItem = if (downloadedMediaItem == null) { @@ -1813,7 +1847,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL if (!functionstarted && !disappeared && PrefManager.getVal(PrefName.AutoHideTimeStamps)) { disappearSkip() - } else if (!PrefManager.getVal(PrefName.AutoHideTimeStamps)){ + } else if (!PrefManager.getVal(PrefName.AutoHideTimeStamps)) { skipTimeButton.visibility = View.VISIBLE exoSkip.visibility = View.GONE skipTimeText.text = new.skipType.getType() @@ -2152,11 +2186,16 @@ class CustomCastButton : MediaRouteButton { fun setCastCallback(castCallback: () -> Unit) { this.castCallback = castCallback } + constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) override fun performClick(): Boolean { return if (PrefManager.getVal(PrefName.UseInternalCast)) { 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 292a9660..5ecd8af7 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt @@ -30,10 +30,13 @@ import ani.dantotsu.currActivity import ani.dantotsu.databinding.BottomSheetSelectorBinding import ani.dantotsu.databinding.ItemStreamBinding import ani.dantotsu.databinding.ItemUrlBinding +import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.video.Helper import ani.dantotsu.hideSystemBars import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsViewModel +import ani.dantotsu.media.MediaType +import ani.dantotsu.media.SubtitleDownloader import ani.dantotsu.navBarHeight import ani.dantotsu.others.Download.download import ani.dantotsu.parsers.Subtitle @@ -376,6 +379,45 @@ class SelectorDialogFragment : BottomSheetDialogFragment() { } else { binding.urlDownload.visibility = View.GONE } + val subtitles = extractor.subtitles + if (subtitles.isNotEmpty()) { + binding.urlSub.visibility = View.VISIBLE + } else { + binding.urlSub.visibility = View.GONE + } + binding.urlSub.setOnClickListener { + if (subtitles.isNotEmpty()) { + val subtitleNames = subtitles.map { it.language } + var subtitleToDownload: Subtitle? = null + val alertDialog = AlertDialog.Builder(context, R.style.MyPopup) + .setTitle("Download Subtitle") + .setSingleChoiceItems( + subtitleNames.toTypedArray(), + -1 + ) { _, which -> + subtitleToDownload = subtitles[which] + } + .setPositiveButton("Download") { dialog, _ -> + scope.launch { + if (subtitleToDownload != null) { + SubtitleDownloader.downloadSubtitle( + requireContext(), + subtitleToDownload!!.file.url, + DownloadedType(media!!.mainName(), media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!.number, MediaType.ANIME) + ) + } + } + dialog.dismiss() + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + } + .show() + alertDialog.window?.setDimAmount(0.8f) + } else { + snackString("No Subtitles Available") + } + } binding.urlDownload.setSafeOnClickListener { media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!.selectedExtractor = extractor.server.name 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 3de12420..769d67d7 100644 --- a/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt +++ b/app/src/main/java/ani/dantotsu/media/comments/CommentItem.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.graphics.Color import android.view.View import android.widget.Toast +import android.widget.PopupMenu import androidx.core.content.ContextCompat import androidx.recyclerview.widget.LinearLayoutManager import ani.dantotsu.R @@ -35,13 +36,15 @@ import java.util.TimeZone import kotlin.math.abs import kotlin.math.sqrt -class CommentItem(val comment: Comment, - private val markwon: Markwon, - val parentSection: Section, - private val commentsFragment: CommentsFragment, - private val backgroundColor: Int, - val commentDepth: Int -) : BindableItem() { +class CommentItem( + val comment: Comment, + private val markwon: Markwon, + val parentSection: Section, + private val commentsFragment: CommentsFragment, + private val backgroundColor: Int, + val commentDepth: Int +) : + BindableItem() { lateinit var binding: ItemCommentsBinding val adapter = GroupieAdapter() private var subCommentIds: MutableList = mutableListOf() @@ -63,9 +66,6 @@ class CommentItem(val comment: Comment, val isUserComment = CommentsAPI.userId == comment.userId val levelColor = getAvatarColor(comment.totalVotes, backgroundColor) markwon.setMarkdown(viewBinding.commentText, comment.content) - viewBinding.commentDelete.visibility = if (isUserComment || CommentsAPI.isAdmin || CommentsAPI.isMod) View.VISIBLE else View.GONE - viewBinding.commentBanUser.visibility = if ((CommentsAPI.isAdmin || CommentsAPI.isMod) && !isUserComment) View.VISIBLE else View.GONE - viewBinding.commentReport.visibility = if (!isUserComment) View.VISIBLE else View.GONE viewBinding.commentEdit.visibility = if (isUserComment) View.VISIBLE else View.GONE if (comment.tag == null) { viewBinding.commentUserTagLayout.visibility = View.GONE @@ -157,41 +157,71 @@ class CommentItem(val comment: Comment, Toast.makeText(context, "Owner", Toast.LENGTH_SHORT).show() } } - viewBinding.commentDelete.setOnClickListener { - dialogBuilder(getAppString(R.string.delete_comment), getAppString(R.string.delete_comment_confirm)) { - CoroutineScope(Dispatchers.Main + SupervisorJob()).launch { - val success = CommentsAPI.deleteComment(comment.commentId) - if (success) { - snackString(R.string.comment_deleted) - parentSection.remove(this@CommentItem) - } - } - } - } - viewBinding.commentBanUser.setOnClickListener { - dialogBuilder(getAppString(R.string.ban_user), getAppString(R.string.ban_user_confirm)) { - CoroutineScope(Dispatchers.Main + SupervisorJob()).launch { - val success = CommentsAPI.banUser(comment.userId) - if (success) { - snackString(R.string.user_banned) - } - } - } - } - viewBinding.commentReport.setOnClickListener { - dialogBuilder(getAppString(R.string.report_comment), getAppString(R.string.report_comment_confirm)) { - CoroutineScope(Dispatchers.Main + SupervisorJob()).launch { - val success = CommentsAPI.reportComment( - comment.commentId, - comment.username, - commentsFragment.mediaName, - comment.userId - ) - if (success) { - snackString(R.string.comment_reported) + viewBinding.commentInfo.setOnClickListener { + val popup = PopupMenu(commentsFragment.requireContext(), viewBinding.commentInfo) + popup.menuInflater.inflate(R.menu.profile_details_menu, popup.menu) + popup.menu.findItem(R.id.commentDelete)?.isVisible = isUserComment || CommentsAPI.isAdmin || CommentsAPI.isMod + popup.menu.findItem(R.id.commentBanUser)?.isVisible = (CommentsAPI.isAdmin || CommentsAPI.isMod) && !isUserComment + popup.menu.findItem(R.id.commentReport)?.isVisible = !isUserComment + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.commentReport -> { + dialogBuilder( + getAppString(R.string.report_comment), + getAppString(R.string.report_comment_confirm) + ) { + CoroutineScope(Dispatchers.Main + SupervisorJob()).launch { + val success = CommentsAPI.reportComment( + comment.commentId, + comment.username, + commentsFragment.mediaName, + comment.userId + ) + if (success) { + snackString(R.string.comment_reported) + } + } + } + true + } + + R.id.commentDelete -> { + dialogBuilder( + getAppString(R.string.delete_comment), + getAppString(R.string.delete_comment_confirm) + ) { + CoroutineScope(Dispatchers.Main + SupervisorJob()).launch { + val success = CommentsAPI.deleteComment(comment.commentId) + if (success) { + snackString(R.string.comment_deleted) + parentSection.remove(this@CommentItem) + } + } + } + true + } + + R.id.commentBanUser -> { + dialogBuilder( + getAppString(R.string.ban_user), + getAppString(R.string.ban_user_confirm) + ) { + CoroutineScope(Dispatchers.Main + SupervisorJob()).launch { + val success = CommentsAPI.banUser(comment.userId) + if (success) { + snackString(R.string.user_banned) + } + } + } + true + } + + else -> { + false } } } + popup.show() } //fill the icon if the user has liked the comment setVoteButtons(viewBinding) @@ -227,7 +257,6 @@ class CommentItem(val comment: Comment, comment.upvotes -= 1 } comment.downvotes += if (voteType == -1) 1 else -1 - notifyChanged() } } 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 7e8143a4..fec27104 100644 --- a/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/comments/CommentsFragment.kt @@ -75,7 +75,7 @@ class CommentsFragment : Fragment() { super.onViewCreated(view, savedInstanceState) activity = requireActivity() as MediaDetailsActivity - binding.commentsList.setBaseline(activity.navBar, activity.binding.commentInputLayout) + binding.commentsListContainer.setBaseline(activity.navBar, activity.binding.commentInputLayout) //get the media id from the intent val mediaId = arguments?.getInt("mediaId") ?: -1 @@ -370,7 +370,6 @@ class CommentsFragment : Fragment() { override fun onResume() { super.onResume() tag = null - binding.commentsList.setBaseline(activity.navBar, activity.binding.commentInputLayout) section.groups.forEach { if (it is CommentItem && it.containsGif()) { it.notifyChanged() diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaChapterAdapter.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaChapterAdapter.kt index 19d039f5..393d87b9 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaChapterAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/MangaChapterAdapter.kt @@ -15,6 +15,7 @@ import ani.dantotsu.currContext import ani.dantotsu.databinding.ItemChapterListBinding import ani.dantotsu.databinding.ItemEpisodeCompactBinding import ani.dantotsu.media.Media +import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.setAnimation import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -267,10 +268,10 @@ class MangaChapterAdapter( val binding = holder.binding setAnimation(fragment.requireContext(), holder.binding.root) val ep = arr[position] - val parsedNumber = MangaNameAdapter.findChapterNumber(ep.number)?.toInt() + val parsedNumber = MediaNameAdapter.findChapterNumber(ep.number)?.toInt() binding.itemEpisodeNumber.text = parsedNumber?.toString() ?: ep.number if (media.userProgress != null) { - if ((MangaNameAdapter.findChapterNumber(ep.number) + if ((MediaNameAdapter.findChapterNumber(ep.number) ?: 9999f) <= media.userProgress!!.toFloat() ) binding.itemEpisodeViewedCover.visibility = View.VISIBLE @@ -279,7 +280,7 @@ class MangaChapterAdapter( binding.itemEpisodeCont.setOnLongClickListener { updateProgress( media, - MangaNameAdapter.findChapterNumber(ep.number).toString() + MediaNameAdapter.findChapterNumber(ep.number).toString() ) true } @@ -315,7 +316,7 @@ class MangaChapterAdapter( } else binding.itemChapterTitle.visibility = View.VISIBLE if (media.userProgress != null) { - if ((MangaNameAdapter.findChapterNumber(ep.number) + if ((MediaNameAdapter.findChapterNumber(ep.number) ?: 9999f) <= media.userProgress!!.toFloat() ) { binding.itemEpisodeViewedCover.visibility = View.VISIBLE @@ -326,7 +327,7 @@ class MangaChapterAdapter( binding.root.setOnLongClickListener { updateProgress( media, - MangaNameAdapter.findChapterNumber(ep.number).toString() + MediaNameAdapter.findChapterNumber(ep.number).toString() ) true } diff --git a/app/src/main/java/ani/dantotsu/media/manga/MangaNameAdapter.kt b/app/src/main/java/ani/dantotsu/media/manga/MangaNameAdapter.kt deleted file mode 100644 index d265b69a..00000000 --- a/app/src/main/java/ani/dantotsu/media/manga/MangaNameAdapter.kt +++ /dev/null @@ -1,29 +0,0 @@ -package ani.dantotsu.media.manga - -import java.util.regex.Matcher -import java.util.regex.Pattern - -class MangaNameAdapter { - companion object { - private const val chapterRegex = "(chapter|chap|ch|c)[\\s:.\\-]*(\\d+\\.?\\d*)[\\s:.\\-]*" - private const val filedChapterNumberRegex = "(?= Build.VERSION_CODES.TIRAMISU) { - ActivityCompat.requestPermissions( - requireActivity(), - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 1 - ) - } - } - - model.continueMedia = false - media.manga?.chapters?.get(i)?.let { chapter -> - val parser = - model.mangaReadSources?.get(media.selected!!.sourceIndex) as? DynamicMangaParser - parser?.let { - CoroutineScope(Dispatchers.IO).launch { - val images = parser.imageList(chapter.sChapter) - - // Create a download task - val downloadTask = MangaDownloaderService.DownloadTask( - title = media.mainName(), - chapter = chapter.title!!, - imageData = images, - sourceMedia = media, - retries = 2, - simultaneousDownloads = 2 + activity?.let { + if (!isNotificationPermissionGranted()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.requestPermissions( + it, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1 ) + } + } + fun continueDownload() { + model.continueMedia = false + media.manga?.chapters?.get(i)?.let { chapter -> + val parser = + model.mangaReadSources?.get(media.selected!!.sourceIndex) as? DynamicMangaParser + parser?.let { + CoroutineScope(Dispatchers.IO).launch { + val images = parser.imageList(chapter.sChapter) - MangaServiceDataSingleton.downloadQueue.offer(downloadTask) + // Create a download task + val downloadTask = MangaDownloaderService.DownloadTask( + title = media.mainName(), + chapter = chapter.title!!, + imageData = images, + sourceMedia = media, + retries = 2, + simultaneousDownloads = 2 + ) - // If the service is not already running, start it - if (!MangaServiceDataSingleton.isServiceRunning) { - val intent = Intent(context, MangaDownloaderService::class.java) - withContext(Dispatchers.Main) { - ContextCompat.startForegroundService(requireContext(), intent) + MangaServiceDataSingleton.downloadQueue.offer(downloadTask) + + // If the service is not already running, start it + if (!MangaServiceDataSingleton.isServiceRunning) { + val intent = Intent(context, MangaDownloaderService::class.java) + withContext(Dispatchers.Main) { + ContextCompat.startForegroundService(requireContext(), intent) + } + MangaServiceDataSingleton.isServiceRunning = true + } + + // Inform the adapter that the download has started + withContext(Dispatchers.Main) { + chapterAdapter.startDownload(i) + } } - MangaServiceDataSingleton.isServiceRunning = true - } - - // Inform the adapter that the download has started - withContext(Dispatchers.Main) { - chapterAdapter.startDownload(i) } } } + if (!hasDirAccess(it)) { + (it as MediaDetailsActivity).accessAlertDialog(it.launcher) { success -> + if (success) { + continueDownload() + } else { + snackString("Permission is required to download") + } + } + } else { + continueDownload() + } } } @@ -499,8 +518,9 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { i, MediaType.MANGA ) - ) - chapterAdapter.deleteDownload(i) + ) { + chapterAdapter.deleteDownload(i) + } } fun onMangaChapterStopDownloadClick(i: String) { @@ -517,8 +537,9 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener { i, MediaType.MANGA ) - ) - chapterAdapter.purgeDownload(i) + ) { + chapterAdapter.purgeDownload(i) + } } private val downloadStatusReceiver = object : BroadcastReceiver() { diff --git a/app/src/main/java/ani/dantotsu/media/manga/mangareader/BaseImageAdapter.kt b/app/src/main/java/ani/dantotsu/media/manga/mangareader/BaseImageAdapter.kt index 67f5048c..7b91d6f7 100644 --- a/app/src/main/java/ani/dantotsu/media/manga/mangareader/BaseImageAdapter.kt +++ b/app/src/main/java/ani/dantotsu/media/manga/mangareader/BaseImageAdapter.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas +import android.net.Uri import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View @@ -36,7 +37,18 @@ abstract class BaseImageAdapter( chapter: MangaChapter ) : RecyclerView.Adapter() { val settings = activity.defaultSettings - val images = chapter.images() + private val chapterImages = chapter.images() + var images = chapterImages + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + images = if (settings.layout == CurrentReaderSettings.Layouts.PAGED + && settings.direction == CurrentReaderSettings.Directions.BOTTOM_TO_TOP) { + chapterImages.reversed() + } else { + chapterImages + } + super.onAttachedToRecyclerView(recyclerView) + } @SuppressLint("ClickableViewAccessibility") override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { @@ -165,6 +177,10 @@ abstract class BaseImageAdapter( it.load(localFile.absoluteFile) .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.NONE) + } else if (link.url.startsWith("content://")) { + it.load(Uri.parse(link.url)) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) } else { mangaCache.get(link.url)?.let { imageData -> val bitmap = imageData.fetchAndProcessImage( @@ -175,6 +191,7 @@ abstract class BaseImageAdapter( .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.NONE) } + } } ?.let { @@ -207,5 +224,4 @@ abstract class BaseImageAdapter( return newBitmap } } - } \ No newline at end of file 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 6f00adb4..57e099af 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 @@ -57,9 +57,9 @@ import ani.dantotsu.logError import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsViewModel import ani.dantotsu.media.MediaSingleton +import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.manga.MangaCache import ani.dantotsu.media.manga.MangaChapter -import ani.dantotsu.media.manga.MangaNameAdapter import ani.dantotsu.others.ImageViewDialog import ani.dantotsu.parsers.HMangaSources import ani.dantotsu.parsers.MangaImage @@ -129,6 +129,11 @@ class MangaReaderActivity : AppCompatActivity() { var sliding = false var isAnimating = false + private val directionRLBT get() = defaultSettings.direction == RIGHT_TO_LEFT + || defaultSettings.direction == BOTTOM_TO_TOP + private val directionPagedBT get() = defaultSettings.layout == CurrentReaderSettings.Layouts.PAGED + && defaultSettings.direction == CurrentReaderSettings.Directions.BOTTOM_TO_TOP + override fun onAttachedToWindow() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !PrefManager.getVal(PrefName.ShowSystemBars)) { val displayCutout = window.decorView.rootWindowInsets.displayCutout @@ -180,7 +185,7 @@ class MangaReaderActivity : AppCompatActivity() { defaultSettings = loadReaderSettings("reader_settings") ?: defaultSettings onBackPressedDispatcher.addCallback(this) { - val chapter = (MangaNameAdapter.findChapterNumber(media.manga!!.selectedChapter!!) + val chapter = (MediaNameAdapter.findChapterNumber(media.manga!!.selectedChapter!!) ?.minus(1L) ?: 0).toString() if (chapter == "0.0" && PrefManager.getVal(PrefName.ChapterZeroReader) // Not asking individually or incognito @@ -224,8 +229,13 @@ class MangaReaderActivity : AppCompatActivity() { binding.mangaReaderRecycler.scrollToPosition((value.toInt() - 1) / (dualPage { 2 } ?: 1)) else - binding.mangaReaderPager.currentItem = - (value.toInt() - 1) / (dualPage { 2 } ?: 1) + if (defaultSettings.direction == CurrentReaderSettings.Directions.BOTTOM_TO_TOP ) { + binding.mangaReaderPager.currentItem = + (maxChapterPage.toInt() - value.toInt()) / (dualPage { 2 } ?: 1) + } else { + binding.mangaReaderPager.currentItem = + (value.toInt() - 1) / (dualPage { 2 } ?: 1) + } pageSliderHide() } } @@ -331,7 +341,7 @@ class MangaReaderActivity : AppCompatActivity() { binding.mangaReaderNextChapter.performClick() } binding.mangaReaderNextChapter.setOnClickListener { - if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) { + if (directionRLBT) { if (currentChapterIndex > 0) change(currentChapterIndex - 1) else snackString(getString(R.string.first_chapter)) } else { @@ -344,7 +354,7 @@ class MangaReaderActivity : AppCompatActivity() { binding.mangaReaderPreviousChapter.performClick() } binding.mangaReaderPreviousChapter.setOnClickListener { - if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) { + if (directionRLBT) { if (chaptersArr.size > currentChapterIndex + 1) progress { change(currentChapterIndex + 1) } else snackString(getString(R.string.next_chapter_not_found)) } else { @@ -361,16 +371,12 @@ class MangaReaderActivity : AppCompatActivity() { PrefManager.setCustomVal("${media.id}_current_chp", chap.number) currentChapterIndex = chaptersArr.indexOf(chap.number) binding.mangaReaderChapterSelect.setSelection(currentChapterIndex) - if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) { - binding.mangaReaderNextChap.text = - chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: "" - binding.mangaReaderPrevChap.text = - chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: "" + if (directionRLBT) { + binding.mangaReaderNextChap.text = chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: "" + binding.mangaReaderPrevChap.text = chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: "" } else { - binding.mangaReaderNextChap.text = - chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: "" - binding.mangaReaderPrevChap.text = - chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: "" + binding.mangaReaderNextChap.text = chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: "" + binding.mangaReaderPrevChap.text = chaptersTitleArr.getOrNull(currentChapterIndex - 1) ?: "" } applySettings() val context = this @@ -378,6 +384,25 @@ class MangaReaderActivity : AppCompatActivity() { 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_manga), media.shareLink ?: ""), + ) + "dantotsu" -> mutableListOf( + RPC.Link(getString(R.string.view_manga), media.shareLink ?: ""), + RPC.Link("Read 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_manga), media.shareLink ?: ""), + RPC.Link("View My AniList", anilistLink) + ) + } + else -> mutableListOf() + } val presence = RPC.createPresence( RPC.Companion.RPCData( applicationId = Discord.application_Id, @@ -386,20 +411,9 @@ class MangaReaderActivity : AppCompatActivity() { details = chap.title?.takeIf { it.isNotEmpty() } ?: getString(R.string.chapter_num, chap.number), state = "${chap.number}/${media.manga?.totalChapters ?: "??"}", - largeImage = media.cover?.let { cover -> - RPC.Link(media.userPreferredName, cover) - }, - smallImage = RPC.Link( - "Dantotsu", - Discord.small_Image - ), - buttons = mutableListOf( - RPC.Link(getString(R.string.view_manga), media.shareLink ?: ""), - RPC.Link( - "Stream on Dantotsu", - getString(R.string.github) - ) - ) + largeImage = media.cover?.let { cover -> RPC.Link(media.userPreferredName, cover) }, + smallImage = RPC.Link("Dantotsu", Discord.small_Image), + buttons = buttons ) ) val intent = Intent(context, DiscordService::class.java).apply { @@ -455,7 +469,11 @@ class MangaReaderActivity : AppCompatActivity() { currentChapterPage = PrefManager.getCustomVal("${media.id}_${chapter.number}", 1L) - val chapImages = chapter.images() + val chapImages = if (directionPagedBT) { + chapter.images().reversed() + } else { + chapter.images() + } maxChapterPage = 0 if (chapImages.isNotEmpty()) { @@ -479,7 +497,11 @@ class MangaReaderActivity : AppCompatActivity() { } - val currentPage = currentChapterPage.toInt() + val currentPage = if (directionPagedBT) { + maxChapterPage - currentChapterPage + 1 + } else { + currentChapterPage + }.toInt() if ((defaultSettings.direction == TOP_TO_BOTTOM || defaultSettings.direction == BOTTOM_TO_TOP)) { binding.mangaReaderSwipy.vertical = true @@ -508,10 +530,10 @@ class MangaReaderActivity : AppCompatActivity() { binding.TopSwipeText.text = chaptersTitleArr.getOrNull(currentChapterIndex + 1) ?: getString(R.string.no_chapter) binding.mangaReaderSwipy.onTopSwiped = { - binding.mangaReaderNextChapter.performClick() + binding.mangaReaderPreviousChapter.performClick() } binding.mangaReaderSwipy.onBottomSwiped = { - binding.mangaReaderPreviousChapter.performClick() + binding.mangaReaderNextChapter.performClick() } } binding.mangaReaderSwipy.topBeingSwiped = { value -> @@ -620,7 +642,7 @@ class MangaReaderActivity : AppCompatActivity() { RecyclerView.VERTICAL else RecyclerView.HORIZONTAL, - !(defaultSettings.direction == TOP_TO_BOTTOM || defaultSettings.direction == LEFT_TO_RIGHT) + directionRLBT ) manager.preloadItemCount = 5 @@ -637,6 +659,8 @@ class MangaReaderActivity : AppCompatActivity() { else false } + manager.setStackFromEnd(defaultSettings.direction == BOTTOM_TO_TOP) + addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(v: RecyclerView, dx: Int, dy: Int) { defaultSettings.apply { @@ -691,9 +715,7 @@ class MangaReaderActivity : AppCompatActivity() { visibility = View.VISIBLE adapter = imageAdapter layoutDirection = - if (defaultSettings.direction == BOTTOM_TO_TOP || defaultSettings.direction == RIGHT_TO_LEFT) - View.LAYOUT_DIRECTION_RTL - else View.LAYOUT_DIRECTION_LTR + if (directionRLBT) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR orientation = if (defaultSettings.direction == LEFT_TO_RIGHT || defaultSettings.direction == RIGHT_TO_LEFT) ViewPager2.ORIENTATION_HORIZONTAL @@ -782,7 +804,7 @@ class MangaReaderActivity : AppCompatActivity() { val screenWidth = Resources.getSystem().displayMetrics.widthPixels //if in the 1st 1/5th of the screen width, left and lower than 1/5th of the screen height, left if (screenWidth / 5 in x + 1.. screenWidth - screenWidth / 5 && y > screenWidth / 5) { - pressLocation = if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) { + pressLocation = if (defaultSettings.direction == RIGHT_TO_LEFT) { PressPos.LEFT } else { PressPos.RIGHT @@ -886,9 +908,10 @@ class MangaReaderActivity : AppCompatActivity() { } } binding.mangaReaderSlider.layoutDirection = - if (defaultSettings.direction == RIGHT_TO_LEFT || defaultSettings.direction == BOTTOM_TO_TOP) + if (directionRLBT) View.LAYOUT_DIRECTION_RTL - else View.LAYOUT_DIRECTION_LTR + else + View.LAYOUT_DIRECTION_LTR shouldShow?.apply { isContVisible = !this } if (isContVisible) { isContVisible = false @@ -896,12 +919,7 @@ class MangaReaderActivity : AppCompatActivity() { isAnimating = true ObjectAnimator.ofFloat(binding.mangaReaderCont, "alpha", 1f, 0f) .setDuration(controllerDuration).start() - ObjectAnimator.ofFloat( - binding.mangaReaderBottomLayout, - "translationY", - 0f, - 128f - ) + ObjectAnimator.ofFloat(binding.mangaReaderBottomLayout, "translationY", 0f, 128f) .apply { interpolator = overshoot;duration = controllerDuration;start() } ObjectAnimator.ofFloat(binding.mangaReaderTopLayout, "translationY", 0f, -128f) .apply { interpolator = overshoot;duration = controllerDuration;start() } @@ -921,7 +939,11 @@ class MangaReaderActivity : AppCompatActivity() { } private var loading = false - fun updatePageNumber(page: Long) { + fun updatePageNumber(pageNumber: Long) { + var page = pageNumber + if (directionPagedBT) { + page = maxChapterPage - pageNumber + 1 + } if (currentChapterPage != page) { currentChapterPage = page PrefManager.setCustomVal("${media.id}_${chapter.number}", page) @@ -969,7 +991,7 @@ class MangaReaderActivity : AppCompatActivity() { PrefManager.setCustomVal("${media.id}_save_progress", true) updateProgress( media, - MangaNameAdapter.findChapterNumber(media.manga!!.selectedChapter!!) + MediaNameAdapter.findChapterNumber(media.manga!!.selectedChapter!!) .toString() ) dialog.dismiss() @@ -991,7 +1013,7 @@ class MangaReaderActivity : AppCompatActivity() { ) updateProgress( media, - MangaNameAdapter.findChapterNumber(media.manga!!.selectedChapter!!) + MediaNameAdapter.findChapterNumber(media.manga!!.selectedChapter!!) .toString() ) runnable.run() @@ -1086,4 +1108,4 @@ class MangaReaderActivity : AppCompatActivity() { } return true } -} \ No newline at end of file +} 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 867912a7..c57f943f 100644 --- a/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/novel/NovelReadFragment.kt @@ -20,6 +20,7 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.currContext import ani.dantotsu.databinding.FragmentAnimeWatchBinding import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager @@ -94,23 +95,23 @@ class NovelReadFragment : Fragment(), ) ) ) { - val file = File( - context?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "${DownloadsManager.novelLocation}/${media.mainName()}/${novel.name}/0.epub" - ) - if (!file.exists()) return false - val fileUri = FileProvider.getUriForFile( - requireContext(), - "${requireContext().packageName}.provider", - file - ) - val intent = Intent(context, NovelReaderActivity::class.java).apply { - action = Intent.ACTION_VIEW - setDataAndType(fileUri, "application/epub+zip") - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + try { + val directory = + DownloadsManager.getSubDirectory(context?:currContext()!!, MediaType.NOVEL, false, novel.name) + val file = directory?.findFile(novel.name) + if (file?.exists() == false) return false + val fileUri = file?.uri ?: return false + val intent = Intent(context, NovelReaderActivity::class.java).apply { + action = Intent.ACTION_VIEW + setDataAndType(fileUri, "application/epub+zip") + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + startActivity(intent) + return true + } catch (e: Exception) { + Logger.log(e) + return false } - startActivity(intent) - return true } else { return false } @@ -135,7 +136,7 @@ class NovelReadFragment : Fragment(), novel.name, MediaType.NOVEL ) - ) + ) {} } private val downloadStatusReceiver = object : BroadcastReceiver() { diff --git a/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt index b8cf6f7f..f6839558 100644 --- a/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt +++ b/app/src/main/java/ani/dantotsu/notifications/comment/CommentNotificationTask.kt @@ -46,11 +46,11 @@ class CommentNotificationTask : Task { ) notifications = - notifications?.filter { it.type != 3 || it.notificationId > recentGlobal } + notifications?.filter { !it.type.isGlobal() || it.notificationId > recentGlobal } ?.toMutableList() val newRecentGlobal = - notifications?.filter { it.type == 3 }?.maxOfOrNull { it.notificationId } + notifications?.filter { it.type.isGlobal() }?.maxOfOrNull { it.notificationId } if (newRecentGlobal != null) { PrefManager.setVal(PrefName.RecentGlobalNotification, newRecentGlobal) } @@ -313,4 +313,6 @@ class CommentNotificationTask : Task { null } } + + private fun Int?.isGlobal() = this == 3 || this == 420 } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionHelper.kt b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionHelper.kt index 9c8498a4..1e2f3db3 100644 --- a/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionHelper.kt +++ b/app/src/main/java/ani/dantotsu/notifications/subscription/SubscriptionHelper.kt @@ -3,13 +3,11 @@ package ani.dantotsu.notifications.subscription import ani.dantotsu.R import ani.dantotsu.currContext import ani.dantotsu.media.Media +import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.Selected -import ani.dantotsu.media.manga.MangaNameAdapter import ani.dantotsu.parsers.AnimeParser import ani.dantotsu.parsers.AnimeSources import ani.dantotsu.parsers.Episode -import ani.dantotsu.parsers.HAnimeSources -import ani.dantotsu.parsers.HMangaSources import ani.dantotsu.parsers.MangaChapter import ani.dantotsu.parsers.MangaParser import ani.dantotsu.parsers.MangaSources @@ -105,7 +103,7 @@ class SubscriptionHelper { } return chp?.apply { - selected.latest = MangaNameAdapter.findChapterNumber(number) ?: 0f + selected.latest = MediaNameAdapter.findChapterNumber(number) ?: 0f saveSelected(id, selected) } } diff --git a/app/src/main/java/ani/dantotsu/others/Download.kt b/app/src/main/java/ani/dantotsu/others/Download.kt index 3a266af4..b80c941d 100644 --- a/app/src/main/java/ani/dantotsu/others/Download.kt +++ b/app/src/main/java/ani/dantotsu/others/Download.kt @@ -36,16 +36,8 @@ object Download { } private fun getDownloadDir(context: Context): File { - val direct: File - if (PrefManager.getVal(PrefName.SdDl)) { - val arrayOfFiles = ContextCompat.getExternalFilesDirs(context, null) - val parentDirectory = arrayOfFiles[1].toString() - direct = File(parentDirectory) - if (!direct.exists()) direct.mkdirs() - } else { - direct = File("storage/emulated/0/${Environment.DIRECTORY_DOWNLOADS}/Dantotsu/") - if (!direct.exists()) direct.mkdirs() - } + val direct = File("storage/emulated/0/${Environment.DIRECTORY_DOWNLOADS}/Dantotsu/") + if (!direct.exists()) direct.mkdirs() return direct } @@ -96,52 +88,10 @@ object Download { when (PrefManager.getVal(PrefName.DownloadManager) as Int) { 1 -> oneDM(context, file, notif ?: fileName) 2 -> adm(context, file, fileName, folder) - else -> defaultDownload(context, file, fileName, folder, notif ?: fileName) + else -> oneDM(context, file, notif ?: fileName) } } - private fun defaultDownload( - context: Context, - file: FileUrl, - fileName: String, - folder: String, - notif: String - ) { - val manager = - context.getSystemService(AppCompatActivity.DOWNLOAD_SERVICE) as DownloadManager - val request: DownloadManager.Request = DownloadManager.Request(Uri.parse(file.url)) - file.headers.forEach { - request.addRequestHeader(it.key, it.value) - } - CoroutineScope(Dispatchers.IO).launch { - try { - request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - - val arrayOfFiles = ContextCompat.getExternalFilesDirs(context, null) - if (PrefManager.getVal(PrefName.SdDl) && arrayOfFiles.size > 1 && arrayOfFiles[0] != null && arrayOfFiles[1] != null) { - val parentDirectory = arrayOfFiles[1].toString() + folder - val direct = File(parentDirectory) - if (!direct.exists()) direct.mkdirs() - request.setDestinationUri(Uri.fromFile(File("$parentDirectory$fileName"))) - } else { - val direct = File(Environment.DIRECTORY_DOWNLOADS + "/Dantotsu$folder") - if (!direct.exists()) direct.mkdirs() - request.setDestinationInExternalPublicDir( - Environment.DIRECTORY_DOWNLOADS, - "/Dantotsu$folder$fileName" - ) - } - request.setTitle(notif) - manager.enqueue(request) - toast(currContext()?.getString(R.string.started_downloading, notif)) - } catch (e: SecurityException) { - toast(currContext()?.getString(R.string.permission_required)) - } catch (e: Exception) { - toast(e.toString()) - } - } - } - private fun oneDM(context: Context, file: FileUrl, notif: String) { val appName = if (isPackageInstalled("idm.internet.download.manager.plus", context.packageManager)) { diff --git a/app/src/main/java/ani/dantotsu/others/ImageViewDialog.kt b/app/src/main/java/ani/dantotsu/others/ImageViewDialog.kt index 346bd592..e721e84d 100644 --- a/app/src/main/java/ani/dantotsu/others/ImageViewDialog.kt +++ b/app/src/main/java/ani/dantotsu/others/ImageViewDialog.kt @@ -12,7 +12,6 @@ import ani.dantotsu.BottomSheetDialogFragment import ani.dantotsu.FileUrl import ani.dantotsu.R import ani.dantotsu.databinding.BottomSheetImageBinding -import ani.dantotsu.downloadsPermission import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.loadBitmap import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.loadBitmapOld import ani.dantotsu.media.manga.mangareader.BaseImageAdapter.Companion.mergeBitmap @@ -22,6 +21,7 @@ import ani.dantotsu.setSafeOnClickListener import ani.dantotsu.shareImage import ani.dantotsu.snackString import ani.dantotsu.toast +import ani.dantotsu.util.StoragePermissions.Companion.downloadsPermission import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import com.davemorrissey.labs.subscaleview.ImageSource import kotlinx.coroutines.launch diff --git a/app/src/main/java/ani/dantotsu/others/Xpandable.kt b/app/src/main/java/ani/dantotsu/others/Xpandable.kt index c4025c58..53518b40 100644 --- a/app/src/main/java/ani/dantotsu/others/Xpandable.kt +++ b/app/src/main/java/ani/dantotsu/others/Xpandable.kt @@ -12,8 +12,8 @@ import ani.dantotsu.R class Xpandable @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : LinearLayout(context, attrs) { - var expanded: Boolean = false - private var listener: OnChangeListener? = null + private var expanded: Boolean = false + private var listeners: ArrayList = arrayListOf() init { context.withStyledAttributes(attrs, R.styleable.Xpandable) { @@ -50,7 +50,9 @@ class Xpandable @JvmOverloads constructor( } } postDelayed({ - listener?.onRetract() + listeners.forEach{ + it.onRetract() + } }, 300) } @@ -64,13 +66,19 @@ class Xpandable @JvmOverloads constructor( } } postDelayed({ - listener?.onExpand() + listeners.forEach{ + it.onExpand() + } }, 300) } @Suppress("unused") - fun setOnChangeListener(listener: OnChangeListener) { - this.listener = listener + fun addOnChangeListener(listener: OnChangeListener) { + listeners.add(listener) + } + + fun removeListener(listener: OnChangeListener) { + listeners.remove(listener) } interface OnChangeListener { diff --git a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt index f23f83ae..823c2d73 100644 --- a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt +++ b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt @@ -3,7 +3,7 @@ package ani.dantotsu.parsers import android.content.Context import ani.dantotsu.FileUrl import ani.dantotsu.currContext -import ani.dantotsu.media.anime.AnimeNameAdapter +import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.media.manga.ImageData import ani.dantotsu.media.manga.MangaCache import ani.dantotsu.snackString @@ -73,12 +73,12 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { configurableSource.getPreferenceKey(), Context.MODE_PRIVATE ) - sharedPreferences.all.filterValues { AnimeNameAdapter.getSubDub(it.toString()) != AnimeNameAdapter.Companion.SubDubType.NULL } + sharedPreferences.all.filterValues { MediaNameAdapter.getSubDub(it.toString()) != MediaNameAdapter.SubDubType.NULL } .forEach { value -> - return when (AnimeNameAdapter.getSubDub(value.value.toString())) { - AnimeNameAdapter.Companion.SubDubType.SUB -> false - AnimeNameAdapter.Companion.SubDubType.DUB -> true - AnimeNameAdapter.Companion.SubDubType.NULL -> false + return when (MediaNameAdapter.getSubDub(value.value.toString())) { + MediaNameAdapter.SubDubType.SUB -> false + MediaNameAdapter.SubDubType.DUB -> true + MediaNameAdapter.SubDubType.NULL -> false } } } @@ -92,8 +92,8 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { val configurableSource = extension.sources[sourceLanguage] as? ConfigurableAnimeSource ?: return val type = when (setDub) { - true -> AnimeNameAdapter.Companion.SubDubType.DUB - false -> AnimeNameAdapter.Companion.SubDubType.SUB + true -> MediaNameAdapter.SubDubType.DUB + false -> MediaNameAdapter.SubDubType.SUB } currContext()?.let { context -> val sharedPreferences = @@ -101,9 +101,9 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { configurableSource.getPreferenceKey(), Context.MODE_PRIVATE ) - sharedPreferences.all.filterValues { AnimeNameAdapter.getSubDub(it.toString()) != AnimeNameAdapter.Companion.SubDubType.NULL } + sharedPreferences.all.filterValues { MediaNameAdapter.getSubDub(it.toString()) != MediaNameAdapter.SubDubType.NULL } .forEach { value -> - val setValue = AnimeNameAdapter.setSubDub(value.value.toString(), type) + val setValue = MediaNameAdapter.setSubDub(value.value.toString(), type) if (setValue != null) { sharedPreferences.edit().putString(value.key, setValue).apply() } @@ -122,9 +122,9 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { Context.MODE_PRIVATE ) sharedPreferences.all.filterValues { - AnimeNameAdapter.setSubDub( + MediaNameAdapter.setSubDub( it.toString(), - AnimeNameAdapter.Companion.SubDubType.NULL + MediaNameAdapter.SubDubType.NULL ) != null } .forEach { _ -> return true } @@ -150,7 +150,7 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { val sortedEpisodes = if (res[0].episode_number == -1f) { // Find the number in the string and sort by that number val sortedByStringNumber = res.sortedBy { - val matchResult = AnimeNameAdapter.findEpisodeNumber(it.name) + val matchResult = MediaNameAdapter.findEpisodeNumber(it.name) val number = matchResult ?: Float.MAX_VALUE it.episode_number = number // Store the found number in episode_number number @@ -171,13 +171,13 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { var episodeCounter = 1f // Group by season, sort within each season, and then renumber while keeping episode number 0 as is val seasonGroups = - res.groupBy { AnimeNameAdapter.findSeasonNumber(it.name) ?: 0 } + res.groupBy { MediaNameAdapter.findSeasonNumber(it.name) ?: 0 } seasonGroups.keys.sortedBy { it } .flatMap { season -> seasonGroups[season]?.sortedBy { it.episode_number }?.map { episode -> if (episode.episode_number != 0f) { // Skip renumbering for episode number 0 val potentialNumber = - AnimeNameAdapter.findEpisodeNumber(episode.name) + MediaNameAdapter.findEpisodeNumber(episode.name) if (potentialNumber != null) { episode.episode_number = potentialNumber } else { diff --git a/app/src/main/java/ani/dantotsu/parsers/MangaParser.kt b/app/src/main/java/ani/dantotsu/parsers/MangaParser.kt index cd757307..bf67a8ca 100644 --- a/app/src/main/java/ani/dantotsu/parsers/MangaParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/MangaParser.kt @@ -1,7 +1,7 @@ package ani.dantotsu.parsers import ani.dantotsu.FileUrl -import ani.dantotsu.media.manga.MangaNameAdapter +import ani.dantotsu.media.MediaNameAdapter import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter @@ -33,9 +33,9 @@ abstract class MangaParser : BaseParser() { ): MangaChapter? { val chapter = loadChapters(mangaLink, extra, sManga) val max = chapter - .maxByOrNull { MangaNameAdapter.findChapterNumber(it.number) ?: 0f } + .maxByOrNull { MediaNameAdapter.findChapterNumber(it.number) ?: 0f } return max - ?.takeIf { latest < (MangaNameAdapter.findChapterNumber(it.number) ?: 0.001f) } + ?.takeIf { latest < (MediaNameAdapter.findChapterNumber(it.number) ?: 0.001f) } } /** diff --git a/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt b/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt index 57098894..f4e30c0d 100644 --- a/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/OfflineAnimeParser.kt @@ -1,11 +1,14 @@ package ani.dantotsu.parsers +import android.app.Application import android.net.Uri import android.os.Environment import ani.dantotsu.currContext import ani.dantotsu.download.DownloadsManager +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory +import ani.dantotsu.download.anime.AnimeDownloaderService.AnimeDownloadTask.Companion.getTaskName import ani.dantotsu.media.MediaType -import ani.dantotsu.media.anime.AnimeNameAdapter +import ani.dantotsu.media.MediaNameAdapter import ani.dantotsu.tryWithSuspend import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.model.SEpisode @@ -18,6 +21,7 @@ import java.util.Locale class OfflineAnimeParser : AnimeParser() { private val downloadManager = Injekt.get() + private val context = Injekt.get() override val name = "Offline" override val saveName = "Offline" @@ -29,22 +33,19 @@ class OfflineAnimeParser : AnimeParser() { extra: Map?, sAnime: SAnime ): List { - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "${DownloadsManager.animeLocation}/$animeLink" - ) + val directory = getSubDirectory(context, MediaType.ANIME, false, animeLink) //get all of the folder names and add them to the list val episodes = mutableListOf() - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { //put the title and episdode number in the extra data val extraData = mutableMapOf() extraData["title"] = animeLink - extraData["episode"] = it.name + extraData["episode"] = it.name!! if (it.isDirectory) { val episode = Episode( - it.name, - "$animeLink - ${it.name}", + it.name!!, + getTaskName(animeLink,it.name!!), it.name, null, null, @@ -54,7 +55,7 @@ class OfflineAnimeParser : AnimeParser() { episodes.add(episode) } } - episodes.sortBy { AnimeNameAdapter.findEpisodeNumber(it.number) } + episodes.sortBy { MediaNameAdapter.findEpisodeNumber(it.number) } return episodes } return emptyList() @@ -131,18 +132,19 @@ class OfflineVideoExtractor(val videoServer: VideoServer) : VideoExtractor() { private fun getSubtitle(title: String, episode: String): List? { currContext()?.let { - DownloadsManager.getDirectory( + DownloadsManager.getSubDirectory( it, MediaType.ANIME, + false, title, episode - ).listFiles()?.forEach { file -> - if (file.name.contains("subtitle")) { + )?.listFiles()?.forEach { file -> + if (file.name?.contains("subtitle") == true) { return listOf( Subtitle( "Downloaded Subtitle", - Uri.fromFile(file).toString(), - determineSubtitletype(file.absolutePath) + file.uri.toString(), + determineSubtitletype(file.name ?: "") ) ) } diff --git a/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt b/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt index 29149873..a3a239a8 100644 --- a/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/OfflineMangaParser.kt @@ -1,9 +1,12 @@ package ani.dantotsu.parsers +import android.app.Application import android.os.Environment import ani.dantotsu.currContext import ani.dantotsu.download.DownloadsManager -import ani.dantotsu.media.manga.MangaNameAdapter +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory +import ani.dantotsu.media.MediaNameAdapter +import ani.dantotsu.media.MediaType import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga @@ -14,6 +17,7 @@ import java.io.File class OfflineMangaParser : MangaParser() { private val downloadManager = Injekt.get() + private val context = Injekt.get() override val hostUrl: String = "Offline" override val name: String = "Offline" @@ -23,17 +27,14 @@ class OfflineMangaParser : MangaParser() { extra: Map?, sManga: SManga ): List { - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/$mangaLink" - ) + val directory = getSubDirectory(context, MediaType.MANGA, false, mangaLink) //get all of the folder names and add them to the list val chapters = mutableListOf() - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { if (it.isDirectory) { val chapter = MangaChapter( - it.name, + it.name!!, "$mangaLink/${it.name}", it.name, null, @@ -43,23 +44,22 @@ class OfflineMangaParser : MangaParser() { chapters.add(chapter) } } - chapters.sortBy { MangaNameAdapter.findChapterNumber(it.number) } + chapters.sortBy { MediaNameAdapter.findChapterNumber(it.number) } return chapters } return emptyList() } override suspend fun loadImages(chapterLink: String, sChapter: SChapter): List { - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Manga/$chapterLink" - ) + val title = chapterLink.split("/").first() + val chapter = chapterLink.split("/").last() + val directory = getSubDirectory(context, MediaType.MANGA, false, title, chapter) val images = mutableListOf() val imageNumberRegex = Regex("""(\d+)\.jpg$""") - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { if (it.isFile) { - val image = MangaImage(it.absolutePath, false, null) + val image = MangaImage(it.uri.toString(), false, null) images.add(image) } } diff --git a/app/src/main/java/ani/dantotsu/parsers/OfflineNovelParser.kt b/app/src/main/java/ani/dantotsu/parsers/OfflineNovelParser.kt index 534c3ac5..2ae88200 100644 --- a/app/src/main/java/ani/dantotsu/parsers/OfflineNovelParser.kt +++ b/app/src/main/java/ani/dantotsu/parsers/OfflineNovelParser.kt @@ -1,9 +1,12 @@ package ani.dantotsu.parsers +import android.app.Application import android.os.Environment import ani.dantotsu.currContext import ani.dantotsu.download.DownloadsManager -import ani.dantotsu.media.manga.MangaNameAdapter +import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory +import ani.dantotsu.media.MediaNameAdapter +import ani.dantotsu.media.MediaType import me.xdrop.fuzzywuzzy.FuzzySearch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -11,6 +14,7 @@ import java.io.File class OfflineNovelParser : NovelParser() { private val downloadManager = Injekt.get() + private val context = Injekt.get() override val hostUrl: String = "Offline" override val name: String = "Offline" @@ -21,24 +25,21 @@ class OfflineNovelParser : NovelParser() { override suspend fun loadBook(link: String, extra: Map?): Book { //link should be a directory - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/$link" - ) + val directory = getSubDirectory(context, MediaType.NOVEL, false, link) val chapters = mutableListOf() - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { if (it.isDirectory) { val chapter = Book( - it.name, - it.absolutePath + "/cover.jpg", + it.name?:"Unknown", + it.uri.toString(), null, - listOf(it.absolutePath + "/0.epub") + listOf(it.uri.toString()) ) chapters.add(chapter) } } - chapters.sortBy { MangaNameAdapter.findChapterNumber(it.name) } + chapters.sortBy { MediaNameAdapter.findChapterNumber(it.name) } return chapters.first() } return Book( @@ -60,20 +61,16 @@ class OfflineNovelParser : NovelParser() { val returnList: MutableList = mutableListOf() for (title in returnTitles) { //need to search the subdirectories for the ShowResponses - val directory = File( - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "Dantotsu/Novel/$title" - ) + val directory = getSubDirectory(context, MediaType.NOVEL, false, title) val names = mutableListOf() - if (directory.exists()) { - directory.listFiles()?.forEach { + if (directory?.exists() == true) { + directory.listFiles().forEach { if (it.isDirectory) { - names.add(it.name) + names.add(it.name?: "Unknown") } } } - val cover = - currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + "/Dantotsu/Novel/$title/cover.jpg" + val cover = directory?.findFile("cover.jpg")?.uri.toString() names.forEach { returnList.add(ShowResponse(it, it, cover)) } diff --git a/app/src/main/java/ani/dantotsu/profile/ProfileActivity.kt b/app/src/main/java/ani/dantotsu/profile/ProfileActivity.kt index abbe26e5..571bc2a5 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.blurImage import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.connections.anilist.api.Query import ani.dantotsu.databinding.ActivityProfileBinding +import ani.dantotsu.databinding.ItemProfileAppBarBinding import ani.dantotsu.initActivity import ani.dantotsu.loadImage import ani.dantotsu.media.user.ListActivity @@ -45,6 +46,7 @@ import kotlin.math.abs class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListener { lateinit var binding: ActivityProfileBinding + private lateinit var bindingProfileAppBar: ItemProfileAppBarBinding private var selected: Int = 0 lateinit var navBar: AnimatedBottomBar @@ -108,145 +110,165 @@ class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListene binding.profileViewPager.setCurrentItem(selected, true) } }) - val userLevel = intent.getStringExtra("userLVL") ?: "" - binding.followButton.isGone = user.id == Anilist.userid || Anilist.userid == null - binding.followButton.text = getString( - when { - user.isFollowing -> R.string.unfollow - user.isFollower -> R.string.follows_you - else -> R.string.follow - } - ) - if (user.isFollowing && user.isFollower) binding.followButton.text = getString(R.string.mutual) - binding.followButton.setOnClickListener { - lifecycleScope.launch(Dispatchers.IO) { - val res = Anilist.query.toggleFollow(user.id) - if (res?.data?.toggleFollow != null) { - withContext(Dispatchers.Main) { - snackString(R.string.success) - user.isFollowing = res.data.toggleFollow.isFollowing - binding.followButton.text = getString( - when { - user.isFollowing -> R.string.unfollow - user.isFollower -> R.string.follows_you - else -> R.string.follow - } - ) - if (user.isFollowing && user.isFollower) - binding.followButton.text = getString(R.string.mutual) + + bindingProfileAppBar = ItemProfileAppBarBinding.bind(binding.root).apply { + + val userLevel = intent.getStringExtra("userLVL") ?: "" + followButton.isGone = + user.id == Anilist.userid || Anilist.userid == null + followButton.text = getString( + when { + user.isFollowing -> R.string.unfollow + user.isFollower -> R.string.follows_you + else -> R.string.follow + } + ) + if (user.isFollowing && user.isFollower) followButton.text = + getString(R.string.mutual) + followButton.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + val res = Anilist.query.toggleFollow(user.id) + if (res?.data?.toggleFollow != null) { + withContext(Dispatchers.Main) { + snackString(R.string.success) + user.isFollowing = res.data.toggleFollow.isFollowing + followButton.text = getString( + when { + user.isFollowing -> R.string.unfollow + user.isFollower -> R.string.follows_you + else -> R.string.follow + } + ) + if (user.isFollowing && user.isFollower) + followButton.text = getString(R.string.mutual) + } } } } - } - binding.profileProgressBar.visibility = View.GONE - binding.profileAppBar.visibility = View.VISIBLE - binding.profileMenuButton.setOnClickListener { - val popup = PopupMenu(this@ProfileActivity, binding.profileMenuButton) - popup.menuInflater.inflate(R.menu.menu_profile, popup.menu) - popup.setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.action_view_following -> { - ContextCompat.startActivity( - this@ProfileActivity, - Intent(this@ProfileActivity, FollowActivity::class.java) - .putExtra("title", "Following") - .putExtra("userId", user.id), - null - ) - true - } + binding.profileProgressBar.visibility = View.GONE + profileAppBar.visibility = View.VISIBLE + profileMenuButton.setOnClickListener { + val popup = PopupMenu(this@ProfileActivity, profileMenuButton) + popup.menuInflater.inflate(R.menu.menu_profile, popup.menu) + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_view_following -> { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, FollowActivity::class.java) + .putExtra("title", "Following") + .putExtra("userId", user.id), + null + ) + true + } - R.id.action_view_followers -> { - ContextCompat.startActivity( - this@ProfileActivity, - Intent(this@ProfileActivity, FollowActivity::class.java) - .putExtra("title", "Followers") - .putExtra("userId", user.id), - null - ) - true - } + R.id.action_view_followers -> { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, FollowActivity::class.java) + .putExtra("title", "Followers") + .putExtra("userId", user.id), + null + ) + true + } - R.id.action_view_on_anilist -> { - openLinkInBrowser("https://anilist.co/user/${user.name}") - true - } + R.id.action_view_on_anilist -> { + openLinkInBrowser("https://anilist.co/user/${user.name}") + true + } - else -> false + else -> false + } } + popup.show() } - popup.show() - } - binding.profileUserAvatar.loadImage(user.avatar?.medium) - binding.profileUserAvatar.setOnLongClickListener { - ImageViewDialog.newInstance( - this@ProfileActivity, - "${user.name}'s [Avatar]", - user.avatar?.medium + profileUserAvatar.loadImage(user.avatar?.medium) + profileUserAvatar.setOnLongClickListener { + ImageViewDialog.newInstance( + this@ProfileActivity, + "${user.name}'s [Avatar]", + user.avatar?.medium + ) + } + + val userLevelText = "${user.name} $userLevel" + profileUserName.text = userLevelText + val bannerAnimations: Boolean = PrefManager.getVal(PrefName.BannerAnimations) + + blurImage( + if (bannerAnimations) profileBannerImage else profileBannerImageNoKen, + user.bannerImage ?: user.avatar?.medium ) - } + profileBannerImage.updateLayoutParams { height += statusBarHeight } + profileBannerImageNoKen.updateLayoutParams { height += statusBarHeight } + profileBannerGradient.updateLayoutParams { height += statusBarHeight } + profileCloseButton.updateLayoutParams { topMargin += statusBarHeight } + profileMenuButton.updateLayoutParams { topMargin += statusBarHeight } + profileButtonContainer.updateLayoutParams { topMargin += statusBarHeight } + profileBannerImage.setOnLongClickListener { + ImageViewDialog.newInstance( + this@ProfileActivity, + user.name + " [Banner]", + user.bannerImage + ) + } - val userLevelText = "${user.name} $userLevel" - binding.profileUserName.text = userLevelText - if (!(PrefManager.getVal(PrefName.BannerAnimations) as Boolean)) binding.profileBannerImage.pause() - blurImage(binding.profileBannerImage, user.bannerImage ?: user.avatar?.medium) - binding.profileBannerImage.updateLayoutParams { height += statusBarHeight } - binding.profileBannerGradient.updateLayoutParams { height += statusBarHeight } - binding.profileMenuButton.updateLayoutParams { topMargin += statusBarHeight } - binding.profileButtonContainer.updateLayoutParams { topMargin += statusBarHeight } - binding.profileBannerImage.setOnLongClickListener { - ImageViewDialog.newInstance( - this@ProfileActivity, - user.name + " [Banner]", - user.bannerImage - ) - } - - mMaxScrollSize = binding.profileAppBar.totalScrollRange - binding.profileAppBar.addOnOffsetChangedListener(this@ProfileActivity) + mMaxScrollSize = profileAppBar.totalScrollRange + profileAppBar.addOnOffsetChangedListener(this@ProfileActivity) - binding.profileFollowerCount.text = followers.toString() - binding.profileFollowerCountContainer.setOnClickListener { - ContextCompat.startActivity( - this@ProfileActivity, - Intent(this@ProfileActivity, FollowActivity::class.java) - .putExtra("title", getString(R.string.followers)) - .putExtra("userId", user.id), - null - ) - } + profileFollowerCount.text = followers.toString() + profileFollowerCountContainer.setOnClickListener { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, FollowActivity::class.java) + .putExtra("title", getString(R.string.followers)) + .putExtra("userId", user.id), + null + ) + } - binding.profileFollowingCount.text = following.toString() - binding.profileFollowingCountContainer.setOnClickListener { - ContextCompat.startActivity( - this@ProfileActivity, - Intent(this@ProfileActivity, FollowActivity::class.java) - .putExtra("title", "Following") - .putExtra("userId", user.id), - null - ) - } + profileFollowingCount.text = following.toString() + profileFollowingCountContainer.setOnClickListener { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, FollowActivity::class.java) + .putExtra("title", "Following") + .putExtra("userId", user.id), + null + ) + } - binding.profileAnimeCount.text = user.statistics.anime.count.toString() - binding.profileAnimeCountContainer.setOnClickListener { - ContextCompat.startActivity( - this@ProfileActivity, Intent(this@ProfileActivity, ListActivity::class.java) - .putExtra("anime", true) - .putExtra("userId", user.id) - .putExtra("username", user.name), null - ) - } + profileAnimeCount.text = user.statistics.anime.count.toString() + profileAnimeCountContainer.setOnClickListener { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, ListActivity::class.java) + .putExtra("anime", true) + .putExtra("userId", user.id) + .putExtra("username", user.name), + null + ) + } - binding.profileMangaCount.text = user.statistics.manga.count.toString() - binding.profileMangaCountContainer.setOnClickListener { - ContextCompat.startActivity( - this@ProfileActivity, Intent(this@ProfileActivity, ListActivity::class.java) - .putExtra("anime", false) - .putExtra("userId", user.id) - .putExtra("username", user.name), null - ) + profileMangaCount.text = user.statistics.manga.count.toString() + profileMangaCountContainer.setOnClickListener { + ContextCompat.startActivity( + this@ProfileActivity, + Intent(this@ProfileActivity, ListActivity::class.java) + .putExtra("anime", false) + .putExtra("userId", user.id) + .putExtra("username", user.name), + null + ) + } + + profileCloseButton.setOnClickListener { + onBackPressedDispatcher.onBackPressed() + } } } } @@ -262,29 +284,31 @@ class ProfileActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedListene if (mMaxScrollSize == 0) mMaxScrollSize = appBar.totalScrollRange val percentage = abs(i) * 100 / mMaxScrollSize - binding.profileUserAvatarContainer.visibility = - if (binding.profileUserAvatarContainer.scaleX == 0f) View.GONE else View.VISIBLE - val duration = (200 * (PrefManager.getVal(PrefName.AnimationSpeed) as Float)).toLong() - if (percentage >= percent && !isCollapsed) { - isCollapsed = true - ObjectAnimator.ofFloat(binding.profileUserDataContainer, "translationX", screenWidth) - .setDuration(duration).start() - ObjectAnimator.ofFloat(binding.profileUserAvatarContainer, "translationX", screenWidth) - .setDuration(duration).start() - ObjectAnimator.ofFloat(binding.profileButtonContainer, "translationX", screenWidth) - .setDuration(duration).start() - binding.profileBannerImage.pause() - } - if (percentage <= percent && isCollapsed) { - isCollapsed = false - ObjectAnimator.ofFloat(binding.profileUserDataContainer, "translationX", 0f) - .setDuration(duration).start() - ObjectAnimator.ofFloat(binding.profileUserAvatarContainer, "translationX", 0f) - .setDuration(duration).start() - ObjectAnimator.ofFloat(binding.profileButtonContainer, "translationX", 0f) - .setDuration(duration).start() + with (bindingProfileAppBar) { + profileUserAvatarContainer.visibility = + if (profileUserAvatarContainer.scaleX == 0f) View.GONE else View.VISIBLE + val duration = (200 * (PrefManager.getVal(PrefName.AnimationSpeed) as Float)).toLong() + if (percentage >= percent && !isCollapsed) { + isCollapsed = true + ObjectAnimator.ofFloat(profileUserDataContainer, "translationX", screenWidth) + .setDuration(duration).start() + ObjectAnimator.ofFloat(profileUserAvatarContainer, "translationX", screenWidth) + .setDuration(duration).start() + ObjectAnimator.ofFloat(profileButtonContainer, "translationX", screenWidth) + .setDuration(duration).start() + profileBannerImage.pause() + } + if (percentage <= percent && isCollapsed) { + isCollapsed = false + ObjectAnimator.ofFloat(profileUserDataContainer, "translationX", 0f) + .setDuration(duration).start() + ObjectAnimator.ofFloat(profileUserAvatarContainer, "translationX", 0f) + .setDuration(duration).start() + ObjectAnimator.ofFloat(profileButtonContainer, "translationX", 0f) + .setDuration(duration).start() - if (PrefManager.getVal(PrefName.BannerAnimations)) binding.profileBannerImage.resume() + if (PrefManager.getVal(PrefName.BannerAnimations)) profileBannerImage.resume() + } } } diff --git a/app/src/main/java/ani/dantotsu/profile/ProfileFragment.kt b/app/src/main/java/ani/dantotsu/profile/ProfileFragment.kt index 6fd425be..f7a04944 100644 --- a/app/src/main/java/ani/dantotsu/profile/ProfileFragment.kt +++ b/app/src/main/java/ani/dantotsu/profile/ProfileFragment.kt @@ -154,22 +154,23 @@ class ProfileFragment : Fragment() { private fun setFavPeople() { if (favStaff.isEmpty()) { binding.profileFavStaffContainer.visibility = View.GONE + } else { + binding.profileFavStaffRecycler.adapter = AuthorAdapter(favStaff) + binding.profileFavStaffRecycler.layoutManager = LinearLayoutManager( + activity, LinearLayoutManager.HORIZONTAL, false + ) + binding.profileFavStaffRecycler.layoutAnimation = LayoutAnimationController(setSlideIn(), 0.25f) } - binding.profileFavStaffRecycler.adapter = AuthorAdapter(favStaff) - binding.profileFavStaffRecycler.layoutManager = LinearLayoutManager( - activity, - LinearLayoutManager.HORIZONTAL, - false - ) + if (favCharacter.isEmpty()) { binding.profileFavCharactersContainer.visibility = View.GONE + } else { + binding.profileFavCharactersRecycler.adapter = CharacterAdapter(favCharacter) + binding.profileFavCharactersRecycler.layoutManager = LinearLayoutManager( + activity, LinearLayoutManager.HORIZONTAL, false + ) + binding.profileFavCharactersRecycler.layoutAnimation = LayoutAnimationController(setSlideIn(), 0.25f) } - binding.profileFavCharactersRecycler.adapter = CharacterAdapter(favCharacter) - binding.profileFavCharactersRecycler.layoutManager = LinearLayoutManager( - activity, - LinearLayoutManager.HORIZONTAL, - false - ) } private fun initRecyclerView( diff --git a/app/src/main/java/ani/dantotsu/profile/activity/UsersAdapter.kt b/app/src/main/java/ani/dantotsu/profile/UsersAdapter.kt similarity index 89% rename from app/src/main/java/ani/dantotsu/profile/activity/UsersAdapter.kt rename to app/src/main/java/ani/dantotsu/profile/UsersAdapter.kt index 52763e09..9f31d27b 100644 --- a/app/src/main/java/ani/dantotsu/profile/activity/UsersAdapter.kt +++ b/app/src/main/java/ani/dantotsu/profile/UsersAdapter.kt @@ -1,14 +1,13 @@ -package ani.dantotsu.profile.activity +package ani.dantotsu.profile import android.content.Intent import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView +import ani.dantotsu.blurImage import ani.dantotsu.databinding.ItemFollowerBinding import ani.dantotsu.loadImage -import ani.dantotsu.profile.ProfileActivity -import ani.dantotsu.profile.User import ani.dantotsu.setAnimation @@ -41,7 +40,7 @@ class UsersAdapter(private val user: ArrayList) : RecyclerView.Adapter binding.radioNothing.isChecked= true + "dantotsu" -> binding.radioDantotsu.isChecked = true + "anilist" -> binding.radioAnilist.isChecked = true + else -> binding.radioAnilist.isChecked = true + } + + binding.anilistLinkPreview.text = getString(R.string.anilist_link, PrefManager.getVal(PrefName.AnilistUserName)) + + binding.radioGroup.setOnCheckedChangeListener { _, checkedId -> + val mode = when (checkedId) { + binding.radioNothing.id -> "nothing" + binding.radioDantotsu.id -> "dantotsu" + binding.radioAnilist.id -> "anilist" + else -> "dantotsu" + } + PrefManager.setCustomVal("discord_mode", mode) + } + } + + override fun onDestroy() { + _binding = null + super.onDestroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/settings/ForksDialogFragment.kt b/app/src/main/java/ani/dantotsu/settings/ForksDialogFragment.kt index b1a4db01..74d00972 100644 --- a/app/src/main/java/ani/dantotsu/settings/ForksDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/ForksDialogFragment.kt @@ -7,21 +7,13 @@ import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager import ani.dantotsu.BottomSheetDialogFragment import ani.dantotsu.R +import ani.dantotsu.connections.github.Forks import ani.dantotsu.databinding.BottomSheetDevelopersBinding class ForksDialogFragment : BottomSheetDialogFragment() { private var _binding: BottomSheetDevelopersBinding? = null private val binding get() = _binding!! - private val developers = arrayOf( - Developer( - "Dantotsu", - "https://avatars.githubusercontent.com/u/87634197?v=4", - "rebelonion", - "https://github.com/rebelonion/Dantotsu" - ), - ) - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -34,7 +26,7 @@ class ForksDialogFragment : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.devsTitle.setText(R.string.forks) - binding.devsRecyclerView.adapter = DevelopersAdapter(developers) + binding.devsRecyclerView.adapter = DevelopersAdapter(Forks().getForks()) binding.devsRecyclerView.layoutManager = LinearLayoutManager(requireContext()) } diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt index e25fb822..3f1a3c49 100644 --- a/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/InstalledAnimeExtensionsFragment.kt @@ -24,7 +24,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface -import ani.dantotsu.databinding.FragmentAnimeExtensionsBinding +import ani.dantotsu.databinding.FragmentExtensionsBinding import ani.dantotsu.others.LanguageMapper import ani.dantotsu.parsers.AnimeSources import ani.dantotsu.settings.extensionprefs.AnimeSourcePreferencesFragment @@ -49,7 +49,7 @@ import java.util.Locale class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { - private var _binding: FragmentAnimeExtensionsBinding? = null + private var _binding: FragmentExtensionsBinding? = null private val binding get() = _binding!! private lateinit var extensionsRecyclerView: RecyclerView private val skipIcons: Boolean = PrefManager.getVal(PrefName.SkipExtensionIcons) @@ -183,9 +183,9 @@ class InstalledAnimeExtensionsFragment : Fragment(), SearchQueryHandler { container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentAnimeExtensionsBinding.inflate(inflater, container, false) + _binding = FragmentExtensionsBinding.inflate(inflater, container, false) - extensionsRecyclerView = binding.allAnimeExtensionsRecyclerView + extensionsRecyclerView = binding.allExtensionsRecyclerView extensionsRecyclerView.layoutManager = LinearLayoutManager(requireContext()) extensionsRecyclerView.adapter = extensionsAdapter diff --git a/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt index 24780ef5..90bfa771 100644 --- a/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/InstalledMangaExtensionsFragment.kt @@ -26,7 +26,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface -import ani.dantotsu.databinding.FragmentMangaExtensionsBinding +import ani.dantotsu.databinding.FragmentExtensionsBinding import ani.dantotsu.others.LanguageMapper import ani.dantotsu.parsers.MangaSources import ani.dantotsu.settings.extensionprefs.MangaSourcePreferencesFragment @@ -48,7 +48,7 @@ import java.util.Collections import java.util.Locale class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { - private var _binding: FragmentMangaExtensionsBinding? = null + private var _binding: FragmentExtensionsBinding? = null private val binding get() = _binding!! private lateinit var extensionsRecyclerView: RecyclerView private val skipIcons: Boolean = PrefManager.getVal(PrefName.SkipExtensionIcons) @@ -181,9 +181,9 @@ class InstalledMangaExtensionsFragment : Fragment(), SearchQueryHandler { container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentMangaExtensionsBinding.inflate(inflater, container, false) + _binding = FragmentExtensionsBinding.inflate(inflater, container, false) - extensionsRecyclerView = binding.allMangaExtensionsRecyclerView + extensionsRecyclerView = binding.allExtensionsRecyclerView extensionsRecyclerView.layoutManager = LinearLayoutManager(requireContext()) extensionsRecyclerView.adapter = extensionsAdapter diff --git a/app/src/main/java/ani/dantotsu/settings/MangaExtensionsFragment.kt b/app/src/main/java/ani/dantotsu/settings/MangaExtensionsFragment.kt index 6f7276ad..c5118096 100644 --- a/app/src/main/java/ani/dantotsu/settings/MangaExtensionsFragment.kt +++ b/app/src/main/java/ani/dantotsu/settings/MangaExtensionsFragment.kt @@ -13,7 +13,7 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface -import ani.dantotsu.databinding.FragmentMangaExtensionsBinding +import ani.dantotsu.databinding.FragmentExtensionsBinding import ani.dantotsu.settings.paging.MangaExtensionAdapter import ani.dantotsu.settings.paging.MangaExtensionsViewModel import ani.dantotsu.settings.paging.MangaExtensionsViewModelFactory @@ -30,7 +30,7 @@ import uy.kohesive.injekt.api.get class MangaExtensionsFragment : Fragment(), SearchQueryHandler, OnMangaInstallClickListener { - private var _binding: FragmentMangaExtensionsBinding? = null + private var _binding: FragmentExtensionsBinding? = null private val binding get() = _binding!! private val viewModel: MangaExtensionsViewModel by viewModels { @@ -49,12 +49,12 @@ class MangaExtensionsFragment : Fragment(), container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentMangaExtensionsBinding.inflate(inflater, container, false) + _binding = FragmentExtensionsBinding.inflate(inflater, container, false) - binding.allMangaExtensionsRecyclerView.isNestedScrollingEnabled = false - binding.allMangaExtensionsRecyclerView.adapter = adapter - binding.allMangaExtensionsRecyclerView.layoutManager = LinearLayoutManager(context) - (binding.allMangaExtensionsRecyclerView.layoutManager as LinearLayoutManager).isItemPrefetchEnabled = + binding.allExtensionsRecyclerView.isNestedScrollingEnabled = false + binding.allExtensionsRecyclerView.adapter = adapter + binding.allExtensionsRecyclerView.layoutManager = LinearLayoutManager(context) + (binding.allExtensionsRecyclerView.layoutManager as LinearLayoutManager).isItemPrefetchEnabled = true lifecycleScope.launch { @@ -92,8 +92,8 @@ class MangaExtensionsFragment : Fragment(), Notifications.CHANNEL_DOWNLOADER_PROGRESS ) .setSmallIcon(R.drawable.ic_round_sync_24) - .setContentTitle("Installing extension") - .setContentText("Step: $installStep") + .setContentTitle(getString(R.string.installing_extension)) + .setContentText(getString(R.string.install_step, installStep)) .setPriority(NotificationCompat.PRIORITY_LOW) notificationManager.notify(1, builder.build()) }, @@ -104,11 +104,11 @@ class MangaExtensionsFragment : Fragment(), Notifications.CHANNEL_DOWNLOADER_ERROR ) .setSmallIcon(R.drawable.ic_round_info_24) - .setContentTitle("Installation failed: ${error.message}") - .setContentText("Error: ${error.message}") + .setContentTitle(getString(R.string.installation_failed, error.message)) + .setContentText(getString(R.string.error_message, error.message)) .setPriority(NotificationCompat.PRIORITY_HIGH) notificationManager.notify(1, builder.build()) - snackString("Installation failed: ${error.message}") + snackString(getString(R.string.installation_failed, error.message)) }, { val builder = NotificationCompat.Builder( @@ -116,12 +116,12 @@ class MangaExtensionsFragment : Fragment(), Notifications.CHANNEL_DOWNLOADER_PROGRESS ) .setSmallIcon(R.drawable.ic_download_24) - .setContentTitle("Installation complete") - .setContentText("The extension has been successfully installed.") + .setContentTitle(getString(R.string.installation_complete)) + .setContentText(getString(R.string.extension_has_been_installed)) .setPriority(NotificationCompat.PRIORITY_LOW) notificationManager.notify(1, builder.build()) viewModel.invalidatePager() - snackString("Extension installed") + snackString(getString(R.string.extension_installed)) } ) } diff --git a/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt index ff18527d..ec431643 100644 --- a/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/PlayerSettingsActivity.kt @@ -137,11 +137,6 @@ class PlayerSettingsActivity : AppCompatActivity() { binding.playerSettingsAutoSkipOpEd.isEnabled = isChecked } - binding.playerSettingsTimeStampsAutoHide.isChecked = PrefManager.getVal(PrefName.AutoHideTimeStamps) - binding.playerSettingsTimeStampsAutoHide.setOnCheckedChangeListener { _, isChecked -> - PrefManager.setVal(PrefName.AutoHideTimeStamps, isChecked) - } - binding.playerSettingsTimeStampsProxy.isChecked = PrefManager.getVal(PrefName.UseProxyForTimeStamps) binding.playerSettingsTimeStampsProxy.setOnCheckedChangeListener { _, isChecked -> @@ -152,6 +147,13 @@ class PlayerSettingsActivity : AppCompatActivity() { PrefManager.getVal(PrefName.ShowTimeStampButton) binding.playerSettingsShowTimeStamp.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.ShowTimeStampButton, isChecked) + binding.playerSettingsTimeStampsAutoHide.isEnabled = isChecked + } + + binding.playerSettingsTimeStampsAutoHide.isChecked = PrefManager.getVal(PrefName.AutoHideTimeStamps) + binding.playerSettingsTimeStampsAutoHide.isEnabled = binding.playerSettingsShowTimeStamp.isChecked + binding.playerSettingsTimeStampsAutoHide.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.AutoHideTimeStamps, isChecked) } // Auto @@ -475,7 +477,7 @@ class PlayerSettingsActivity : AppCompatActivity() { updateSubPreview() } } - binding.subtitleTest.setOnChangeListener(object: Xpandable.OnChangeListener { + binding.subtitleTest.addOnChangeListener(object: Xpandable.OnChangeListener { override fun onExpand() { updateSubPreview() } diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt index 8733da53..faa4d70f 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt @@ -5,6 +5,7 @@ import android.app.AlertDialog import android.content.Context import android.content.Intent import android.graphics.drawable.Animatable +import android.net.Uri import android.os.Build import android.os.Build.BRAND import android.os.Build.DEVICE @@ -13,23 +14,25 @@ import android.os.Build.VERSION.CODENAME import android.os.Build.VERSION.RELEASE import android.os.Build.VERSION.SDK_INT import android.os.Bundle +import android.view.HapticFeedbackConstants +import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AnimationUtils import android.view.inputmethod.EditorInfo import android.widget.ArrayAdapter +import android.widget.EditText import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.offline.DownloadService import ani.dantotsu.BuildConfig import ani.dantotsu.R import ani.dantotsu.connections.anilist.Anilist @@ -47,9 +50,8 @@ import ani.dantotsu.databinding.ActivitySettingsExtensionsBinding import ani.dantotsu.databinding.ActivitySettingsMangaBinding import ani.dantotsu.databinding.ActivitySettingsNotificationsBinding import ani.dantotsu.databinding.ActivitySettingsThemeBinding +import ani.dantotsu.databinding.ItemRepositoryBinding import ani.dantotsu.download.DownloadsManager -import ani.dantotsu.download.video.ExoplayerDownloadService -import ani.dantotsu.downloadsPermission import ani.dantotsu.initActivity import ani.dantotsu.loadImage import ani.dantotsu.media.MediaType @@ -78,19 +80,27 @@ import ani.dantotsu.startMainActivity import ani.dantotsu.statusBarHeight import ani.dantotsu.themes.ThemeManager import ani.dantotsu.toast +import ani.dantotsu.util.LauncherWrapper import ani.dantotsu.util.Logger +import ani.dantotsu.util.StoragePermissions.Companion.downloadsPermission import com.google.android.material.textfield.TextInputEditText import eltos.simpledialogfragment.SimpleDialog import eltos.simpledialogfragment.SimpleDialog.OnDialogResultListener.BUTTON_POSITIVE import eltos.simpledialogfragment.color.SimpleColorDialog import eu.kanade.domain.base.BasePreferences +import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager +import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager import io.noties.markwon.Markwon import io.noties.markwon.SoftBreakAddsNewLinePlugin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import kotlin.random.Random @@ -99,6 +109,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene override fun handleOnBackPressed() = startMainActivity(this@SettingsActivity) } lateinit var binding: ActivitySettingsBinding + lateinit var launcher: LauncherWrapper private lateinit var bindingAccounts: ActivitySettingsAccountsBinding private lateinit var bindingTheme: ActivitySettingsThemeBinding private lateinit var bindingExtensions: ActivitySettingsExtensionsBinding @@ -109,7 +120,10 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene private lateinit var bindingAbout: ActivitySettingsAboutBinding private val extensionInstaller = Injekt.get().extensionInstaller() private var cursedCounter = 0 + private val animeExtensionManager: AnimeExtensionManager by injectLazy() + private val mangaExtensionManager: MangaExtensionManager by injectLazy() + @kotlin.OptIn(DelicateCoroutinesApi::class) @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -161,6 +175,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } } } + val contract = ActivityResultContracts.OpenDocumentTree() + launcher = LauncherWrapper(this, contract) binding.settingsVersion.text = getString(R.string.version_current, BuildConfig.VERSION_NAME) binding.settingsVersion.setOnLongClickListener { @@ -207,6 +223,15 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene settingsAnilistUsername.visibility = View.VISIBLE settingsAnilistUsername.text = Anilist.username settingsAnilistAvatar.loadImage(Anilist.avatar) + settingsAnilistAvatar.setOnClickListener { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + val anilistLink = getString( + R.string.anilist_link, + PrefManager.getVal(PrefName.AnilistUserName) + ) + openLinkInBrowser(anilistLink) + true + } settingsMALLoginRequired.visibility = View.GONE settingsMALLogin.visibility = View.VISIBLE @@ -222,6 +247,12 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene settingsMALUsername.visibility = View.VISIBLE settingsMALUsername.text = MAL.username settingsMALAvatar.loadImage(MAL.avatar) + settingsMALAvatar.setOnClickListener { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + val myanilistLink = getString(R.string.myanilist_link, MAL.username) + openLinkInBrowser(myanilistLink) + true + } } else { settingsMALAvatar.setImageResource(R.drawable.ic_round_person_24) settingsMALUsername.visibility = View.GONE @@ -248,6 +279,12 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene val username = PrefManager.getVal(PrefName.DiscordUserName, null as String?) if (id != null && avatar != null) { settingsDiscordAvatar.loadImage("https://cdn.discordapp.com/avatars/$id/$avatar.png") + settingsDiscordAvatar.setOnClickListener { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + val discordLink = getString(R.string.discord_link, id) + openLinkInBrowser(discordLink) + true + } } settingsDiscordUsername.visibility = View.VISIBLE settingsDiscordUsername.text = @@ -259,18 +296,18 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene reload() } - imageSwitcher.visibility = View.VISIBLE + settingsImageSwitcher.visibility = View.VISIBLE var initialStatus = when (PrefManager.getVal(PrefName.DiscordStatus)) { "online" -> R.drawable.discord_status_online "idle" -> R.drawable.discord_status_idle "dnd" -> R.drawable.discord_status_dnd else -> R.drawable.discord_status_online } - imageSwitcher.setImageResource(initialStatus) + settingsImageSwitcher.setImageResource(initialStatus) val zoomInAnimation = AnimationUtils.loadAnimation(this@SettingsActivity, R.anim.bounce_zoom) - imageSwitcher.setOnClickListener { + settingsImageSwitcher.setOnClickListener { var status = "online" initialStatus = when (initialStatus) { R.drawable.discord_status_online -> { @@ -292,11 +329,16 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } PrefManager.setVal(PrefName.DiscordStatus, status) - imageSwitcher.setImageResource(initialStatus) - imageSwitcher.startAnimation(zoomInAnimation) + settingsImageSwitcher.setImageResource(initialStatus) + settingsImageSwitcher.startAnimation(zoomInAnimation) + } + settingsImageSwitcher.setOnLongClickListener { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + DiscordDialogFragment().show(supportFragmentManager, "dialog") + true } } else { - imageSwitcher.visibility = View.GONE + settingsImageSwitcher.visibility = View.GONE settingsDiscordAvatar.setImageResource(R.drawable.ic_round_person_24) settingsDiscordUsername.visibility = View.GONE settingsDiscordLogin.setText(R.string.login) @@ -429,11 +471,6 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene .setPositiveButton(R.string.yes) { dialog, _ -> val downloadsManager = Injekt.get() downloadsManager.purgeDownloads(MediaType.ANIME) - DownloadService.sendRemoveAllDownloads( - this@SettingsActivity, - ExoplayerDownloadService::class.java, - false - ) dialog.dismiss() } .setNegativeButton(R.string.no) { dialog, _ -> @@ -454,6 +491,11 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene settingsShowYt.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.ShowYtButton, isChecked) } + settingsIncludeAnimeList.isChecked = PrefManager.getVal(PrefName.IncludeAnimeList) + settingsIncludeAnimeList.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.IncludeAnimeList, isChecked) + restartApp(binding.root) + } var previousEp: View = when (PrefManager.getVal(PrefName.AnimeDefaultView)) { 0 -> settingsEpList @@ -541,9 +583,178 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene settingsChpCompact.setOnClickListener { uiChp(1, it) } + + settingsIncludeMangaList.isChecked = PrefManager.getVal(PrefName.IncludeMangaList) + settingsIncludeMangaList.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.IncludeMangaList, isChecked) + restartApp(binding.root) + } } bindingExtensions = ActivitySettingsExtensionsBinding.bind(binding.root).apply { + + fun setExtensionOutput() { + animeRepoInventory.removeAllViews() + PrefManager.getVal>(PrefName.AnimeExtensionRepos).forEach { item -> + val view = ItemRepositoryBinding.inflate( + LayoutInflater.from(animeRepoInventory.context), animeRepoInventory, true + ) + view.repositoryItem.text = item.replace("https://raw.githubusercontent.com/", "") + view.repositoryItem.setOnClickListener { + AlertDialog.Builder(this@SettingsActivity, R.style.MyPopup) + .setTitle("Delete Anime Repository") + .setMessage(item) + .setPositiveButton(getString(R.string.ok)) { dialog, _ -> + val anime = PrefManager.getVal>(PrefName.AnimeExtensionRepos).minus(item) + PrefManager.setVal(PrefName.AnimeExtensionRepos, anime) + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + setExtensionOutput() + CoroutineScope(Dispatchers.IO).launch { + animeExtensionManager.findAvailableExtensions() + } + dialog.dismiss() + } + .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + } + .create() + .show() + } + view.repositoryItem.setOnLongClickListener { + copyToClipboard(item, true) + true + } + } + animeRepoInventory.isVisible = animeRepoInventory.childCount > 0 + mangaRepoInventory.removeAllViews() + PrefManager.getVal>(PrefName.MangaExtensionRepos).forEach { item -> + val view = ItemRepositoryBinding.inflate( + LayoutInflater.from(mangaRepoInventory.context), mangaRepoInventory, true + ) + view.repositoryItem.text = item.replace("https://raw.githubusercontent.com/", "") + view.repositoryItem.setOnClickListener { + AlertDialog.Builder(this@SettingsActivity, R.style.MyPopup) + .setTitle("Delete Manga Repository") + .setMessage(item) + .setPositiveButton(getString(R.string.ok)) { dialog, _ -> + val manga = PrefManager.getVal>(PrefName.MangaExtensionRepos).minus(item) + PrefManager.setVal(PrefName.MangaExtensionRepos, manga) + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + setExtensionOutput() + CoroutineScope(Dispatchers.IO).launch { + mangaExtensionManager.findAvailableExtensions() + } + dialog.dismiss() + } + .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + } + .create() + .show() + } + view.repositoryItem.setOnLongClickListener { + copyToClipboard(item, true) + true + } + } + mangaRepoInventory.isVisible = mangaRepoInventory.childCount > 0 + } + + fun processUserInput(input: String, mediaType: MediaType) { + val entry = if (input.endsWith("/") || input.endsWith("index.min.json")) + input.substring(0, input.lastIndexOf("/")) else input + if (mediaType == MediaType.ANIME) { + val anime = + PrefManager.getVal>(PrefName.AnimeExtensionRepos).plus(entry) + PrefManager.setVal(PrefName.AnimeExtensionRepos, anime) + CoroutineScope(Dispatchers.IO).launch { + animeExtensionManager.findAvailableExtensions() + } + } + if (mediaType == MediaType.MANGA) { + val manga = + PrefManager.getVal>(PrefName.MangaExtensionRepos).plus(entry) + PrefManager.setVal(PrefName.MangaExtensionRepos, manga) + CoroutineScope(Dispatchers.IO).launch { + mangaExtensionManager.findAvailableExtensions() + } + } + setExtensionOutput() + } + + fun processEditorAction(dialog: AlertDialog, editText: EditText, mediaType: MediaType) { + 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) + ) { + processUserInput(textView.text.toString(), mediaType) + dialog.dismiss() + return@setOnEditorActionListener true + } + false + } + } + + setExtensionOutput() + animeAddRepository.setOnClickListener { + val dialogView = layoutInflater.inflate(R.layout.dialog_user_agent, null) + val editText = + dialogView.findViewById(R.id.userAgentTextBox).apply { + hint = getString(R.string.anime_add_repository) + } + val alertDialog = AlertDialog.Builder(this@SettingsActivity, R.style.MyPopup) + .setTitle(R.string.add_repository) + .setMessage("Add additional repo for anime extensions") + .setView(dialogView) + .setPositiveButton(getString(R.string.ok)) { dialog, _ -> + processUserInput(editText.text.toString(), MediaType.ANIME) + dialog.dismiss() + } + .setNeutralButton(getString(R.string.reset)) { dialog, _ -> + PrefManager.removeVal(PrefName.DefaultUserAgent) + editText.setText("") + dialog.dismiss() + } + .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + } + .create() + + processEditorAction(alertDialog, editText, MediaType.ANIME) + alertDialog.show() + alertDialog.window?.setDimAmount(0.8f) + } + + mangaAddRepository.setOnClickListener { + val dialogView = layoutInflater.inflate(R.layout.dialog_user_agent, null) + val editText = + dialogView.findViewById(R.id.userAgentTextBox).apply { + hint = getString(R.string.manga_add_repository) + } + val alertDialog = AlertDialog.Builder(this@SettingsActivity, R.style.MyPopup) + .setTitle(R.string.add_repository) + .setView(dialogView) + .setMessage("Add additional repo for manga extensions") + .setPositiveButton(getString(R.string.ok)) { dialog, _ -> + processUserInput(editText.text.toString(), MediaType.MANGA) + dialog.dismiss() + } + .setNeutralButton(getString(R.string.reset)) { dialog, _ -> + PrefManager.removeVal(PrefName.DefaultUserAgent) + editText.setText("") + dialog.dismiss() + } + .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + } + .create() + + processEditorAction(alertDialog, editText, MediaType.MANGA) + alertDialog.show() + alertDialog.window?.setDimAmount(0.8f) + } + settingsForceLegacyInstall.isChecked = extensionInstaller.get() == BasePreferences.ExtensionInstaller.LEGACY settingsForceLegacyInstall.setOnCheckedChangeListener { _, isChecked -> @@ -628,18 +839,18 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene val filteredLocations = Location.entries.filter { it.exportable } selectedArray.addAll(List(filteredLocations.size - 1) { false }) val dialog = AlertDialog.Builder(this@SettingsActivity, R.style.MyPopup) - .setTitle(R.string.import_export_settings) + .setTitle(R.string.backup_restore) .setMultiChoiceItems( filteredLocations.map { it.name }.toTypedArray(), selectedArray.toBooleanArray() ) { _, which, isChecked -> selectedArray[which] = isChecked } - .setPositiveButton(R.string.button_import) { dialog, _ -> + .setPositiveButton(R.string.button_restore) { dialog, _ -> openDocumentLauncher.launch(arrayOf("*/*")) dialog.dismiss() } - .setNegativeButton(R.string.button_export) { dialog, _ -> + .setNegativeButton(R.string.button_backup) { dialog, _ -> if (!selectedArray.contains(true)) { toast(R.string.no_location_selected) return@setNegativeButton @@ -678,27 +889,19 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } settingsExtensionDns.setText(exDns[PrefManager.getVal(PrefName.DohProvider)]) - settingsExtensionDns.setAdapter(ArrayAdapter(this@SettingsActivity, R.layout.item_dropdown, exDns)) + settingsExtensionDns.setAdapter( + ArrayAdapter( + this@SettingsActivity, + R.layout.item_dropdown, + exDns + ) + ) settingsExtensionDns.setOnItemClickListener { _, _, i, _ -> PrefManager.setVal(PrefName.DohProvider, i) settingsExtensionDns.clearFocus() restartApp(binding.root) } - settingsDownloadInSd.isChecked = PrefManager.getVal(PrefName.SdDl) - settingsDownloadInSd.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - val arrayOfFiles = ContextCompat.getExternalFilesDirs(this@SettingsActivity, null) - if (arrayOfFiles.size > 1 && arrayOfFiles[1] != null) { - PrefManager.setVal(PrefName.SdDl, true) - } else { - settingsDownloadInSd.isChecked = false - PrefManager.setVal(PrefName.SdDl, true) - snackString(getString(R.string.noSdFound)) - } - } else PrefManager.setVal(PrefName.SdDl, true) - } - settingsContinueMedia.isChecked = PrefManager.getVal(PrefName.ContinueMedia) settingsContinueMedia.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.ContinueMedia, isChecked) @@ -713,6 +916,48 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene settingsRecentlyListOnly.setOnCheckedChangeListener { _, isChecked -> PrefManager.setVal(PrefName.RecentlyListOnly, isChecked) } + settingsAdultAnimeOnly.isChecked = PrefManager.getVal(PrefName.AdultOnly) + settingsAdultAnimeOnly.setOnCheckedChangeListener { _, isChecked -> + PrefManager.setVal(PrefName.AdultOnly, isChecked) + restartApp(binding.root) + } + + settingsDownloadLocation.setOnClickListener { + val dialog = AlertDialog.Builder(this@SettingsActivity, R.style.MyPopup) + .setTitle(R.string.change_download_location) + .setMessage(R.string.download_location_msg) + .setPositiveButton(R.string.ok) { dialog, _ -> + val oldUri = PrefManager.getVal(PrefName.DownloadsDir) + launcher.registerForCallback { success -> + if (success) { + toast(getString(R.string.please_wait)) + val newUri = PrefManager.getVal(PrefName.DownloadsDir) + GlobalScope.launch(Dispatchers.IO) { + Injekt.get().moveDownloadsDir( + this@SettingsActivity, + Uri.parse(oldUri), Uri.parse(newUri) + ) { finished, message -> + if (finished) { + toast(getString(R.string.success)) + } else { + toast(message) + } + } + } + } else { + toast(getString(R.string.error)) + } + } + launcher.launch() + dialog.dismiss() + } + .setNeutralButton(R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + .create() + dialog.window?.setDimAmount(0.8f) + dialog.show() + } var previousStart: View = when (PrefManager.getVal(PrefName.DefaultStartUpTab)) { 0 -> uiSettingsAnime @@ -742,7 +987,12 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } settingsUi.setOnClickListener { - startActivity(Intent(this@SettingsActivity, UserInterfaceSettingsActivity::class.java)) + startActivity( + Intent( + this@SettingsActivity, + UserInterfaceSettingsActivity::class.java + ) + ) } } @@ -766,7 +1016,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene getString(R.string.subscriptions_checking_time_s, timeNames[i]) PrefManager.setVal(PrefName.SubscriptionNotificationInterval, curTime) dialog.dismiss() - TaskScheduler.create(this@SettingsActivity, + TaskScheduler.create( + this@SettingsActivity, PrefManager.getVal(PrefName.UseAlarmManager) ).scheduleAllTasks(this@SettingsActivity) }.show() @@ -774,7 +1025,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } settingsSubscriptionsTime.setOnLongClickListener { - TaskScheduler.create(this@SettingsActivity, + TaskScheduler.create( + this@SettingsActivity, PrefManager.getVal(PrefName.UseAlarmManager) ).scheduleAllTasks(this@SettingsActivity) true @@ -788,7 +1040,10 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene else getString(R.string.do_not_update) } settingsAnilistSubscriptionsTime.text = - getString(R.string.anilist_notifications_checking_time, aItems[PrefManager.getVal(PrefName.AnilistNotificationInterval)]) + getString( + R.string.anilist_notifications_checking_time, + aItems[PrefManager.getVal(PrefName.AnilistNotificationInterval)] + ) settingsAnilistSubscriptionsTime.setOnClickListener { val selected = PrefManager.getVal(PrefName.AnilistNotificationInterval) @@ -799,7 +1054,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene settingsAnilistSubscriptionsTime.text = getString(R.string.anilist_notifications_checking_time, aItems[i]) dialog.dismiss() - TaskScheduler.create(this@SettingsActivity, + TaskScheduler.create( + this@SettingsActivity, PrefManager.getVal(PrefName.UseAlarmManager) ).scheduleAllTasks(this@SettingsActivity) } @@ -810,7 +1066,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene settingsAnilistNotifications.setOnClickListener { val types = NotificationType.entries.map { it.name } - val filteredTypes = PrefManager.getVal>(PrefName.AnilistFilteredTypes).toMutableSet() + val filteredTypes = + PrefManager.getVal>(PrefName.AnilistFilteredTypes).toMutableSet() val selected = types.map { filteredTypes.contains(it) }.toBooleanArray() val dialog = AlertDialog.Builder(this@SettingsActivity, R.style.MyPopup) .setTitle(R.string.anilist_notification_filters) @@ -837,7 +1094,10 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } settingsCommentSubscriptionsTime.text = - getString(R.string.comment_notification_checking_time, cItems[PrefManager.getVal(PrefName.CommentNotificationInterval)]) + getString( + R.string.comment_notification_checking_time, + cItems[PrefManager.getVal(PrefName.CommentNotificationInterval)] + ) settingsCommentSubscriptionsTime.setOnClickListener { val selected = PrefManager.getVal(PrefName.CommentNotificationInterval) val dialog = AlertDialog.Builder(this@SettingsActivity, R.style.MyPopup) @@ -847,7 +1107,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene settingsCommentSubscriptionsTime.text = getString(R.string.comment_notification_checking_time, cItems[i]) dialog.dismiss() - TaskScheduler.create(this@SettingsActivity, + TaskScheduler.create( + this@SettingsActivity, PrefManager.getVal(PrefName.UseAlarmManager) ).scheduleAllTasks(this@SettingsActivity) } @@ -878,7 +1139,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene PrefManager.setVal(PrefName.UseAlarmManager, true) if (SDK_INT >= Build.VERSION_CODES.S) { if (!(getSystemService(Context.ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()) { - val intent = Intent("android.settings.REQUEST_SCHEDULE_EXACT_ALARM") + val intent = + Intent("android.settings.REQUEST_SCHEDULE_EXACT_ALARM") startActivity(intent) settingsNotificationsCheckingSubscriptions.isChecked = true } @@ -896,7 +1158,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } else { PrefManager.setVal(PrefName.UseAlarmManager, false) TaskScheduler.create(this@SettingsActivity, true).cancelAllTasks() - TaskScheduler.create(this@SettingsActivity, false).scheduleAllTasks(this@SettingsActivity) + TaskScheduler.create(this@SettingsActivity, false) + .scheduleAllTasks(this@SettingsActivity) } } } @@ -1068,6 +1331,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene callback(null) } .create() + fun handleOkAction() { val editText = dialog.findViewById(R.id.userAgentTextBox) if (editText?.text?.isNotBlank() == true) { @@ -1126,4 +1390,4 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene ?: "Unknown Architecture" } } -} \ No newline at end of file +} 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 76e80bb2..fb59a98c 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/Preferences.kt @@ -13,7 +13,6 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files OfflineView(Pref(Location.General, Int::class, 0)), DownloadManager(Pref(Location.General, Int::class, 0)), NSFWExtension(Pref(Location.General, Boolean::class, true)), - SdDl(Pref(Location.General, Boolean::class, false)), ContinueMedia(Pref(Location.General, Boolean::class, true)), SearchSources(Pref(Location.General, Boolean::class, true)), RecentlyListOnly(Pref(Location.General, Boolean::class, false)), @@ -29,6 +28,9 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files "Mozilla/5.0 (Linux; Android 13; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36" ) ), + AnimeExtensionRepos(Pref(Location.General, Set::class, setOf())), + MangaExtensionRepos(Pref(Location.General, Set::class, setOf())), + SharedRepositories(Pref(Location.General, Boolean::class, false)), AnimeSourcesOrder(Pref(Location.General, List::class, listOf())), AnimeSearchHistory(Pref(Location.General, Set::class, setOf())), MangaSourcesOrder(Pref(Location.General, List::class, listOf())), @@ -40,6 +42,9 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files LastAnilistNotificationId(Pref(Location.General, Int::class, 0)), AnilistFilteredTypes(Pref(Location.General, Set::class, setOf())), UseAlarmManager(Pref(Location.General, Boolean::class, false)), + IncludeAnimeList(Pref(Location.General, Boolean::class, true)), + IncludeMangaList(Pref(Location.General, Boolean::class, true)), + AdultOnly(Pref(Location.General, Boolean::class, false)), //User Interface UseOLED(Pref(Location.UI, Boolean::class, false)), @@ -78,6 +83,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files CommentSortOrder(Pref(Location.UI, String::class, "newest")), FollowerLayout(Pref(Location.UI, Int::class, 0)), + //Player DefaultSpeed(Pref(Location.Player, Int::class, 5)), CursedSpeeds(Pref(Location.Player, Boolean::class, false)), @@ -178,6 +184,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files RecentGlobalNotification(Pref(Location.Irrelevant, Int::class, 0)), CommentNotificationStore(Pref(Location.Irrelevant, List::class, listOf())), UnreadCommentNotifications(Pref(Location.Irrelevant, Int::class, 0)), + DownloadsDir(Pref(Location.Irrelevant, String::class, "")), //Protected DiscordToken(Pref(Location.Protected, String::class, "")), diff --git a/app/src/main/java/ani/dantotsu/util/CountUpTimer.kt b/app/src/main/java/ani/dantotsu/util/CountUpTimer.kt new file mode 100644 index 00000000..725781ff --- /dev/null +++ b/app/src/main/java/ani/dantotsu/util/CountUpTimer.kt @@ -0,0 +1,22 @@ +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/StoragePermissions.kt b/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt new file mode 100644 index 00000000..485b0dd9 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/util/StoragePermissions.kt @@ -0,0 +1,132 @@ +package ani.dantotsu.util + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import ani.dantotsu.R +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.toast + +class StoragePermissions { + companion object { + fun downloadsPermission(activity: AppCompatActivity): Boolean { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true + val permissions = arrayOf( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + + val requiredPermissions = permissions.filter { + ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED + }.toTypedArray() + + return if (requiredPermissions.isNotEmpty()) { + ActivityCompat.requestPermissions( + activity, + requiredPermissions, + DOWNLOADS_PERMISSION_REQUEST_CODE + ) + false + } else { + true + } + } + + fun hasDirAccess(context: Context, path: String): Boolean { + val uri = pathToUri(path) + return context.contentResolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + + } + + fun hasDirAccess(context: Context, uri: Uri): Boolean { + return context.contentResolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + } + + fun hasDirAccess(context: Context): Boolean { + val path = PrefManager.getVal(PrefName.DownloadsDir) + return hasDirAccess(context, path) + } + + fun AppCompatActivity.accessAlertDialog(launcher: LauncherWrapper, + force: Boolean = false, + complete: (Boolean) -> Unit + ) { + if ((PrefManager.getVal(PrefName.DownloadsDir).isNotEmpty() || hasDirAccess(this)) && !force) { + 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) + } + + private fun pathToUri(path: String): Uri { + return Uri.parse(path) + } + + private const val DOWNLOADS_PERMISSION_REQUEST_CODE = 100 + } +} + + +class LauncherWrapper( + activity: AppCompatActivity, + contract: ActivityResultContracts.OpenDocumentTree) +{ + private var launcher: ActivityResultLauncher + var complete: (Boolean) -> Unit = {} + init{ + launcher = activity.registerForActivityResult(contract) { uri -> + if (uri != null) { + activity.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + if (StoragePermissions.hasDirAccess(activity, uri)) { + PrefManager.setVal(PrefName.DownloadsDir, uri.toString()) + complete(true) + } else { + toast(activity.getString(R.string.dir_error)) + complete(false) + } + } else { + toast(activity.getString(R.string.dir_error)) + complete(false) + } + } + } + + fun registerForCallback(callback: (Boolean) -> Unit) { + complete = callback + } + + fun launch() { + launcher.launch(null) + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/widgets/statistics/ProfileStatsConfigure.kt b/app/src/main/java/ani/dantotsu/widgets/statistics/ProfileStatsConfigure.kt new file mode 100644 index 00000000..b3ddec46 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/widgets/statistics/ProfileStatsConfigure.kt @@ -0,0 +1,268 @@ +package ani.dantotsu.widgets.statistics + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import ani.dantotsu.R +import ani.dantotsu.databinding.StatisticsWidgetConfigureBinding + +import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.widgets.upcoming.UpcomingWidget +import com.google.android.material.button.MaterialButton +import eltos.simpledialogfragment.SimpleDialog +import eltos.simpledialogfragment.color.SimpleColorDialog + +/** + * The configuration screen for the [ProfileStatsWidget] AppWidget. + */ +class ProfileStatsConfigure : AppCompatActivity(), + SimpleDialog.OnDialogResultListener { + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + private var isMonetEnabled = false + private var onClickListener = View.OnClickListener { + val context = this@ProfileStatsConfigure + + // It is the responsibility of the configuration activity to update the app widget + val appWidgetManager = AppWidgetManager.getInstance(context) + //updateAppWidget(context, appWidgetManager, appWidgetId) + + + ProfileStatsWidget.updateAppWidget( + context, + appWidgetManager, + appWidgetId + ) + + // Make sure we pass back the original appWidgetId + val resultValue = Intent() + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setResult(RESULT_OK, resultValue) + finish() + } + private lateinit var binding: StatisticsWidgetConfigureBinding + + public override fun onCreate(icicle: Bundle?) { + + ThemeManager(this).applyTheme() + super.onCreate(icicle) + + // Set the result to CANCELED. This will cause the widget host to cancel + // out of the widget placement if the user presses the back button. + setResult(RESULT_CANCELED) + + binding = StatisticsWidgetConfigureBinding.inflate(layoutInflater) + setContentView(binding.root) + + val prefs = getSharedPreferences(ProfileStatsWidget.PREFS_NAME, Context.MODE_PRIVATE) + val topBackground = prefs.getInt(ProfileStatsWidget.PREF_BACKGROUND_COLOR, Color.parseColor("#80000000")) + (binding.topBackgroundButton as MaterialButton).iconTint = ColorStateList.valueOf(topBackground) + binding.topBackgroundButton.setOnClickListener { + val tag = ProfileStatsWidget.PREF_BACKGROUND_COLOR + SimpleColorDialog().title(R.string.custom_theme) + .colorPreset(topBackground) + .colors( + this@ProfileStatsConfigure, + SimpleColorDialog.MATERIAL_COLOR_PALLET + ) + .setupColorWheelAlpha(true) + .allowCustom(true) + .showOutline(0x46000000) + .gridNumColumn(5) + .choiceMode(SimpleColorDialog.SINGLE_CHOICE) + .neg() + .show(this@ProfileStatsConfigure, tag) + } + val bottomBackground = prefs.getInt(ProfileStatsWidget.PREF_BACKGROUND_FADE, Color.parseColor("#00000000")) + (binding.bottomBackgroundButton as MaterialButton).iconTint = ColorStateList.valueOf(bottomBackground) + binding.bottomBackgroundButton.setOnClickListener { + val tag = ProfileStatsWidget.PREF_BACKGROUND_FADE + SimpleColorDialog().title(R.string.custom_theme) + .colorPreset(bottomBackground) + .colors( + this@ProfileStatsConfigure, + SimpleColorDialog.MATERIAL_COLOR_PALLET + ) + .setupColorWheelAlpha(true) + .allowCustom(true) + .showOutline(0x46000000) + .gridNumColumn(5) + .choiceMode(SimpleColorDialog.SINGLE_CHOICE) + .neg() + .show(this@ProfileStatsConfigure, tag) + } + val titleColor = prefs.getInt(ProfileStatsWidget.PREF_TITLE_TEXT_COLOR, Color.WHITE) + (binding.titleColorButton as MaterialButton).iconTint = ColorStateList.valueOf(titleColor) + binding.titleColorButton.setOnClickListener { + val tag = ProfileStatsWidget.PREF_TITLE_TEXT_COLOR + SimpleColorDialog().title(R.string.custom_theme) + .colorPreset(titleColor) + .colors( + this@ProfileStatsConfigure, + SimpleColorDialog.MATERIAL_COLOR_PALLET + ) + .setupColorWheelAlpha(true) + .allowCustom(true) + .showOutline(0x46000000) + .gridNumColumn(5) + .choiceMode(SimpleColorDialog.SINGLE_CHOICE) + .neg() + .show(this@ProfileStatsConfigure, tag) + } + val statsColor = prefs.getInt(ProfileStatsWidget.PREF_STATS_TEXT_COLOR, Color.WHITE) + (binding.statsColorButton as MaterialButton).iconTint = ColorStateList.valueOf(statsColor) + binding.statsColorButton.setOnClickListener { + val tag = ProfileStatsWidget.PREF_STATS_TEXT_COLOR + SimpleColorDialog().title(R.string.custom_theme) + .colorPreset(statsColor) + .colors( + this@ProfileStatsConfigure, + SimpleColorDialog.MATERIAL_COLOR_PALLET + ) + .setupColorWheelAlpha(true) + .allowCustom(true) + .showOutline(0x46000000) + .gridNumColumn(5) + .choiceMode(SimpleColorDialog.SINGLE_CHOICE) + .neg() + .show(this@ProfileStatsConfigure, tag) + } + binding.useAppTheme.setOnCheckedChangeListener { _, isChecked -> + isMonetEnabled = isChecked + if (isChecked) { + binding.topBackgroundButton.visibility = View.GONE + binding.bottomBackgroundButton.visibility = View.GONE + binding.titleColorButton.visibility = View.GONE + binding.statsColorButton.visibility = View.GONE + themeColors() + + } else { + binding.topBackgroundButton.visibility = View.VISIBLE + binding.bottomBackgroundButton.visibility = View.VISIBLE + binding.titleColorButton.visibility = View.VISIBLE + binding.statsColorButton.visibility = View.VISIBLE + } + } + binding.addButton.setOnClickListener(onClickListener) + + // Find the widget id from the intent. + val intent = intent + val extras = intent.extras + if (extras != null) { + appWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID + ) + } + + // If this activity was started with an intent without an app widget ID, finish with an error. + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + } + + private fun themeColors() { + val typedValueSurface = TypedValue() + theme.resolveAttribute( + com.google.android.material.R.attr.colorSurface, + typedValueSurface, + true + ) + val backgroundColor = typedValueSurface.data + + val typedValuePrimary = TypedValue() + theme.resolveAttribute( + com.google.android.material.R.attr.colorPrimary, + typedValuePrimary, + true + ) + val textColor = typedValuePrimary.data + + val typedValueOutline = TypedValue() + theme.resolveAttribute( + com.google.android.material.R.attr.colorOutline, + typedValueOutline, + true + ) + val subTextColor = typedValueOutline.data + + getSharedPreferences(ProfileStatsWidget.PREFS_NAME, Context.MODE_PRIVATE).edit().apply { + putInt(ProfileStatsWidget.PREF_BACKGROUND_COLOR, backgroundColor) + putInt(ProfileStatsWidget.PREF_BACKGROUND_FADE, backgroundColor) + putInt(ProfileStatsWidget.PREF_TITLE_TEXT_COLOR, textColor) + putInt(ProfileStatsWidget.PREF_STATS_TEXT_COLOR, subTextColor) + apply() + } + } + + override fun onResult(dialogTag: String, which: Int, extras: Bundle): Boolean { + if (which == SimpleDialog.OnDialogResultListener.BUTTON_POSITIVE) { + if (!isMonetEnabled) { + when (dialogTag) { + ProfileStatsWidget.PREF_BACKGROUND_COLOR -> { + getSharedPreferences( + ProfileStatsWidget.PREFS_NAME, + Context.MODE_PRIVATE + ).edit() + .putInt( + ProfileStatsWidget.PREF_BACKGROUND_COLOR, + extras.getInt(SimpleColorDialog.COLOR) + ) + .apply() + (binding.topBackgroundButton as MaterialButton).iconTint = + ColorStateList.valueOf(extras.getInt(SimpleColorDialog.COLOR)) + } + + ProfileStatsWidget.PREF_BACKGROUND_FADE -> { + getSharedPreferences( + ProfileStatsWidget.PREFS_NAME, + Context.MODE_PRIVATE + ).edit() + .putInt( + ProfileStatsWidget.PREF_BACKGROUND_FADE, + extras.getInt(SimpleColorDialog.COLOR) + ) + .apply() + (binding.bottomBackgroundButton as MaterialButton).iconTint = + ColorStateList.valueOf(extras.getInt(SimpleColorDialog.COLOR)) + } + + ProfileStatsWidget.PREF_TITLE_TEXT_COLOR -> { + getSharedPreferences( + ProfileStatsWidget.PREFS_NAME, + Context.MODE_PRIVATE + ).edit() + .putInt( + ProfileStatsWidget.PREF_TITLE_TEXT_COLOR, + extras.getInt(SimpleColorDialog.COLOR) + ) + .apply() + (binding.titleColorButton as MaterialButton).iconTint = + ColorStateList.valueOf(extras.getInt(SimpleColorDialog.COLOR)) + } + + ProfileStatsWidget.PREF_STATS_TEXT_COLOR -> { + getSharedPreferences( + ProfileStatsWidget.PREFS_NAME, + Context.MODE_PRIVATE + ).edit() + .putInt( + ProfileStatsWidget.PREF_STATS_TEXT_COLOR, + extras.getInt(SimpleColorDialog.COLOR) + ) + .apply() + (binding.statsColorButton as MaterialButton).iconTint = + ColorStateList.valueOf(extras.getInt(SimpleColorDialog.COLOR)) + } + } + } + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/widgets/statistics/ProfileStatsWidget.kt b/app/src/main/java/ani/dantotsu/widgets/statistics/ProfileStatsWidget.kt new file mode 100644 index 00000000..24ac05c8 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/widgets/statistics/ProfileStatsWidget.kt @@ -0,0 +1,257 @@ +package ani.dantotsu.widgets.statistics + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.widget.RemoteViews +import androidx.core.content.res.ResourcesCompat +import ani.dantotsu.MainActivity +import ani.dantotsu.R +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.profile.ProfileActivity +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName +import ani.dantotsu.util.BitmapUtil +import ani.dantotsu.widgets.WidgetSizeProvider +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import tachiyomi.core.util.lang.launchIO +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL + +/** + * Implementation of App Widget functionality. + */ +class ProfileStatsWidget : AppWidgetProvider() { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + appWidgetIds.forEach { appWidgetId -> + updateAppWidget(context, appWidgetManager, appWidgetId) + } + super.onUpdate(context, appWidgetManager, appWidgetIds) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + } + + override fun onEnabled(context: Context) { + super.onEnabled(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + } + + companion object { + private fun downloadImageAsBitmap(imageUrl: String): Bitmap? { + var bitmap: Bitmap? = null + + runBlocking(Dispatchers.IO) { + var inputStream: InputStream? = null + var urlConnection: HttpURLConnection? = null + try { + val url = URL(imageUrl) + urlConnection = url.openConnection() as HttpURLConnection + urlConnection.requestMethod = "GET" + urlConnection.connect() + + if (urlConnection.responseCode == HttpURLConnection.HTTP_OK) { + inputStream = urlConnection.inputStream + bitmap = BitmapFactory.decodeStream(inputStream) + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + inputStream?.close() + urlConnection?.disconnect() + } + } + return bitmap?.let { BitmapUtil.roundCorners(it) } + } + + @OptIn(DelicateCoroutinesApi::class) + fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val backgroundColor = + prefs.getInt(PREF_BACKGROUND_COLOR, Color.parseColor("#80000000")) + val backgroundFade = prefs.getInt(PREF_BACKGROUND_FADE, Color.parseColor("#00000000")) + val titleTextColor = prefs.getInt(PREF_TITLE_TEXT_COLOR, Color.WHITE) + val statsTextColor = prefs.getInt(PREF_STATS_TEXT_COLOR, Color.WHITE) + + val gradientDrawable = ResourcesCompat.getDrawable( + context.resources, + R.drawable.linear_gradient_black, + null + ) as GradientDrawable + gradientDrawable.colors = intArrayOf(backgroundColor, backgroundFade) + val widgetSizeProvider = WidgetSizeProvider(context) + var (width, height) = widgetSizeProvider.getWidgetsSize(appWidgetId) + if (width > 0 && height > 0) { + gradientDrawable.cornerRadius = 64f + } else { + width = 300 + height = 300 + } + + launchIO { + val userPref = PrefManager.getVal(PrefName.AnilistUserId, "") + if (userPref.isNotEmpty()) { + val respond = Anilist.query.getUserProfile(userPref.toInt()) + respond?.data?.user?.let { user -> + withContext(Dispatchers.Main) { + val views = RemoteViews(context.packageName, R.layout.statistics_widget).apply { + setImageViewBitmap( + R.id.backgroundView, + BitmapUtil.convertDrawableToBitmap( + gradientDrawable, + width, + height + ) + ) + setOnClickPendingIntent( + R.id.userAvatar, + PendingIntent.getActivity( + context, + 1, + Intent(context, ProfileStatsConfigure::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + setTextColor(R.id.userLabel, titleTextColor) + setTextColor(R.id.topLeftItem, titleTextColor) + setTextColor(R.id.topLeftLabel, statsTextColor) + setTextColor(R.id.topRightItem, titleTextColor) + setTextColor(R.id.topRightLabel, statsTextColor) + setTextColor(R.id.bottomLeftItem, titleTextColor) + setTextColor(R.id.bottomLeftLabel, statsTextColor) + setTextColor(R.id.bottomRightItem, titleTextColor) + setTextColor(R.id.bottomRightLabel, statsTextColor) + + setImageViewBitmap( + R.id.userAvatar, + user.avatar?.medium?.let { it1 -> downloadImageAsBitmap(it1) } + ) + setTextViewText( + R.id.userLabel, + context.getString(R.string.user_stats, user.name) + ) + + setTextViewText( + R.id.topLeftItem, + user.statistics.anime.count.toString() + ) + setTextViewText( + R.id.topLeftLabel, + context.getString(R.string.anime_watched) + ) + + setTextViewText( + R.id.topRightItem, + user.statistics.anime.episodesWatched.toString() + ) + setTextViewText( + R.id.topRightLabel, + context.getString(R.string.episodes_watched_n) + ) + + setTextViewText( + R.id.bottomLeftItem, + user.statistics.manga.count.toString() + ) + setTextViewText( + R.id.bottomLeftLabel, + context.getString(R.string.manga_read) + ) + + setTextViewText( + R.id.bottomRightItem, + user.statistics.manga.chaptersRead.toString() + ) + setTextViewText( + R.id.bottomRightLabel, + context.getString(R.string.chapters_read_n) + ) + + val intent = Intent(context, ProfileActivity::class.java) + .putExtra("userId", userPref.toInt()) + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, PendingIntent.FLAG_IMMUTABLE + ) + setOnClickPendingIntent(R.id.widgetContainer, pendingIntent) + } + // Instruct the widget manager to update the widget + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } ?: showLoginCascade(context, appWidgetManager, appWidgetId) + } else showLoginCascade(context, appWidgetManager, appWidgetId) + } + } + + private suspend fun showLoginCascade( + context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int + ) { + + withContext(Dispatchers.Main) { + val views = RemoteViews(context.packageName, R.layout.statistics_widget) + + views.setTextViewText(R.id.topLeftItem, "") + views.setTextViewText( + R.id.topLeftLabel, + context.getString(R.string.please) + ) + + views.setTextViewText(R.id.topRightItem, "") + views.setTextViewText( + R.id.topRightLabel, + context.getString(R.string.log_in) + ) + + views.setTextViewText( + R.id.bottomLeftItem, + context.getString(R.string.or_join) + ) + views.setTextViewText(R.id.bottomLeftLabel, "") + + views.setTextViewText( + R.id.bottomRightItem, + context.getString(R.string.anilist) + ) + views.setTextViewText(R.id.bottomRightLabel, "") + + val intent = Intent(context, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widgetContainer, pendingIntent) + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } + + const val PREFS_NAME = "ani.dantotsu.widgets.ResumableWidget" + const val PREF_BACKGROUND_COLOR = "background_color" + const val PREF_BACKGROUND_FADE = "background_fade" + const val PREF_TITLE_TEXT_COLOR = "title_text_color" + const val PREF_STATS_TEXT_COLOR = "stats_text_color" + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidget.kt b/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidget.kt index a9f22130..fc8850f3 100644 --- a/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidget.kt +++ b/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidget.kt @@ -10,7 +10,6 @@ import android.graphics.drawable.GradientDrawable import android.net.Uri import android.os.Bundle import android.widget.RemoteViews -import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import ani.dantotsu.MainActivity import ani.dantotsu.R @@ -19,7 +18,7 @@ import ani.dantotsu.widgets.WidgetSizeProvider /** * Implementation of App Widget functionality. - * App Widget Configuration implemented in [UpcomingWidgetConfigureActivity] + * App Widget Configuration implemented in [UpcomingWidgetConfigure] */ class UpcomingWidget : AppWidgetProvider() { override fun onUpdate( @@ -69,8 +68,8 @@ class UpcomingWidget : AppWidgetProvider() { ): RemoteViews { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val backgroundColor = - prefs.getInt(PREF_BACKGROUND_COLOR, ContextCompat.getColor(context, R.color.theme)) - val backgroundFade = prefs.getInt(PREF_BACKGROUND_FADE, Color.GRAY) + prefs.getInt(PREF_BACKGROUND_COLOR, Color.parseColor("#80000000")) + val backgroundFade = prefs.getInt(PREF_BACKGROUND_FADE, Color.parseColor("#00000000")) val titleTextColor = prefs.getInt(PREF_TITLE_TEXT_COLOR, Color.WHITE) val countdownTextColor = prefs.getInt(PREF_COUNTDOWN_TEXT_COLOR, Color.WHITE) @@ -80,14 +79,14 @@ class UpcomingWidget : AppWidgetProvider() { } val gradientDrawable = ResourcesCompat.getDrawable( context.resources, - R.drawable.gradient_background, + R.drawable.linear_gradient_black, null ) as GradientDrawable gradientDrawable.colors = intArrayOf(backgroundColor, backgroundFade) val widgetSizeProvider = WidgetSizeProvider(context) var (width, height) = widgetSizeProvider.getWidgetsSize(appWidgetId) if (width > 0 && height > 0) { - gradientDrawable.cornerRadius = 50f + gradientDrawable.cornerRadius = 64f } else { width = 300 height = 300 @@ -118,7 +117,7 @@ class UpcomingWidget : AppWidgetProvider() { PendingIntent.getActivity( context, 1, - Intent(context, UpcomingWidgetConfigureActivity::class.java).apply { + Intent(context, UpcomingWidgetConfigure::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE diff --git a/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidgetConfigure.kt b/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidgetConfigure.kt new file mode 100644 index 00000000..a465a48a --- /dev/null +++ b/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidgetConfigure.kt @@ -0,0 +1,240 @@ +package ani.dantotsu.widgets.upcoming + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import ani.dantotsu.R +import ani.dantotsu.databinding.UpcomingWidgetConfigureBinding +import ani.dantotsu.themes.ThemeManager +import com.google.android.material.button.MaterialButton +import eltos.simpledialogfragment.SimpleDialog +import eltos.simpledialogfragment.color.SimpleColorDialog + +/** + * The configuration screen for the [UpcomingWidget] AppWidget. + */ +class UpcomingWidgetConfigure : AppCompatActivity(), + SimpleDialog.OnDialogResultListener { + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + private var isMonetEnabled = false + private var onClickListener = View.OnClickListener { + val context = this@UpcomingWidgetConfigure + val appWidgetManager = AppWidgetManager.getInstance(context) + + updateAppWidget( + context, + appWidgetManager, + appWidgetId, + ) + + val resultValue = Intent() + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setResult(RESULT_OK, resultValue) + finish() + } + private lateinit var binding: UpcomingWidgetConfigureBinding + + public override fun onCreate(icicle: Bundle?) { + ThemeManager(this).applyTheme() + super.onCreate(icicle) + setResult(RESULT_CANCELED) + + binding = UpcomingWidgetConfigureBinding.inflate(layoutInflater) + setContentView(binding.root) + val prefs = getSharedPreferences(UpcomingWidget.PREFS_NAME, Context.MODE_PRIVATE) + val topBackground = prefs.getInt(UpcomingWidget.PREF_BACKGROUND_COLOR, Color.parseColor("#80000000")) + (binding.topBackgroundButton as MaterialButton).iconTint = ColorStateList.valueOf(topBackground) + binding.topBackgroundButton.setOnClickListener { + val tag = UpcomingWidget.PREF_BACKGROUND_COLOR + SimpleColorDialog().title(R.string.custom_theme) + .colorPreset(topBackground) + .colors( + this@UpcomingWidgetConfigure, + SimpleColorDialog.MATERIAL_COLOR_PALLET + ) + .setupColorWheelAlpha(true) + .allowCustom(true) + .showOutline(0x46000000) + .gridNumColumn(5) + .choiceMode(SimpleColorDialog.SINGLE_CHOICE) + .neg() + .show(this@UpcomingWidgetConfigure, tag) + } + val bottomBackground = prefs.getInt(UpcomingWidget.PREF_BACKGROUND_FADE, Color.parseColor("#00000000")) + (binding.bottomBackgroundButton as MaterialButton).iconTint = ColorStateList.valueOf(bottomBackground) + binding.bottomBackgroundButton.setOnClickListener { + val tag = UpcomingWidget.PREF_BACKGROUND_FADE + SimpleColorDialog().title(R.string.custom_theme) + .colorPreset(bottomBackground) + .colors( + this@UpcomingWidgetConfigure, + SimpleColorDialog.MATERIAL_COLOR_PALLET + ) + .setupColorWheelAlpha(true) + .allowCustom(true) + .showOutline(0x46000000) + .gridNumColumn(5) + .choiceMode(SimpleColorDialog.SINGLE_CHOICE) + .neg() + .show(this@UpcomingWidgetConfigure, tag) + } + val titleTextColor = prefs.getInt(UpcomingWidget.PREF_TITLE_TEXT_COLOR, Color.WHITE) + (binding.titleColorButton as MaterialButton).iconTint = ColorStateList.valueOf(titleTextColor) + binding.titleColorButton.setOnClickListener { + val tag = UpcomingWidget.PREF_TITLE_TEXT_COLOR + SimpleColorDialog().title(R.string.custom_theme) + .colorPreset(titleTextColor) + .colors( + this@UpcomingWidgetConfigure, + SimpleColorDialog.MATERIAL_COLOR_PALLET + ) + .allowCustom(true) + .showOutline(0x46000000) + .gridNumColumn(5) + .choiceMode(SimpleColorDialog.SINGLE_CHOICE) + .neg() + .show(this@UpcomingWidgetConfigure, tag) + } + val countdownTextColor = prefs.getInt(UpcomingWidget.PREF_COUNTDOWN_TEXT_COLOR, Color.WHITE) + (binding.countdownColorButton as MaterialButton).iconTint = ColorStateList.valueOf(countdownTextColor) + binding.countdownColorButton.setOnClickListener { + val tag = UpcomingWidget.PREF_COUNTDOWN_TEXT_COLOR + SimpleColorDialog().title(R.string.custom_theme) + .colorPreset(countdownTextColor) + .colors( + this@UpcomingWidgetConfigure, + SimpleColorDialog.MATERIAL_COLOR_PALLET + ) + .allowCustom(true) + .showOutline(0x46000000) + .gridNumColumn(5) + .choiceMode(SimpleColorDialog.SINGLE_CHOICE) + .neg() + .show(this@UpcomingWidgetConfigure, tag) + } + binding.useAppTheme.setOnCheckedChangeListener { _, isChecked -> + isMonetEnabled = isChecked + if (isChecked) { + binding.topBackgroundButton.visibility = View.GONE + binding.bottomBackgroundButton.visibility = View.GONE + binding.titleColorButton.visibility = View.GONE + binding.countdownColorButton.visibility = View.GONE + themeColors() + + } else { + binding.topBackgroundButton.visibility = View.VISIBLE + binding.bottomBackgroundButton.visibility = View.VISIBLE + binding.titleColorButton.visibility = View.VISIBLE + binding.countdownColorButton.visibility = View.VISIBLE + } + } + binding.addButton.setOnClickListener(onClickListener) + + val intent = intent + val extras = intent.extras + if (extras != null) { + appWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID + ) + } + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + } + + private fun themeColors() { + val typedValueSurface = TypedValue() + theme.resolveAttribute(com.google.android.material.R.attr.colorSurface, typedValueSurface, true) + val backgroundColor = typedValueSurface.data + + val typedValuePrimary = TypedValue() + theme.resolveAttribute(com.google.android.material.R.attr.colorPrimary, typedValuePrimary, true) + val textColor = typedValuePrimary.data + + val typedValueOutline = TypedValue() + theme.resolveAttribute(com.google.android.material.R.attr.colorOutline, typedValueOutline, true) + val subTextColor = typedValueOutline.data + + getSharedPreferences(UpcomingWidget.PREFS_NAME, Context.MODE_PRIVATE).edit().apply { + putInt(UpcomingWidget.PREF_BACKGROUND_COLOR, backgroundColor) + putInt(UpcomingWidget.PREF_BACKGROUND_FADE, backgroundColor) + putInt(UpcomingWidget.PREF_TITLE_TEXT_COLOR, textColor) + putInt(UpcomingWidget.PREF_COUNTDOWN_TEXT_COLOR, subTextColor) + apply() + } + } + + override fun onResult(dialogTag: String, which: Int, extras: Bundle): Boolean { + if (which == SimpleDialog.OnDialogResultListener.BUTTON_POSITIVE) { + if (!isMonetEnabled) { + when (dialogTag) { + UpcomingWidget.PREF_BACKGROUND_COLOR -> { + getSharedPreferences( + UpcomingWidget.PREFS_NAME, + Context.MODE_PRIVATE + ).edit() + .putInt( + UpcomingWidget.PREF_BACKGROUND_COLOR, + extras.getInt(SimpleColorDialog.COLOR) + ) + .apply() + (binding.topBackgroundButton as MaterialButton).iconTint = + ColorStateList.valueOf(extras.getInt(SimpleColorDialog.COLOR)) + } + + UpcomingWidget.PREF_BACKGROUND_FADE -> { + getSharedPreferences( + UpcomingWidget.PREFS_NAME, + Context.MODE_PRIVATE + ).edit() + .putInt( + UpcomingWidget.PREF_BACKGROUND_FADE, + extras.getInt(SimpleColorDialog.COLOR) + ) + .apply() + (binding.bottomBackgroundButton as MaterialButton).iconTint = + ColorStateList.valueOf(extras.getInt(SimpleColorDialog.COLOR)) + } + + UpcomingWidget.PREF_TITLE_TEXT_COLOR -> { + getSharedPreferences( + UpcomingWidget.PREFS_NAME, + Context.MODE_PRIVATE + ).edit() + .putInt( + UpcomingWidget.PREF_TITLE_TEXT_COLOR, + extras.getInt(SimpleColorDialog.COLOR) + ) + .apply() + (binding.titleColorButton as MaterialButton).iconTint = + ColorStateList.valueOf(extras.getInt(SimpleColorDialog.COLOR)) + } + + UpcomingWidget.PREF_COUNTDOWN_TEXT_COLOR -> { + getSharedPreferences( + UpcomingWidget.PREFS_NAME, + Context.MODE_PRIVATE + ).edit() + .putInt( + UpcomingWidget.PREF_COUNTDOWN_TEXT_COLOR, + extras.getInt(SimpleColorDialog.COLOR) + ) + .apply() + (binding.countdownColorButton as MaterialButton).iconTint = + ColorStateList.valueOf(extras.getInt(SimpleColorDialog.COLOR)) + } + + } + } + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidgetConfigureActivity.kt b/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidgetConfigureActivity.kt deleted file mode 100644 index f14efaa7..00000000 --- a/app/src/main/java/ani/dantotsu/widgets/upcoming/UpcomingWidgetConfigureActivity.kt +++ /dev/null @@ -1,195 +0,0 @@ -package ani.dantotsu.widgets.upcoming - -import android.appwidget.AppWidgetManager -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import ani.dantotsu.R -import ani.dantotsu.databinding.UpcomingWidgetConfigureBinding -import ani.dantotsu.themes.ThemeManager -import eltos.simpledialogfragment.SimpleDialog -import eltos.simpledialogfragment.color.SimpleColorDialog - -/** - * The configuration screen for the [UpcomingWidget] AppWidget. - */ -class UpcomingWidgetConfigureActivity : AppCompatActivity(), - SimpleDialog.OnDialogResultListener { - private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID - - private var onClickListener = View.OnClickListener { - val context = this@UpcomingWidgetConfigureActivity - val appWidgetManager = AppWidgetManager.getInstance(context) - - updateAppWidget( - context, - appWidgetManager, - appWidgetId, - ) - - val resultValue = Intent() - resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) - setResult(RESULT_OK, resultValue) - finish() - } - private lateinit var binding: UpcomingWidgetConfigureBinding - - public override fun onCreate(icicle: Bundle?) { - ThemeManager(this).applyTheme() - super.onCreate(icicle) - setResult(RESULT_CANCELED) - - binding = UpcomingWidgetConfigureBinding.inflate(layoutInflater) - setContentView(binding.root) - val prefs = getSharedPreferences(UpcomingWidget.PREFS_NAME, Context.MODE_PRIVATE) - - binding.topBackgroundButton.setOnClickListener { - val tag = UpcomingWidget.PREF_BACKGROUND_COLOR - SimpleColorDialog().title(R.string.custom_theme) - .colorPreset( - prefs.getInt( - UpcomingWidget.PREF_BACKGROUND_COLOR, - ContextCompat.getColor(this, R.color.theme) - ) - ) - .colors( - this@UpcomingWidgetConfigureActivity, - SimpleColorDialog.MATERIAL_COLOR_PALLET - ) - .setupColorWheelAlpha(true) - .allowCustom(true) - .showOutline(0x46000000) - .gridNumColumn(5) - .choiceMode(SimpleColorDialog.SINGLE_CHOICE) - .neg() - .show(this@UpcomingWidgetConfigureActivity, tag) - } - binding.bottomBackgroundButton.setOnClickListener { - val tag = UpcomingWidget.PREF_BACKGROUND_FADE - SimpleColorDialog().title(R.string.custom_theme) - .colorPreset(prefs.getInt(UpcomingWidget.PREF_BACKGROUND_FADE, Color.GRAY)) - .colors( - this@UpcomingWidgetConfigureActivity, - SimpleColorDialog.MATERIAL_COLOR_PALLET - ) - .setupColorWheelAlpha(true) - .allowCustom(true) - .showOutline(0x46000000) - .gridNumColumn(5) - .choiceMode(SimpleColorDialog.SINGLE_CHOICE) - .neg() - .show(this@UpcomingWidgetConfigureActivity, tag) - } - binding.titleColorButton.setOnClickListener { - val tag = UpcomingWidget.PREF_TITLE_TEXT_COLOR - SimpleColorDialog().title(R.string.custom_theme) - .colorPreset(prefs.getInt(UpcomingWidget.PREF_TITLE_TEXT_COLOR, Color.WHITE)) - .colors( - this@UpcomingWidgetConfigureActivity, - SimpleColorDialog.MATERIAL_COLOR_PALLET - ) - .allowCustom(true) - .showOutline(0x46000000) - .gridNumColumn(5) - .choiceMode(SimpleColorDialog.SINGLE_CHOICE) - .neg() - .show(this@UpcomingWidgetConfigureActivity, tag) - } - binding.countdownColorButton.setOnClickListener { - val tag = UpcomingWidget.PREF_COUNTDOWN_TEXT_COLOR - SimpleColorDialog().title(R.string.custom_theme) - .colorPreset( - prefs.getInt( - UpcomingWidget.PREF_COUNTDOWN_TEXT_COLOR, - Color.WHITE - ) - ) - .colors( - this@UpcomingWidgetConfigureActivity, - SimpleColorDialog.MATERIAL_COLOR_PALLET - ) - .allowCustom(true) - .showOutline(0x46000000) - .gridNumColumn(5) - .choiceMode(SimpleColorDialog.SINGLE_CHOICE) - .neg() - .show(this@UpcomingWidgetConfigureActivity, tag) - } - - binding.addButton.setOnClickListener(onClickListener) - - val intent = intent - val extras = intent.extras - if (extras != null) { - appWidgetId = extras.getInt( - AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID - ) - } - - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - finish() - return - } - } - - override fun onResult(dialogTag: String, which: Int, extras: Bundle): Boolean { - if (which == SimpleDialog.OnDialogResultListener.BUTTON_POSITIVE) { - when (dialogTag) { - UpcomingWidget.PREF_BACKGROUND_COLOR -> { - getSharedPreferences( - UpcomingWidget.PREFS_NAME, - Context.MODE_PRIVATE - ).edit() - .putInt( - UpcomingWidget.PREF_BACKGROUND_COLOR, - extras.getInt(SimpleColorDialog.COLOR) - ) - .apply() - } - - UpcomingWidget.PREF_BACKGROUND_FADE -> { - getSharedPreferences( - UpcomingWidget.PREFS_NAME, - Context.MODE_PRIVATE - ).edit() - .putInt( - UpcomingWidget.PREF_BACKGROUND_FADE, - extras.getInt(SimpleColorDialog.COLOR) - ) - .apply() - } - - UpcomingWidget.PREF_TITLE_TEXT_COLOR -> { - getSharedPreferences( - UpcomingWidget.PREFS_NAME, - Context.MODE_PRIVATE - ).edit() - .putInt( - UpcomingWidget.PREF_TITLE_TEXT_COLOR, - extras.getInt(SimpleColorDialog.COLOR) - ) - .apply() - } - - UpcomingWidget.PREF_COUNTDOWN_TEXT_COLOR -> { - getSharedPreferences( - UpcomingWidget.PREFS_NAME, - Context.MODE_PRIVATE - ).edit() - .putInt( - UpcomingWidget.PREF_COUNTDOWN_TEXT_COLOR, - extras.getInt(SimpleColorDialog.COLOR) - ) - .apply() - } - - } - } - return true - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/animesource/online/AnimeHttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/animesource/online/AnimeHttpSource.kt index 197be008..51f593a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/animesource/online/AnimeHttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/animesource/online/AnimeHttpSource.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.NetworkHelper.Companion.defaultUserAgentProvider import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.newCachelessCallWithProgress import okhttp3.Headers @@ -69,7 +70,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource { * Headers builder for requests. Implementations can override this method for custom headers. */ protected open fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", network.defaultUserAgentProvider()) + add("User-Agent", defaultUserAgentProvider()) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateNotifier.kt index 84acd4b3..559cc35f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateNotifier.kt @@ -15,7 +15,7 @@ class ExtensionUpdateNotifier(private val context: Context) { Notifications.CHANNEL_EXTENSIONS_UPDATE, ) { setContentTitle( - "Extension updates available" + context.getString(R.string.extension_updates_available) ) val extNames = names.joinToString(", ") setContentText(extNames) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionGithubApi.kt index 556ec343..0c0fd25e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/api/AnimeExtensionGithubApi.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.extension.anime.api import android.content.Context +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier import eu.kanade.tachiyomi.extension.anime.AnimeExtensionManager @@ -32,42 +34,55 @@ internal class AnimeExtensionGithubApi { preferenceStore.getLong("last_ext_check", 0) } - private var requiresFallbackSource = false - suspend fun findExtensions(): List { return withIOContext { - val githubResponse = if (requiresFallbackSource) { - null - } else { + + val extensions: ArrayList = arrayListOf() + + 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 { - networkService.client - .newCall(GET("${REPO_URL_PREFIX}index.min.json")) - .awaitSuccess() + val githubResponse = try { + networkService.client + .newCall(GET("${it}/index.min.json")) + .awaitSuccess() + } catch (e: Throwable) { + Logger.log("Failed to get repo: $it") + Logger.log(e) + null + } + + val response = githubResponse ?: run { + networkService.client + .newCall(GET(fallbackRepoUrl(it) + "/index.min.json")) + .awaitSuccess() + } + + val repoExtensions = with(json) { + response + .parseAs>() + .toExtensions(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") - requiresFallbackSource = true - null + Logger.log(e) } } - val response = githubResponse ?: run { - networkService.client - .newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")) - .awaitSuccess() - } - - val extensions = with(json) { - response - .parseAs>() - .toExtensions() - } - - // Sanity check - a small number of extensions probably means something broke - // with the repo generator - if (extensions.size < 10) { - throw Exception() - } - extensions } } @@ -111,7 +126,7 @@ internal class AnimeExtensionGithubApi { return extensionsWithUpdate } - private fun List.toExtensions(): List { + private fun List.toExtensions(repository: String): List { return this .filter { val libVersion = it.extractLibVersion() @@ -130,7 +145,8 @@ internal class AnimeExtensionGithubApi { hasChangelog = it.hasChangelog == 1, sources = it.sources?.toAnimeExtensionSources().orEmpty(), apkName = it.apk, - iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png", + repository = repository, + iconUrl = "${repository}/icon/${it.pkg}.png", ) } } @@ -147,15 +163,27 @@ internal class AnimeExtensionGithubApi { } fun getApkUrl(extension: AnimeExtension.Available): String { - return "${getUrlPrefix()}apk/${extension.apkName}" + return "${extension.repository}/apk/${extension.apkName}" } - private fun getUrlPrefix(): String { - return if (requiresFallbackSource) { - FALLBACK_REPO_URL_PREFIX - } else { - REPO_URL_PREFIX + private fun fallbackRepoUrl(repoUrl: String): String? { + var fallbackRepoUrl = "https://gcore.jsdelivr.net/gh/" + val strippedRepoUrl = + repoUrl.removePrefix("https://").removePrefix("http://").removeSuffix("/") + val repoUrlParts = strippedRepoUrl.split("/") + if (repoUrlParts.size < 3) { + return null } + val repoOwner = repoUrlParts[1] + val repoName = repoUrlParts[2] + fallbackRepoUrl += "$repoOwner/$repoName" + val repoBranch = if (repoUrlParts.size > 3) { + repoUrlParts[3] + } else { + "main" + } + fallbackRepoUrl += "@$repoBranch" + return fallbackRepoUrl } } @@ -163,11 +191,6 @@ private fun AnimeExtensionJsonObject.extractLibVersion(): Double { return version.substringBeforeLast('.').toDouble() } -private const val REPO_URL_PREFIX = - "https://raw.githubusercontent.com/aniyomiorg/aniyomi-extensions/repo/" -private const val FALLBACK_REPO_URL_PREFIX = - "https://gcore.jsdelivr.net/gh/aniyomiorg/aniyomi-extensions@repo/" - @Serializable private data class AnimeExtensionJsonObject( val name: String, diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt index 4f552879..ad6d6b4f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/anime/model/AnimeExtension.kt @@ -47,6 +47,7 @@ sealed class AnimeExtension { val sources: List, val apkName: String, val iconUrl: String, + val repository: String ) : AnimeExtension() data class Untrusted( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/api/MangaExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/api/MangaExtensionGithubApi.kt index d3d162a1..8791f572 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/api/MangaExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/api/MangaExtensionGithubApi.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.extension.manga.api import android.content.Context +import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.util.Logger import eu.kanade.tachiyomi.extension.ExtensionUpdateNotifier import eu.kanade.tachiyomi.extension.manga.MangaExtensionManager @@ -32,42 +34,55 @@ internal class MangaExtensionGithubApi { preferenceStore.getLong("last_ext_check", 0) } - private var requiresFallbackSource = false - suspend fun findExtensions(): List { return withIOContext { - val githubResponse = if (requiresFallbackSource) { - null - } else { + + val extensions: ArrayList = arrayListOf() + + 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 { try { - networkService.client - .newCall(GET("${REPO_URL_PREFIX}index.min.json")) - .awaitSuccess() + val githubResponse = try { + networkService.client + .newCall(GET("${it}/index.min.json")) + .awaitSuccess() + } catch (e: Throwable) { + Logger.log("Failed to get repo: $it") + Logger.log(e) + null + } + + val response = githubResponse ?: run { + networkService.client + .newCall(GET(fallbackRepoUrl(it) + "/index.min.json")) + .awaitSuccess() + } + + val repoExtensions = with(json) { + response + .parseAs>() + .toExtensions(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") - requiresFallbackSource = true - null + Logger.log(e) } } - val response = githubResponse ?: run { - networkService.client - .newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")) - .awaitSuccess() - } - - val extensions = with(json) { - response - .parseAs>() - .toExtensions() - } - - // Sanity check - a small number of extensions probably means something broke - // with the repo generator - if (extensions.size < 100) { - throw Exception() - } - extensions } } @@ -110,7 +125,7 @@ internal class MangaExtensionGithubApi { return extensionsWithUpdate } - private fun List.toExtensions(): List { + private fun List.toExtensions(repository: String): List { return this .filter { val libVersion = it.extractLibVersion() @@ -129,7 +144,8 @@ internal class MangaExtensionGithubApi { hasChangelog = it.hasChangelog == 1, sources = it.sources?.toExtensionSources().orEmpty(), apkName = it.apk, - iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png", + repository = repository, + iconUrl = "${repository}/icon/${it.pkg}.png", ) } } @@ -146,25 +162,33 @@ internal class MangaExtensionGithubApi { } fun getApkUrl(extension: MangaExtension.Available): String { - return "${getUrlPrefix()}apk/${extension.apkName}" - } - - private fun getUrlPrefix(): String { - return if (requiresFallbackSource) { - FALLBACK_REPO_URL_PREFIX - } else { - REPO_URL_PREFIX - } + return "${extension.repository}/apk/${extension.apkName}" } private fun ExtensionJsonObject.extractLibVersion(): Double { return version.substringBeforeLast('.').toDouble() } -} -private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/keiyoushi/extensions/main/" -private const val FALLBACK_REPO_URL_PREFIX = - "https://gcore.jsdelivr.net/gh/keiyoushi/extensions@main/" + private fun fallbackRepoUrl(repoUrl: String): String? { + var fallbackRepoUrl = "https://gcore.jsdelivr.net/gh/" + val strippedRepoUrl = + repoUrl.removePrefix("https://").removePrefix("http://").removeSuffix("/") + val repoUrlParts = strippedRepoUrl.split("/") + if (repoUrlParts.size < 3) { + return null + } + val repoOwner = repoUrlParts[1] + val repoName = repoUrlParts[2] + fallbackRepoUrl += "$repoOwner/$repoName" + val repoBranch = if (repoUrlParts.size > 3) { + repoUrlParts[3] + } else { + "main" + } + fallbackRepoUrl += "@$repoBranch" + return fallbackRepoUrl + } +} @Serializable private data class ExtensionJsonObject( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/model/MangaExtension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/model/MangaExtension.kt index d0b6d448..2ae5a165 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/manga/model/MangaExtension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/manga/model/MangaExtension.kt @@ -47,6 +47,7 @@ sealed class MangaExtension { val sources: List, val apkName: String, val iconUrl: String, + val repository: String ) : MangaExtension() data class Untrusted( 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 8ae4a8fd..f7c08d19 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -89,5 +89,7 @@ class NetworkHelper( responseParser = Mapper ) - fun defaultUserAgentProvider() = PrefManager.getVal(PrefName.DefaultUserAgent) + companion object { + fun defaultUserAgentProvider() = PrefManager.getVal(PrefName.DefaultUserAgent) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt index 16fd9935..613b58ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source.online import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.NetworkHelper.Companion.defaultUserAgentProvider import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.newCachelessCallWithProgress @@ -69,7 +70,7 @@ abstract class HttpSource : CatalogueSource { * Headers builder for requests. Implementations can override this method for custom headers. */ protected open fun headersBuilder() = Headers.Builder().apply { - add("User-Agent", network.defaultUserAgentProvider()) + add("User-Agent", defaultUserAgentProvider()) } /** diff --git a/app/src/main/res/drawable-night/widget_stats_rounded.xml b/app/src/main/res/drawable-night/widget_stats_rounded.xml new file mode 100644 index 00000000..933eab8b --- /dev/null +++ b/app/src/main/res/drawable-night/widget_stats_rounded.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-nodpi/example_appwidget_preview.png b/app/src/main/res/drawable-nodpi/example_appwidget_preview.png deleted file mode 100644 index 52c4ef28..00000000 Binary files a/app/src/main/res/drawable-nodpi/example_appwidget_preview.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/statistics_widget_preview.png b/app/src/main/res/drawable-nodpi/statistics_widget_preview.png new file mode 100644 index 00000000..85d3aaef Binary files /dev/null and b/app/src/main/res/drawable-nodpi/statistics_widget_preview.png differ diff --git a/app/src/main/res/drawable-nodpi/upcoming_widget_preview.png b/app/src/main/res/drawable-nodpi/upcoming_widget_preview.png new file mode 100644 index 00000000..fcfa6e7d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/upcoming_widget_preview.png differ diff --git a/app/src/main/res/drawable/adjust.xml b/app/src/main/res/drawable/adjust.xml new file mode 100644 index 00000000..6736f14f --- /dev/null +++ b/app/src/main/res/drawable/adjust.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/backup_restore.xml b/app/src/main/res/drawable/backup_restore.xml new file mode 100644 index 00000000..2c551ca4 --- /dev/null +++ b/app/src/main/res/drawable/backup_restore.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/blur_on.xml b/app/src/main/res/drawable/blur_on.xml new file mode 100644 index 00000000..11630fa4 --- /dev/null +++ b/app/src/main/res/drawable/blur_on.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/cast_warning.xml b/app/src/main/res/drawable/cast_warning.xml new file mode 100644 index 00000000..15f35a55 --- /dev/null +++ b/app/src/main/res/drawable/cast_warning.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_camera_roll_24.xml b/app/src/main/res/drawable/ic_camera_roll_24.xml new file mode 100644 index 00000000..1c4b99e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_roll_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_circle_arrow_left_24.xml b/app/src/main/res/drawable/ic_circle_arrow_left_24.xml new file mode 100644 index 00000000..687a99d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_arrow_left_24.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_round_area_chart_24.xml b/app/src/main/res/drawable/ic_round_area_chart_24.xml new file mode 100644 index 00000000..b1d6f78c --- /dev/null +++ b/app/src/main/res/drawable/ic_round_area_chart_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_round_assist_walker_24.xml b/app/src/main/res/drawable/ic_round_assist_walker_24.xml new file mode 100644 index 00000000..0d38faeb --- /dev/null +++ b/app/src/main/res/drawable/ic_round_assist_walker_24.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_round_early_bird_special.xml b/app/src/main/res/drawable/ic_round_early_bird_special.xml deleted file mode 100644 index 4aa4292f..00000000 --- a/app/src/main/res/drawable/ic_round_early_bird_special.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_round_filter_list_24.xml b/app/src/main/res/drawable/ic_round_filter_list_24.xml new file mode 100644 index 00000000..e3dc45c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_filter_list_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_round_filter_list_24_reverse.xml b/app/src/main/res/drawable/ic_round_filter_list_24_reverse.xml new file mode 100644 index 00000000..ac200103 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_filter_list_24_reverse.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_round_filter_peak_24.xml b/app/src/main/res/drawable/ic_round_filter_peak_24.xml new file mode 100644 index 00000000..061d0ec0 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_filter_peak_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_round_globe_china_googlefonts.xml b/app/src/main/res/drawable/ic_round_globe_china_googlefonts.xml new file mode 100644 index 00000000..1a36e8f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_globe_china_googlefonts.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_globe_japan_googlefonts.xml b/app/src/main/res/drawable/ic_round_globe_japan_googlefonts.xml new file mode 100644 index 00000000..24803edd --- /dev/null +++ b/app/src/main/res/drawable/ic_round_globe_japan_googlefonts.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_globe_search_googlefonts.xml b/app/src/main/res/drawable/ic_round_globe_search_googlefonts.xml new file mode 100644 index 00000000..97b82b0e --- /dev/null +++ b/app/src/main/res/drawable/ic_round_globe_search_googlefonts.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_globe_south_korea_googlefonts.xml b/app/src/main/res/drawable/ic_round_globe_south_korea_googlefonts.xml new file mode 100644 index 00000000..dff31e26 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_globe_south_korea_googlefonts.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_globe_taiwan_googlefonts.xml b/app/src/main/res/drawable/ic_round_globe_taiwan_googlefonts.xml new file mode 100644 index 00000000..52052789 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_globe_taiwan_googlefonts.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_reset_star_24.xml b/app/src/main/res/drawable/ic_round_reset_star_24.xml new file mode 100644 index 00000000..00830a3e --- /dev/null +++ b/app/src/main/res/drawable/ic_round_reset_star_24.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_round_star_graph_24.xml b/app/src/main/res/drawable/ic_round_star_graph_24.xml new file mode 100644 index 00000000..590f5f18 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_star_graph_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/stacks.xml b/app/src/main/res/drawable/stacks.xml new file mode 100644 index 00000000..29383b41 --- /dev/null +++ b/app/src/main/res/drawable/stacks.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/trail_length_short.xml b/app/src/main/res/drawable/trail_length_short.xml new file mode 100644 index 00000000..81166f10 --- /dev/null +++ b/app/src/main/res/drawable/trail_length_short.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/view_list_24.xml b/app/src/main/res/drawable/view_list_24.xml new file mode 100644 index 00000000..f7378f12 --- /dev/null +++ b/app/src/main/res/drawable/view_list_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/widget_stats_rounded.xml b/app/src/main/res/drawable/widget_stats_rounded.xml new file mode 100644 index 00000000..2b32dd96 --- /dev/null +++ b/app/src/main/res/drawable/widget_stats_rounded.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-land/activity_profile.xml b/app/src/main/res/layout-land/activity_profile.xml index 7e01d082..b4844201 100644 --- a/app/src/main/res/layout-land/activity_profile.xml +++ b/app/src/main/res/layout-land/activity_profile.xml @@ -24,253 +24,7 @@ android:layout_height="wrap_content" /> - - - - - - - - - - - - - - - - - - - - -