[API/Usos] Implement first login.

This commit is contained in:
Kuba Szczodrzyński 2022-10-15 19:07:12 +02:00
parent 2ff784066e
commit 7ded400a30
No known key found for this signature in database
GPG Key ID: 70CB8A85BA1633CB
15 changed files with 255 additions and 116 deletions

View File

@ -157,6 +157,10 @@
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:theme="@style/Base.Theme.AppCompat" />
<activity android:name=".ui.login.oauth.OAuthLoginActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:theme="@style/AppTheme.Light" />
<activity android:name=".ui.base.BuildInvalidActivity" android:exported="false" />
<activity android:name=".ui.settings.contributors.ContributorsActivity" android:exported="false" />

View File

@ -205,6 +205,9 @@ const val ERROR_PODLASIE_API_OTHER = 631
const val ERROR_PODLASIE_API_DATA_MISSING = 632
const val ERROR_USOS_OAUTH_LOGIN_REQUEST = 701
const val ERROR_USOS_OAUTH_GOT_DIFFERENT_TOKEN = 702
const val ERROR_USOS_OAUTH_INCOMPLETE_RESPONSE = 703
const val ERROR_USOS_NO_STUDENT_PROGRAMMES = 704
const val ERROR_TEMPLATE_WEB_OTHER = 801

View File

