46 Commits

Author SHA1 Message Date
4a5a6c04ca Update fastlane metadata AoD -> Crunchyroll 2022-03-19 20:56:37 +01:00
554c66e11f update agp
7.1.0 -> 7.1.2
2022-03-19 20:46:01 +01:00
0aece1d8fa Merge pull request 'crunchyroll support' (#49) from feature/crunchyroll into develop
Reviewed-on: #49
2022-03-19 20:42:54 +01:00
f820d2aac0 Udate readme Aod -> Crunchyroll 2022-03-19 20:42:15 +01:00
0ea2e5ee97 update version to 1.0.0-beta1 2022-03-19 20:38:23 +01:00
a092c5b8be fix mosad/NonePublicIssues#1 2022-03-19 20:14:16 +01:00
ab660d0ae7 Show season number in MediaFragment 2022-03-19 13:10:36 +01:00
be1c001942 Fix getPreferredSeason() (again)
fix selection of preferred season for languages other than english
2022-03-07 19:43:26 +01:00
30a5331bbc load preferred sub/content language on startup 2022-03-06 18:57:55 +01:00
0797e9fa3d Fix multiple language related issues
* fix playback for other  shows with no language set in cr API
* fix selection of preferred season for languages other than german
* add support for all content languages to TMDBApiController
* preferSecondary is now preferSubbed, this describes the function more clearly
* remove jsoup, not used anymore
2022-03-06 18:43:02 +01:00
75204e522d Use ktor instead of fuel for http requests [Part 2/2]
* update preferred locale in preferences, is is the actual locale implementation
* update token handling for crunchy (country via token)
* update TMDBApiController to use ktor
* add parsable dates to NoneTMDBTVShow and NoneTMDBMovie
2022-03-05 20:41:39 +01:00
2016e03e56 Use ktor instead of fuel for http requests [Part 1/2] 2022-03-05 19:22:47 +01:00
4505f95309 don't show fully watched episodes in "Up next" 2022-03-04 20:42:21 +01:00
e8bf63a666 add preferred content language selection
followup to 0b5a8e69fb
2022-03-04 20:29:37 +01:00
a51001ec2e replace MaterialDialog with MaterialAlertDialogBuilder in AboutFragment 2022-02-05 20:10:59 +01:00
0b5a8e69fb add preferred content language selection to AccountFragment
this contains only gui work
2022-02-05 20:02:33 +01:00
61c96f5ce2 update playhead on manually selected next episode & start fully watched episodes from the beginning 2022-02-04 23:07:48 +01:00
9bf0ae2f63 refresh access token, if it is expired, before doing a request 2022-02-01 17:21:42 +01:00
f66fca7ebb MediaFragment: update playhead progress/fully watched on resume 2022-02-01 17:21:42 +01:00
df4f43c0a2 Player: load media async and use playhead for initial episode 2022-02-01 17:21:42 +01:00
287ef57bdb don't show next ep button or autoplay if the current ep is the last ep
next_episode_id can be non null, even if it's the last episode
2022-02-01 17:21:42 +01:00
aa41884db5 the media type should not change while playing a media (tv show/movie) 2022-02-01 17:21:42 +01:00
bec0dc2628 implement playhead reporting to crunchyroll 2022-02-01 17:21:42 +01:00
4fed3ddb91 add upNextSeries
the MediaFragment will show the next episodes title instead for the series title and play the "next up" episode when the play button is clicked
2022-02-01 17:21:42 +01:00
e652c001d3 Update the onboarding process to support crunchyroll
* only save credentials during onboarding, if login was successful
* show onboarding, if login failed
2022-02-01 17:21:42 +01:00
2f78fbea73 add highlight (random of newly added (n=10)) 2022-02-01 17:21:42 +01:00
a1fe08840f add newly added title to HomeFragment
* add support for season_list to crunchyroll parser
2022-02-01 17:21:42 +01:00
402fb06c9e add playheads to crunchyroll parser
* show watched icon, if episode has been fully watched
* add seasonTag to browse()
2022-02-01 17:21:42 +01:00
188d0d9162 add up next to home screen
for now up next will show the series and not play the actual episode
2022-02-01 17:21:42 +01:00
d5d70e49d2 add watchlist to home fragment 2022-02-01 17:21:42 +01:00
f100b4abf3 fix proguard for changes in 7491e7fd93056569a823b292483a114300ca86fb 2022-02-01 17:21:42 +01:00
f2a798d4f7 add watchlist support for media fragment 2022-02-01 17:21:42 +01:00
d427691f6e update copyright/license notice 2022-02-01 17:21:42 +01:00
b4daac0814 replace tmdb multi search with type search (movie/tv)
multi search often retuns a wrong result, therfore use movie or tv show search
2022-02-01 17:21:42 +01:00
554af530e3 move TMDBApiCOntroller to Fuel and kotlinx.serialization
* add year and maturityRatings to MediaFragment
* don't show season selection if only one season is present
2022-02-01 17:21:42 +01:00
27e7f2a249 add subtitle selection to player 2022-02-01 17:21:42 +01:00
f97d07c2b8 implement season selection in MediaFragment 2022-02-01 17:21:42 +01:00
ecbbc5db7b implement preferred season/languag choosing in MediaFragment 2022-02-01 17:21:42 +01:00
4fd6f9ca7e add search for tv shows
media items are currently not selectable, the app will crash
2022-02-01 17:21:42 +01:00
63ce910ec5 implement lazy loading for LibraryFragment & code cleanup 2022-02-01 17:21:42 +01:00
7dc41da13c add support for crunchyroll media playback in player 2022-02-01 17:21:42 +01:00
236ca9a6c9 Implement media fragment for tv shows 2022-02-01 17:21:42 +01:00
a46fd4c6d2 implement index call
index is needed to retrieve identifiers necessary for streaming
2022-02-01 17:21:42 +01:00
c4bc3c7ea2 add rudimentary parsing for browsing results 2022-02-01 17:21:42 +01:00
844ff41dd3 add crunchyroll login and browse (no parsing for now) 2022-02-01 17:21:42 +01:00
487c0c3c39 update gradle wrapper, kotlin and agp
* gradle wrapper 7.2 ->7.3.3
* kotlin 1.6.0 -> 1.6.10
* agp 7.0.3 -> 7.1.0
2022-02-01 17:20:58 +01:00
30 changed files with 907 additions and 464 deletions

View File

@ -1,14 +1,13 @@
# Teapod # Teapod
Teapod is a unofficial App for Anime on Demand (AoD). It allows you to watch all your favourite animes from AoD on your android device. To use Teapod you need to have a subscription to AoD. Teapod is a unofficial App for Crunchyroll. It allows you to watch all your favourite animes from Crunchyroll on your android device. To use Teapod you need to have a account at Crunchyroll.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">](https://f-droid.org/de/packages/org.mosad.teapod/) [<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">](https://f-droid.org/de/packages/org.mosad.teapod/)
## Features ## Features
* Watch all animes from AoD on your Android device * Watch all animes from Crunchyroll on your Android device
* Native Player based on ExoPayer * Native Player based on ExoPayer
* Prefer the OmU version via the app settings * Prefer the OmU version via the app settings
* Save your favorite animes to "My List"
## Screenshots ## Screenshots
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Home.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Home.webp) [<img src="https://www.mosad.xyz/images/Teapod/Teapod_Home.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Home.webp)
@ -17,10 +16,10 @@ Teapod is a unofficial App for Anime on Demand (AoD). It allows you to watch all
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Search.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Search.webp) [<img src="https://www.mosad.xyz/images/Teapod/Teapod_Search.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Search.webp)
### License ### License
Teapod is licensed under the terms and conditions of GPL 3. This Project is not associated with Anime on Demand in any way. But they allow open source apps for their service. Teapod is licensed under the terms and conditions of GPL 3. This Project is not associated with Crunchyroll in any way.
### Contributing ### Contributing
Currently you need to have an AoD account to contribute to Teapod. Contributing without on is kind of impossible.If you want to contribute to Teapod and need an account on this gitea instance, please write me an email. Currently you need to have an Crunchyroll account to contribute to Teapod. Contributing without one is impossible.If you want to contribute to Teapod and need an account on this gitea instance, please write an email.
### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe) ### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe)

View File

@ -13,8 +13,8 @@ android {
applicationId "org.mosad.teapod" applicationId "org.mosad.teapod"
minSdkVersion 23 minSdkVersion 23
targetSdkVersion 30 targetSdkVersion 30
versionCode 4200 //00.04.200 versionCode 9000 //00.09.000
versionName "1.0.0-alpha3" versionName "1.0.0-beta1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime() resValue "string", "build_time", buildTime()
@ -59,22 +59,21 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
implementation 'com.google.android.material:material:1.4.0' implementation 'com.google.android.material:material:1.4.0'
implementation 'com.google.code.gson:gson:2.8.8' implementation 'com.google.code.gson:gson:2.8.8' // TODO remove, still used by metadb
implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0' implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0'
implementation 'com.google.android.exoplayer:exoplayer-hls:2.15.0' implementation 'com.google.android.exoplayer:exoplayer-hls:2.15.0'
implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.0' implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.0'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0' implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0'
implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0' implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0'
implementation 'org.jsoup:jsoup:1.14.2'
implementation 'com.github.bumptech.glide:glide:4.12.0' implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation 'jp.wasabeef:glide-transformations:4.3.0' implementation 'jp.wasabeef:glide-transformations:4.3.0'
implementation 'com.afollestad.material-dialogs:core:3.3.0' implementation 'com.afollestad.material-dialogs:core:3.3.0' // TODO remove once unused
implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0' implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0' // TODO remove once unused
implementation 'com.github.kittinunf.fuel:fuel:2.3.1' implementation "io.ktor:ktor-client-core:$ktor_version"
implementation 'com.github.kittinunf.fuel:fuel-android:2.3.1' implementation "io.ktor:ktor-client-android:$ktor_version"
implementation 'com.github.kittinunf.fuel:fuel-json:2.3.1' implementation "io.ktor:ktor-client-serialization:$ktor_version"
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.ext:junit:1.1.3'

View File

@ -1,16 +1,40 @@
/**
* Teapod
*
* Copyright 2020-2022 <seil0@mosad.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.teapod.parser.crunchyroll package org.mosad.teapod.parser.crunchyroll
import android.util.Log import android.util.Log
import com.github.kittinunf.fuel.Fuel import io.ktor.client.*
import com.github.kittinunf.fuel.core.FuelError import io.ktor.client.call.*
import com.github.kittinunf.fuel.core.Parameters import io.ktor.client.features.json.*
import com.github.kittinunf.fuel.core.extensions.jsonBody import io.ktor.client.features.json.serializer.*
import com.github.kittinunf.fuel.json.FuelJson import io.ktor.client.request.*
import com.github.kittinunf.fuel.json.responseJson import io.ktor.client.request.forms.*
import com.github.kittinunf.result.Result import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.preferences.EncryptedPreferences
@ -20,11 +44,18 @@ import org.mosad.teapod.util.concatenate
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
object Crunchyroll { object Crunchyroll {
private val TAG = javaClass.name
private val client = HttpClient {
install(JsonFeature) {
serializer = KotlinxSerializer(json)
}
}
private const val baseUrl = "https://beta-api.crunchyroll.com" private const val baseUrl = "https://beta-api.crunchyroll.com"
private const val basicApiTokenUrl = "https://gitlab.com/-/snippets/2274956/raw/main/snippetfile1.txt"
private var basicApiToken: String = ""
private var accessToken = "" private lateinit var token: Token
private var tokenType = ""
private var tokenValidUntil: Long = 0 private var tokenValidUntil: Long = 0
private var accountID = "" private var accountID = ""
@ -33,12 +64,21 @@ object Crunchyroll {
private var signature = "" private var signature = ""
private var keyPairID = "" private var keyPairID = ""
// TODO temp helper vary
private var locale: String = Preferences.preferredLocal.toLanguageTag()
private var country: String = Preferences.preferredLocal.country
private val browsingCache = arrayListOf<Item>() private val browsingCache = arrayListOf<Item>()
/**
* Load the pai token, see:
* https://git.mosad.xyz/mosad/NonePublicIssues/issues/1
*
* TODO handle empty file
*/
fun initBasicApiToken() = runBlocking {
withContext(Dispatchers.IO) {
basicApiToken = (client.get(basicApiTokenUrl) as HttpResponse).readText()
Log.i(TAG, "basic auth token: $basicApiToken")
}
}
/** /**
* Login to the crunchyroll API. * Login to the crunchyroll API.
* *
@ -49,39 +89,24 @@ object Crunchyroll {
*/ */
fun login(username: String, password: String): Boolean = runBlocking { fun login(username: String, password: String): Boolean = runBlocking {
val tokenEndpoint = "/auth/v1/token" val tokenEndpoint = "/auth/v1/token"
val formData = listOf( val formData = Parameters.build {
"username" to username, append("username", username)
"password" to password, append("password", password)
"grant_type" to "password", append("grant_type", "password")
"scope" to "offline_access" append("scope", "offline_access")
) }
var success: Boolean // is false var success = false// is false
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val (request, response, result) = Fuel.post("$baseUrl$tokenEndpoint", parameters = formData) // TODO handle exceptions
.header("Content-Type", "application/x-www-form-urlencoded") val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) {
.appendHeader( header("Authorization", "Basic $basicApiToken")
"Authorization",
"Basic "
)
.responseJson()
// TODO fix JSONException: No value for
result.component1()?.obj()?.let {
accessToken = it.get("access_token").toString()
tokenType = it.get("token_type").toString()
// token will be invalid 1 sec
val expiresIn = (it.get("expires_in").toString().toLong() - 1)
tokenValidUntil = System.currentTimeMillis() + (expiresIn * 1000)
} }
token = response.receive()
tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000)
// println("request: $request") Log.i(TAG, "login complete with code ${response.status}")
// println("response: $response") success = (response.status == HttpStatusCode.OK)
// println("response: $result")
Log.i(javaClass.name, "login complete with code ${response.statusCode}")
success = (response.statusCode == 200)
} }
return@runBlocking success return@runBlocking success
@ -95,56 +120,74 @@ object Crunchyroll {
* Requests: get, post, delete * Requests: get, post, delete
*/ */
private suspend fun request( private suspend inline fun <reified T> request(
endpoint: String, url: String,
params: Parameters = listOf(), httpMethod: HttpMethod,
url: String = "" params: List<Pair<String, Any?>> = listOf(),
): Result<FuelJson, FuelError> = coroutineScope { bodyObject: Any = Any()
val path = url.ifEmpty { "$baseUrl$endpoint" } ): T = coroutineScope {
if (System.currentTimeMillis() > tokenValidUntil) refreshToken() if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
return@coroutineScope (Dispatchers.IO) { return@coroutineScope (Dispatchers.IO) {
val (request, response, result) = Fuel.get(path, params) val response: T = client.request(url) {
.header("Authorization", "$tokenType $accessToken") method = httpMethod
.responseJson() header("Authorization", "${token.tokenType} ${token.accessToken}")
params.forEach {
parameter(it.first, it.second)
}
// println("request request: $request") // for json set body and content type
// println("request response: $response") if (bodyObject is JsonObject) {
// println("request result: $result") body = bodyObject
contentType(ContentType.Application.Json)
}
}
result response
} }
} }
private suspend inline fun <reified T> requestGet(
endpoint: String,
params: List<Pair<String, Any?>> = listOf(),
url: String = ""
): T {
val path = url.ifEmpty { "$baseUrl$endpoint" }
return request(path, HttpMethod.Get, params)
}
private suspend fun requestPost( private suspend fun requestPost(
endpoint: String, endpoint: String,
params: Parameters = listOf(), params: List<Pair<String, Any?>> = listOf(),
body: String bodyObject: JsonObject
) = coroutineScope { ) {
val path = "$baseUrl$endpoint" val path = "$baseUrl$endpoint"
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
withContext(Dispatchers.IO) { val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject)
Fuel.post(path, params) Log.i(TAG, "Response: $response")
.header("Authorization", "$tokenType $accessToken") }
.jsonBody(body)
.response() // without a response, crunchy doesn't accept the request private suspend fun requestPatch(
} endpoint: String,
params: List<Pair<String, Any?>> = listOf(),
bodyObject: JsonObject
) {
val path = "$baseUrl$endpoint"
val response: HttpResponse = request(path, HttpMethod.Patch, params, bodyObject)
Log.i(TAG, "Response: $response")
} }
private suspend fun requestDelete( private suspend fun requestDelete(
endpoint: String, endpoint: String,
params: Parameters = listOf(), params: List<Pair<String, Any?>> = listOf(),
url: String = "" url: String = ""
) = coroutineScope { ) = coroutineScope {
val path = url.ifEmpty { "$baseUrl$endpoint" } val path = url.ifEmpty { "$baseUrl$endpoint" }
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
withContext(Dispatchers.IO) { val response: HttpResponse = request(path, HttpMethod.Delete, params)
Fuel.delete(path, params) Log.i(TAG, "Response: $response")
.header("Authorization", "$tokenType $accessToken")
.response() // without a response, crunchy doesn't accept the request
}
} }
/** /**
@ -158,17 +201,15 @@ object Crunchyroll {
*/ */
suspend fun index() { suspend fun index() {
val indexEndpoint = "/index/v2" val indexEndpoint = "/index/v2"
val result = request(indexEndpoint)
result.component1()?.obj()?.getJSONObject("cms")?.let { val index: Index = requestGet(indexEndpoint)
policy = it.get("policy").toString() policy = index.cms.policy
signature = it.get("signature").toString() signature = index.cms.signature
keyPairID = it.get("key_pair_id").toString() keyPairID = index.cms.keyPairId
}
println("policy: $policy") Log.i(TAG, "Policy : $policy")
println("signature: $signature") Log.i(TAG, "Signature : $signature")
println("keyPairID: $keyPairID") Log.i(TAG, "Key Pair ID : $keyPairID")
} }
/** /**
@ -179,18 +220,22 @@ object Crunchyroll {
*/ */
suspend fun account() { suspend fun account() {
val indexEndpoint = "/accounts/v1/me" val indexEndpoint = "/accounts/v1/me"
val result = request(indexEndpoint)
result.component1()?.obj()?.let { val account: Account = try {
accountID = it.get("account_id").toString() requestGet(indexEndpoint)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in account(). This is bad!", ex)
NoneAccount
} }
accountID = account.accountId
} }
/** /**
* General element/media functions: browse, search, objects, season_list * General element/media functions: browse, search, objects, season_list
*/ */
// TODO locale de-DE, categories // TODO categories
/** /**
* Browse the media available on crunchyroll. * Browse the media available on crunchyroll.
* *
@ -206,7 +251,12 @@ object Crunchyroll {
n: Int = 10 n: Int = 10
): BrowseResult { ): BrowseResult {
val browseEndpoint = "/content/v1/browse" val browseEndpoint = "/content/v1/browse"
val noneOptParams = listOf("sort_by" to sortBy.str, "start" to start, "n" to n) val noneOptParams = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"sort_by" to sortBy.str,
"start" to start,
"n" to n
)
// if a season tag is present add it to the parameters // if a season tag is present add it to the parameters
val parameters = if (seasonTag.isNotEmpty()) { val parameters = if (seasonTag.isNotEmpty()) {
@ -215,10 +265,12 @@ object Crunchyroll {
noneOptParams noneOptParams
} }
val result = request(browseEndpoint, parameters) val browseResult: BrowseResult = try {
val browseResult = result.component1()?.obj()?.let { requestGet(browseEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneBrowseResult Log.e(TAG, "SerializationException in browse().", ex)
NoneBrowseResult
}
// add results to cache TODO improve // add results to cache TODO improve
browsingCache.clear() browsingCache.clear()
@ -232,15 +284,22 @@ object Crunchyroll {
*/ */
suspend fun search(query: String, n: Int = 10): SearchResult { suspend fun search(query: String, n: Int = 10): SearchResult {
val searchEndpoint = "/content/v1/search" val searchEndpoint = "/content/v1/search"
val parameters = listOf("q" to query, "n" to n, "locale" to locale, "type" to "series") val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"q" to query,
"n" to n,
"type" to "series"
)
val result = request(searchEndpoint, parameters)
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall, // TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
// to work around this, for now only tv shows are supported // to work around this, for now only tv shows are supported
return result.component1()?.obj()?.let { return try {
json.decodeFromString(it.toString()) requestGet(searchEndpoint, parameters)
} ?: NoneSearchResult }catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in search(), with query = \"$query\".", ex)
NoneSearchResult
}
} }
/** /**
@ -253,17 +312,18 @@ object Crunchyroll {
suspend fun objects(objects: List<String>): Collection<Item> { suspend fun objects(objects: List<String>): Collection<Item> {
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}" val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
val parameters = listOf( val parameters = listOf(
"locale" to locale, "locale" to Preferences.preferredLocale.toLanguageTag(),
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
val result = request(episodesEndpoint, parameters) return try {
requestGet(episodesEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in objects().", ex)
} ?: NoneCollection NoneCollection
}
} }
/** /**
@ -272,13 +332,14 @@ object Crunchyroll {
@Suppress("unused") @Suppress("unused")
suspend fun seasonList(): DiscSeasonList { suspend fun seasonList(): DiscSeasonList {
val seasonListEndpoint = "/content/v1/season_list" val seasonListEndpoint = "/content/v1/season_list"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val result = request(seasonListEndpoint, parameters) return try {
requestGet(seasonListEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in seasonList().", ex)
} ?: NoneDiscSeasonList NoneDiscSeasonList
}
} }
/** /**
@ -289,19 +350,20 @@ object Crunchyroll {
* series id == crunchyroll id? * series id == crunchyroll id?
*/ */
suspend fun series(seriesId: String): Series { suspend fun series(seriesId: String): Series {
val seriesEndpoint = "/cms/v2/$country/M3/crunchyroll/series/$seriesId" val seriesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/series/$seriesId"
val parameters = listOf( val parameters = listOf(
"locale" to locale, "locale" to Preferences.preferredLocale.toLanguageTag(),
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
val result = request(seriesEndpoint, parameters) return try {
requestGet(seriesEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in series().", ex)
} ?: NoneSeries NoneSeries
}
} }
/** /**
@ -311,56 +373,60 @@ object Crunchyroll {
val upNextSeriesEndpoint = "/content/v1/up_next_series" val upNextSeriesEndpoint = "/content/v1/up_next_series"
val parameters = listOf( val parameters = listOf(
"series_id" to seriesId, "series_id" to seriesId,
"locale" to locale "locale" to Preferences.preferredLocale.toLanguageTag()
) )
val result = request(upNextSeriesEndpoint, parameters) return try {
requestGet(upNextSeriesEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in upNextSeries().", ex)
} ?: NoneUpNextSeriesItem NoneUpNextSeriesItem
}
} }
suspend fun seasons(seriesId: String): Seasons { suspend fun seasons(seriesId: String): Seasons {
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons" val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons"
val parameters = listOf( val parameters = listOf(
"series_id" to seriesId, "series_id" to seriesId,
"locale" to locale, "locale" to Preferences.preferredLocale.toLanguageTag(),
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
val result = request(episodesEndpoint, parameters) return try {
requestGet(seasonsEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in seasons().", ex)
} ?: NoneSeasons NoneSeasons
}
} }
suspend fun episodes(seasonId: String): Episodes { suspend fun episodes(seasonId: String): Episodes {
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/episodes" val episodesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/episodes"
val parameters = listOf( val parameters = listOf(
"season_id" to seasonId, "season_id" to seasonId,
"locale" to locale, "locale" to Preferences.preferredLocale.toLanguageTag(),
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
val result = request(episodesEndpoint, parameters) return try {
requestGet(episodesEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in episodes().", ex)
} ?: NoneEpisodes NoneEpisodes
}
} }
suspend fun playback(url: String): Playback { suspend fun playback(url: String): Playback {
val result = request("", url = url) return try {
requestGet("", url = url)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in playback(), with url = $url.", ex)
} ?: NonePlayback NonePlayback
}
} }
/** /**
@ -375,12 +441,15 @@ object Crunchyroll {
*/ */
suspend fun isWatchlist(seriesId: String): Boolean { suspend fun isWatchlist(seriesId: String): Boolean {
val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId" val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val result = request(watchlistSeriesEndpoint, parameters) return try {
// if needed implement parsing (requestGet(watchlistSeriesEndpoint, parameters) as JsonObject)
.containsKey(seriesId)
return result.component1()?.obj()?.has(seriesId) ?: false }catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex)
false
}
} }
/** /**
@ -390,13 +459,13 @@ object Crunchyroll {
*/ */
suspend fun postWatchlist(seriesId: String) { suspend fun postWatchlist(seriesId: String) {
val watchlistPostEndpoint = "/content/v1/watchlist/$accountID" val watchlistPostEndpoint = "/content/v1/watchlist/$accountID"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val json = buildJsonObject { val json = buildJsonObject {
put("content_id", seriesId) put("content_id", seriesId)
} }
requestPost(watchlistPostEndpoint, parameters, json.toString()) requestPost(watchlistPostEndpoint, parameters, json)
} }
/** /**
@ -406,7 +475,7 @@ object Crunchyroll {
*/ */
suspend fun deleteWatchlist(seriesId: String) { suspend fun deleteWatchlist(seriesId: String) {
val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId" val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
requestDelete(watchlistDeleteEndpoint, parameters) requestDelete(watchlistDeleteEndpoint, parameters)
} }
@ -421,25 +490,26 @@ object Crunchyroll {
*/ */
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap { suspend fun playheads(episodeIDs: List<String>): PlayheadsMap {
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}" val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val result = request(playheadsEndpoint, parameters) return try {
requestGet(playheadsEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in upNextSeries().", ex)
} ?: emptyMap() emptyMap()
}
} }
suspend fun postPlayheads(episodeId: String, playhead: Int) { suspend fun postPlayheads(episodeId: String, playhead: Int) {
val playheadsEndpoint = "/content/v1/playheads/$accountID" val playheadsEndpoint = "/content/v1/playheads/$accountID"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val json = buildJsonObject { val json = buildJsonObject {
put("content_id", episodeId) put("content_id", episodeId)
put("playhead", playhead) put("playhead", playhead)
} }
requestPost(playheadsEndpoint, parameters, json.toString()) requestPost(playheadsEndpoint, parameters, json)
} }
/** /**
@ -454,12 +524,17 @@ object Crunchyroll {
*/ */
suspend fun watchlist(n: Int = 20): Watchlist { suspend fun watchlist(n: Int = 20): Watchlist {
val watchlistEndpoint = "/content/v1/$accountID/watchlist" val watchlistEndpoint = "/content/v1/$accountID/watchlist"
val parameters = listOf("locale" to locale, "n" to n) val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"n" to n
)
val watchlistResult = request(watchlistEndpoint, parameters) val list: ContinueWatchingList = try {
val list: ContinueWatchingList = watchlistResult.component1()?.obj()?.let { requestGet(watchlistEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneContinueWatchingList Log.e(TAG, "SerializationException in watchlist().", ex)
NoneContinueWatchingList
}
val objects = list.items.map{ it.panel.episodeMetadata.seriesId } val objects = list.items.map{ it.panel.episodeMetadata.seriesId }
return objects(objects) return objects(objects)
@ -473,12 +548,41 @@ object Crunchyroll {
*/ */
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList { suspend fun upNextAccount(n: Int = 20): ContinueWatchingList {
val watchlistEndpoint = "/content/v1/$accountID/up_next_account" val watchlistEndpoint = "/content/v1/$accountID/up_next_account"
val parameters = listOf("locale" to locale, "n" to n) val parameters = listOf(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"n" to n
)
val resultUpNextAccount = request(watchlistEndpoint, parameters) return try {
return resultUpNextAccount.component1()?.obj()?.let { requestGet(watchlistEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneContinueWatchingList Log.e(TAG, "SerializationException in upNextAccount().", ex)
NoneContinueWatchingList
}
}
/**
* Account/Profile functions
*/
suspend fun profile(): Profile {
val profileEndpoint = "/accounts/v1/me/profile"
return try {
requestGet(profileEndpoint)
}catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in profile().", ex)
NoneProfile
}
}
suspend fun postPrefSubLanguage(languageTag: String) {
val profileEndpoint = "/accounts/v1/me/profile"
val json = buildJsonObject {
put("preferred_content_subtitle_language", languageTag)
}
requestPatch(profileEndpoint, bodyObject = json)
} }
} }

View File

@ -1,9 +1,45 @@
/**
* Teapod
*
* Copyright 2020-2022 <seil0@mosad.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.teapod.parser.crunchyroll package org.mosad.teapod.parser.crunchyroll
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.* import java.util.*
val supportedLocals = listOf(
Locale.forLanguageTag("ar-SA"),
Locale.forLanguageTag("de-DE"),
Locale.forLanguageTag("en-US"),
Locale.forLanguageTag("es-419"),
Locale.forLanguageTag("es-ES"),
Locale.forLanguageTag("fr-FR"),
Locale.forLanguageTag("it-IT"),
Locale.forLanguageTag("pt-BR"),
Locale.forLanguageTag("pt-PT"),
Locale.forLanguageTag("ru-RU"),
Locale.ROOT
)
/** /**
* data classes for browse * data classes for browse
* TODO make class names more clear/possibly overlapping for now * TODO make class names more clear/possibly overlapping for now
@ -14,6 +50,44 @@ enum class SortBy(val str: String) {
POPULARITY("popularity") POPULARITY("popularity")
} }
/**
* token, index, account. This must pe present for the app to work!
*/
@Serializable
data class Token(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("expires_in") val expiresIn: Int,
@SerialName("token_type") val tokenType: String,
@SerialName("scope") val scope: String,
@SerialName("country") val country: String,
@SerialName("account_id") val accountId: String,
)
@Serializable
data class Index(
@SerialName("cms") val cms: CMS,
@SerialName("service_available") val serviceAvailable: Boolean,
)
@Serializable
data class CMS(
@SerialName("bucket") val bucket: String,
@SerialName("policy") val policy: String,
@SerialName("signature") val signature: String,
@SerialName("key_pair_id") val keyPairId: String,
@SerialName("expires") val expires: String,
)
@Serializable
data class Account(
@SerialName("account_id") val accountId: String,
@SerialName("external_id") val externalId: String,
@SerialName("email_verified") val emailVerified: Boolean,
@SerialName("created") val created: String,
)
val NoneAccount = Account("", "", false, "")
/** /**
* search, browse, DiscSeasonList, Watchlist, ContinueWatchingList data types all use Collection * search, browse, DiscSeasonList, Watchlist, ContinueWatchingList data types all use Collection
*/ */
@ -33,10 +107,10 @@ typealias ContinueWatchingList = Collection<ContinueWatchingItem>
@Serializable @Serializable
data class UpNextSeriesItem( data class UpNextSeriesItem(
val playhead: Int, @SerialName("playhead") val playhead: Int,
val fully_watched: Boolean, @SerialName("fully_watched") val fullyWatched: Boolean,
val never_watched: Boolean, @SerialName("never_watched") val neverWatched: Boolean,
val panel: EpisodePanel, @SerialName("panel") val panel: EpisodePanel,
) )
/** /**
@ -92,7 +166,7 @@ data class ContinueWatchingItem(
// @SerialName("completion_status") val completionStatus: Boolean, // @SerialName("completion_status") val completionStatus: Boolean,
@SerialName("playhead") val playhead: Int, @SerialName("playhead") val playhead: Int,
// not present in watchlist -> continue_watching_item // not present in watchlist -> continue_watching_item
// @SerialName("fully_watched") val fullyWatched: Boolean, @SerialName("fully_watched") val fullyWatched: Boolean = false,
) )
// EpisodePanel is used in ContinueWatchingItem // EpisodePanel is used in ContinueWatchingItem
@ -126,7 +200,7 @@ val NoneBrowseResult = BrowseResult(0, emptyList())
val NoneDiscSeasonList = DiscSeasonList(0, emptyList()) val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList()) val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
val NoneUpNextSeriesItem =UpNextSeriesItem(0, false, false, NoneEpisodePanel) val NoneUpNextSeriesItem = UpNextSeriesItem(0, false, false, NoneEpisodePanel)
/** /**
* Series data type * Series data type
@ -150,22 +224,13 @@ data class Seasons(
@SerialName("items") val items: List<Season> @SerialName("items") val items: List<Season>
) { ) {
fun getPreferredSeason(local: Locale): Season { fun getPreferredSeason(local: Locale): Season {
// try to get the the first seasons which matches the preferred local return items.firstOrNull { season ->
items.forEach { season -> // try to get the the first seasons which matches the preferred local
if (season.title.startsWith("(${local.language})", true)) { season.slugTitle.endsWith("${local.getDisplayLanguage(Locale.ENGLISH)}-dub", true)
return season } ?: items.firstOrNull { season ->
} // if there is no season with the preferred local, try to find a subbed season
} season.isSubbed
} ?: items.first() // if no preferred language and no sub, use the first season
// if there is no season with the preferred local, try to find a subbed season
items.forEach { season ->
if (season.isSubbed) {
return season
}
}
// if there is no preferred language season and no sub, use the first season
return items.first()
} }
} }
@ -173,6 +238,7 @@ data class Seasons(
data class Season( data class Season(
@SerialName("id") val id: String, @SerialName("id") val id: String,
@SerialName("title") val title: String, @SerialName("title") val title: String,
@SerialName("slug_title") val slugTitle: String,
@SerialName("series_id") val seriesId: String, @SerialName("series_id") val seriesId: String,
@SerialName("season_number") val seasonNumber: Int, @SerialName("season_number") val seasonNumber: Int,
@SerialName("is_subbed") val isSubbed: Boolean, @SerialName("is_subbed") val isSubbed: Boolean,
@ -180,7 +246,7 @@ data class Season(
) )
val NoneSeasons = Seasons(0, emptyList()) val NoneSeasons = Seasons(0, emptyList())
val NoneSeason = Season("", "", "", 0, isSubbed = false, isDubbed = false) val NoneSeason = Season("", "", "", "", 0, isSubbed = false, isDubbed = false)
/** /**
@ -295,3 +361,19 @@ val NonePlayback = Playback(
mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(), mapOf(),
) )
) )
@Serializable
data class Profile(
@SerialName("avatar") val avatar: String,
@SerialName("email") val email: String,
@SerialName("maturity_rating") val maturityRating: String,
@SerialName("preferred_content_subtitle_language") val preferredContentSubtitleLanguage: String,
@SerialName("username") val username: String,
)
val NoneProfile = Profile(
avatar = "",
email = "",
maturityRating = "",
preferredContentSubtitleLanguage = "",
username = ""
)

View File

@ -8,9 +8,9 @@ import java.util.*
object Preferences { object Preferences {
var preferSecondary = false var preferredLocale: Locale = Locale.forLanguageTag("en-US") // TODO this should be saved (potential offline usage) but fetched on start
internal set internal set
var preferredLocal = Locale.GERMANY var preferSubbed = false
internal set internal set
var autoplay = true var autoplay = true
internal set internal set
@ -26,13 +26,22 @@ object Preferences {
) )
} }
fun savePreferSecondary(context: Context, preferSecondary: Boolean) { fun savePreferredLocal(context: Context, preferredLocale: Locale) {
with(getSharedPref(context).edit()) { with(getSharedPref(context).edit()) {
putBoolean(context.getString(R.string.save_key_prefer_secondary), preferSecondary) putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag())
apply() apply()
} }
this.preferSecondary = preferSecondary this.preferredLocale = preferredLocale
}
fun savePreferSecondary(context: Context, preferSubbed: Boolean) {
with(getSharedPref(context).edit()) {
putBoolean(context.getString(R.string.save_key_prefer_secondary), preferSubbed)
apply()
}
this.preferSubbed = preferSubbed
} }
fun saveAutoplay(context: Context, autoplay: Boolean) { fun saveAutoplay(context: Context, autoplay: Boolean) {
@ -68,7 +77,12 @@ object Preferences {
fun load(context: Context) { fun load(context: Context) {
val sharedPref = getSharedPref(context) val sharedPref = getSharedPref(context)
preferSecondary = sharedPref.getBoolean( preferredLocale = Locale.forLanguageTag(
sharedPref.getString(
context.getString(R.string.save_key_preferred_local), "en-US"
) ?: "en-US"
)
preferSubbed = sharedPref.getBoolean(
context.getString(R.string.save_key_prefer_secondary), false context.getString(R.string.save_key_prefer_secondary), false
) )
autoplay = sharedPref.getBoolean( autoplay = sharedPref.getBoolean(

View File

@ -44,9 +44,11 @@ import org.mosad.teapod.ui.activity.onboarding.OnboardingActivity
import org.mosad.teapod.ui.activity.player.PlayerActivity import org.mosad.teapod.ui.activity.player.PlayerActivity
import org.mosad.teapod.ui.components.LoginDialog import org.mosad.teapod.ui.components.LoginDialog
import org.mosad.teapod.util.DataTypes import org.mosad.teapod.util.DataTypes
import java.util.*
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener { class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener {
private val classTag = javaClass.name
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private var activeBaseFragment: Fragment = HomeFragment() // the currently active fragment, home at the start private var activeBaseFragment: Fragment = HomeFragment() // the currently active fragment, home at the start
@ -135,6 +137,9 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
Preferences.load(this) Preferences.load(this)
EncryptedPreferences.readCredentials(this) EncryptedPreferences.readCredentials(this)
// always initialize the api token
Crunchyroll.initBasicApiToken()
// show onboarding if no password is set, or login fails // show onboarding if no password is set, or login fails
if (EncryptedPreferences.password.isEmpty() || !Crunchyroll.login( if (EncryptedPreferences.password.isEmpty() || !Crunchyroll.login(
EncryptedPreferences.login, EncryptedPreferences.login,
@ -146,16 +151,20 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
runBlocking { initCrunchyroll().joinAll() } runBlocking { initCrunchyroll().joinAll() }
} }
} }
Log.i(javaClass.name, "loading in $time ms") Log.i(classTag, "loading in $time ms")
} }
private fun initCrunchyroll(): List<Job> { private fun initCrunchyroll(): List<Job> {
println("init")
val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope"))
return listOf( return listOf(
scope.launch { Crunchyroll.index() }, scope.launch { Crunchyroll.index() },
scope.launch { Crunchyroll.account() } scope.launch { Crunchyroll.account() },
scope.launch {
// update the local preferred content language, since it may have changed
val locale = Locale.forLanguageTag(Crunchyroll.profile().preferredContentSubtitleLanguage)
Preferences.savePreferredLocal(this@MainActivity, locale)
}
) )
} }
@ -169,7 +178,7 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
// Log.w(javaClass.name, "Login failed, please try again.") // Log.w(javaClass.name, "Login failed, please try again.")
// } // }
}.negativeButton { }.negativeButton {
Log.i(javaClass.name, "Login canceled, exiting.") Log.i(classTag, "Login canceled, exiting.")
finish() finish()
}.show() }.show()
} }

View File

@ -9,7 +9,7 @@ import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.annotation.RawRes import androidx.annotation.RawRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.afollestad.materialdialogs.MaterialDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.mosad.teapod.BuildConfig import org.mosad.teapod.BuildConfig
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentAboutBinding import org.mosad.teapod.databinding.FragmentAboutBinding
@ -68,9 +68,9 @@ class AboutFragment : Fragment() {
} }
binding.linearLicense.setOnClickListener { binding.linearLicense.setOnClickListener {
MaterialDialog(requireContext()) MaterialAlertDialogBuilder(requireContext())
.title(text = License.GPL3.long) .setTitle(License.GPL3.long)
.message(text = parseLicense(R.raw.gpl_3_full)) .setMessage(parseLicense(R.raw.gpl_3_full))
.show() .show()
} }
} }
@ -113,9 +113,9 @@ class AboutFragment : Fragment() {
"https://github.com/google/material-design-icons", License.APACHE2), "https://github.com/google/material-design-icons", License.APACHE2),
ThirdPartyComponent("Material Dialogs", "", "Aidan Follestad", ThirdPartyComponent("Material Dialogs", "", "Aidan Follestad",
"https://github.com/afollestad/material-dialogs", License.APACHE2), "https://github.com/afollestad/material-dialogs", License.APACHE2),
ThirdPartyComponent("Jsoup", "2009 - 2020", "Jonathan Hedley", ThirdPartyComponent("Ktor", "2014-2021", "JetBrains s.r.o and contributors",
"https://jsoup.org/", License.MIT), "https://ktor.io/", License.APACHE2),
ThirdPartyComponent("kotlinx.coroutines", "2016 - 2019", "JetBrains", ThirdPartyComponent("kotlinx.coroutines", "2016-2021", "JetBrains s.r.o",
"https://github.com/Kotlin/kotlinx.coroutines", License.APACHE2), "https://github.com/Kotlin/kotlinx.coroutines", License.APACHE2),
ThirdPartyComponent("Glide", "2014", "Google Inc.", ThirdPartyComponent("Glide", "2014", "Google Inc.",
"https://github.com/bumptech/glide", License.BSD2), "https://github.com/bumptech/glide", License.BSD2),
@ -132,9 +132,9 @@ class AboutFragment : Fragment() {
License.MIT -> parseLicense(R.raw.mit_full) License.MIT -> parseLicense(R.raw.mit_full)
} }
MaterialDialog(requireContext()) MaterialAlertDialogBuilder(requireContext())
.title(text = license.long) .setTitle(license.long)
.message(text = licenseText) .setMessage(licenseText)
.show() .show()
} }

View File

@ -6,27 +6,36 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.afollestad.materialdialogs.MaterialDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.afollestad.materialdialogs.list.listItemsSingleChoice import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.mosad.teapod.BuildConfig import org.mosad.teapod.BuildConfig
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentAccountBinding import org.mosad.teapod.databinding.FragmentAccountBinding
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.parser.crunchyroll.Profile
import org.mosad.teapod.parser.crunchyroll.supportedLocals
import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.preferences.EncryptedPreferences
import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.ui.activity.main.MainActivity
import org.mosad.teapod.ui.components.LoginDialog import org.mosad.teapod.ui.components.LoginDialog
import org.mosad.teapod.util.DataTypes.Theme import org.mosad.teapod.util.DataTypes.Theme
import org.mosad.teapod.util.showFragment import org.mosad.teapod.util.showFragment
import org.mosad.teapod.util.toDisplayString
import java.util.*
class AccountFragment : Fragment() { class AccountFragment : Fragment() {
private lateinit var binding: FragmentAccountBinding private lateinit var binding: FragmentAccountBinding
private var profile: Deferred<Profile> = lifecycleScope.async {
Crunchyroll.profile()
}
private val getUriExport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val getUriExport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
@ -58,7 +67,9 @@ class AccountFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// TODO reimplement for ct, if possible (maybe account status would be better? (premium)) binding.textAccountLogin.text = EncryptedPreferences.login
// TODO reimplement for cr, if possible (maybe account status would be better? (premium))
// load subscription (async) info before anything else // load subscription (async) info before anything else
binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading)) binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading))
lifecycleScope.launch { lifecycleScope.launch {
@ -68,18 +79,23 @@ class AccountFragment : Fragment() {
) )
} }
binding.textAccountLogin.text = EncryptedPreferences.login // add preferred subtitles
binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time)) lifecycleScope.launch {
binding.textSettingsContentLanguageDesc.text = Locale.forLanguageTag(
profile.await().preferredContentSubtitleLanguage
).displayLanguage
}
binding.switchSecondary.isChecked = Preferences.preferSubbed
binding.switchAutoplay.isChecked = Preferences.autoplay
binding.textThemeSelected.text = when (Preferences.theme) { binding.textThemeSelected.text = when (Preferences.theme) {
Theme.DARK -> getString(R.string.theme_dark) Theme.DARK -> getString(R.string.theme_dark)
else -> getString(R.string.theme_light) else -> getString(R.string.theme_light)
} }
binding.switchSecondary.isChecked = Preferences.preferSecondary
binding.switchAutoplay.isChecked = Preferences.autoplay
binding.linearDevSettings.isVisible = Preferences.devSettings binding.linearDevSettings.isVisible = Preferences.devSettings
binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time))
initActions() initActions()
} }
@ -93,12 +109,9 @@ class AccountFragment : Fragment() {
//startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl()))) //startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl())))
} }
binding.linearTheme.setOnClickListener {
showThemeDialog()
}
binding.linearInfo.setOnClickListener { binding.linearSettingsContentLanguage.setOnClickListener {
activity?.showFragment(AboutFragment()) showContentLanguageSelection()
} }
binding.switchSecondary.setOnClickListener { binding.switchSecondary.setOnClickListener {
@ -109,6 +122,14 @@ class AccountFragment : Fragment() {
Preferences.saveAutoplay(requireContext(), binding.switchAutoplay.isChecked) Preferences.saveAutoplay(requireContext(), binding.switchAutoplay.isChecked)
} }
binding.linearTheme.setOnClickListener {
showThemeDialog()
}
binding.linearInfo.setOnClickListener {
activity?.showFragment(AboutFragment())
}
binding.linearExportData.setOnClickListener { binding.linearExportData.setOnClickListener {
val i = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { val i = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE) addCategory(Intent.CATEGORY_OPENABLE)
@ -142,24 +163,67 @@ class AccountFragment : Fragment() {
} }
} }
private fun showThemeDialog() { private fun showContentLanguageSelection() {
val themes = listOf( // we should be able to use the index of supportedLocals for language selection, items is GUI only
resources.getString(R.string.theme_light), val items = supportedLocals.map {
resources.getString(R.string.theme_dark) it.toDisplayString(getString(R.string.settings_content_language_none))
) }.toTypedArray()
MaterialDialog(requireContext()).show { var initialSelection: Int
title(R.string.theme) // profile should be completed here, therefore blocking
listItemsSingleChoice(items = themes, initialSelection = Preferences.theme.ordinal) { _, index, _ -> runBlocking {
when(index) { initialSelection = supportedLocals.indexOf(Locale.forLanguageTag(
0 -> Preferences.saveTheme(context, Theme.LIGHT) profile.await().preferredContentSubtitleLanguage))
1 -> Preferences.saveTheme(context, Theme.DARK) if (initialSelection < 0) initialSelection = supportedLocals.lastIndex
else -> Preferences.saveTheme(context, Theme.DARK) }
}
(activity as MainActivity).restart() MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.settings_content_language)
.setSingleChoiceItems(items, initialSelection){ dialog, which ->
updatePrefContentLanguage(supportedLocals[which])
dialog.dismiss()
}
.show()
}
@kotlinx.coroutines.ExperimentalCoroutinesApi
private fun updatePrefContentLanguage(preferredLocale: Locale) {
lifecycleScope.launch {
Crunchyroll.postPrefSubLanguage(preferredLocale.toLanguageTag())
}.invokeOnCompletion {
// update the local preferred content language
Preferences.savePreferredLocal(requireContext(), preferredLocale)
// update profile since the language selection might have changed
profile = lifecycleScope.async { Crunchyroll.profile() }
profile.invokeOnCompletion {
// update language once loading profile is completed
binding.textSettingsContentLanguageDesc.text = Locale.forLanguageTag(
profile.getCompleted().preferredContentSubtitleLanguage
).displayLanguage
} }
} }
} }
private fun showThemeDialog() {
val items = arrayOf(
resources.getString(R.string.theme_light),
resources.getString(R.string.theme_dark)
)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.settings_content_language)
.setSingleChoiceItems(items, Preferences.theme.ordinal){ _, which ->
when(which) {
0 -> Preferences.saveTheme(requireContext(), Theme.LIGHT)
1 -> Preferences.saveTheme(requireContext(), Theme.DARK)
else -> Preferences.saveTheme(requireContext(), Theme.DARK)
}
(activity as MainActivity).restart()
}
.show()
}
} }

View File

@ -81,7 +81,8 @@ class HomeFragment : Fragment() {
// continue watching // continue watching
val upNextJob = lifecycleScope.launch { val upNextJob = lifecycleScope.launch {
// TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately // TODO create EpisodeItemAdapter, which will start the playback of the selected episode immediately
adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().toItemMediaList()) adapterUpNext = MediaItemAdapter(Crunchyroll.upNextAccount().items
.filter { !it.fullyWatched }.toItemMediaList())
binding.recyclerNewEpisodes.adapter = adapterUpNext binding.recyclerNewEpisodes.adapter = adapterUpNext
} }
asyncJobList.add(upNextJob) asyncJobList.add(upNextJob)

View File

@ -51,9 +51,6 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
println("onViewCreated")
binding.frameLoading.visibility = View.VISIBLE binding.frameLoading.visibility = View.VISIBLE
// tab layout and pager // tab layout and pager

View File

@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentMediaEpisodesBinding import org.mosad.teapod.databinding.FragmentMediaEpisodesBinding
import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.ui.activity.main.MainActivity
import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel
@ -47,7 +48,11 @@ class MediaFragmentEpisodes : Fragment() {
if (model.seasonsCrunchy.total < 2) { if (model.seasonsCrunchy.total < 2) {
binding.buttonSeasonSelection.visibility = View.GONE binding.buttonSeasonSelection.visibility = View.GONE
} else { } else {
binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title binding.buttonSeasonSelection.text = getString(
R.string.season_number_title,
model.currentSeasonCrunchy.seasonNumber,
model.currentSeasonCrunchy.title
)
binding.buttonSeasonSelection.setOnClickListener { v -> binding.buttonSeasonSelection.setOnClickListener { v ->
showSeasonSelection(v) showSeasonSelection(v)
} }
@ -64,7 +69,12 @@ class MediaFragmentEpisodes : Fragment() {
// TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus // TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus
val popup = PopupMenu(requireContext(), v) val popup = PopupMenu(requireContext(), v)
model.seasonsCrunchy.items.forEach { season -> model.seasonsCrunchy.items.forEach { season ->
popup.menu.add(season.title).also { popup.menu.add(getString(
R.string.season_number_title,
season.seasonNumber,
season.title
)
).also {
it.setOnMenuItemClickListener { it.setOnMenuItemClickListener {
onSeasonSelected(season.id) onSeasonSelected(season.id)
false false
@ -86,7 +96,11 @@ class MediaFragmentEpisodes : Fragment() {
// load the new season // load the new season
lifecycleScope.launch { lifecycleScope.launch {
model.setCurrentSeason(seasonId) model.setCurrentSeason(seasonId)
binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title binding.buttonSeasonSelection.text = getString(
R.string.season_number_title,
model.currentSeasonCrunchy.seasonNumber,
model.currentSeasonCrunchy.title
)
adapterRecEpisodes.notifyDataSetChanged() adapterRecEpisodes.notifyDataSetChanged()
} }
} }

View File

@ -62,7 +62,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
println(upNextSeries) println(upNextSeries)
// load the preferred season (preferred language, language per season, not per stream) // load the preferred season (preferred language, language per season, not per stream)
currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocal) currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocale)
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes) // load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes)
listOf( listOf(

View File

@ -47,8 +47,6 @@ import org.mosad.teapod.parser.crunchyroll.NoneEpisodes
import org.mosad.teapod.parser.crunchyroll.NonePlayback import org.mosad.teapod.parser.crunchyroll.NonePlayback
import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.util.EpisodeMeta import org.mosad.teapod.util.EpisodeMeta
import org.mosad.teapod.util.Meta
import org.mosad.teapod.util.TVShowMeta
import org.mosad.teapod.util.tmdb.TMDBTVSeason import org.mosad.teapod.util.tmdb.TMDBTVSeason
import java.util.* import java.util.*
@ -64,12 +62,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
private val mediaSession = MediaSessionCompat(application, "TEAPOD_PLAYER_SESSION") private val mediaSession = MediaSessionCompat(application, "TEAPOD_PLAYER_SESSION")
val currentEpisodeChangedListener = ArrayList<() -> Unit>() val currentEpisodeChangedListener = ArrayList<() -> Unit>()
private val preferredLanguage = if (Preferences.preferSecondary) Locale.JAPANESE else Locale.GERMAN
private var currentPlayhead: Long = 0 private var currentPlayhead: Long = 0
// tmdb/meta data TODO currently not implemented for cr // tmdb/meta data
var mediaMeta: Meta? = null // TODO meta data currently not implemented for cr
internal set // var mediaMeta: Meta? = null
// internal set
var tmdbTVSeason: TMDBTVSeason? =null var tmdbTVSeason: TMDBTVSeason? =null
internal set internal set
var currentEpisodeMeta: EpisodeMeta? = null var currentEpisodeMeta: EpisodeMeta? = null
@ -83,7 +81,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
var currentPlayback = NonePlayback var currentPlayback = NonePlayback
// current playback settings // current playback settings
var currentLanguage: Locale = Preferences.preferredLocal var currentLanguage: Locale = Preferences.preferredLocale
internal set internal set
init { init {
@ -102,8 +100,6 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
if (!isPlaying) updatePlayhead() if (!isPlaying) updatePlayhead()
} }
}) })
} }
override fun onCleared() { override fun onCleared() {
@ -130,7 +126,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
episodes = Crunchyroll.episodes(seasonId) episodes = Crunchyroll.episodes(seasonId)
setCurrentEpisode(episodeId) setCurrentEpisode(episodeId)
playCurrentMedia(currentPlayhead) // TODO, if fully watched, start from 0 playCurrentMedia(currentPlayhead)
// TODO reimplement for cr // TODO reimplement for cr
// run async as it should be loaded by the time the episodes a // run async as it should be loaded by the time the episodes a
@ -165,6 +161,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
* play the next episode, if nextEpisodeId is not null * play the next episode, if nextEpisodeId is not null
*/ */
fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId -> fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId ->
updatePlayhead() // update playhead before switching to new episode
setCurrentEpisode(nextEpisodeId, startPlayback = true) setCurrentEpisode(nextEpisodeId, startPlayback = true)
} }
@ -188,7 +185,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
}, },
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let { Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let {
currentPlayhead = (it.playhead.times(1000)).toLong() // if the episode was fully watched, start at the beginning
currentPlayhead = if (it.fullyWatched) {
0
} else {
(it.playhead.times(1000)).toLong()
}
} }
} }
) )
@ -220,8 +222,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
currentPlayback.streams.adaptive_hls[fallbackLocal.toLanguageTag()]?.url currentPlayback.streams.adaptive_hls[fallbackLocal.toLanguageTag()]?.url
} }
else -> { else -> {
// if no language tag is present use the first entry
currentLanguage = Locale.ROOT currentLanguage = Locale.ROOT
currentPlayback.streams.adaptive_hls[Locale.ROOT.toLanguageTag()]?.url ?: "" currentPlayback.streams.adaptive_hls.entries.first().value.url
} }
} }
println("stream url: $url") println("stream url: $url")
@ -263,25 +266,25 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
return episodes.items.lastOrNull()?.id == currentEpisode.id return episodes.items.lastOrNull()?.id == currentEpisode.id
} }
fun getEpisodeMetaByAoDMediaId(aodMediaId: Int): EpisodeMeta? {
val meta = mediaMeta
return if (meta is TVShowMeta) {
meta.episodes.firstOrNull { it.aodMediaId == aodMediaId }
} else {
null
}
}
// TODO reimplement for cr // TODO reimplement for cr
private suspend fun loadMediaMeta(aodId: Int): Meta? { // fun getEpisodeMetaByAoDMediaId(aodMediaId: Int): EpisodeMeta? {
// val meta = mediaMeta
// return if (meta is TVShowMeta) {
// meta.episodes.firstOrNull { it.aodMediaId == aodMediaId }
// } else {
// null
// }
// }
//
// private suspend fun loadMediaMeta(aodId: Int): Meta? {
// return if (media.type == DataTypes.MediaType.TVSHOW) { // return if (media.type == DataTypes.MediaType.TVSHOW) {
// MetaDBController().getTVShowMetadata(aodId) // MetaDBController().getTVShowMetadata(aodId)
// } else { // } else {
// null // null
// } // }
//
return null // return null
} // }
/** /**
* Update the playhead of the current episode, if currentPosition > 1000ms. * Update the playhead of the current episode, if currentPosition > 1000ms.

View File

@ -31,6 +31,7 @@ import com.afollestad.materialdialogs.customview.customView
import com.afollestad.materialdialogs.customview.getCustomView import com.afollestad.materialdialogs.customview.getCustomView
import org.mosad.teapod.R import org.mosad.teapod.R
// TODO rework and port away from MaterialDialog
class LoginDialog(val context: Context, firstTry: Boolean) { class LoginDialog(val context: Context, firstTry: Boolean) {
private val dialog = MaterialDialog(context, BottomSheet()) private val dialog = MaterialDialog(context, BottomSheet())

View File

@ -3,8 +3,8 @@ package org.mosad.teapod.util
import android.widget.TextView import android.widget.TextView
import org.mosad.teapod.parser.crunchyroll.Collection import org.mosad.teapod.parser.crunchyroll.Collection
import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem import org.mosad.teapod.parser.crunchyroll.ContinueWatchingItem
import org.mosad.teapod.parser.crunchyroll.ContinueWatchingList
import org.mosad.teapod.parser.crunchyroll.Item import org.mosad.teapod.parser.crunchyroll.Item
import java.util.*
fun TextView.setDrawableTop(drawable: Int) { fun TextView.setDrawableTop(drawable: Int) {
this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0) this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0)
@ -23,7 +23,23 @@ fun Collection<Item>.toItemMediaList(): List<ItemMedia> {
@JvmName("toItemMediaListContinueWatchingItem") @JvmName("toItemMediaListContinueWatchingItem")
fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> { fun Collection<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> {
return this.items.map { return items.map {
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source) ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
} }
} }
fun List<ContinueWatchingItem>.toItemMediaList(): List<ItemMedia> {
return this.map {
ItemMedia(it.panel.episodeMetadata.seriesId, it.panel.title, it.panel.images.thumbnail[0][0].source)
}
}
fun Locale.toDisplayString(fallback: String): String {
return if (this.displayLanguage.isNotEmpty() && this.displayCountry.isNotEmpty()) {
"${this.displayLanguage} (${this.displayCountry})"
} else if (this.displayCountry.isNotEmpty()) {
this.displayLanguage
} else {
fallback
}
}

View File

@ -22,16 +22,19 @@
package org.mosad.teapod.util.tmdb package org.mosad.teapod.util.tmdb
import com.github.kittinunf.fuel.Fuel import android.util.Log
import com.github.kittinunf.fuel.core.FuelError import io.ktor.client.*
import com.github.kittinunf.fuel.core.Parameters import io.ktor.client.call.*
import com.github.kittinunf.fuel.json.FuelJson import io.ktor.client.features.json.*
import com.github.kittinunf.fuel.json.responseJson import io.ktor.client.features.json.serializer.*
import com.github.kittinunf.result.Result import io.ktor.client.request.*
import kotlinx.coroutines.* import io.ktor.client.statement.*
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.decodeFromString import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.invoke
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.util.concatenate import org.mosad.teapod.util.concatenate
/** /**
@ -41,30 +44,41 @@ import org.mosad.teapod.util.concatenate
* *
*/ */
class TMDBApiController { class TMDBApiController {
private val classTag = javaClass.name
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val client = HttpClient {
install(JsonFeature) {
serializer = KotlinxSerializer(json)
}
}
private val apiUrl = "https://api.themoviedb.org/3" private val apiUrl = "https://api.themoviedb.org/3"
private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2" private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2"
private val language = "de"
companion object{ companion object{
const val imageUrl = "https://image.tmdb.org/t/p/w500" const val imageUrl = "https://image.tmdb.org/t/p/w500"
} }
private suspend fun request( private suspend inline fun <reified T> request(
endpoint: String, endpoint: String,
parameters: Parameters = emptyList() parameters: List<Pair<String, Any?>> = emptyList()
): Result<FuelJson, FuelError> = coroutineScope { ): T = coroutineScope {
val path = "$apiUrl$endpoint" val path = "$apiUrl$endpoint"
val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters) val params = concatenate(
listOf("api_key" to apiKey, "language" to Preferences.preferredLocale.language),
parameters
)
// TODO handle FileNotFoundException // TODO handle FileNotFoundException
return@coroutineScope (Dispatchers.IO) { return@coroutineScope (Dispatchers.IO) {
val (_, _, result) = Fuel.get(path, params) val response: HttpResponse = client.get(path) {
.responseJson() params.forEach {
parameter(it.first, it.second)
}
}
result response.receive<T>()
} }
} }
@ -78,10 +92,12 @@ class TMDBApiController {
val searchEndpoint = "/search/multi" val searchEndpoint = "/search/multi"
val parameters = listOf("query" to query, "include_adult" to false) val parameters = listOf("query" to query, "include_adult" to false)
val result = request(searchEndpoint, parameters) return try {
return result.component1()?.obj()?.let { request(searchEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBSearchMovie Log.e(classTag, "SerializationException in searchMovie(), with query = $query.", ex)
NoneTMDBSearchMovie
}
} }
/** /**
@ -94,10 +110,12 @@ class TMDBApiController {
val searchEndpoint = "/search/tv" val searchEndpoint = "/search/tv"
val parameters = listOf("query" to query, "include_adult" to false) val parameters = listOf("query" to query, "include_adult" to false)
val result = request(searchEndpoint, parameters) return try {
return result.component1()?.obj()?.let { request(searchEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBSearchTVShow Log.e(classTag, "SerializationException in searchTVShow(), with query = $query.", ex)
NoneTMDBSearchTVShow
}
} }
/** /**
@ -109,10 +127,12 @@ class TMDBApiController {
val movieEndpoint = "/movie/$movieId" val movieEndpoint = "/movie/$movieId"
// TODO is FileNotFoundException handling needed? // TODO is FileNotFoundException handling needed?
val result = request(movieEndpoint) return try {
return result.component1()?.obj()?.let { request(movieEndpoint)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBMovie Log.e(classTag, "SerializationException in getMovieDetails(), with movieId = $movieId.", ex)
NoneTMDBMovie
}
} }
/** /**
@ -124,10 +144,12 @@ class TMDBApiController {
val tvShowEndpoint = "/tv/$tvId" val tvShowEndpoint = "/tv/$tvId"
// TODO is FileNotFoundException handling needed? // TODO is FileNotFoundException handling needed?
val result = request(tvShowEndpoint) return try {
return result.component1()?.obj()?.let { request(tvShowEndpoint)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBTVShow Log.e(classTag, "SerializationException in getTVShowDetails(), with tvId = $tvId.", ex)
NoneTMDBTVShow
}
} }
@Suppress("unused") @Suppress("unused")
@ -141,10 +163,12 @@ class TMDBApiController {
val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber" val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
// TODO is FileNotFoundException handling needed? // TODO is FileNotFoundException handling needed?
val result = request(tvShowSeasonEndpoint) return try {
return result.component1()?.obj()?.let { request(tvShowSeasonEndpoint)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBTVSeason Log.e(classTag, "SerializationException in getTVSeasonDetails(), with tvId = $tvId, seasonNumber = $seasonNumber.", ex)
NoneTMDBTVSeason
}
} }
} }

View File

@ -110,8 +110,8 @@ data class TMDBTVShow(
// use null for nullable types, the gui needs to handle/implement a fallback for null values // use null for nullable types, the gui needs to handle/implement a fallback for null values
val NoneTMDB = TMDBBase(0, "", "", null, null) val NoneTMDB = TMDBBase(0, "", "", null, null)
val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "", null, "") val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "1970-01-01", null, "")
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "", "", "") val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "1970-01-01", "1970-01-01", "")
@Serializable @Serializable
data class TMDBTVSeason( data class TMDBTVSeason(

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM18.92,8h-2.95c-0.32,-1.25 -0.78,-2.45 -1.38,-3.56 1.84,0.63 3.37,1.91 4.33,3.56zM12,4.04c0.83,1.2 1.48,2.53 1.91,3.96h-3.82c0.43,-1.43 1.08,-2.76 1.91,-3.96zM4.26,14C4.1,13.36 4,12.69 4,12s0.1,-1.36 0.26,-2h3.38c-0.08,0.66 -0.14,1.32 -0.14,2 0,0.68 0.06,1.34 0.14,2L4.26,14zM5.08,16h2.95c0.32,1.25 0.78,2.45 1.38,3.56 -1.84,-0.63 -3.37,-1.9 -4.33,-3.56zM8.03,8L5.08,8c0.96,-1.66 2.49,-2.93 4.33,-3.56C8.81,5.55 8.35,6.75 8.03,8zM12,19.96c-0.83,-1.2 -1.48,-2.53 -1.91,-3.96h3.82c-0.43,1.43 -1.08,2.76 -1.91,3.96zM14.34,14L9.66,14c-0.09,-0.66 -0.16,-1.32 -0.16,-2 0,-0.68 0.07,-1.35 0.16,-2h4.68c0.09,0.65 0.16,1.32 0.16,2 0,0.68 -0.07,1.34 -0.16,2zM14.59,19.56c0.6,-1.11 1.06,-2.31 1.38,-3.56h2.95c-0.96,1.65 -2.49,2.93 -4.33,3.56zM16.36,14c0.08,-0.66 0.14,-1.32 0.14,-2 0,-0.68 -0.06,-1.34 -0.14,-2h3.38c0.16,0.64 0.26,1.31 0.26,2s-0.1,1.36 -0.26,2h-3.38z"/>
</vector>

View File

@ -146,6 +146,46 @@
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
<LinearLayout
android:id="@+id/linear_settings_content_language"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:padding="7dp">
<ImageView
android:id="@+id/imageView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/settings_content_language"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="9dp"
android:scaleType="fitXY"
android:src="@drawable/ic_baseline_language_24"
app:tint="?iconColor" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/text_settings_content_language"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_content_language"
android:textSize="16sp" />
<TextView
android:id="@+id/text_settings_content_language_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_content_language_desc"
android:textColor="?textSecondary" />
</LinearLayout>
</LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/linear_settings_secondary" android:id="@+id/linear_settings_secondary"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -158,7 +198,7 @@
android:id="@+id/imageView3" android:id="@+id/imageView3"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:contentDescription="@string/settings_secondary" android:contentDescription="@string/settings_prefer_subbed"
android:minWidth="48dp" android:minWidth="48dp"
android:minHeight="48dp" android:minHeight="48dp"
android:padding="9dp" android:padding="9dp"
@ -185,7 +225,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="@string/settings_secondary" android:text="@string/settings_prefer_subbed"
android:textSize="16sp" /> android:textSize="16sp" />
<TextView <TextView
@ -194,7 +234,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:maxLines="2" android:maxLines="2"
android:text="@string/settings_secondary_desc" android:text="@string/settings_prefer_subbed_desc"
android:textColor="?textSecondary" /> android:textColor="?textSecondary" />
</LinearLayout> </LinearLayout>

View File

@ -39,8 +39,11 @@
<string name="info">Info</string> <string name="info">Info</string>
<string name="info_about_desc">Version %1$s (%2$s)</string> <string name="info_about_desc">Version %1$s (%2$s)</string>
<string name="settings">Einstellungen</string> <string name="settings">Einstellungen</string>
<string name="settings_secondary">Bevorzuge Japanisch (OmU)</string> <string name="settings_content_language">Bevorzuge Inhaltssprache</string>
<string name="settings_secondary_desc">Japanisch verwenden, sofern vorhanden</string> <string name="settings_content_language_desc">Englisch</string>
<string name="settings_content_language_none">Keine</string>
<string name="settings_prefer_subbed">Bevorzuge OmU</string>
<string name="settings_prefer_subbed_desc">Original Sprache verwenden, sofern vorhanden</string>
<string name="settings_autoplay">Autoplay</string> <string name="settings_autoplay">Autoplay</string>
<string name="settings_autoplay_desc">Nächste Episode automatisch abspielen</string> <string name="settings_autoplay_desc">Nächste Episode automatisch abspielen</string>
<string name="theme">Design</string> <string name="theme">Design</string>

View File

@ -34,24 +34,25 @@
<item quantity="one">%d Minute</item> <item quantity="one">%d Minute</item>
<item quantity="other">%d Minutes</item> <item quantity="other">%d Minutes</item>
</plurals> </plurals>
<string name="season_number_title" translatable="false">S%1$d - %2$s</string>
<string name="similar_titles">Similar titles</string> <string name="similar_titles">Similar titles</string>
<string name="component_episode_title">Ep. %1$s %2$s</string> <string name="component_episode_title">Ep. %1$s %2$s</string>
<string name="component_episode_title_sub">Ep. %1$s %2$s (Sub)</string> <string name="component_episode_title_sub">Ep. %1$s %2$s (Sub)</string>
<string name="component_poster_desc" translatable="false">episode poster</string> <string name="component_poster_desc" translatable="false">episode poster</string>
<string name="component_watched_desc" translatable="false">already watched</string> <string name="component_watched_desc" translatable="false">already watched</string>
<!-- settings fragment --> <!-- account fragment -->
<string name="account">Account</string> <string name="account">Account</string>
<string name="account_login_ex" translatable="false">user@example.com</string> <string name="account_login_ex" translatable="false">user@example.com</string>
<string name="account_login_desc">Tap to edit</string> <string name="account_login_desc">Tap to edit</string>
<string name="account_subscription">Subscription %1$s</string> <string name="account_subscription">Subscription %1$s</string>
<string name="account_subscription_desc">Tap to extend</string> <string name="account_subscription_desc">Tap to extend</string>
<string name="info">Info</string>
<string name="info_about" translatable="false">Teapod by @Seil0</string>
<string name="info_about_desc">Version %1$s (%2$s)</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="settings_secondary">Prefer japanese (sub)</string> <string name="settings_content_language">Preferred content language</string>
<string name="settings_secondary_desc">Use the japanese, if present</string> <string name="settings_content_language_desc">English</string>
<string name="settings_content_language_none">None</string>
<string name="settings_prefer_subbed">Prefer subbed</string>
<string name="settings_prefer_subbed_desc">Use original language, if present</string>
<string name="settings_autoplay">Autoplay</string> <string name="settings_autoplay">Autoplay</string>
<string name="settings_autoplay_desc">Play next episode automatically</string> <string name="settings_autoplay_desc">Play next episode automatically</string>
<string name="theme">Theme</string> <string name="theme">Theme</string>
@ -63,7 +64,9 @@
<string name="import_data">import data</string> <string name="import_data">import data</string>
<string name="import_data_desc">import "My list" from a file</string> <string name="import_data_desc">import "My list" from a file</string>
<string name="import_data_success">imported "My list" successfully</string> <string name="import_data_success">imported "My list" successfully</string>
<string name="info">Info</string>
<string name="info_about" translatable="false">Teapod by @Seil0</string>
<string name="info_about_desc">Version %1$s (%2$s)</string>
<!-- about fragment --> <!-- about fragment -->
<string name="version">Version</string> <string name="version">Version</string>
@ -128,7 +131,9 @@
<string name="preference_file_key" translatable="false">org.mosad.teapod.preferences</string> <string name="preference_file_key" translatable="false">org.mosad.teapod.preferences</string>
<string name="save_key_user_login" translatable="false">org.mosad.teapod.user_login</string> <string name="save_key_user_login" translatable="false">org.mosad.teapod.user_login</string>
<string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string> <string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string>
<!-- for legacy reasons the prefer subbed key is called prefer_secondary-->
<string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string> <string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string>
<string name="save_key_preferred_local" translatable="false">org.mosad.teapod.preferred_local</string>
<string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string> <string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string>
<string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string> <string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string>
<string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string> <string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string>

View File

@ -36,15 +36,30 @@
<item name="shapeTextBackground">@color/textBackgroundDark</item> <item name="shapeTextBackground">@color/textBackgroundDark</item>
<item name="iconColor">@color/iconColorDark</item> <item name="iconColor">@color/iconColorDark</item>
<item name="buttonBackground">@color/buttonBackgroundDark</item> <item name="buttonBackground">@color/buttonBackgroundDark</item>
<item name="md_background_color">@color/themeSecondaryDark</item> <item name="md_background_color">@color/themeSecondaryDark</item>
<item name="md_color_content">@color/textSecondaryDark</item> <item name="md_color_content">@color/textSecondaryDark</item>
<!-- without this, the unchecked single choice buttons while be black --> <!-- without this, the unchecked single choice buttons while be black -->
<item name="md_color_widget_unchecked">@color/textSecondaryDark</item> <item name="md_color_widget_unchecked">@color/textSecondaryDark</item>
<item name="materialAlertDialogTheme">@style/ThemeOverlay.App.MaterialAlertDialog.Dark</item>
<!-- change on click indicator color for manually set components --> <!-- change on click indicator color for manually set components -->
<item name="colorControlHighlight">@color/controlHighlightDark</item> <item name="colorControlHighlight">@color/controlHighlightDark</item>
</style> </style>
<!-- dialog themes -->
<style name="ThemeOverlay.App.MaterialAlertDialog.Dark" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorSurface">@color/themeSecondaryDark</item>
<item name="colorOnSurface">@color/textPrimaryDark</item>
<item name="android:colorControlNormal">@color/textSecondaryDark</item> <!-- Radio button unchecked-->
<item name="materialAlertDialogTitleTextStyle">@style/MaterialAlertDialog.App.Title.Text</item>
</style>
<style name="MaterialAlertDialog.App.Title.Text" parent="MaterialAlertDialog.MaterialComponents.Title.Text">
<item name="android:textColor">?textPrimary</item>
</style>
<!-- player theme --> <!-- player theme -->
<style name="PlayerTheme" parent="Theme.MaterialComponents.Light.NoActionBar"> <style name="PlayerTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>

View File

@ -1,12 +1,13 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = "1.6.0" ext.kotlin_version = "1.6.10"
ext.ktor_version = "1.6.7"
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.0.3' classpath 'com.android.tools.build:gradle:7.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong

View File

@ -1,11 +1,10 @@
Teapod ist eine inoffizielle App für Anime-on-Demand (AoD). Teapod ist eine inoffizielle App für Crunchyroll.
* Schau dir alle Titel von AoD auf deinem Android Gerät an * Schau dir alle Titel von Crunchyroll auf deinem Android Gerät an
* Nativer Player auf Basis des ExoPayers * Nativer Player auf Basis des ExoPayers
* Bevorzuge die OmU Version über die App-Einstellungen * Bevorzuge die OmU Version über die App-Einstellungen
* Speicher deine lieblings Anime in "Meine Liste"
Um Teapod zu verwenden musst du dich mit deinem AoD Account anmelden. Um Teapod zu verwenden musst du dich mit deinem Crunchyroll Account anmelden.
Dieses Projekt ist in keiner Weise mit Anime-on-Demand verbunden. Dieses Projekt ist in keiner Weise mit Crunchyroll verbunden.
Bitte melde Fehler und Probleme an support@mosad.xyz Bitte melde Fehler und Probleme an support@mosad.xyz

View File

@ -1 +1 @@
Android App für AoD Android App für Crunchyroll

View File

@ -1,11 +1,10 @@
Teapod is a unofficial App for Anime-on-Demand (AoD). Teapod is a unofficial App for Crunchyroll.
* Watch all animes from AoD on your Android device * Watch all animes from Crunchyroll on your Android device
* Native Player based on ExoPayer * Native Player based on ExoPayer
* Prefer the OmU version via the app settings * Prefer the OmU version via the app settings
* Save your favorite animes to "My List"
To use Teapod you have to login with your AoD account. To use Teapod you have to login with your Crunchyroll account.
This Project is not associated with Anime-on-Demand in any way. This Project is not associated with Crunchyroll in any way.
Please report bugs and issues to support@mosad.xyz Please report bugs and issues to support@mosad.xyz

View File

@ -1 +1 @@
Android App for AoD Android App for Crunchyroll

Binary file not shown.

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

269
gradlew vendored
View File

@ -1,7 +1,7 @@
#!/usr/bin/env sh #!/bin/sh
# #
# Copyright 2015 the original author or authors. # Copyright © 2015-2021 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -17,67 +17,101 @@
# #
############################################################################## ##############################################################################
## #
## Gradle start up script for UN*X # Gradle start up script for POSIX generated by Gradle.
## #
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
############################################################################## ##############################################################################
# Attempt to set APP_HOME # Attempt to set APP_HOME
# Resolve links: $0 may be a link # Resolve links: $0 may be a link
PRG="$0" app_path=$0
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do # Need this for daisy-chained symlinks.
ls=`ls -ld "$PRG"` while
link=`expr "$ls" : '.*-> \(.*\)$'` APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
if expr "$link" : '/.*' > /dev/null; then [ -h "$app_path" ]
PRG="$link" do
else ls=$( ls -ld "$app_path" )
PRG=`dirname "$PRG"`"/$link" link=${ls#*' -> '}
fi case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle" APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"` APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum" MAX_FD=maximum
warn () { warn () {
echo "$*" echo "$*"
} } >&2
die () { die () {
echo echo
echo "$*" echo "$*"
echo echo
exit 1 exit 1
} } >&2
# OS specific support (must be 'true' or 'false'). # OS specific support (must be 'true' or 'false').
cygwin=false cygwin=false
msys=false msys=false
darwin=false darwin=false
nonstop=false nonstop=false
case "`uname`" in case "$( uname )" in #(
CYGWIN* ) CYGWIN* ) cygwin=true ;; #(
cygwin=true Darwin* ) darwin=true ;; #(
;; MSYS* | MINGW* ) msys=true ;; #(
Darwin* ) NONSTOP* ) nonstop=true ;;
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ] ; then if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables # IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java" JAVACMD=$JAVA_HOME/jre/sh/java
else else
JAVACMD="$JAVA_HOME/bin/java" JAVACMD=$JAVA_HOME/bin/java
fi fi
if [ ! -x "$JAVACMD" ] ; then if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi fi
else else
JAVACMD="java" JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the Please set the JAVA_HOME variable in your environment to match the
@ -106,80 +140,95 @@ location of your Java installation."
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
MAX_FD_LIMIT=`ulimit -H -n` case $MAX_FD in #(
if [ $? -eq 0 ] ; then max*)
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD=$( ulimit -H -n ) ||
MAX_FD="$MAX_FD_LIMIT" warn "Could not query maximum file descriptor limit"
fi esac
ulimit -n $MAX_FD case $MAX_FD in #(
if [ $? -ne 0 ] ; then '' | soft) :;; #(
warn "Could not set maximum file descriptor limit: $MAX_FD" *)
fi ulimit -n "$MAX_FD" ||
else warn "Could not set maximum file descriptor limit to $MAX_FD"
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac esac
fi fi
# Escape application args # Collect all arguments for the java command, stacking in reverse order:
save () { # * args from the command line
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done # * the main class name
echo " " # * -classpath
} # * -D...appname settings
APP_ARGS=`save "$@"` # * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# Collect all arguments for the java command, following the shell quoting and substitution rules # For Cygwin or MSYS, switch paths to Windows format before running java
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@" exec "$JAVACMD" "$@"