diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 92b9b17..accc8d1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,6 +47,10 @@ android { versionCode = androidGitVersion.code() versionName = androidGitVersion.name() + + manifestPlaceholders = mapOf( + "appAuthRedirectScheme" to "urn" + ) } signingConfigs { @@ -133,6 +137,7 @@ dependencies { implementation("com.google.android.exoplayer:exoplayer-ui:2.11.5") implementation("com.google.android.exoplayer:extension-mediasession:2.11.5") + implementation("com.github.openid:AppAuth-Android:27b62d5da9") implementation("com.aliassadi:power-preference-lib:1.4.1") implementation("com.github.kittinunf.fuel:fuel:2.1.0") implementation("com.github.kittinunf.fuel:fuel-coroutines:2.1.0") diff --git a/app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt b/app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt index a9d37f0..458af27 100644 --- a/app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt +++ b/app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.content.res.Configuration import android.net.Uri import android.os.Bundle -import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.doOnLayout @@ -12,12 +11,13 @@ import androidx.lifecycle.lifecycleScope import com.github.apognu.otter.R import com.github.apognu.otter.fragments.LoginDialog import com.github.apognu.otter.utils.AppContext +import com.github.apognu.otter.utils.OAuth import com.github.apognu.otter.utils.Userinfo +import com.github.apognu.otter.utils.log import com.github.kittinunf.fuel.Fuel import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult import com.github.kittinunf.fuel.gson.gsonDeserializerOf import com.github.kittinunf.result.Result -import com.google.gson.Gson import com.preference.PowerPreference import kotlinx.android.synthetic.main.activity_login.* import kotlinx.coroutines.Dispatchers.Main @@ -37,20 +37,8 @@ class LoginActivity : AppCompatActivity() { override fun onResume() { super.onResume() - anonymous?.setOnCheckedChangeListener { _, isChecked -> - val state = when (isChecked) { - true -> View.GONE - false -> View.VISIBLE - } - - username_field.visibility = state - password_field.visibility = state - } - login?.setOnClickListener { var hostname = hostname.text.toString().trim() - val username = username.text.toString() - val password = password.text.toString() try { if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname)) @@ -71,7 +59,7 @@ class LoginActivity : AppCompatActivity() { hostname_field.error = "" when (anonymous.isChecked) { - false -> authedLogin(hostname, username, password) + false -> authedLogin(hostname) true -> anonymousLogin(hostname) } } catch (e: Exception) { @@ -90,64 +78,39 @@ class LoginActivity : AppCompatActivity() { limitContainerWidth() } - private fun authedLogin(hostname: String, username: String, password: String) { - val body = mapOf( - "username" to username, - "password" to password - ).toList() + private fun authedLogin(hostname: String) { + PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("hostname", hostname) - val dialog = LoginDialog().apply { - show(supportFragmentManager, "LoginDialog") + OAuth.init(hostname) + + OAuth.register(this) { + OAuth.authorize(this) } + } - lifecycleScope.launch(Main) { - try { - val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body) - .awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java)) + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) - when (result) { - is Result.Success -> { - PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply { - setString("hostname", hostname) - setBoolean("anonymous", false) - setString("username", username) - setString("password", password) - setString("access_token", result.get().token) - } + data?.let { + when (requestCode) { + 0 -> { + OAuth.exchange(this, data, + { + PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setBoolean("anonymous", false) - Userinfo.get()?.let { - dialog.dismiss() - startActivity(Intent(this@LoginActivity, MainActivity::class.java)) + lifecycleScope.launch(Main) { + Userinfo.get(this@LoginActivity)?.let { + startActivity(Intent(this@LoginActivity, MainActivity::class.java)) - return@launch finish() - } + return@launch finish() + } - throw Exception(getString(R.string.login_error_userinfo)) - } - - is Result.Failure -> { - dialog.dismiss() - - val error = Gson().fromJson(String(response.data), FwCredentials::class.java) - - hostname_field.error = null - username_field.error = null - - if (error != null && error.non_field_errors?.isNotEmpty() == true) { - username_field.error = error.non_field_errors[0] - } else { - hostname_field.error = result.error.localizedMessage - } - } + throw Exception(getString(R.string.login_error_userinfo)) + } + }, + { "error".log() } + ) } - } catch (e: Exception) { - dialog.dismiss() - - val message = - if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname) - else e.message - - hostname_field.error = message } } } diff --git a/app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt b/app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt index 0d6190b..b66e135 100644 --- a/app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt +++ b/app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt @@ -97,7 +97,7 @@ class MainActivity : AppCompatActivity() { CommandBus.send(Command.RefreshService) lifecycleScope.launch(IO) { - Userinfo.get() + Userinfo.get(this@MainActivity) } now_playing_toggle.setOnClickListener { @@ -515,7 +515,7 @@ class MainActivity : AppCompatActivity() { try { Fuel .post(mustNormalizeUrl("/api/v1/history/listenings/")) - .authorize() + .authorize(this@MainActivity) .header("Content-Type", "application/json") .body(Gson().toJson(mapOf("track" to track.id))) .awaitStringResponse() diff --git a/app/src/main/java/com/github/apognu/otter/playback/RadioPlayer.kt b/app/src/main/java/com/github/apognu/otter/playback/RadioPlayer.kt index 7ca7cfc..b378b93 100644 --- a/app/src/main/java/com/github/apognu/otter/playback/RadioPlayer.kt +++ b/app/src/main/java/com/github/apognu/otter/playback/RadioPlayer.kt @@ -80,7 +80,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) { val body = Gson().toJson(request) val (_, response, result) = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/")) - .authorize() + .authorize(context) .header("Content-Type", "application/json") .body(body) .awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java)) @@ -107,7 +107,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) { try { val body = Gson().toJson(RadioTrackBody(session)) val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/")) - .authorize() + .authorize(context) .header("Content-Type", "application/json") .apply { cookie?.let { @@ -118,7 +118,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) { .awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java)) val trackResponse = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/")) - .authorize() + .authorize(context) .awaitObjectResult(gsonDeserializerOf(Track::class.java)) val favorites = favoritedRepository.fetch(Repository.Origin.Cache.origin) diff --git a/app/src/main/java/com/github/apognu/otter/repositories/FavoritesRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/FavoritesRepository.kt index c72ab56..1de5b27 100644 --- a/app/src/main/java/com/github/apognu/otter/repositories/FavoritesRepository.kt +++ b/app/src/main/java/com/github/apognu/otter/repositories/FavoritesRepository.kt @@ -1,7 +1,6 @@ package com.github.apognu.otter.repositories import android.content.Context -import androidx.lifecycle.lifecycleScope import com.github.apognu.otter.Otter import com.github.apognu.otter.utils.* import com.github.kittinunf.fuel.Fuel @@ -11,7 +10,6 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.io.BufferedReader diff --git a/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt b/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt index 64a1fa7..7714e18 100644 --- a/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt +++ b/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt @@ -1,5 +1,6 @@ package com.github.apognu.otter.utils +import android.content.Context import android.os.Build import androidx.fragment.app.Fragment import com.github.apognu.otter.R @@ -68,10 +69,12 @@ fun Picasso.maybeLoad(url: String?): RequestCreator { else load(url) } -fun Request.authorize(): Request { +fun Request.authorize(context: Context): Request { return this.apply { if (!Settings.isAnonymous()) { - header("Authorization", "Bearer ${Settings.getAccessToken()}") + OAuth.state().performActionWithFreshTokens(OAuth.service(context)) { token, _, _ -> + header("Authorization", "Bearer $token") + } } } } diff --git a/app/src/main/java/com/github/apognu/otter/utils/OAuth.kt b/app/src/main/java/com/github/apognu/otter/utils/OAuth.kt new file mode 100644 index 0000000..1b24388 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/utils/OAuth.kt @@ -0,0 +1,144 @@ +package com.github.apognu.otter.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.github.kittinunf.fuel.Fuel +import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult +import com.github.kittinunf.fuel.gson.gsonDeserializerOf +import com.github.kittinunf.fuel.gson.jsonBody +import com.github.kittinunf.result.Result +import com.preference.PowerPreference +import kotlinx.coroutines.runBlocking +import net.openid.appauth.* +import java.util.* + +fun AuthState.save() { + PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply { + setString("state", jsonSerializeString()) + } +} + +object OAuth { + data class App(val client_id: String, val client_secret: String) + + val REDIRECT_URI = Uri.parse("urn:/com.github.apognu.otter/oauth/callback") + + fun state(): AuthState = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("state").run { + AuthState.jsonDeserialize(this) + } + + fun init(hostname: String) { + AuthState(config(hostname)).save() + } + + fun service(context: Context) = AuthorizationService(context) + + fun register(context: Context, callback: () -> Unit) { + state().authorizationServiceConfiguration?.let { config -> + val body = mapOf( + "name" to UUID.randomUUID(), + "redirect_uris" to REDIRECT_URI.toString() + ) + + runBlocking { + val (_, _, result) = Fuel.post(config.registrationEndpoint.toString()) + .header("Content-Type", "application/json") + .jsonBody(body) + .awaitObjectResponseResult(gsonDeserializerOf(App::class.java)) + + when (result) { + is Result.Success -> { + val app = result.get() + + val response = RegistrationResponse.Builder(registration()!!) + .setClientId(app.client_id) + .setClientSecret(app.client_secret) + .setClientIdIssuedAt(0) + .setClientSecretExpiresAt(null) + .build() + + state().apply { + update(response) + save() + + callback() + } + } + + is Result.Failure -> { + } + } + } + } + } + + fun authorize(context: Activity) { + val intent = service(context).run { + authorizationRequest()?.let { + getAuthorizationRequestIntent(it) + } + } + + context.startActivityForResult(intent, 0) + } + + fun exchange(context: Activity, authorization: Intent, success: () -> Unit, error: () -> Unit) { + state().let { state -> + state.apply { + update(AuthorizationResponse.fromIntent(authorization), AuthorizationException.fromIntent(authorization)) + save() + } + + AuthorizationResponse.fromIntent(authorization)?.let { + val auth = ClientSecretPost(state().clientSecret) + + service(context).performTokenRequest(it.createTokenExchangeRequest(), auth) { response, e -> + state + .apply { + update(response, e) + save() + } + + if (response != null) success() + else error() + } + } + } + } + + private fun config(hostname: String) = AuthorizationServiceConfiguration( + Uri.parse("$hostname/authorize"), + Uri.parse("$hostname/api/v1/oauth/token/"), + Uri.parse("$hostname/api/v1/oauth/apps/") + ) + + private fun registration() = state().authorizationServiceConfiguration?.let { config -> + RegistrationRequest.Builder(config, listOf(REDIRECT_URI)).build() + } + + private fun authorizationRequest() = state().let { state -> + state.authorizationServiceConfiguration?.let { config -> + AuthorizationRequest.Builder( + config, + state.lastRegistrationResponse?.clientId ?: "", + ResponseTypeValues.CODE, + REDIRECT_URI + ) + .setScopes( + /* "read:profile", + "read:libraries", + "write:libraries", + "read:favorites", + "write:favorites", + "read:playlists", + "write:playlists", + "read:radios", + "write:listenings" */ + "read", "write" + ) + .build() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/utils/Userinfo.kt b/app/src/main/java/com/github/apognu/otter/utils/Userinfo.kt index 823a069..a2335ab 100644 --- a/app/src/main/java/com/github/apognu/otter/utils/Userinfo.kt +++ b/app/src/main/java/com/github/apognu/otter/utils/Userinfo.kt @@ -1,5 +1,6 @@ package com.github.apognu.otter.utils +import android.content.Context import com.github.kittinunf.fuel.Fuel import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult import com.github.kittinunf.fuel.gson.gsonDeserializerOf @@ -7,11 +8,11 @@ import com.github.kittinunf.result.Result import com.preference.PowerPreference object Userinfo { - suspend fun get(): User? { + suspend fun get(context: Context): User? { try { val hostname = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname") val (_, _, result) = Fuel.get("$hostname/api/v1/users/users/me/") - .authorize() + .authorize(context) .awaitObjectResponseResult(gsonDeserializerOf(User::class.java)) return when (result) { @@ -28,6 +29,7 @@ object Userinfo { else -> null } } catch (e: Exception) { + e.printStackTrace() return null } } diff --git a/app/src/main/java/com/github/apognu/otter/utils/Util.kt b/app/src/main/java/com/github/apognu/otter/utils/Util.kt index f0b8da8..844793d 100644 --- a/app/src/main/java/com/github/apognu/otter/utils/Util.kt +++ b/app/src/main/java/com/github/apognu/otter/utils/Util.kt @@ -4,6 +4,7 @@ import android.content.Context import android.widget.Toast import com.google.android.exoplayer2.util.Log import com.preference.PowerPreference +import net.openid.appauth.AuthState import java.net.URI fun Context?.toast(message: String, length: Int = Toast.LENGTH_SHORT) { @@ -71,8 +72,8 @@ fun toDurationString(duration: Long, showSeconds: Boolean = false): String { } object Settings { - fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("access_token") - fun getAccessToken(): String = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token", "") + fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("state") && OAuth.state().isAuthorized + fun getAccessToken() = OAuth.state().accessToken fun isAnonymous() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false) fun areExperimentsEnabled() = PowerPreference.getDefaultFile().getBoolean("experiments", false) fun getScope() = PowerPreference.getDefaultFile().getString("scope", "all") diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 7ca20a1..c0fbc98 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -69,57 +69,6 @@ android:text="@string/login_anonymous" android:textColor="@android:color/white" /> - - - - - - - - - - - -