add d1kt SSO integration: OAuth2 login via WebView + phone binding
This commit is contained in:
@@ -109,6 +109,24 @@ class UserApi internal constructor(
|
||||
onComplete: (Result<AuthSession>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) { requestBlocking("api/v1/auth/login", username, password, auth = false) }
|
||||
|
||||
fun ssoExchange(
|
||||
accessToken: String,
|
||||
onComplete: (Result<SsoExchangeResult>) -> Unit,
|
||||
): CancellableRequest = submit(onComplete) {
|
||||
val root = JSONObject().put("access_token", accessToken)
|
||||
val response = request("api/v1/auth/sso/exchange", body = root.toString(), method = "POST")
|
||||
parseSsoExchangeResult(JSONObject(response))
|
||||
}
|
||||
|
||||
fun ssoBindPhone(
|
||||
token: String,
|
||||
phone: String,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
): CancellableRequest = submitUnit(onComplete, token) {
|
||||
val root = JSONObject().put("phone", phone)
|
||||
request("api/v1/auth/sso/bind-phone", token = token, body = root.toString(), method = "POST")
|
||||
}
|
||||
|
||||
fun courses(
|
||||
token: String,
|
||||
onComplete: (Result<List<Course>>) -> Unit,
|
||||
@@ -279,4 +297,25 @@ class UserApi internal constructor(
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSsoExchangeResult(root: JSONObject): SsoExchangeResult {
|
||||
val user = root.getJSONObject("user")
|
||||
return SsoExchangeResult(
|
||||
token = root.getString("token"),
|
||||
user = AuthUser(
|
||||
id = user.getString("id"),
|
||||
username = user.getString("username"),
|
||||
nickname = user.getString("nickname"),
|
||||
),
|
||||
phoneBound = root.optBoolean("phone_bound", false),
|
||||
isNewUser = root.optBoolean("is_new_user", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SsoExchangeResult(
|
||||
val token: String,
|
||||
val user: AuthUser,
|
||||
val phoneBound: Boolean,
|
||||
val isNewUser: Boolean,
|
||||
)
|
||||
|
||||
@@ -46,6 +46,7 @@ import android.widget.Toast
|
||||
import cn.learningpad.oraltrainer.sdk.GestureEvent
|
||||
import cn.learningpad.oraltrainer.sdk.GestureKind
|
||||
import cn.learningpad.oraltrainer.sdk.AuthSession
|
||||
import cn.learningpad.oraltrainer.sdk.AuthUser
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
|
||||
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
|
||||
import cn.learningpad.oraltrainer.sdk.LoopMode
|
||||
@@ -69,6 +70,9 @@ import java.net.URL
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Locale
|
||||
import org.json.JSONObject
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlin.math.max
|
||||
import androidx.media3.common.MediaItem
|
||||
@@ -122,6 +126,10 @@ class MainActivity : Activity() {
|
||||
private var regPhoneInput: EditText? = null
|
||||
private var regStatusText: TextView? = null
|
||||
private var showingRegisterPage = false
|
||||
private var showingSsoWebView = false
|
||||
private var showingPhoneBinding = false
|
||||
private var ssoPendingToken: String? = null
|
||||
private var ssoWebView: WebView? = null
|
||||
private var myContentList: LinearLayout? = null
|
||||
private var sentenceBoundaryRequestGeneration = 0
|
||||
private var catalogVideos: List<TrainingVideoSummary> = emptyList()
|
||||
@@ -331,6 +339,8 @@ class MainActivity : Activity() {
|
||||
setContentView(
|
||||
when {
|
||||
isLoggedIn() -> createContentView()
|
||||
showingSsoWebView -> createSsoWebView()
|
||||
showingPhoneBinding -> createPhoneBindingView()
|
||||
showingRegisterPage -> createRegisterView()
|
||||
else -> createAuthView()
|
||||
}
|
||||
@@ -395,6 +405,23 @@ class MainActivity : Activity() {
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
|
||||
})
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = "── 或 ──"
|
||||
textSize = 13f
|
||||
setTextColor(COLOR_TEXT_MUTED)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(0, 12.dp, 0, 12.dp)
|
||||
})
|
||||
addView(Button(this@MainActivity).apply {
|
||||
text = "第一课堂一键登录"
|
||||
background = rounded(COLOR_BUTTON, 8f)
|
||||
setTextColor(Color.WHITE)
|
||||
setOnClickListener {
|
||||
showingSsoWebView = true
|
||||
setRootView()
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0)
|
||||
})
|
||||
addView(authStatusText)
|
||||
}
|
||||
}
|
||||
@@ -534,6 +561,203 @@ class MainActivity : Activity() {
|
||||
else sdk.userApi.login(username, password, callback)
|
||||
}
|
||||
|
||||
// ─── 第一课堂 SSO ────────────────────────────────────────────
|
||||
|
||||
@Suppress("SetJavaScriptEnabled")
|
||||
private fun createSsoWebView(): View {
|
||||
val authorizeUrl = Uri.parse(SSO_AUTHORIZE_URL).buildUpon()
|
||||
.appendQueryParameter("client_id", SSO_CLIENT_ID)
|
||||
.appendQueryParameter("redirect_uri", SSO_REDIRECT_URI)
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.appendQueryParameter("scope", SSO_SCOPE)
|
||||
.appendQueryParameter("state", "oral-trainer")
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
val container = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(COLOR_BACKGROUND)
|
||||
}
|
||||
|
||||
container.addView(Button(this).apply {
|
||||
text = "取消登录"
|
||||
setOnClickListener {
|
||||
showingSsoWebView = false
|
||||
ssoWebView = null
|
||||
setRootView()
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp)
|
||||
})
|
||||
|
||||
ssoWebView = WebView(this).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
val url = request?.url ?: return false
|
||||
if (url.scheme == "oraltrainer" && url.host == "oauth") {
|
||||
val code = url.getQueryParameter("code")
|
||||
handleSsoCallback(code)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
loadUrl(authorizeUrl)
|
||||
}
|
||||
container.addView(ssoWebView, LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f
|
||||
))
|
||||
return container
|
||||
}
|
||||
|
||||
private fun handleSsoCallback(code: String?) {
|
||||
if (code.isNullOrBlank()) {
|
||||
showingSsoWebView = false
|
||||
ssoWebView = null
|
||||
setRootView()
|
||||
return
|
||||
}
|
||||
|
||||
Thread {
|
||||
try {
|
||||
val accessToken = exchangeSsoCodeForToken(code)
|
||||
sdk.userApi.ssoExchange(accessToken) { result ->
|
||||
result.fold(
|
||||
onSuccess = { ssoResult ->
|
||||
showingSsoWebView = false
|
||||
ssoWebView = null
|
||||
ssoPendingToken = ssoResult.token
|
||||
if (!ssoResult.phoneBound) {
|
||||
showingPhoneBinding = true
|
||||
setRootView()
|
||||
} else {
|
||||
saveAuthSession(AuthSession(ssoResult.token, ssoResult.user))
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
showingSsoWebView = false
|
||||
ssoWebView = null
|
||||
setRootView()
|
||||
runOnUiThread {
|
||||
authStatusText?.text = "SSO 登录失败:${error.message}"
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
showingSsoWebView = false
|
||||
ssoWebView = null
|
||||
setRootView()
|
||||
authStatusText?.text = "SSO 登录失败:${e.message}"
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun exchangeSsoCodeForToken(code: String): String {
|
||||
val url = URL(SSO_TOKEN_URL)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 15_000
|
||||
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
val body = "grant_type=authorization_code" +
|
||||
"&code=$code" +
|
||||
"&client_id=$SSO_CLIENT_ID" +
|
||||
"&client_secret=$SSO_CLIENT_SECRET" +
|
||||
"&redirect_uri=${Uri.encode(SSO_REDIRECT_URI)}"
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
|
||||
val statusCode = connection.responseCode
|
||||
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream
|
||||
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
if (statusCode !in 200..299) throw RuntimeException("Token exchange failed: HTTP $statusCode $text")
|
||||
val json = JSONObject(text)
|
||||
return json.getString("access_token")
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPhoneBindingView(): View {
|
||||
return ScrollView(this).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
setBackgroundColor(COLOR_BACKGROUND)
|
||||
addView(LinearLayout(this@MainActivity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding(28.dp, 60.dp, 28.dp, 28.dp)
|
||||
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = "绑定手机号"
|
||||
textSize = 28f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(Color.WHITE)
|
||||
})
|
||||
addView(TextView(this@MainActivity).apply {
|
||||
text = "已通过第一课堂登录,请绑定手机号后继续"
|
||||
textSize = 14f
|
||||
setTextColor(COLOR_TEXT_MUTED)
|
||||
setPadding(0, 6.dp, 0, 24.dp)
|
||||
})
|
||||
|
||||
addView(fieldLabel("手机号"))
|
||||
val phoneInput = EditText(this@MainActivity).apply {
|
||||
hint = "请输入 11 位手机号"
|
||||
inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||
setSingleLine(true)
|
||||
}
|
||||
addView(phoneInput)
|
||||
|
||||
val bindStatusText = TextView(this@MainActivity).apply {
|
||||
setTextColor(COLOR_ACCENT_LIGHT)
|
||||
textSize = 13f
|
||||
setPadding(0, 14.dp, 0, 0)
|
||||
}
|
||||
|
||||
addView(Button(this@MainActivity).apply {
|
||||
text = "绑定并继续"
|
||||
background = rounded(COLOR_ACCENT, 8f)
|
||||
setTextColor(Color.WHITE)
|
||||
setOnClickListener {
|
||||
val phone = phoneInput.text?.toString()?.trim().orEmpty()
|
||||
if (!Regex("^1[3-9]\\d{9}$").matches(phone)) {
|
||||
bindStatusText.text = "请输入有效的 11 位手机号"
|
||||
return@setOnClickListener
|
||||
}
|
||||
val token = ssoPendingToken
|
||||
if (token.isNullOrBlank()) {
|
||||
bindStatusText.text = "登录状态已过期,请重新登录"
|
||||
return@setOnClickListener
|
||||
}
|
||||
bindStatusText.text = "绑定中..."
|
||||
sdk.userApi.ssoBindPhone(token, phone) { result ->
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
showingPhoneBinding = false
|
||||
ssoPendingToken = null
|
||||
saveAuthSession(AuthSession(token, AuthUser("", "", "")))
|
||||
setRootView()
|
||||
loadCatalog()
|
||||
},
|
||||
onFailure = { error ->
|
||||
bindStatusText.text = "绑定失败:${error.message}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(0, 20.dp, 0, 0)
|
||||
})
|
||||
addView(bindStatusText)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAuthSession(session: AuthSession) {
|
||||
authToken = session.token
|
||||
authNickname = session.user.nickname
|
||||
@@ -2222,6 +2446,14 @@ class MainActivity : Activity() {
|
||||
// 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。
|
||||
const val ASSESSMENT_API_KEY = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220"
|
||||
|
||||
// 第一课堂 OAuth2 SSO 配置
|
||||
const val SSO_AUTHORIZE_URL = "https://d1kt.cn/api/oauth2/authorize"
|
||||
const val SSO_TOKEN_URL = "https://d1kt.cn/api/oauth2/token"
|
||||
const val SSO_CLIENT_ID = "oral-trainer-app"
|
||||
const val SSO_CLIENT_SECRET = "oral-trainer-secret-2024"
|
||||
const val SSO_REDIRECT_URI = "oraltrainer://oauth/callback"
|
||||
const val SSO_SCOPE = "openid profile email username"
|
||||
|
||||
const val PICK_VIDEO_REQUEST = 1001
|
||||
const val RECORD_AUDIO_REQUEST = 1002
|
||||
const val PROGRESS_MAX = 1000
|
||||
|
||||
Reference in New Issue
Block a user