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" />
-
-
-
-
-
-
-
-
-
-
-
-