├── app ├── .gitignore ├── src │ ├── main │ │ ├── ic_launcher-web.png │ │ ├── ic_launcher-playstore.png │ │ ├── res │ │ │ ├── font │ │ │ │ ├── circularstd_bold.ttf │ │ │ │ ├── circularstd_book.ttf │ │ │ │ ├── circularstd_black.ttf │ │ │ │ └── circularstd_medium.ttf │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── styles.xml │ │ │ │ └── strings.xml │ │ │ ├── xml │ │ │ │ └── searchable.xml │ │ │ ├── drawable │ │ │ │ ├── header_bg_gradient.xml │ │ │ │ ├── ic_at_state_in.xml │ │ │ │ ├── ic_at_state_out.xml │ │ │ │ ├── ic_at_state_buffer_in.xml │ │ │ │ ├── ic_at_state_buffer_out.xml │ │ │ │ ├── ic_check.xml │ │ │ │ ├── ic_home.xml │ │ │ │ ├── ic_chevron_right.xml │ │ │ │ ├── ic_at_play.xml │ │ │ │ ├── ic_keyboard_arrow_down.xml │ │ │ │ ├── ic_at_pause.xml │ │ │ │ ├── ic_file_download.xml │ │ │ │ ├── ic_dismiss.xml │ │ │ │ ├── ic_at_forward.xml │ │ │ │ ├── ic_at_logo.xml │ │ │ │ ├── ic_play_circle_outline.xml │ │ │ │ ├── player_gradient.xml │ │ │ │ ├── ic_at_state.xml │ │ │ │ ├── player_gradient_reverse.xml │ │ │ │ ├── ic_at_seeker.xml │ │ │ │ ├── ic_account_circle.xml │ │ │ │ ├── ic_day_night.xml │ │ │ │ ├── ic_search.xml │ │ │ │ ├── ic_screen_rotation.xml │ │ │ │ ├── ic_animated_spinner.xml │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ ├── ic_no_connection.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── anim │ │ │ │ ├── left_in.xml │ │ │ │ ├── right_in.xml │ │ │ │ ├── left_out.xml │ │ │ │ ├── right_out.xml │ │ │ │ ├── scale_in.xml │ │ │ │ └── scale_out.xml │ │ │ ├── menu │ │ │ │ ├── bottom_nav_menu.xml │ │ │ │ ├── toolbar_menu.xml │ │ │ │ └── home_menu.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values-night │ │ │ │ ├── colors.xml │ │ │ │ └── styles.xml │ │ │ ├── animator │ │ │ │ ├── state_path_in.xml │ │ │ │ ├── state_path_out.xml │ │ │ │ ├── state_path_buffer_in.xml │ │ │ │ └── state_path_buffer_out.xml │ │ │ ├── layout │ │ │ │ ├── activity_auth.xml │ │ │ │ ├── activity_anime_player.xml │ │ │ │ ├── animelist_item.xml │ │ │ │ ├── episode_item.xml │ │ │ │ ├── fragment_search.xml │ │ │ │ ├── anime_search_item.xml │ │ │ │ ├── activity_main.xml │ │ │ │ └── fragment_login.xml │ │ │ ├── values-v23 │ │ │ │ └── styles.xml │ │ │ ├── values-night-v23 │ │ │ │ └── styles.xml │ │ │ ├── values-v27 │ │ │ │ └── styles.xml │ │ │ ├── values-night-v27 │ │ │ │ └── styles.xml │ │ │ ├── navigation │ │ │ │ ├── nav_graph_auth.xml │ │ │ │ └── nav_graph.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── dev │ │ │ │ └── smoketrees │ │ │ │ └── twist │ │ │ │ ├── model │ │ │ │ └── twist │ │ │ │ │ ├── LoginDetails.kt │ │ │ │ │ ├── ApiResponse.kt │ │ │ │ │ ├── NejireAnimeModel.kt │ │ │ │ │ ├── PatchLibRequest.kt │ │ │ │ │ ├── RegisterDetails.kt │ │ │ │ │ ├── LoginResponse.kt │ │ │ │ │ ├── PatchLibResponse.kt │ │ │ │ │ ├── AnimeWithEpisodes.kt │ │ │ │ │ ├── LibraryEpisode.kt │ │ │ │ │ ├── Motd.kt │ │ │ │ │ ├── AnimeDetailsEntity.kt │ │ │ │ │ ├── Slug.kt │ │ │ │ │ ├── Result.kt │ │ │ │ │ ├── AnimeSource.kt │ │ │ │ │ ├── Episode.kt │ │ │ │ │ ├── TrendingAnimeItem.kt │ │ │ │ │ ├── AnimeDetails.kt │ │ │ │ │ └── AnimeItem.kt │ │ │ │ ├── di │ │ │ │ ├── module │ │ │ │ │ ├── RepoModule.kt │ │ │ │ │ ├── CacheModule.kt │ │ │ │ │ ├── ViewModelModule.kt │ │ │ │ │ ├── RoomModule.kt │ │ │ │ │ └── ApiModule.kt │ │ │ │ └── AppComponent.kt │ │ │ │ ├── utils │ │ │ │ ├── BindingAdapters.kt │ │ │ │ ├── Constants.kt │ │ │ │ ├── AutoClearedValue.kt │ │ │ │ ├── PreferenceHelper.kt │ │ │ │ ├── Messages.kt │ │ │ │ ├── search │ │ │ │ │ └── WinklerWeightedRatio.kt │ │ │ │ ├── Extensions.kt │ │ │ │ └── CryptoHelper.kt │ │ │ │ ├── DownloadInfoReceiver.kt │ │ │ │ ├── ui │ │ │ │ ├── player │ │ │ │ │ ├── EpisodesViewModel.kt │ │ │ │ │ ├── PlayerViewModel.kt │ │ │ │ │ └── EpisodesFragment.kt │ │ │ │ ├── auth │ │ │ │ │ ├── AuthActivity.kt │ │ │ │ │ ├── LoginFragment.kt │ │ │ │ │ └── AccountFragment.kt │ │ │ │ ├── base │ │ │ │ │ └── BaseFragment.kt │ │ │ │ ├── home │ │ │ │ │ ├── AnimeViewModel.kt │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── search │ │ │ │ │ └── SearchFragment.kt │ │ │ │ ├── App.kt │ │ │ │ ├── db │ │ │ │ ├── TrendingAnimeDao.kt │ │ │ │ ├── EpisodeListTypeConverter.kt │ │ │ │ ├── AnimeDb.kt │ │ │ │ ├── AnimeDetailsDao.kt │ │ │ │ └── AnimeDao.kt │ │ │ │ ├── api │ │ │ │ ├── Helper.kt │ │ │ │ ├── BaseApiClient.kt │ │ │ │ └── anime │ │ │ │ │ ├── AnimeWebService.kt │ │ │ │ │ └── AnimeWebClient.kt │ │ │ │ ├── pagination │ │ │ │ ├── KitsuDataSourceFactory.kt │ │ │ │ ├── FilteredKitsuDataSourceFactory.kt │ │ │ │ ├── PagedAnimeDatasource.kt │ │ │ │ └── FilteredPagedAnimeDatasource.kt │ │ │ │ ├── repository │ │ │ │ ├── BaseRepo.kt │ │ │ │ └── AnimeRepo.kt │ │ │ │ └── adapters │ │ │ │ ├── SearchListAdapter.kt │ │ │ │ ├── EpisodeListAdapter.kt │ │ │ │ ├── PagedAnimeListAdapter.kt │ │ │ │ └── AnimeListAdapter.kt │ │ └── AndroidManifest.xml │ └── debug │ │ └── res │ │ └── values │ │ └── strings.xml ├── proguard-rules.pro └── build.gradle.kts ├── settings.gradle ├── keystore.jks ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── PRIV_POLICY.md ├── gradle.properties ├── .github └── workflows │ └── android.yml ├── README.md ├── gradlew.bat └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /src/main/res/values/secrets.xml 3 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | rootProject.name='Twist.moe' 3 | -------------------------------------------------------------------------------- /keystore.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/keystore.jks -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/res/font/circularstd_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/font/circularstd_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/circularstd_book.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/font/circularstd_book.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/circularstd_black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/font/circularstd_black.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/circularstd_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/font/circularstd_medium.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Anime Twist DEBUG 4 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnimeTwist/twist-mobile/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #000000 4 | -------------------------------------------------------------------------------- /app/src/main/res/xml/searchable.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/LoginDetails.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class LoginDetails( 7 | var username: String, 8 | var password: String 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/di/module/RepoModule.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.di.module 2 | 3 | import dev.smoketrees.twist.repository.AnimeRepo 4 | import org.koin.dsl.module 5 | 6 | val repoModule = module { 7 | factory { AnimeRepo(get(), get(), get(), get()) } 8 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/ApiResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class ApiResponse( 7 | val status: String?, 8 | val message: String?, 9 | val token: String? 10 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable/header_bg_gradient.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/di/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.di 2 | 3 | import dev.smoketrees.twist.di.module.* 4 | 5 | val appComponent = listOf( 6 | apiModule, 7 | cacheModule, 8 | repoModule, 9 | viewModelModule, 10 | roomModule 11 | ) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Jul 09 14:12:12 IST 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip 7 | -------------------------------------------------------------------------------- /app/src/main/res/anim/left_in.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/anim/right_in.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/utils/BindingAdapters.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.utils 2 | 3 | import android.view.View 4 | import androidx.databinding.BindingAdapter 5 | 6 | @BindingAdapter("isGone") 7 | fun bindIsGone(view: View, isGone: Boolean) { 8 | if (isGone) view.hide() else view.show() 9 | } -------------------------------------------------------------------------------- /app/src/main/res/anim/left_out.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/anim/right_out.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/menu/bottom_nav_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/NejireAnimeModel.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.Entity 5 | 6 | @Keep 7 | @Entity 8 | data class NejireExtension( 9 | val cover_image: String, 10 | val poster_image: String 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/PatchLibRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | data class PatchLibRequest ( 4 | val number: Long, 5 | val animeID: Long, 6 | val progress: Double, 7 | val watchedAt: String, 8 | val episodeID: Long, 9 | val completed: Boolean 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/RegisterDetails.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class RegisterDetails( 7 | var username: String, 8 | var email: String, 9 | var password: String, 10 | var passwordConfirm: String 11 | ) -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/DownloadInfoReceiver.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | 7 | class DownloadInfoReceiver : BroadcastReceiver() { 8 | override fun onReceive(context: Context, intent: Intent) {} 9 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/LoginResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class LoginResponse( 7 | val donation_rank: Int, 8 | val id: Int, 9 | val rank: Int, 10 | val token: String, 11 | val username: String 12 | ) -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_state_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | .idea/ 16 | app/release 17 | secrets.properties 18 | app/debug 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_state_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_state_buffer_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_state_buffer_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/ui/player/EpisodesViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.ui.player 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dev.smoketrees.twist.repository.AnimeRepo 5 | 6 | class EpisodesViewModel(private val repo: AnimeRepo) : ViewModel() { 7 | fun getAnimeDetails(animeName: String, id: Int) = repo.getAnimeDetails(animeName, id) 8 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_check.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_home.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_chevron_right.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_play.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/utils/Constants.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.utils 2 | 3 | import dev.smoketrees.twist.BuildConfig 4 | 5 | object Constants { 6 | const val PREF = "${BuildConfig.APPLICATION_ID}.pref" 7 | 8 | const val WEB = "https://twist.moe/" 9 | 10 | object PreferenceKeys { 11 | const val IS_DAY = "is_day" 12 | const val JWT = "jwt" 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_keyboard_arrow_down.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/di/module/CacheModule.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.di.module 2 | 3 | import dev.smoketrees.twist.utils.Constants 4 | import dev.smoketrees.twist.utils.PreferenceHelper 5 | import org.koin.android.ext.koin.androidContext 6 | import org.koin.dsl.module 7 | 8 | val cacheModule = module { 9 | single { PreferenceHelper.customPrefs(androidContext(), Constants.PREF) } 10 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/PatchLibResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | data class PatchLibResponse ( 4 | val id: Long, 5 | val userID: Long, 6 | val animeID: Long, 7 | val episodeID: Long, 8 | val progress: Double, 9 | val completed: Boolean, 10 | val watchedAt: String, 11 | val createdAt: String, 12 | val updatedAt: String 13 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/AnimeWithEpisodes.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Relation 5 | 6 | data class AnimeWithEpisodes( 7 | @Embedded val animeItem: AnimeItem, 8 | @Relation( 9 | parentColumn = "uid", 10 | entityColumn = "anime_id" 11 | ) 12 | val watchedEpisodes: List 13 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_pause.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_file_download.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/ui/auth/AuthActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.ui.auth 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import dev.smoketrees.twist.R 6 | 7 | class AuthActivity : AppCompatActivity() { 8 | override fun onCreate(savedInstanceState: Bundle?) { 9 | super.onCreate(savedInstanceState) 10 | setContentView(R.layout.activity_auth) 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_dismiss.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_forward.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_logo.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/menu/toolbar_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | #e53232 6 | #e53232 7 | #e53232 8 | 9 | #FFF 10 | #FAFAFA 11 | #e53232 12 | 13 | #212121 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/anim/scale_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/anim/scale_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | #e53232 6 | #e53232 7 | #e53232 8 | 9 | #1c1f22 10 | #282a2d 11 | #e53232 12 | 13 | #FAFAFA 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/di/module/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.di.module 2 | 3 | import dev.smoketrees.twist.ui.home.AnimeViewModel 4 | import dev.smoketrees.twist.ui.player.EpisodesViewModel 5 | import dev.smoketrees.twist.ui.player.PlayerViewModel 6 | import org.koin.android.viewmodel.dsl.viewModel 7 | import org.koin.dsl.module 8 | 9 | val viewModelModule = module { 10 | viewModel { AnimeViewModel(get()) } 11 | viewModel { EpisodesViewModel(get()) } 12 | viewModel { PlayerViewModel(get()) } 13 | } -------------------------------------------------------------------------------- /PRIV_POLICY.md: -------------------------------------------------------------------------------- 1 | # In short 2 | 3 | We don't log or share your personal information. 4 | 5 | We don't track you, we don't profile you. Period. 6 | 7 | # The longer version 8 | 9 | _We believe privacy is a fundamental human right._ 10 | 11 | **We do not store any data about you whatsoever.** 12 | 13 | We physically can't. We have nowhere to store it. We don't even have a server or a database. What happens on your device, stays on your device. We welcome all audits. Anyone can inspect our source [here](https://github.com/AnimeTwist/twist-mobile). 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play_circle_outline.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/player_gradient.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_state.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/player_gradient_reverse.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/animator/state_path_in.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/animator/state_path_out.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_at_seeker.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/animator/state_path_buffer_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/animator/state_path_buffer_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/App.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist 2 | 3 | import androidx.multidex.MultiDexApplication 4 | import dev.smoketrees.twist.di.appComponent 5 | import org.koin.android.ext.koin.androidContext 6 | import org.koin.android.ext.koin.androidLogger 7 | import org.koin.core.context.startKoin 8 | 9 | class App : MultiDexApplication() { 10 | override fun onCreate() { 11 | super.onCreate() 12 | 13 | startKoin { 14 | androidLogger() 15 | androidContext(this@App) 16 | modules(appComponent) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_account_circle.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/LibraryEpisode.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.ColumnInfo 5 | import androidx.room.Entity 6 | import androidx.room.PrimaryKey 7 | 8 | @Keep 9 | @Entity 10 | data class LibraryEpisode( 11 | val id: Int, 12 | val user_id: Int, 13 | @ColumnInfo(name = "anime_id") val anime_id: Int, 14 | @PrimaryKey val episode_id: Int, 15 | val progress: Double, 16 | val completed: Int, 17 | val watched_at: String, 18 | val created_at: String, 19 | val updated_at: String 20 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/db/TrendingAnimeDao.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.db 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.Dao 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import dev.smoketrees.twist.model.twist.TrendingAnimeItem 9 | 10 | @Dao 11 | interface TrendingAnimeDao { 12 | @Query("SELECT * FROM trendinganimeitem") 13 | fun getTrendingAnime(): LiveData> 14 | 15 | @Insert(onConflict = OnConflictStrategy.REPLACE) 16 | suspend fun saveTrendingAnime(animeItems: List) 17 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_day_night.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_search.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/di/module/RoomModule.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.di.module 2 | 3 | import androidx.room.Room 4 | import dev.smoketrees.twist.db.AnimeDb 5 | import org.koin.android.ext.koin.androidApplication 6 | import org.koin.dsl.module 7 | 8 | val roomModule = module { 9 | single { 10 | Room.databaseBuilder(androidApplication(), AnimeDb::class.java, "anime-database") 11 | .fallbackToDestructiveMigration() 12 | .build() 13 | } 14 | 15 | single { get().animeDao() } 16 | single { get().episodeDao() } 17 | single { get().trendingAnimeDao() } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/api/Helper.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.api 2 | 3 | import dev.smoketrees.twist.BuildConfig 4 | import okhttp3.OkHttpClient 5 | import okhttp3.logging.HttpLoggingInterceptor 6 | import java.util.concurrent.TimeUnit 7 | 8 | fun getOkHttpClient(): OkHttpClient { 9 | 10 | val httpClient = OkHttpClient.Builder() 11 | 12 | if (BuildConfig.DEBUG) { 13 | val httpLoggingInterceptor = HttpLoggingInterceptor() 14 | httpClient.addInterceptor(httpLoggingInterceptor.apply { 15 | httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BASIC 16 | }) 17 | } 18 | 19 | return httpClient.readTimeout(60, TimeUnit.SECONDS) 20 | .connectTimeout(60, TimeUnit.SECONDS) 21 | .build() 22 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/ui/player/PlayerViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.ui.player 2 | 3 | import android.content.pm.ActivityInfo 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import dev.smoketrees.twist.model.twist.AnimeSource 7 | import dev.smoketrees.twist.repository.AnimeRepo 8 | 9 | class PlayerViewModel(private val repo: AnimeRepo) : ViewModel() { 10 | fun getAnimeSources(animeName: String) = repo.getAnimeSources(animeName) 11 | 12 | var playWhenReady = true 13 | var currentWindowIndex = 0 14 | var playbackPosition = 0L 15 | var currEp = MutableLiveData(null) 16 | var sources: List? = null 17 | var orientation: Int = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/pagination/KitsuDataSourceFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.pagination 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.paging.DataSource 5 | import dev.smoketrees.twist.api.anime.AnimeWebClient 6 | import dev.smoketrees.twist.model.twist.AnimeItem 7 | 8 | class KitsuDataSourceFactory( 9 | private val webClient: AnimeWebClient, 10 | private val sort: String 11 | ) : DataSource.Factory() { 12 | val animeLiveDataSource = MutableLiveData() 13 | 14 | override fun create(): DataSource { 15 | val animeDataSource = PagedAnimeDatasource(webClient, sort) 16 | animeLiveDataSource.postValue(animeDataSource) 17 | return animeDataSource 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_screen_rotation.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/Motd.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import com.google.gson.annotations.Expose 5 | import com.google.gson.annotations.SerializedName 6 | import org.apache.commons.lang3.builder.ToStringBuilder 7 | import java.io.Serializable 8 | 9 | @Keep 10 | class Motd : Serializable { 11 | 12 | @SerializedName("id") 13 | @Expose 14 | var id: Int? = null 15 | 16 | @SerializedName("title") 17 | @Expose 18 | var title: String? = null 19 | 20 | @SerializedName("message") 21 | @Expose 22 | var message: String? = null 23 | 24 | override fun toString(): String { 25 | return ToStringBuilder(this).append("id", id).append("title", title) 26 | .append("message", message).toString() 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/db/EpisodeListTypeConverter.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.db 2 | 3 | import androidx.room.TypeConverter 4 | import com.google.gson.Gson 5 | import com.google.gson.reflect.TypeToken 6 | import dev.smoketrees.twist.model.twist.Episode 7 | import java.util.Collections.emptyList 8 | 9 | 10 | class EpisodeListTypeConverter { 11 | val gson = Gson() 12 | 13 | @TypeConverter 14 | fun stringToEpisodeList(data: String?): List { 15 | if (data == null) { 16 | return emptyList() 17 | } 18 | 19 | val listType = object : TypeToken>() {}.type 20 | return gson.fromJson(data, listType) 21 | } 22 | 23 | @TypeConverter 24 | fun episodeListToString(someObjects: List): String { 25 | return gson.toJson(someObjects) 26 | } 27 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/pagination/FilteredKitsuDataSourceFactory.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.pagination 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.paging.DataSource 5 | import dev.smoketrees.twist.api.anime.AnimeWebClient 6 | import dev.smoketrees.twist.model.twist.AnimeItem 7 | 8 | class FilteredKitsuDataSourceFactory( 9 | private val webClient: AnimeWebClient, 10 | private val sort: String, 11 | private val filter: String 12 | ) : DataSource.Factory() { 13 | val animeLiveDataSource = MutableLiveData() 14 | 15 | override fun create(): DataSource { 16 | val animeDataSource = FilteredPagedAnimeDatasource(webClient, sort, filter) 17 | animeLiveDataSource.postValue(animeDataSource) 18 | return animeDataSource 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/db/AnimeDb.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.db 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import androidx.room.TypeConverters 6 | import dev.smoketrees.twist.model.twist.AnimeDetailsEntity 7 | import dev.smoketrees.twist.model.twist.AnimeItem 8 | import dev.smoketrees.twist.model.twist.LibraryEpisode 9 | import dev.smoketrees.twist.model.twist.TrendingAnimeItem 10 | 11 | @Database( 12 | entities = [AnimeItem::class, AnimeDetailsEntity::class, TrendingAnimeItem::class, LibraryEpisode::class], 13 | version = 4, 14 | exportSchema = false 15 | ) 16 | @TypeConverters(EpisodeListTypeConverter::class) 17 | abstract class AnimeDb : RoomDatabase() { 18 | abstract fun animeDao(): AnimeDao 19 | abstract fun episodeDao(): AnimeDetailsDao 20 | abstract fun trendingAnimeDao(): TrendingAnimeDao 21 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_auth.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/menu/home_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ## For more details on how to configure your build environment visit 2 | # http://www.gradle.org/docs/current/userguide/build_environment.html 3 | # 4 | # Specifies the JVM arguments used for the daemon process. 5 | # The setting is particularly useful for tweaking memory settings. 6 | # Default value: -Xmx1024m -XX:MaxPermSize=256m 7 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 8 | # 9 | # When configured, Gradle will run in incubating parallel mode. 10 | # This option should only be used with decoupled projects. More details, visit 11 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 12 | # org.gradle.parallel=true 13 | #Sun Mar 08 14:49:44 IST 2020 14 | android.enableJetifier=true 15 | android.useAndroidX=true 16 | kotlin.code.style=official 17 | org.gradle.jvmargs=-Xmx1024M -Dkotlin.daemon.jvm.options\="-Xmx1536M" 18 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/AnimeDetailsEntity.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.ColumnInfo 5 | import androidx.room.Entity 6 | import androidx.room.PrimaryKey 7 | 8 | @Keep 9 | @Entity 10 | data class AnimeDetailsEntity( 11 | val airing: Boolean? = false, 12 | val endDate: String? = "", 13 | val episodes: Int? = 0, 14 | val imageUrl: String? = "", 15 | @PrimaryKey 16 | @ColumnInfo(name = "anime_id") 17 | val id: Int? = 0, 18 | val malId: Int? = 0, 19 | val members: Int? = 0, 20 | val rated: String? = "", 21 | val score: Double? = 0.0, 22 | val startDate: String? = "", 23 | val synopsis: String? = "", 24 | val title: String? = "", 25 | val type: String? = "", 26 | val url: String? = "", 27 | // @TypeConverters(EpisodeListTypeConverter::class) 28 | val episodeList: List 29 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/di/module/ApiModule.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.di.module 2 | 3 | import dev.smoketrees.twist.api.anime.AnimeWebClient 4 | import dev.smoketrees.twist.api.anime.AnimeWebService 5 | import dev.smoketrees.twist.api.getOkHttpClient 6 | import org.koin.core.qualifier.named 7 | import org.koin.dsl.module 8 | import retrofit2.Retrofit 9 | import retrofit2.converter.gson.GsonConverterFactory 10 | 11 | private const val TWIST_BASE_URL = "https://twist.suzuha.moe/" 12 | private const val TWIST_API = "TWIST_API" 13 | 14 | val apiModule = module { 15 | factory { getOkHttpClient() } 16 | 17 | single(named(TWIST_API)) { 18 | Retrofit.Builder() 19 | .baseUrl(TWIST_BASE_URL) 20 | .addConverterFactory(GsonConverterFactory.create()) 21 | .client(get()) 22 | .build() 23 | } 24 | 25 | factory { get(named(TWIST_API)).create(AnimeWebService::class.java) } 26 | 27 | factory { AnimeWebClient(get()) } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/db/AnimeDetailsDao.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.db 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.Dao 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import dev.smoketrees.twist.model.twist.AnimeDetailsEntity 9 | 10 | @Dao 11 | interface AnimeDetailsDao { 12 | // @Query("SELECT * FROM episode WHERE animeId = :animeId") 13 | // fun getEpisodesForAnime(animeId: Int): LiveData> 14 | // 15 | // @Insert(onConflict = OnConflictStrategy.REPLACE) 16 | // suspend fun saveEpisodes(episodes: List) 17 | // 18 | // @Delete 19 | // suspend fun deleteEpisodes(episode: Episode) 20 | 21 | @Query("SELECT * FROM animedetailsentity WHERE anime_id = :animeId") 22 | fun getAnimeDetails(animeId: Int): LiveData 23 | 24 | @Insert(onConflict = OnConflictStrategy.REPLACE) 25 | suspend fun saveAnimeDetails(animeDetailsEntity: AnimeDetailsEntity) 26 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/Slug.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import com.google.gson.annotations.Expose 5 | import com.google.gson.annotations.SerializedName 6 | import org.apache.commons.lang3.builder.ToStringBuilder 7 | 8 | @Keep 9 | class Slug { 10 | 11 | @SerializedName("anime_id") 12 | @Expose 13 | var animeId: Int? = null 14 | 15 | @SerializedName("created_at") 16 | @Expose 17 | var createdAt: String? = null 18 | 19 | @SerializedName("id") 20 | @Expose 21 | var id: Int? = null 22 | 23 | @SerializedName("slug") 24 | @Expose 25 | var slug: String? = null 26 | 27 | @SerializedName("updated_at") 28 | @Expose 29 | var updatedAt: String? = null 30 | 31 | override fun toString(): String { 32 | return ToStringBuilder(this).append("animeId", animeId).append("createdAt", createdAt) 33 | .append("id", id).append("slug", slug).append("updatedAt", updatedAt).toString() 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/Result.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import dev.smoketrees.twist.utils.Messages 5 | 6 | @Keep 7 | data class Result(val status: Status, val data: T?, val message: Messages.Message?) { 8 | 9 | enum class Status { 10 | SUCCESS, 11 | ERROR, 12 | LOADING 13 | } 14 | 15 | companion object { 16 | fun success(data: T): Result { 17 | return Result( 18 | Status.SUCCESS, 19 | data, 20 | null 21 | ) 22 | } 23 | 24 | fun error(message: Messages.Message, data: T? = null): Result { 25 | return Result( 26 | Status.ERROR, 27 | data, 28 | message 29 | ) 30 | } 31 | 32 | fun loading(data: T? = null): Result { 33 | return Result( 34 | Status.LOADING, 35 | data, 36 | null 37 | ) 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/res/values-v23/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/values-night-v23/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | build: 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: checkout submodule 14 | run: git submodule sync --recursive && git submodule update --init --recursive 15 | 16 | - name: "Build" 17 | uses: vgaidarji/android-github-actions-build@v1.0.1 18 | env: 19 | BOT_TOKEN: ${{ secrets.BOT_TOKEN }} 20 | BUILD_SHA: ${{ github.sha }} 21 | DECRYPT_KEY: ${{ secrets.DECRYPT_KEY }} 22 | ACCESS_KEY: ${{ secrets.ACCESS_KEY }} 23 | CI: "true" 24 | with: 25 | args: '" 26 | apt-get update; 27 | apt-get install -y git; 28 | chmod +x gradlew; 29 | ./gradlew assembleDebug; 30 | BUILD_SHA_SHORT=$(git rev-parse --short ${BUILD_SHA}); 31 | cp app/build/outputs/apk/debug/*.apk Anime-Twist-Debug-${BUILD_SHA_SHORT}.apk; 32 | curl -F chat_id="-387713366" -F document=@"Anime-Twist-Debug-${BUILD_SHA_SHORT}.apk" https://api.telegram.org/bot$BOT_TOKEN/sendDocument; 33 | "' -------------------------------------------------------------------------------- /app/src/main/res/values-v27/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/values-night-v27/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_anime_player.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/AnimeSource.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import com.google.gson.annotations.Expose 5 | import com.google.gson.annotations.SerializedName 6 | import org.apache.commons.lang3.builder.ToStringBuilder 7 | 8 | @Keep 9 | class AnimeSource { 10 | 11 | @SerializedName("id") 12 | @Expose 13 | var id: Int? = null 14 | 15 | @SerializedName("source") 16 | @Expose 17 | var source: String? = null 18 | 19 | @SerializedName("number") 20 | @Expose 21 | var number: Int? = null 22 | 23 | @SerializedName("anime_id") 24 | @Expose 25 | var animeId: Int? = null 26 | 27 | @SerializedName("created_at") 28 | @Expose 29 | var createdAt: String? = null 30 | 31 | @SerializedName("updated_at") 32 | @Expose 33 | var updatedAt: String? = null 34 | 35 | override fun toString(): String { 36 | return ToStringBuilder(this).append("id", id).append("source", source) 37 | .append("number", number).append("animeId", animeId).append("createdAt", createdAt) 38 | .append("updatedAt", updatedAt).toString() 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 24 | 25 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/Episode.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.ColumnInfo 5 | import com.google.gson.annotations.Expose 6 | import com.google.gson.annotations.SerializedName 7 | import org.apache.commons.lang3.builder.ToStringBuilder 8 | 9 | //@Entity( 10 | // foreignKeys = [ 11 | // ForeignKey( 12 | // entity = AnimeDetailsEntity::class, 13 | // parentColumns = arrayOf("anime_id"), 14 | // childColumns = arrayOf("animeId") 15 | // ) 16 | // ] 17 | //) 18 | 19 | @Keep 20 | class Episode { 21 | 22 | @SerializedName("anime_id") 23 | @Expose 24 | var animeId: Int? = null 25 | 26 | @SerializedName("created_at") 27 | @Expose 28 | var createdAt: String? = null 29 | 30 | @SerializedName("id") 31 | @ColumnInfo(name = "ep_id") 32 | @Expose 33 | var id: Int? = null 34 | 35 | @SerializedName("number") 36 | @Expose 37 | var number: Int? = null 38 | 39 | @SerializedName("updated_at") 40 | @Expose 41 | var updatedAt: String? = null 42 | 43 | override fun toString(): String { 44 | return ToStringBuilder(this).append("animeId", animeId).append("createdAt", createdAt) 45 | .append("id", id).append("number", number).append("updatedAt", updatedAt).toString() 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/utils/AutoClearedValue.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.utils 2 | 3 | 4 | import androidx.fragment.app.Fragment 5 | import androidx.lifecycle.Lifecycle 6 | import androidx.lifecycle.LifecycleObserver 7 | import androidx.lifecycle.OnLifecycleEvent 8 | import kotlin.properties.ReadWriteProperty 9 | import kotlin.reflect.KProperty 10 | 11 | 12 | /** 13 | * A lazy property that gets cleaned up when the fragment is destroyed. 14 | * 15 | * Accessing this variable in a destroyed fragment will throw NPE. 16 | */ 17 | class AutoClearedValue(val fragment: Fragment) : ReadWriteProperty, 18 | LifecycleObserver { 19 | private var _value: T? = null 20 | 21 | override fun getValue(thisRef: Fragment, property: KProperty<*>): T { 22 | return _value ?: throw IllegalStateException( 23 | "should never call auto-cleared-value get when it might not be available" 24 | ) 25 | } 26 | 27 | override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) { 28 | thisRef.viewLifecycleOwner.lifecycle.removeObserver(this) 29 | _value = value 30 | thisRef.viewLifecycleOwner.lifecycle.addObserver(this) 31 | } 32 | 33 | @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) 34 | fun onDestroy() { 35 | _value = null 36 | } 37 | } 38 | 39 | /** 40 | * Creates an [AutoClearedValue] associated with this fragment. 41 | */ 42 | fun Fragment.autoCleared() = AutoClearedValue(this) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_animated_spinner.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 16 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | 27 | 28 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/TrendingAnimeItem.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import androidx.recyclerview.widget.DiffUtil 5 | import androidx.room.Entity 6 | import org.apache.commons.lang3.builder.ToStringBuilder 7 | 8 | @Keep 9 | @Entity 10 | class TrendingAnimeItem : AnimeItem() { 11 | companion object { 12 | var DIFF_CALLBACK: DiffUtil.ItemCallback = 13 | object : DiffUtil.ItemCallback() { 14 | override fun areItemsTheSame( 15 | oldItem: TrendingAnimeItem, 16 | newItem: TrendingAnimeItem 17 | ): Boolean { 18 | return oldItem.id == newItem.id 19 | } 20 | 21 | override fun areContentsTheSame( 22 | oldItem: TrendingAnimeItem, 23 | newItem: TrendingAnimeItem 24 | ): Boolean { 25 | return oldItem.id == newItem.id 26 | } 27 | } 28 | } 29 | 30 | 31 | override fun toString(): String { 32 | return ToStringBuilder(this).append("id", id).append("title", title) 33 | .append("altTitle", altTitle).append("season", season).append("ongoing", ongoing) 34 | .append("hbId", hbId).append("hidden", hidden).append("malId", malId) 35 | .append("createdAt", createdAt).append("updatedAt", updatedAt).append("slug", slug) 36 | .append("nejireExtension", nejireExtension) 37 | .toString() 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 18 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/repository/BaseRepo.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.repository 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.liveData 5 | import androidx.lifecycle.map 6 | import dev.smoketrees.twist.model.twist.Result 7 | import kotlinx.coroutines.Dispatchers 8 | 9 | open class BaseRepo { 10 | 11 | protected fun makeRequest(request: suspend () -> Result) = liveData { 12 | emit(Result.loading()) 13 | 14 | val response = request.invoke() 15 | 16 | when (response.status) { 17 | Result.Status.SUCCESS -> { 18 | emit(Result.success(response.data)) 19 | } 20 | Result.Status.ERROR -> { 21 | emit(Result.error(response.message!!)) 22 | } 23 | else -> { 24 | } 25 | } 26 | } 27 | 28 | protected fun makeRequestAndSave( 29 | databaseQuery: () -> LiveData, 30 | networkCall: suspend () -> Result, 31 | saveCallResult: suspend (A) -> Unit 32 | ): LiveData> = liveData(Dispatchers.IO) { 33 | emit(Result.loading()) 34 | 35 | val source = databaseQuery.invoke().map { Result.success(it) } 36 | emitSource(source) 37 | 38 | val response = networkCall.invoke() 39 | when (response.status) { 40 | Result.Status.SUCCESS -> { 41 | saveCallResult(response.data!!) 42 | } 43 | Result.Status.ERROR -> { 44 | emit(Result.error(response.message!!)) 45 | emitSource(source) 46 | } 47 | else -> { 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/db/AnimeDao.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.db 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.* 5 | import dev.smoketrees.twist.model.twist.AnimeItem 6 | import dev.smoketrees.twist.model.twist.AnimeWithEpisodes 7 | import dev.smoketrees.twist.model.twist.LibraryEpisode 8 | 9 | @Dao 10 | interface AnimeDao { 11 | @Query("SELECT * FROM animeitem") 12 | fun getAllAnime(): LiveData> 13 | 14 | @Query("SELECT * FROM animeitem") 15 | fun getAllAnimeList(): List 16 | 17 | @Query("SELECT * FROM animeitem WHERE title LIKE :searchText OR altTitle LIKE :searchText") 18 | fun searchAnime(searchText: String): LiveData> 19 | 20 | @Query("SELECT * FROM animeitem WHERE id = :id") 21 | fun getAnimeById(id: Int): LiveData 22 | 23 | @Query("SELECT * FROM animeitem WHERE ongoing = 1") 24 | fun getOngoingAnime(): LiveData> 25 | 26 | @Query("SELECT * FROM animeitem WHERE ongoing = 1") 27 | fun getOngoingAnimeList(): List 28 | 29 | @Query("SELECT * FROM animeitem WHERE uid IN (:ids)") 30 | fun getAnimeByIds(ids: List): LiveData> 31 | 32 | @Transaction 33 | @Query("SELECT * FROM animeitem WHERE uid = :id") 34 | fun getWatchedEpisodes(id: Int): LiveData 35 | 36 | @Insert(onConflict = OnConflictStrategy.REPLACE) 37 | suspend fun saveWatchedEpisodes(episodes: List) 38 | 39 | @Insert(onConflict = OnConflictStrategy.REPLACE) 40 | suspend fun saveAnime(animeItems: List) 41 | 42 | @Delete 43 | suspend fun deleteAnime(animeItem: AnimeItem) 44 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/api/BaseApiClient.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.api 2 | 3 | import dev.smoketrees.twist.model.twist.Result 4 | import dev.smoketrees.twist.utils.Messages 5 | import retrofit2.Response 6 | import java.net.ConnectException 7 | import java.net.SocketTimeoutException 8 | import java.net.UnknownHostException 9 | 10 | open class BaseApiClient { 11 | 12 | protected suspend fun getResult(request: suspend () -> Response): Result { 13 | try { 14 | val response = request() 15 | return if (response.isSuccessful) { 16 | val body = response.body() 17 | if (body != null) { 18 | Result.success(body) 19 | } else { 20 | Result.error(Messages.Message(0,"Server response error")) 21 | } 22 | } else { 23 | Result.error(Messages.Message(response.code(), response.message())) 24 | } 25 | } catch (e: Exception) { 26 | val errorMessage = e.message ?: e.toString() 27 | return when (e) { 28 | is SocketTimeoutException -> { 29 | Result.error(Messages.Message(408,"Timed out!")) 30 | } 31 | is ConnectException -> { 32 | Result.error(Messages.Message(111,"Check your internet connection!")) 33 | } 34 | is UnknownHostException -> { 35 | Result.error(Messages.Message(111,"Check your internet connection!")) 36 | } 37 | else -> { 38 | Result.error(Messages.Message(0, errorMessage)) 39 | } 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/utils/PreferenceHelper.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.utils 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | 6 | object PreferenceHelper { 7 | 8 | fun customPrefs(context: Context, name: String): SharedPreferences = 9 | context.getSharedPreferences(name, Context.MODE_PRIVATE) 10 | 11 | private inline fun SharedPreferences.edit(operation: (SharedPreferences.Editor) -> Unit) { 12 | val editor = this.edit() 13 | operation(editor) 14 | editor.apply() 15 | } 16 | 17 | operator fun SharedPreferences.set(key: String, value: Any?) { 18 | when (value) { 19 | is String? -> edit { it.putString(key, value) } 20 | is Int -> edit { it.putInt(key, value) } 21 | is Boolean -> edit { it.putBoolean(key, value) } 22 | is Float -> edit { it.putFloat(key, value) } 23 | is Long -> edit { it.putLong(key, value) } 24 | else -> throw UnsupportedOperationException("Not yet implemented") 25 | } 26 | } 27 | 28 | inline operator fun SharedPreferences.get( 29 | key: String, 30 | defaultValue: T? = null 31 | ): T? { 32 | return when (T::class) { 33 | String::class -> getString(key, defaultValue as? String) as T? 34 | Int::class -> getInt(key, defaultValue as? Int ?: -1) as T? 35 | Boolean::class -> getBoolean(key, defaultValue as? Boolean ?: false) as T? 36 | Float::class -> getFloat(key, defaultValue as? Float ?: -1f) as T? 37 | Long::class -> getLong(key, defaultValue as? Long ?: -1) as T? 38 | else -> throw UnsupportedOperationException("Not yet implemented") 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph_auth.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 20 | 23 | 24 | 29 | 32 | 35 | 36 | 41 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/api/anime/AnimeWebService.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.api.anime 2 | 3 | import dev.smoketrees.twist.model.twist.* 4 | import retrofit2.Response 5 | import retrofit2.http.* 6 | 7 | interface AnimeWebService { 8 | @GET("anime") 9 | suspend fun getAllAnime(): Response> 10 | 11 | @GET("anime/{animeName}") 12 | suspend fun getAnimeDetails(@Path("animeName") animeName: String): Response 13 | 14 | @GET("anime/{animeName}/sources") 15 | suspend fun getAnimeSources(@Path("animeName") animeName: String): Response> 16 | 17 | @GET("list/anime") 18 | suspend fun filteredKitsuRequest( 19 | @Query("page[limit]") pageLimit: Int, 20 | @Query("sort") sort: String, 21 | @Query("filter[status]") filterStatus: String, 22 | @Query("page[offset]") pageOffset: Int 23 | ): Response> 24 | 25 | @GET("list/anime") 26 | suspend fun kitsuRequest( 27 | @Query("page[limit]") pageLimit: Int, 28 | @Query("sort") sort: String, 29 | @Query("page[offset]") pageOffset: Int 30 | ): Response> 31 | 32 | @GET("list/trending/anime") 33 | suspend fun getTrendingAnime( 34 | @Query("limit") limit: Int 35 | ): Response> 36 | 37 | @GET("motd") 38 | suspend fun getMotd(): Response 39 | 40 | @POST("auth/signin") 41 | suspend fun signIn(@Body loginDetails: LoginDetails): Response 42 | 43 | @POST("auth/signup") 44 | suspend fun signUp(@Body registerDetails: RegisterDetails): Response 45 | 46 | @GET("user/library") 47 | suspend fun getUserLibrary(@Header("jwt") jwt: String): Response>> 48 | 49 | @PATCH("user/library/episode") 50 | suspend fun updateUserLibrary(@Header("jwt") jwt: String, @Body body: PatchLibRequest): Response 51 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/animelist_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 16 | 17 | 28 | 29 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/api/anime/AnimeWebClient.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.api.anime 2 | 3 | import dev.smoketrees.twist.api.BaseApiClient 4 | import dev.smoketrees.twist.model.twist.LoginDetails 5 | import dev.smoketrees.twist.model.twist.PatchLibRequest 6 | import dev.smoketrees.twist.model.twist.RegisterDetails 7 | 8 | class AnimeWebClient(private val webService: AnimeWebService) : BaseApiClient() { 9 | suspend fun getAllAnime() = getResult { 10 | webService.getAllAnime() 11 | } 12 | 13 | suspend fun getAnimeDetails(animeName: String) = getResult { 14 | webService.getAnimeDetails(animeName) 15 | } 16 | 17 | suspend fun getAnimeSources(animeName: String) = getResult { 18 | webService.getAnimeSources(animeName) 19 | } 20 | 21 | suspend fun filteredKitsuRequest( 22 | pageLimit: Int, 23 | sort: String, 24 | filter: String, 25 | offset: Int 26 | ) = getResult { 27 | webService.filteredKitsuRequest(pageLimit, sort, filter, offset) 28 | } 29 | 30 | suspend fun kitsuRequest( 31 | pageLimit: Int, 32 | sort: String, 33 | offset: Int 34 | ) = getResult { 35 | webService.kitsuRequest(pageLimit, sort, offset) 36 | } 37 | 38 | suspend fun getTrendingAnime(limit: Int) = getResult { 39 | webService.getTrendingAnime(limit) 40 | } 41 | 42 | suspend fun getMotd() = getResult { 43 | webService.getMotd() 44 | } 45 | 46 | suspend fun signIn(loginDetails: LoginDetails) = getResult { 47 | webService.signIn(loginDetails) 48 | } 49 | 50 | suspend fun signUp(registerDetails: RegisterDetails) = getResult { 51 | webService.signUp(registerDetails) 52 | } 53 | 54 | suspend fun getUserLibrary(jwt: String) = getResult { 55 | webService.getUserLibrary(jwt) 56 | } 57 | 58 | suspend fun updateUserLibrary(jwt: String, body: PatchLibRequest) = getResult { 59 | webService.updateUserLibrary(jwt, body) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/res/layout/episode_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 15 | 16 | 29 | 30 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/adapters/SearchListAdapter.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.adapters 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.RecyclerView 7 | import dev.smoketrees.twist.R 8 | import dev.smoketrees.twist.model.twist.AnimeItem 9 | import dev.smoketrees.twist.utils.hide 10 | import dev.smoketrees.twist.utils.show 11 | import kotlinx.android.extensions.LayoutContainer 12 | import kotlinx.android.synthetic.main.anime_search_item.* 13 | 14 | class SearchListAdapter(val listener: (AnimeItem) -> Unit) : 15 | RecyclerView.Adapter() { 16 | 17 | private var animeList: List = mutableListOf() 18 | 19 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = 20 | SearchViewHolder( 21 | LayoutInflater.from(parent.context).inflate( 22 | R.layout.anime_search_item, 23 | parent, 24 | false 25 | ) 26 | ) 27 | 28 | override fun getItemCount() = animeList.size 29 | 30 | override fun onBindViewHolder(holder: SearchViewHolder, position: Int) { 31 | val item = animeList[position] 32 | holder.anime_name.text = item.title 33 | holder.anime_alt_title.hide() 34 | item.altTitle?.let { 35 | holder.anime_alt_title.show() 36 | holder.anime_alt_title.text = it 37 | } 38 | if (item.ongoing == 1) { 39 | holder.anime_ongoing.show(0) 40 | } else { 41 | holder.anime_ongoing.hide(0) 42 | } 43 | 44 | holder.containerView.setOnClickListener { 45 | listener(animeList[position]) 46 | } 47 | } 48 | 49 | 50 | class SearchViewHolder(override val containerView: View) : 51 | RecyclerView.ViewHolder(containerView), LayoutContainer 52 | 53 | fun updateData(newData: List) { 54 | animeList = newData 55 | notifyDataSetChanged() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/utils/Messages.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.utils 2 | 3 | import android.app.Activity 4 | import android.content.ContextWrapper 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.view.View 8 | import dev.smoketrees.twist.R 9 | 10 | 11 | object Messages { 12 | 13 | // Specify notice messages 14 | val DEFAULT_NOTICE = Notice( 15 | null, 16 | R.string.error_default, 17 | null 18 | ) 19 | val NOTICES : HashMap = hashMapOf( 20 | 111 to Notice( 21 | R.drawable.ic_no_connection, 22 | R.string.no_connection, 23 | Buttons.REFRESH 24 | ), 25 | 502 to Notice( 26 | R.drawable.ic_bad_gateway, 27 | R.string.server_error_502, 28 | Buttons.OPEN 29 | ), 30 | 408 to Notice( 31 | null, 32 | R.string.client_error_408, 33 | Buttons.REFRESH 34 | ) 35 | ) 36 | 37 | class Notice( 38 | val icon: Int?, 39 | val msg_id: Int, 40 | val button: Buttons? 41 | ) { 42 | private val listeners = hashMapOf( 43 | Buttons.REFRESH to View.OnClickListener { 44 | val a = it.context as Activity 45 | a.recreate() 46 | }, 47 | Buttons.OPEN to View.OnClickListener { 48 | val browser = Intent(Intent.ACTION_VIEW, Uri.parse(Constants.WEB)) 49 | it.context.startActivity(browser) 50 | } 51 | ) 52 | 53 | var listener: View.OnClickListener? = null 54 | init { 55 | if (button != null) 56 | if (listeners.containsKey(button)) 57 | listener = listeners[button]!! 58 | } 59 | 60 | } 61 | 62 | class Message( 63 | val code: Int?, 64 | val msg: String 65 | ) 66 | 67 | enum class Buttons(val text : String) { 68 | REFRESH("Refresh"), 69 | OPEN("Open in browser") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_search.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 11 | 12 | 15 | 16 | 17 | 21 | 22 | 30 | 31 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/adapters/EpisodeListAdapter.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.adapters 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.RecyclerView 7 | import dev.smoketrees.twist.R 8 | import dev.smoketrees.twist.model.twist.Episode 9 | import dev.smoketrees.twist.model.twist.LibraryEpisode 10 | import dev.smoketrees.twist.utils.show 11 | import kotlinx.android.extensions.LayoutContainer 12 | import kotlinx.android.synthetic.main.episode_item.* 13 | 14 | class EpisodeListAdapter( 15 | private val listener: (Episode, Boolean) -> Unit 16 | ) : 17 | RecyclerView.Adapter() { 18 | 19 | private var episodeList: List = mutableListOf() 20 | private var watchedEpisodeList: Map = mutableMapOf() 21 | lateinit var onBottomReachedListener: (Int) -> Unit 22 | 23 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = 24 | EpisodeViewHolder( 25 | LayoutInflater.from(parent.context).inflate( 26 | R.layout.episode_item, 27 | parent, 28 | false 29 | ) 30 | ) 31 | 32 | override fun getItemCount() = episodeList.size 33 | 34 | override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) { 35 | if (position == episodeList.size - 1) { 36 | onBottomReachedListener(position) 37 | } 38 | 39 | holder.episode_text.text = episodeList[position].number!!.toString() 40 | holder.containerView.setOnClickListener { 41 | listener(episodeList[position], false) 42 | } 43 | if (watchedEpisodeList[episodeList[position].id] != null) { 44 | holder.is_watched.show() 45 | } 46 | } 47 | 48 | class EpisodeViewHolder(override val containerView: View) : 49 | RecyclerView.ViewHolder(containerView), LayoutContainer 50 | 51 | fun updateData(newData: List) { 52 | episodeList = newData 53 | notifyDataSetChanged() 54 | } 55 | 56 | fun updateWatchedEps(newData: Map) { 57 | watchedEpisodeList = newData 58 | notifyDataSetChanged() 59 | } 60 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![AnimeTwist](./app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) 2 | 3 | # Official Anime Twist android application 4 | 5 | Client app for [twist.moe](https://twist.moe/) anime streaming site. 6 | 7 | ## App development summary 8 | 9 | - Written in Kotlin 10 | - Following MVVM architectural pattern 11 | - Uses Navigation, Livedata and ViewModel architecture components 12 | - Uses Koin for dependency injection 13 | 14 | ## Submitting issues 15 | 16 | Any feature requests or bugs can be reported [here](../../issues). 17 | 18 | Quick guidelines: 19 | ***For bugs:*** Describe the problem and the steps to reproduce it, maybe include some screenshots from the app for reference. 20 | ***For feature requests:*** Describe all of the new features in detail so they can be easily understood and implemented. 21 | 22 | ## Setup the dev environment 23 | First of all, you'll need the latest version of the android studio or any other version that supports the points mentioned in the dev summary. 24 | 25 | As for the project setup, just clone this repository using `git clone https://github.com/AnimeTwist/twist-mobile.git` 26 | 27 | ### API / Decrypt keys 28 | 29 | Before building you'll need to provide secrets for some services we use in the app. Currently, it's just one decrypt key to decode anime media data from the API, but in the future, we'll maybe expand them by adding anime tracking services. 30 | 31 | To make these secrets accessible to the app add secrets.properties file to the root directory. Modify its contents to look like this example: 32 | ``` 33 | decrypt_key= 34 | ``` 35 | *(We can't release the decrypt key to the public for obvious reasons, just add some random string of characters so the app builds. If you seriously need it for the development contact someone from the dev team)* 36 | 37 | ## Contributing 38 | 39 | Contributions and patches are encouraged and may be submitted by forking this project and 40 | submitting a pull request. You can also help out and implement some of the open feature requests [here](../../issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement+no%3Aassignee). 41 | 42 | ## License & Privacy Policy 43 | 44 | This project is licensed under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html) License. You can read the details here: 45 | - [License](./LICENSE) 46 | - [Privacy Policy](./PRIV_POLICY.md) 47 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/adapters/PagedAnimeListAdapter.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.adapters 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.paging.PagedListAdapter 9 | import androidx.recyclerview.widget.RecyclerView 10 | import androidx.swiperefreshlayout.widget.CircularProgressDrawable 11 | import com.bumptech.glide.Glide 12 | import dev.smoketrees.twist.R 13 | import dev.smoketrees.twist.model.twist.AnimeItem 14 | import kotlinx.android.extensions.LayoutContainer 15 | import kotlinx.android.synthetic.main.animelist_item.* 16 | 17 | class PagedAnimeListAdapter( 18 | private val context: Context, 19 | private val listener: (AnimeItem) -> Unit 20 | ) : 21 | PagedListAdapter(AnimeItem.DIFF_CALLBACK) { 22 | 23 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = 24 | AnimeViewHolder( 25 | LayoutInflater.from(parent.context).inflate( 26 | R.layout.animelist_item, 27 | parent, 28 | false 29 | ) 30 | ) 31 | 32 | override fun onBindViewHolder(holder: AnimeViewHolder, position: Int) { 33 | val item = getItem(position) 34 | 35 | holder.anime_name.text = item?.title 36 | holder.containerView.setOnClickListener { 37 | item?.let(listener) 38 | } 39 | if (item?.nejireExtension?.poster_image == null || item.nejireExtension?.poster_image == "") { 40 | Glide.with(context).clear(holder.anime_image) 41 | holder.anime_image.setImageDrawable(null) 42 | } else { 43 | val circularProgressDrawable = CircularProgressDrawable(context) 44 | circularProgressDrawable.apply { 45 | setColorSchemeColors(Color.rgb(105, 240, 174)) 46 | strokeWidth = 10f 47 | centerRadius = 40f 48 | start() 49 | } 50 | Glide.with(context).load(item.nejireExtension?.poster_image) 51 | .placeholder(circularProgressDrawable).into(holder.anime_image) 52 | } 53 | } 54 | 55 | 56 | class AnimeViewHolder(override val containerView: View) : 57 | RecyclerView.ViewHolder(containerView), LayoutContainer 58 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/AnimeDetails.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import com.google.gson.annotations.Expose 5 | import com.google.gson.annotations.SerializedName 6 | import org.apache.commons.lang3.builder.ToStringBuilder 7 | 8 | @Keep 9 | class AnimeDetails { 10 | 11 | @SerializedName("id") 12 | @Expose 13 | var id: Int? = null 14 | 15 | @SerializedName("title") 16 | @Expose 17 | var title: String? = null 18 | 19 | @SerializedName("alt_title") 20 | @Expose 21 | var altTitle: String? = null 22 | 23 | @SerializedName("description") 24 | @Expose 25 | var description: String? = null 26 | 27 | @SerializedName("episodes") 28 | @Expose 29 | var episodes: List? = null 30 | 31 | @SerializedName("season") 32 | @Expose 33 | var season: Int? = null 34 | 35 | @SerializedName("ongoing") 36 | @Expose 37 | var ongoing: Int? = null 38 | 39 | @SerializedName("hb_id") 40 | @Expose 41 | var hbId: Int? = null 42 | 43 | @SerializedName("hidden") 44 | @Expose 45 | var hidden: Int? = null 46 | 47 | @SerializedName("mal_id") 48 | @Expose 49 | var malId: Int? = null 50 | 51 | @SerializedName("created_at") 52 | @Expose 53 | var createdAt: String? = null 54 | 55 | @SerializedName("updated_at") 56 | @Expose 57 | var updatedAt: String? = null 58 | 59 | @SerializedName("slug") 60 | @Expose 61 | var slug: Slug? = null 62 | 63 | @SerializedName("nejire_extension") 64 | @Expose 65 | var extension: DetailsNejireExtension? = null 66 | 67 | 68 | override fun toString(): String { 69 | return ToStringBuilder(this).append("id", id).append("title", title) 70 | .append("altTitle", altTitle).append("description", description) 71 | .append("episodes", episodes).append("season", season).append("ongoing", ongoing) 72 | .append("hbId", hbId).append("hidden", hidden).append("malId", malId) 73 | .append("createdAt", createdAt).append("updatedAt", updatedAt).append("slug", slug) 74 | .toString() 75 | } 76 | 77 | } 78 | 79 | @Keep 80 | class DetailsNejireExtension { 81 | @SerializedName("poster_image") 82 | @Expose 83 | var posterImage: String? = null 84 | 85 | @SerializedName("cover_image") 86 | @Expose 87 | var coverImage: String? = null 88 | 89 | @SerializedName("avg_score") 90 | @Expose 91 | var avgScore: Double? = null 92 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/adapters/AnimeListAdapter.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.adapters 2 | 3 | 4 | import android.content.Context 5 | import android.graphics.Color 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import androidx.recyclerview.widget.RecyclerView 10 | import androidx.swiperefreshlayout.widget.CircularProgressDrawable 11 | import com.bumptech.glide.Glide 12 | import dev.smoketrees.twist.R 13 | import dev.smoketrees.twist.model.twist.AnimeItem 14 | import kotlinx.android.extensions.LayoutContainer 15 | import kotlinx.android.synthetic.main.animelist_item.* 16 | 17 | open class AnimeListAdapter( 18 | private val context: Context, 19 | val listener: (AnimeItem) -> Unit 20 | ) : 21 | RecyclerView.Adapter() { 22 | 23 | private var animeList: List = mutableListOf() 24 | 25 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = 26 | AnimeViewHolder( 27 | LayoutInflater.from(parent.context).inflate( 28 | R.layout.animelist_item, 29 | parent, 30 | false 31 | ) 32 | ) 33 | 34 | override fun getItemCount() = animeList.size 35 | 36 | override fun onBindViewHolder(holder: AnimeViewHolder, position: Int) { 37 | holder.anime_name.text = animeList[position].title 38 | holder.containerView.setOnClickListener { 39 | listener(animeList[position]) 40 | } 41 | if (animeList[position].nejireExtension?.poster_image == null || animeList[position].nejireExtension?.poster_image == "") { 42 | Glide.with(context).clear(holder.anime_image) 43 | holder.anime_image.setImageDrawable(null) 44 | } else { 45 | val circularProgressDrawable = CircularProgressDrawable(context) 46 | circularProgressDrawable.apply { 47 | setColorSchemeColors(Color.rgb(105, 240, 174)) 48 | strokeWidth = 10f 49 | centerRadius = 40f 50 | start() 51 | } 52 | Glide.with(context).load(animeList[position].nejireExtension?.poster_image) 53 | .placeholder(circularProgressDrawable).into(holder.anime_image) 54 | } 55 | } 56 | 57 | 58 | class AnimeViewHolder(override val containerView: View) : 59 | RecyclerView.ViewHolder(containerView), LayoutContainer 60 | 61 | fun updateData(newData: List) { 62 | animeList = newData 63 | notifyDataSetChanged() 64 | } 65 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/ui/base/BaseFragment.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.ui.base 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.annotation.LayoutRes 8 | import androidx.databinding.DataBindingUtil 9 | import androidx.databinding.ViewDataBinding 10 | import androidx.fragment.app.Fragment 11 | import androidx.lifecycle.ViewModel 12 | import dev.smoketrees.twist.ui.home.MainActivity 13 | import dev.smoketrees.twist.utils.autoCleared 14 | import kotlinx.android.synthetic.main.activity_main.* 15 | import org.koin.android.viewmodel.ext.android.sharedViewModel 16 | import org.koin.android.viewmodel.ext.android.viewModel 17 | import kotlin.reflect.KClass 18 | 19 | abstract class BaseFragment( 20 | @LayoutRes private val layout: Int, 21 | private val clazz: KClass, 22 | private val share: Boolean = false 23 | ) : Fragment() { 24 | 25 | 26 | abstract val bindingVariable: Int 27 | 28 | protected var dataBinding by autoCleared() 29 | 30 | protected val viewModel: VM by lazy { if (share) sharedViewModel(clazz) else viewModel(clazz) }.value 31 | 32 | protected val ctx by lazy { requireContext() } 33 | 34 | private val m: Lazy by lazy { sharedViewModel(clazz) } 35 | 36 | protected val appBar by lazy { (requireActivity() as MainActivity).appbar } 37 | 38 | protected fun showLoader() = (requireActivity() as MainActivity).showLoader() 39 | protected fun hideLoader() = (requireActivity() as MainActivity).hideLoader() 40 | 41 | protected fun notice(err_code: Int?) = (requireActivity() as MainActivity).notice(err_code) 42 | protected fun noticeClear() = (requireActivity() as MainActivity).noticeClear() 43 | 44 | override fun onCreateView( 45 | inflater: LayoutInflater, 46 | container: ViewGroup?, 47 | savedInstanceState: Bundle? 48 | ): View? { 49 | dataBinding = DataBindingUtil.inflate(inflater, layout, container, false) 50 | dataBinding.apply { 51 | lifecycleOwner = lifecycleOwner 52 | setVariable(bindingVariable, viewModel) 53 | executePendingBindings() 54 | } 55 | return dataBinding.root 56 | } 57 | 58 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 59 | super.onViewCreated(view, savedInstanceState) 60 | // To make sure App bar is always visible after fragment change 61 | appBar.setExpanded(true, true) 62 | } 63 | 64 | override fun onDestroyView() { 65 | hideLoader() 66 | super.onDestroyView() 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/model/twist/AnimeItem.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.model.twist 2 | 3 | import androidx.annotation.Keep 4 | import androidx.recyclerview.widget.DiffUtil 5 | import androidx.room.* 6 | import com.google.gson.annotations.Expose 7 | import com.google.gson.annotations.SerializedName 8 | import org.apache.commons.lang3.builder.ToStringBuilder 9 | 10 | @Keep 11 | @Entity 12 | open class AnimeItem { 13 | 14 | @PrimaryKey 15 | @ColumnInfo(name = "uid") 16 | @SerializedName("id") 17 | @Expose 18 | var id: Int? = null 19 | 20 | @SerializedName("title") 21 | @Expose 22 | var title: String? = null 23 | 24 | @SerializedName("alt_title") 25 | @Expose 26 | var altTitle: String? = null 27 | 28 | @SerializedName("season") 29 | @Expose 30 | var season: Int? = null 31 | 32 | @SerializedName("ongoing") 33 | @Expose 34 | var ongoing: Int? = null 35 | 36 | @SerializedName("hb_id") 37 | @Expose 38 | var hbId: Int? = null 39 | 40 | @Ignore 41 | @SerializedName("hidden") 42 | @Expose 43 | var hidden: Int? = null 44 | 45 | @SerializedName("mal_id") 46 | @Expose 47 | var malId: Int? = null 48 | 49 | @Ignore 50 | @SerializedName("created_at") 51 | @Expose 52 | var createdAt: String? = null 53 | 54 | @Ignore 55 | @SerializedName("updated_at") 56 | @Expose 57 | var updatedAt: String? = null 58 | 59 | @Embedded 60 | @SerializedName("slug") 61 | @Expose 62 | var slug: Slug? = null 63 | 64 | @ColumnInfo(name = "img_url") 65 | var imgUrl: String? = null 66 | 67 | @Embedded 68 | @SerializedName("nejire_extension") 69 | @Expose 70 | var nejireExtension: NejireExtension? = null 71 | 72 | companion object { 73 | var DIFF_CALLBACK: DiffUtil.ItemCallback = 74 | object : DiffUtil.ItemCallback() { 75 | override fun areItemsTheSame(oldItem: AnimeItem, newItem: AnimeItem): Boolean { 76 | return oldItem.id == newItem.id 77 | } 78 | 79 | override fun areContentsTheSame(oldItem: AnimeItem, newItem: AnimeItem): Boolean { 80 | return oldItem.id == newItem.id 81 | } 82 | } 83 | } 84 | 85 | 86 | override fun toString(): String { 87 | return ToStringBuilder(this).append("id", id).append("title", title) 88 | .append("altTitle", altTitle).append("season", season).append("ongoing", ongoing) 89 | .append("hbId", hbId).append("hidden", hidden).append("malId", malId) 90 | .append("createdAt", createdAt).append("updatedAt", updatedAt).append("slug", slug) 91 | .append("nejireExtension", nejireExtension) 92 | .toString() 93 | } 94 | 95 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/anime_search_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 15 | 16 | 32 | 33 | 46 | 47 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/utils/search/WinklerWeightedRatio.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019 Lawnchair Team. 3 | * 4 | * This file is part of Lawnchair Launcher. 5 | * 6 | * Lawnchair Launcher is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * Lawnchair Launcher is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with Lawnchair Launcher. If not, see . 18 | */ 19 | 20 | package dev.smoketrees.twist.utils.search 21 | 22 | import me.xdrop.fuzzywuzzy.ToStringFunction 23 | import me.xdrop.fuzzywuzzy.algorithms.WeightedRatio 24 | import kotlin.math.roundToInt 25 | 26 | /** 27 | * Weighted ratio with higher scores for strings with common prefix like in the Jaro-Winkler algorithm 28 | */ 29 | class WinklerWeightedRatio : WeightedRatio() { 30 | 31 | override fun apply(s1: String?, s2: String?, stringProcessor: ToStringFunction): Int { 32 | val first = stringProcessor.apply(s1) 33 | val second = stringProcessor.apply(s2) 34 | 35 | val ratio = super.apply(s1, s2, stringProcessor) / 100.0 36 | val cl = commonPrefixLength(first, second) 37 | return ((ratio + SCALING_FACTOR * cl * (1.0 - ratio)) * 100).roundToInt() 38 | } 39 | 40 | /** 41 | * Calculates the number of characters from the beginning of the strings that match exactly one-to-one, 42 | * up to a maximum of four (4) characters. 43 | * @param first The first string. 44 | * @param second The second string. 45 | * @return A number between 0 and 4. 46 | * @see https://github.com/rrice/java-string-similarity/blob/master/src/main/java/net/ricecode/similarity/JaroWinklerStrategy.java 47 | */ 48 | private fun commonPrefixLength(first: String, second: String): Int { 49 | val shorter: String 50 | val longer: String 51 | 52 | // Determine which string is longer. 53 | if (first.length > second.length) { 54 | longer = first.toLowerCase() 55 | shorter = second.toLowerCase() 56 | } else { 57 | longer = second.toLowerCase() 58 | shorter = first.toLowerCase() 59 | } 60 | 61 | var result = 0 62 | 63 | // Iterate through the shorter string. 64 | for (i in shorter.indices) { 65 | if (shorter[i] != longer[i]) { 66 | break 67 | } 68 | result++ 69 | } 70 | 71 | // Limit the result to 4. 72 | return if (result > 4) 4 else result 73 | } 74 | 75 | 76 | companion object { 77 | const val SCALING_FACTOR = .15 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | 20 | 27 | 28 | 33 | 36 | 39 | 43 | 48 | 49 | 54 | 57 | 60 | 63 | 64 | 68 | 71 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Anime Twist 3 | Top airing 4 | Fall season has started! 5 | We\'re adding over 50 new ongoing shows to Anime Twist this season. Find out if your favourite new anime will be added here https://bit.ly/2SKsfpC 6 | Search 7 | Search for anime 8 | ONGOING 9 | Hot picks 🔥 10 | NANI? This must be the work of an enemy stand! No results were found. 11 | Anime poster 12 | Top rated 13 | Log in 14 | 15 | Hello blank fragment 16 | Home 17 | Account 18 | Username 19 | Password 20 | Email 21 | Enter password again 22 | Register 23 | Enter a valid email. 24 | Passwords do not match 25 | Password must be greater than 7 characters 26 | Sign up 27 | Switch theme 28 | %d episodes 29 | Score: %s 30 | Episode number %d 31 | EP%d 32 | Already have an account? Log in 33 | Scroll down to bottom of anime list 34 | Don\'t have an account? Register 35 | Username/Email 36 | 4-20 letters/dashes/underscores 37 | Must not be blank! 38 | Anime Twist 39 | 00:00 40 | Twist API Server is unreachable 41 | Request timed out. 42 | Check your internet connection. 43 | An unexpected error occurred. 44 | Next episode in: %s 45 | Next 46 | Cancel 47 | Retry 48 | Recently watched 49 | 50 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/utils/Extensions.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.utils 2 | 3 | import android.animation.Animator 4 | import android.animation.AnimatorListenerAdapter 5 | import android.app.Activity 6 | import android.content.Context 7 | import android.util.DisplayMetrics 8 | import android.util.Patterns 9 | import android.view.LayoutInflater 10 | import android.view.View 11 | import android.view.ViewGroup 12 | import android.view.inputmethod.InputMethodManager 13 | import android.widget.Toast 14 | import androidx.fragment.app.Fragment 15 | 16 | fun Context.longToast(message: String) { 17 | Toast.makeText(this, message, Toast.LENGTH_LONG).show() 18 | } 19 | 20 | fun Context.toast(message: String) { 21 | Toast.makeText(this, message, Toast.LENGTH_SHORT).show() 22 | } 23 | 24 | fun ViewGroup.inflate(layoutRes: Int): View { 25 | return LayoutInflater.from(context).inflate(layoutRes, this, false) 26 | } 27 | 28 | fun View.hide(duration: Long = 300) { 29 | animate().alpha(0.0f).setDuration(duration).setListener(object : AnimatorListenerAdapter() { 30 | override fun onAnimationEnd(animation: Animator?) { 31 | super.onAnimationEnd(animation) 32 | visibility = View.GONE 33 | } 34 | }) 35 | } 36 | 37 | fun View.invisible() { 38 | visibility = View.INVISIBLE 39 | } 40 | 41 | fun View.show(duration: Long = 300) { 42 | animate().alpha(1.0f).setDuration(duration).setListener(object : AnimatorListenerAdapter() { 43 | override fun onAnimationEnd(animation: Animator?) { 44 | super.onAnimationEnd(animation) 45 | visibility = View.VISIBLE 46 | } 47 | }) 48 | } 49 | 50 | fun Fragment.hideKeyboard() { 51 | view?.let { activity?.hideKeyboard(it) } 52 | } 53 | 54 | fun Activity.hideKeyboard() { 55 | if (currentFocus == null) View(this) else currentFocus?.let { hideKeyboard(it) } 56 | } 57 | 58 | fun Context.hideKeyboard(view: View) { 59 | val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager 60 | inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) 61 | } 62 | 63 | fun Fragment.toast(message: String) { 64 | requireContext().toast(message) 65 | } 66 | 67 | fun Fragment.longToast(message: String) { 68 | requireContext().longToast(message) 69 | } 70 | 71 | fun Activity.getHeight(): Int { 72 | val displayMetrics = DisplayMetrics(); 73 | this.windowManager.defaultDisplay.getMetrics(displayMetrics) 74 | return displayMetrics.heightPixels 75 | } 76 | 77 | fun Activity.getWidth(): Int { 78 | val displayMetrics = DisplayMetrics(); 79 | this.windowManager.defaultDisplay.getMetrics(displayMetrics) 80 | return displayMetrics.widthPixels 81 | } 82 | 83 | fun Fragment.getHeight(): Int { 84 | return requireActivity().getHeight() 85 | } 86 | 87 | fun Fragment.getWidth(): Int { 88 | return requireActivity().getWidth() 89 | } 90 | 91 | fun String.isValidEmail(): Boolean { 92 | return isNotEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches() 93 | } 94 | 95 | fun String.isValidPass(): Boolean { 96 | return length >= 7 97 | } 98 | 99 | fun Context.calculateNoOfColumns(columnWidthDp: Float): Int { 100 | val displayMetrics = resources.displayMetrics 101 | val screenWidthDp = displayMetrics.widthPixels / displayMetrics.density 102 | return (screenWidthDp / columnWidthDp + 0.5).toInt() 103 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/repository/AnimeRepo.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.repository 2 | 3 | 4 | import dev.smoketrees.twist.api.anime.AnimeWebClient 5 | import dev.smoketrees.twist.db.AnimeDao 6 | import dev.smoketrees.twist.db.AnimeDetailsDao 7 | import dev.smoketrees.twist.db.TrendingAnimeDao 8 | import dev.smoketrees.twist.model.twist.* 9 | import dev.smoketrees.twist.utils.Messages 10 | 11 | class AnimeRepo( 12 | val webClient: AnimeWebClient, 13 | private val animeDao: AnimeDao, 14 | private val episodeDao: AnimeDetailsDao, 15 | private val trendingAnimeDao: TrendingAnimeDao 16 | ) : BaseRepo() { 17 | 18 | fun getAllDbAnime() = animeDao.getAllAnime() 19 | suspend fun getAllNetworkAnime() = webClient.getAllAnime() 20 | suspend fun saveAnime(animeItems: List) = animeDao.saveAnime(animeItems) 21 | suspend fun saveTrendingAnime(animeItems: List) = 22 | trendingAnimeDao.saveTrendingAnime(animeItems) 23 | 24 | 25 | suspend fun getNetworkTrendingAnime(limit: Int) = webClient.getTrendingAnime(limit) 26 | 27 | fun getDbTrendingAnime() = trendingAnimeDao.getTrendingAnime() 28 | 29 | fun getMotd() = makeRequest { 30 | webClient.getMotd() 31 | } 32 | 33 | fun getAnimeDetails(name: String, id: Int) = makeRequestAndSave( 34 | databaseQuery = { episodeDao.getAnimeDetails(id) }, 35 | networkCall = { 36 | saveEpisodeDetails( 37 | webClient.getAnimeDetails(name) 38 | ) 39 | }, 40 | saveCallResult = { 41 | episodeDao.saveAnimeDetails(it) 42 | } 43 | ) 44 | 45 | private fun saveEpisodeDetails( 46 | episodeResult: Result 47 | ): Result { 48 | return if (episodeResult.status == Result.Status.SUCCESS) { 49 | Result.success( 50 | getAnimeDetailsEntity(episodeResult.data!!) 51 | ) 52 | } else { 53 | Result.error(Messages.Message(null, "")) 54 | } 55 | } 56 | 57 | fun getAnimeByIds(ids: List) = animeDao.getAnimeByIds(ids) 58 | 59 | // TODO: add support for this data to nejire and use it in the app 60 | private fun getAnimeDetailsEntity( 61 | episodeDetails: AnimeDetails 62 | ) = AnimeDetailsEntity( 63 | airing = episodeDetails.ongoing == 1, 64 | //endDate = result?.endDate, 65 | //episodes = result?.episodes, 66 | imageUrl = episodeDetails.extension?.posterImage, 67 | id = episodeDetails.id, 68 | //malId = result?.malId, 69 | //members = result?.members, 70 | //rated = result?.rated, 71 | score = episodeDetails.extension?.avgScore, 72 | //startDate = result?.startDate, 73 | synopsis = episodeDetails.description, 74 | title = episodeDetails.title, 75 | //type = result?.type, 76 | //url = result?.url, 77 | episodeList = episodeDetails.episodes!! 78 | ) 79 | 80 | fun getAnimeSources(animeName: String) = makeRequest { 81 | webClient.getAnimeSources(animeName) 82 | } 83 | 84 | fun kitsuRequest(pageLimit: Int, sort: String, offset: Int) = makeRequest { 85 | webClient.kitsuRequest(pageLimit, sort, offset) 86 | } 87 | 88 | fun signIn(loginDetails: LoginDetails) = makeRequest { 89 | webClient.signIn(loginDetails) 90 | } 91 | 92 | fun signUp(registerDetails: RegisterDetails) = makeRequest { 93 | webClient.signUp(registerDetails) 94 | } 95 | 96 | fun getUserLibrary(jwt: String) = makeRequest { 97 | webClient.getUserLibrary(jwt) 98 | } 99 | 100 | suspend fun getUserLibrarySync(jwt: String) = webClient.getUserLibrary(jwt) 101 | 102 | suspend fun saveWatchedEpisodes(episodes: List) = 103 | animeDao.saveWatchedEpisodes(episodes) 104 | 105 | fun getWatchedEpisodes(id: Int) = animeDao.getWatchedEpisodes(id) 106 | 107 | fun updateUserLibrary(jwt: String, body: PatchLibRequest) = makeRequest { 108 | webClient.updateUserLibrary(jwt, body) 109 | } 110 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/pagination/PagedAnimeDatasource.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.pagination 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.paging.PageKeyedDataSource 5 | import dev.smoketrees.twist.api.anime.AnimeWebClient 6 | import dev.smoketrees.twist.model.twist.AnimeItem 7 | import dev.smoketrees.twist.model.twist.Result 8 | import kotlinx.coroutines.GlobalScope 9 | import kotlinx.coroutines.launch 10 | 11 | class PagedAnimeDatasource( 12 | private val webClient: AnimeWebClient, 13 | private val sort: String 14 | ) : 15 | PageKeyedDataSource() { 16 | val animeLiveData: MutableLiveData?>> = MutableLiveData() 17 | 18 | 19 | override fun loadInitial( 20 | params: LoadInitialParams, 21 | callback: LoadInitialCallback 22 | ) { 23 | GlobalScope.launch { 24 | animeLiveData.postValue(Result.loading()) 25 | val response = webClient.kitsuRequest(params.requestedLoadSize, sort, 0) 26 | when (response.status) { 27 | Result.Status.SUCCESS -> { 28 | animeLiveData.postValue( 29 | Result.success( 30 | response.data 31 | ) 32 | ) 33 | response.data?.toMutableList() 34 | ?.let { callback.onResult(it, null, params.requestedLoadSize) } 35 | } 36 | Result.Status.ERROR -> { 37 | animeLiveData.postValue( 38 | Result.error( 39 | response.message!! 40 | ) 41 | ) 42 | } 43 | else -> { 44 | } 45 | } 46 | } 47 | } 48 | 49 | override fun loadAfter(params: LoadParams, callback: LoadCallback) { 50 | GlobalScope.launch { 51 | animeLiveData.postValue(Result.loading()) 52 | val response = 53 | webClient.kitsuRequest(params.requestedLoadSize, sort, params.key) 54 | when (response.status) { 55 | Result.Status.SUCCESS -> { 56 | animeLiveData.postValue( 57 | Result.success( 58 | response.data 59 | ) 60 | ) 61 | response.data?.toMutableList() 62 | ?.let { callback.onResult(it, params.key + params.requestedLoadSize) } 63 | } 64 | Result.Status.ERROR -> { 65 | animeLiveData.postValue( 66 | Result.error( 67 | response.message!! 68 | ) 69 | ) 70 | } 71 | else -> { 72 | } 73 | } 74 | } 75 | } 76 | 77 | override fun loadBefore(params: LoadParams, callback: LoadCallback) { 78 | GlobalScope.launch { 79 | animeLiveData.postValue(Result.loading()) 80 | val response = 81 | webClient.kitsuRequest(params.requestedLoadSize, sort, params.key) 82 | when (response.status) { 83 | Result.Status.SUCCESS -> { 84 | animeLiveData.postValue( 85 | Result.success( 86 | response.data 87 | ) 88 | ) 89 | val key = if (params.key > 1) params.key - params.requestedLoadSize else 0 90 | response.data?.toMutableList()?.let { callback.onResult(it, key) } 91 | } 92 | Result.Status.ERROR -> { 93 | animeLiveData.postValue( 94 | Result.error( 95 | response.message!! 96 | ) 97 | ) 98 | } 99 | else -> { 100 | } 101 | } 102 | } 103 | } 104 | } 105 | 106 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/pagination/FilteredPagedAnimeDatasource.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.pagination 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import androidx.paging.PageKeyedDataSource 5 | import dev.smoketrees.twist.api.anime.AnimeWebClient 6 | import dev.smoketrees.twist.model.twist.AnimeItem 7 | import dev.smoketrees.twist.model.twist.Result 8 | import kotlinx.coroutines.GlobalScope 9 | import kotlinx.coroutines.launch 10 | 11 | class FilteredPagedAnimeDatasource( 12 | private val webClient: AnimeWebClient, 13 | private val sort: String, 14 | private val filter: String 15 | ) : 16 | PageKeyedDataSource() { 17 | val animeLiveData: MutableLiveData?>> = MutableLiveData() 18 | 19 | 20 | override fun loadInitial( 21 | params: LoadInitialParams, 22 | callback: LoadInitialCallback 23 | ) { 24 | GlobalScope.launch { 25 | animeLiveData.postValue(Result.loading()) 26 | val response = webClient.filteredKitsuRequest(params.requestedLoadSize, sort, filter, 0) 27 | when (response.status) { 28 | Result.Status.SUCCESS -> { 29 | animeLiveData.postValue( 30 | Result.success( 31 | response.data 32 | ) 33 | ) 34 | response.data?.toMutableList() 35 | ?.let { callback.onResult(it, null, params.requestedLoadSize) } 36 | } 37 | Result.Status.ERROR -> { 38 | animeLiveData.postValue( 39 | Result.error( 40 | response.message!! 41 | ) 42 | ) 43 | } 44 | else -> { 45 | } 46 | } 47 | } 48 | } 49 | 50 | override fun loadAfter(params: LoadParams, callback: LoadCallback) { 51 | GlobalScope.launch { 52 | animeLiveData.postValue(Result.loading()) 53 | val response = 54 | webClient.filteredKitsuRequest(params.requestedLoadSize, sort, filter, params.key) 55 | when (response.status) { 56 | Result.Status.SUCCESS -> { 57 | animeLiveData.postValue( 58 | Result.success( 59 | response.data 60 | ) 61 | ) 62 | response.data?.toMutableList() 63 | ?.let { callback.onResult(it, params.key + params.requestedLoadSize) } 64 | } 65 | Result.Status.ERROR -> { 66 | animeLiveData.postValue( 67 | Result.error( 68 | response.message!! 69 | ) 70 | ) 71 | } 72 | else -> { 73 | } 74 | } 75 | } 76 | } 77 | 78 | override fun loadBefore(params: LoadParams, callback: LoadCallback) { 79 | GlobalScope.launch { 80 | animeLiveData.postValue(Result.loading()) 81 | val response = 82 | webClient.filteredKitsuRequest(params.requestedLoadSize, sort, filter, params.key) 83 | when (response.status) { 84 | Result.Status.SUCCESS -> { 85 | animeLiveData.postValue( 86 | Result.success( 87 | response.data 88 | ) 89 | ) 90 | val key = if (params.key > 1) params.key - params.requestedLoadSize else 0 91 | response.data?.toMutableList()?.let { callback.onResult(it, key) } 92 | } 93 | Result.Status.ERROR -> { 94 | animeLiveData.postValue( 95 | Result.error( 96 | response.message!! 97 | ) 98 | ) 99 | } 100 | else -> { 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/utils/CryptoHelper.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.utils 2 | 3 | import dev.smoketrees.twist.BuildConfig 4 | import java.nio.charset.StandardCharsets 5 | import java.security.DigestException 6 | import java.security.InvalidAlgorithmParameterException 7 | import java.security.MessageDigest 8 | import java.security.NoSuchAlgorithmException 9 | import java.util.* 10 | import javax.crypto.BadPaddingException 11 | import javax.crypto.Cipher 12 | import javax.crypto.IllegalBlockSizeException 13 | import javax.crypto.NoSuchPaddingException 14 | import javax.crypto.spec.IvParameterSpec 15 | import javax.crypto.spec.SecretKeySpec 16 | 17 | object CryptoHelper { 18 | fun generateKeyAndIV( 19 | keyLength: Int, 20 | ivLength: Int, 21 | iterations: Int, 22 | salt: ByteArray?, 23 | password: ByteArray, 24 | md: MessageDigest 25 | ): Array { 26 | 27 | val digestLength = md.digestLength 28 | val requiredLength = (keyLength + ivLength + digestLength - 1) / digestLength * digestLength 29 | val generatedData = ByteArray(requiredLength) 30 | var generatedLength = 0 31 | 32 | try { 33 | md.reset() 34 | 35 | // Repeat process until sufficient data has been generated 36 | while (generatedLength < keyLength + ivLength) { 37 | 38 | // Digest data (last digest if available, password data, salt if available) 39 | if (generatedLength > 0) 40 | md.update(generatedData, generatedLength - digestLength, digestLength) 41 | md.update(password) 42 | if (salt != null) 43 | md.update(salt, 0, 8) 44 | md.digest(generatedData, generatedLength, digestLength) 45 | 46 | // additional rounds 47 | for (i in 1 until iterations) { 48 | md.update(generatedData, generatedLength, digestLength) 49 | md.digest(generatedData, generatedLength, digestLength) 50 | } 51 | 52 | generatedLength += digestLength 53 | } 54 | 55 | // Copy key and IV into separate byte arrays 56 | val result = arrayOfNulls(2) 57 | result[0] = generatedData.copyOfRange(0, keyLength) 58 | if (ivLength > 0) 59 | result[1] = generatedData.copyOfRange(keyLength, keyLength + ivLength) 60 | 61 | return result 62 | 63 | } catch (e: DigestException) { 64 | throw RuntimeException(e) 65 | 66 | } finally { 67 | // Clean out temporary data 68 | Arrays.fill(generatedData, 0.toByte()) 69 | } 70 | } 71 | 72 | fun decryptSourceUrl(sourceUrl: String): String { 73 | val cipherData = android.util.Base64.decode(sourceUrl, android.util.Base64.DEFAULT) 74 | val saltData = Arrays.copyOfRange(cipherData, 8, 16) 75 | 76 | var md5: MessageDigest? = null 77 | try { 78 | md5 = MessageDigest.getInstance("MD5") 79 | } catch (e: NoSuchAlgorithmException) { 80 | e.printStackTrace() 81 | } 82 | 83 | val keyAndIV = 84 | generateKeyAndIV(32, 16, 1, saltData, BuildConfig.CRYPTO_KEY, md5!!) 85 | val key = SecretKeySpec(keyAndIV[0], "AES") 86 | val iv = IvParameterSpec(keyAndIV[1]) 87 | 88 | val encrypted = Arrays.copyOfRange(cipherData, 16, cipherData.size) 89 | var aesCBC: Cipher? = null 90 | try { 91 | aesCBC = Cipher.getInstance("AES/CBC/PKCS5Padding") 92 | } catch (e: NoSuchAlgorithmException) { 93 | e.printStackTrace() 94 | } catch (e: NoSuchPaddingException) { 95 | e.printStackTrace() 96 | } 97 | 98 | try { 99 | Objects.requireNonNull(aesCBC).init(Cipher.DECRYPT_MODE, key, iv) 100 | } catch (e: InvalidAlgorithmParameterException) { 101 | e.printStackTrace() 102 | } 103 | var decryptedData = ByteArray(0) 104 | try { 105 | decryptedData = aesCBC!!.doFinal(encrypted) 106 | } catch (e: BadPaddingException) { 107 | e.printStackTrace() 108 | } catch (e: IllegalBlockSizeException) { 109 | e.printStackTrace() 110 | } 111 | 112 | return String(decryptedData, StandardCharsets.UTF_8) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /app/src/main/java/dev/smoketrees/twist/ui/home/AnimeViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.smoketrees.twist.ui.home 2 | 3 | import androidx.lifecycle.* 4 | import androidx.paging.LivePagedListBuilder 5 | import androidx.paging.PagedList 6 | import dev.smoketrees.twist.model.twist.* 7 | import dev.smoketrees.twist.pagination.FilteredKitsuDataSourceFactory 8 | import dev.smoketrees.twist.pagination.KitsuDataSourceFactory 9 | import dev.smoketrees.twist.repository.AnimeRepo 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.launch 12 | 13 | class AnimeViewModel(private val repo: AnimeRepo) : ViewModel() { 14 | private val _dbAnime = repo.getAllDbAnime() 15 | private val _dbTrendingAnime = repo.getDbTrendingAnime() 16 | val motdLiveData = repo.getMotd() 17 | val allAnimeLivedata = MediatorLiveData>>() 18 | val trendingAnimeLiveData = MediatorLiveData>>() 19 | 20 | fun getAllAnime() = viewModelScope.launch(Dispatchers.IO) { 21 | val result = repo.getAllNetworkAnime() 22 | when (result.status) { 23 | Result.Status.SUCCESS -> { 24 | result.data?.let { repo.saveAnime(it) } 25 | areAllLoaded.postValue(true) 26 | lastCode.postValue(null) 27 | } 28 | Result.Status.ERROR -> lastCode.postValue(result.message!!.code) 29 | else -> { 30 | } 31 | } 32 | } 33 | 34 | fun updateUserLibrary(jwt: String, body: PatchLibRequest) = repo.updateUserLibrary(jwt, body) 35 | 36 | fun getTrendingAnime(limit: Int) = viewModelScope.launch(Dispatchers.IO) { 37 | val result = repo.getNetworkTrendingAnime(limit) 38 | when (result.status) { 39 | Result.Status.SUCCESS -> { 40 | result.data?.let { repo.saveTrendingAnime(it) } 41 | } 42 | else -> { 43 | } 44 | } 45 | } 46 | 47 | val searchResults: MutableLiveData> = MutableLiveData() 48 | val searchQuery = MutableLiveData() 49 | 50 | var areAllLoaded = MutableLiveData(false) 51 | var lastCode = MutableLiveData(null) 52 | 53 | var topAiringAnime: LiveData> 54 | var topAiringAnimeNetworkState: LiveData?>> 55 | var topRatedAnime: LiveData> 56 | 57 | val userLibrary = MutableLiveData>>() 58 | 59 | fun signIn(loginDetails: LoginDetails) = repo.signIn(loginDetails) 60 | fun signUp(registerDetails: RegisterDetails) = repo.signUp(registerDetails) 61 | 62 | fun getAnimeByIds(ids: List) = repo.getAnimeByIds(ids) 63 | 64 | fun getWatchedEpisodes(id: Int) = repo.getWatchedEpisodes(id) 65 | 66 | fun getUserLibrary(jwt: String) = viewModelScope.launch(Dispatchers.IO) { 67 | val result = repo.getUserLibrarySync(jwt) 68 | 69 | when (result.status) { 70 | Result.Status.SUCCESS -> { 71 | userLibrary.postValue(result.data!!) 72 | result.data.forEach { 73 | repo.saveWatchedEpisodes(it.value.values.toList()) 74 | } 75 | } 76 | 77 | else -> { 78 | } 79 | } 80 | 81 | } 82 | 83 | init { 84 | allAnimeLivedata.addSource(_dbAnime) { 85 | allAnimeLivedata.value = if (it.isEmpty()) Result.loading() else Result.success(it) 86 | } 87 | 88 | trendingAnimeLiveData.addSource(_dbTrendingAnime) { 89 | trendingAnimeLiveData.value = if (it.isEmpty()) Result.loading() else Result.success(it) 90 | } 91 | 92 | val topAiringDataSourceFactory = 93 | FilteredKitsuDataSourceFactory(repo.webClient, "-user_count", "current") 94 | topAiringAnimeNetworkState = topAiringDataSourceFactory.animeLiveDataSource.switchMap { 95 | it.animeLiveData 96 | } 97 | 98 | val config = PagedList.Config.Builder() 99 | .setEnablePlaceholders(false) 100 | .setInitialLoadSizeHint(20) 101 | .setPageSize(20) 102 | .build() 103 | topAiringAnime = LivePagedListBuilder(topAiringDataSourceFactory, config).build() 104 | 105 | val topRatedDataSourceFactory = KitsuDataSourceFactory(repo.webClient, "-average_rating") 106 | topRatedAnime = LivePagedListBuilder(topRatedDataSourceFactory, config).build() 107 | } 108 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 17 | 18 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 49 | 50 | 58 | 59 | 66 | 67 | 75 | 76 | 85 | 86 |