@ -16,7 +16,7 @@ class DataUsos(
loginStore: LoginStore,
) : Data(app, profile, loginStore) {
fun isApiLoginValid() = oauthTokenKey != null && oauthTokenSecret != null
fun isApiLoginValid() = oauthTokenKey != null && oauthTokenSecret != null && oauthTokenIsUser
override fun satisfyLoginMethods() {
loginMethods.clear()
@ -25,7 +25,7 @@ class DataUsos(
}
}
override fun generateUserCode() = "USOS:TEST"
override fun generateUserCode() = "$schoolId:${studentNumber ?: studentId}"
var schoolId: String?
get() { mSchoolId = mSchoolId ?: loginStore.getLoginData("schoolId", null); return mSchoolId }
@ -62,6 +62,11 @@ class DataUsos(
set(value) { loginStore.putLoginData("oauthTokenSecret", value); mOauthTokenSecret = value }
private var mOauthTokenSecret: String? = null
var oauthTokenIsUser: Boolean
get() { mOauthTokenIsUser = mOauthTokenIsUser ?: loginStore.getLoginData("oauthTokenIsUser", false); return mOauthTokenIsUser ?: false }
set(value) { loginStore.putLoginData("oauthTokenIsUser", value); mOauthTokenIsUser = value }
private var mOauthTokenIsUser: Boolean? = null
var studentId: String?
get() { mStudentId = mStudentId ?: profile?.getStudentData("studentId", null); return mStudentId }
set(value) { profile?.putStudentData("studentId", value) ?: return; mStudentId = value }

View File

@ -76,7 +76,7 @@ open class UsosApi(open val data: DataUsos, open val lastSync: Long?) {
service: String,
params: Map<String, Any>,
responseType: ResponseType,
onSuccess: (data: T) -> Unit,
onSuccess: (data: T, response: Response?) -> Unit,
) {
val url = "${data.instanceUrl}services/$service"
d(tag, "Request: Usos/Api - $url")
@ -123,10 +123,10 @@ open class UsosApi(open val data: DataUsos, open val lastSync: Long?) {
private fun <T> getCallback(
tag: String,
responseType: ResponseType,
onSuccess: (data: T) -> Unit,
onSuccess: (data: T, response: Response?) -> Unit,
) = when (responseType) {
ResponseType.OBJECT -> object : JsonCallbackHandler() {
override fun onSuccess(data: JsonObject?, response: Response?) {
override fun onSuccess(data: JsonObject?, response: Response) {
processResponse(response, data as T, onSuccess)
}
@ -135,7 +135,7 @@ open class UsosApi(open val data: DataUsos, open val lastSync: Long?) {
}
}
ResponseType.ARRAY -> object : JsonArrayCallbackHandler() {
override fun onSuccess(data: JsonArray?, response: Response?) {
override fun onSuccess(data: JsonArray?, response: Response) {
processResponse(response, data as T, onSuccess)
}
@ -144,7 +144,7 @@ open class UsosApi(open val data: DataUsos, open val lastSync: Long?) {
}
}
ResponseType.PLAIN -> object : TextCallbackHandler() {
override fun onSuccess(data: String?, response: Response?) {
override fun onSuccess(data: String?, response: Response) {
processResponse(response, data as T, onSuccess)
}
@ -155,11 +155,11 @@ open class UsosApi(open val data: DataUsos, open val lastSync: Long?) {
}
private fun <T> processResponse(
response: Response?,
response: Response,
data: T,
onSuccess: (data: T) -> Unit,
onSuccess: (data: T, response: Response?) -> Unit,
) {
onSuccess(data)
onSuccess(data, response)
}
private fun processError(

View File

@ -4,9 +4,18 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.firstlogin
import com.google.gson.JsonObject
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_NO_STUDENT_PROGRAMMES
import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_USOS
import pl.szczodrzynski.edziennik.data.api.edziennik.librus.firstlogin.LibrusFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.login.UsosLoginApi
import pl.szczodrzynski.edziennik.data.api.events.FirstLoginFinishedEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.ext.*
class UsosFirstLogin(val data: DataUsos, val onSuccess: () -> Unit) {
companion object {
@ -16,8 +25,60 @@ class UsosFirstLogin(val data: DataUsos, val onSuccess: () -> Unit) {
private val api = UsosApi(data, null)
init {
UsosLoginApi(data) {
val loginStoreId = data.loginStore.id
val loginStoreType = LOGIN_TYPE_USOS
var firstProfileId = loginStoreId
UsosLoginApi(data) {
api.apiRequest<JsonObject>(
tag = TAG,
service = "users/user",
params = mapOf(
"fields" to listOf(
"id",
"first_name",
"last_name",
"student_number",
"student_programmes" to listOf(
"programme" to listOf("id"),
),
),
),
responseType = UsosApi.ResponseType.OBJECT,
) { json, response ->
val programmes = json.getJsonArray("student_programmes")
if (programmes.isNullOrEmpty()) {
data.error(ApiError(TAG, ERROR_USOS_NO_STUDENT_PROGRAMMES)
.withApiResponse(json)
.withResponse(response))
return@apiRequest
}
val firstName = json.getString("first_name")
val lastName = json.getString("last_name")
val studentName = buildFullName(firstName, lastName)
val profile = Profile(
id = firstProfileId++,
loginStoreId = loginStoreId, loginStoreType = loginStoreType,
name = studentName,
subname = data.schoolId,
studentNameLong = studentName,
studentNameShort = studentName.getShortName(),
accountName = null, // student account
studentData = JsonObject(
"studentId" to json.getInt("id"),
"studentNumber" to json.getInt("student_number"),
),
).also {
it.studentClassName = programmes.getJsonObject(0).getJsonObject("programme").getString("id")
}
EventBus.getDefault().postSticky(
FirstLoginFinishedEvent(listOf(profile), data.loginStore),
)
onSuccess()
}
}
}
}

View File

@ -4,9 +4,7 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.login
import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_OAUTH_LOGIN_REQUEST
import pl.szczodrzynski.edziennik.data.api.USOS_API_OAUTH_REDIRECT_URL
import pl.szczodrzynski.edziennik.data.api.USOS_API_SCOPES
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.data.api.models.ApiError
@ -21,19 +19,21 @@ class UsosLoginApi(val data: DataUsos, val onSuccess: () -> Unit) {
private const val TAG = "UsosLoginApi"
}
init { run {
if (data.isApiLoginValid()) {
onSuccess()
} else if (data.oauthLoginResponse != null) {
login()
} else {
authorize()
private val api = UsosApi(data, null)
init {
run {
if (data.isApiLoginValid()) {
onSuccess()
} else if (data.oauthLoginResponse != null) {
login()
} else {
authorize()
}
}
}}
}
private fun authorize() {
val api = UsosApi(data, null)
api.apiRequest<String>(
tag = TAG,
service = "oauth/request_token",
@ -42,10 +42,11 @@ class UsosLoginApi(val data: DataUsos, val onSuccess: () -> Unit) {
"scopes" to USOS_API_SCOPES,
),
responseType = UsosApi.ResponseType.PLAIN,
) {
val response = it.fromQueryString()
data.oauthTokenKey = response["oauth_token"]
data.oauthTokenSecret = response["oauth_token_secret"]
) { text, _ ->
val authorizeData = text.fromQueryString()
data.oauthTokenKey = authorizeData["oauth_token"]
data.oauthTokenSecret = authorizeData["oauth_token_secret"]
data.oauthTokenIsUser = false
val authUrl = "${data.instanceUrl}services/oauth/authorize"
val authParams = mapOf(
@ -63,6 +64,42 @@ class UsosLoginApi(val data: DataUsos, val onSuccess: () -> Unit) {
}
private fun login() {
d(TAG, "Login to ${data.schoolId} with ${data.oauthLoginResponse} (${data.oauthTokenSecret})")
d(TAG, "Login to ${data.schoolId} with ${data.oauthLoginResponse}")
val authorizeResponse = data.oauthLoginResponse?.fromQueryString()
?: return // checked in init {}
if (authorizeResponse["oauth_token"] != data.oauthTokenKey) {
// got different token
data.error(ApiError(TAG, ERROR_USOS_OAUTH_GOT_DIFFERENT_TOKEN)
.withApiResponse(data.oauthLoginResponse))
return
}
val verifier = authorizeResponse["oauth_verifier"]
if (verifier.isNullOrBlank()) {
data.error(ApiError(TAG, ERROR_USOS_OAUTH_INCOMPLETE_RESPONSE)
.withApiResponse(data.oauthLoginResponse))
return
}
api.apiRequest<String>(
tag = TAG,
service = "oauth/access_token",
params = mapOf(
"oauth_verifier" to verifier,
),
responseType = UsosApi.ResponseType.PLAIN,
) { text, response ->
val accessData = text.fromQueryString()
data.oauthTokenKey = accessData["oauth_token"]
data.oauthTokenSecret = accessData["oauth_token_secret"]
data.oauthTokenIsUser = data.oauthTokenKey != null && data.oauthTokenSecret != null
if (!data.oauthTokenIsUser)
data.error(ApiError(TAG, ERROR_USOS_OAUTH_INCOMPLETE_RESPONSE)
.withApiResponse(text)
.withResponse(response))
else
onSuccess()
}
}
}

View File

@ -10,6 +10,8 @@ import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.JsonPrimitive
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
fun JsonObject?.get(key: String): JsonElement? = this?.get(key)
@ -93,7 +95,13 @@ fun JsonArray(vararg properties: Any?): JsonArray {
}
}
fun JsonArray?.isNullOrEmpty(): Boolean = (this?.size() ?: 0) == 0
@OptIn(ExperimentalContracts::class)
fun JsonArray?.isNullOrEmpty(): Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}
return this == null || this.isEmpty
}
operator fun JsonArray.plusAssign(o: JsonElement) = this.add(o)
operator fun JsonArray.plusAssign(o: String) = this.add(o)
operator fun JsonArray.plusAssign(o: Char) = this.add(o)

View File

@ -355,6 +355,7 @@ fun Map<String, String>.toQueryString() = this
.joinToString("&") { "${it.first}=${it.second}" }
fun String.fromQueryString() = this
.substringAfter('?')
.split("&")
.map { it.split("=") }
.associate { it[0].urlDecode() to it[1].urlDecode() }

View File

@ -1,67 +0,0 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-14.
*/
package pl.szczodrzynski.edziennik.ui.dialogs
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.updateLayoutParams
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.ext.dp
import pl.szczodrzynski.edziennik.ui.dialogs.base.ViewDialog
import pl.szczodrzynski.edziennik.utils.Utils.d
class OAuthLoginDialog(
activity: AppCompatActivity,
private val authorizeUrl: String,
private val redirectUrl: String,
private val onSuccess: (responseUrl: String) -> Unit,
private val onFailure: (() -> Unit)?,
onShowListener: ((tag: String) -> Unit)? = null,
onDismissListener: ((tag: String) -> Unit)? = null,
) : ViewDialog<WebView>(activity, onShowListener, onDismissListener) {
override val TAG = "OAuthLoginDialog"
override fun getTitleRes() = R.string.oauth_dialog_title
override fun getPositiveButtonText() = R.string.close
private var isSuccessful = false
@SuppressLint("SetJavaScriptEnabled")
override fun getRootView(): WebView {
val webView = WebView(activity)
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
d(TAG, "Navigating to $url")
if (url.startsWith(redirectUrl)) {
isSuccessful = true
onSuccess(url)
dismiss()
}
}
}
webView.settings.javaScriptEnabled = true
return webView
}
override suspend fun onShow() {
dialog.window?.setLayout(MATCH_PARENT, MATCH_PARENT)
root.minimumHeight = activity.windowManager.defaultDisplay?.height?.div(2) ?: 300.dp
root.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
root.loadUrl(authorizeUrl)
}
override fun onDismiss() {
root.stopLoading()
if (!isSuccessful)
onFailure?.invoke()
}
}

View File

@ -324,7 +324,7 @@ object LoginInfo {
Mode(
loginMode = LOGIN_MODE_USOS_OAUTH,
name = R.string.login_mode_usos_oauth,
icon = R.drawable.login_logo_usos,
icon = R.drawable.login_mode_usos_api,
guideText = R.string.login_mode_usos_oauth_guide,
isPlatformSelection = true,
credentials = listOf(),

View File

@ -0,0 +1,64 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-15.
*/
package pl.szczodrzynski.edziennik.ui.login.oauth
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Bundle
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.utils.Utils.d
class OAuthLoginActivity : AppCompatActivity() {
companion object {
private const val TAG = "OAuthLoginActivity"
}
private var isSuccessful = false
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.oauth_dialog_title)
val authorizeUrl = intent.getStringExtra("authorizeUrl") ?: return
val redirectUrl = intent.getStringExtra("redirectUrl") ?: return
val webView = WebView(this)
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
d(TAG, "Navigating to $url")
if (url.startsWith(redirectUrl)) {
isSuccessful = true
EventBus.getDefault().post(OAuthLoginResult(
isError = false,
responseUrl = url,
))
finish()
}
}
}
webView.settings.javaScriptEnabled = true
webView.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setContentView(webView)
webView.loadUrl(authorizeUrl)
}
override fun onDestroy() {
super.onDestroy()
if (!isSuccessful)
EventBus.getDefault().post(OAuthLoginResult(
isError = false,
responseUrl = null,
))
}
}

View File

@ -0,0 +1,10 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-15.
*/
package pl.szczodrzynski.edziennik.ui.login.oauth
data class OAuthLoginResult(
val isError: Boolean,
val responseUrl: String?,
)

View File

@ -11,6 +11,8 @@ import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationCompat
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.R
@ -21,7 +23,8 @@ import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.ui.captcha.LibrusCaptchaDialog
import pl.szczodrzynski.edziennik.ui.dialogs.OAuthLoginDialog
import pl.szczodrzynski.edziennik.ui.login.oauth.OAuthLoginActivity
import pl.szczodrzynski.edziennik.ui.login.oauth.OAuthLoginResult
import pl.szczodrzynski.edziennik.utils.Utils.d
class UserActionManager(val app: App) {
@ -143,25 +146,35 @@ class UserActionManager(val app: App) {
return false
val extras = params.getBundle("extras")
val storeKey = params.getString("responseStoreKey") ?: return false
params.getString("authorizeUrl") ?: return false
params.getString("redirectUrl") ?: return false
OAuthLoginDialog(
activity = activity,
authorizeUrl = params.getString("authorizeUrl") ?: return false,
redirectUrl = params.getString("redirectUrl") ?: return false,
onSuccess = { responseUrl ->
val args = Bundle(
storeKey to responseUrl,
)
if (extras != null)
args.putAll(extras)
var listener: Any? = null
listener = object {
@Subscribe(threadMode = ThreadMode.MAIN)
fun onOAuthLoginResult(result: OAuthLoginResult) {
EventBus.getDefault().unregister(listener)
when {
result.isError -> onFailure?.invoke()
result.responseUrl != null -> {
val args = Bundle(
storeKey to result.responseUrl,
)
if (extras != null)
args.putAll(extras)
if (onSuccess != null)
onSuccess(args)
else
EdziennikTask.syncProfile(profileId, arguments = args.toJsonObject()).enqueue(activity)
},
onFailure = onFailure,
).show()
if (onSuccess != null)
onSuccess(args)
else
EdziennikTask.syncProfile(profileId, arguments = args.toJsonObject()).enqueue(activity)
}
}
}
}
EventBus.getDefault().register(listener)
val intent = Intent(activity, OAuthLoginActivity::class.java).putExtras(params)
activity.startActivity(intent)
return true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB