From 84ce84e4614e0b55a40e3cf04ed485a3fdec5aa3 Mon Sep 17 00:00:00 2001 From: lhwdev Date: Sun, 1 Aug 2021 23:43:19 +0900 Subject: [PATCH] Fixed bug - Add transkey patch --- .idea/compiler.xml | 7 +- .idea/dictionaries/LHW.xml | 3 + .idea/gradle.xml | 5 +- .idea/uiDesigner.xml | 124 ++++++ .../com/lhwdev/selfTestMacro/httpFetch.kt | 361 +++++++++++------- .../com/lhwdev/selfTestMacro/httpSession.kt | 6 +- .../kotlin/com/lhwdev/selfTestMacro/utils.kt | 92 ++++- api/build.gradle | 1 + .../com/lhwdev/selfTestMacro/api/findUser.kt | 2 +- .../selfTestMacro/api/getInstituteData.kt | 8 +- .../lhwdev/selfTestMacro/api/getUserGroup.kt | 2 +- .../lhwdev/selfTestMacro/api/getUserInfo.kt | 2 +- .../selfTestMacro/api/registerServey.kt | 4 +- .../selfTestMacro/api/validatePassword.kt | 84 +++- .../com/lhwdev/selfTestMacro/selfTestUtils.kt | 2 +- app/build.gradle | 4 +- app/proguard-rules.pro | 20 +- app/src/main/AndroidManifest.xml | 3 +- .../com/lhwdev/selfTestMacro/AlarmReceiver.kt | 3 +- .../com/lhwdev/selfTestMacro/FirstActivity.kt | 32 +- .../com/lhwdev/selfTestMacro/MainActivity.kt | 13 +- .../lhwdev/selfTestMacro/MainApplication.kt | 28 -- .../selfTestMacro/PersistentCookieStore.kt | 250 ++++++++++++ .../com/lhwdev/selfTestMacro/selfTestUtils.kt | 52 ++- .../java/com/lhwdev/selfTestMacro/utils.kt | 46 ++- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- transkey | 2 +- 28 files changed, 908 insertions(+), 252 deletions(-) create mode 100644 .idea/dictionaries/LHW.xml create mode 100644 .idea/uiDesigner.xml delete mode 100644 app/src/main/java/com/lhwdev/selfTestMacro/MainApplication.kt create mode 100644 app/src/main/java/com/lhwdev/selfTestMacro/PersistentCookieStore.kt diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 61a9130c..33a7f972 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,11 @@ - + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/LHW.xml b/.idea/dictionaries/LHW.xml new file mode 100644 index 00000000..15bd1886 --- /dev/null +++ b/.idea/dictionaries/LHW.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index ec4e558e..c25f48da 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,9 +4,11 @@ diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 00000000..e96534fb --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/httpFetch.kt b/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/httpFetch.kt index 317f321f..11554e6b 100644 --- a/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/httpFetch.kt +++ b/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/httpFetch.kt @@ -1,3 +1,5 @@ +@file:Suppress("BlockingMethodInNonBlockingContext") + package com.lhwdev.selfTestMacro import kotlinx.coroutines.Dispatchers @@ -11,7 +13,196 @@ import java.net.URL import java.net.URLDecoder -fun interface HttpBody { +fun interface FetchInterceptor { + suspend fun intercept( + url: URL, + method: FetchMethod?, + headers: Map, + session: Session?, + body: FetchBody? + ): FetchResult? +} + + +private val sHttpProtocols = listOf("http", "https") + +fun interface HttpInterceptor : FetchInterceptor { + override suspend fun intercept( + url: URL, + method: FetchMethod?, + headers: Map, + session: Session?, + body: FetchBody? + ): FetchResult? { + if(method !is HttpMethod? || body !is HttpBody?) return null + if(url.protocol !in sHttpProtocols) return null + + return intercept(url, method ?: HttpMethod.get, headers, session, body) + } + + suspend fun intercept( + url: URL, + method: HttpMethod, + headers: Map, + session: Session?, + body: HttpBody? + ): FetchResult? +} + +val HttpInterceptorImpl: HttpInterceptor = HttpInterceptor { url, method, headers, session, body -> + if(sDebugFetch) { + println("") + + val lastCookieManager = threadLocalCookieHandler + if(session != null) setThreadLocalCookieHandler(session.cookieManager) + + // open + try { + val connection = url.openConnection() as HttpURLConnection + + println( + "\u001b[1;91m<- send HTTP \u001B[93m${method}\u001b[0m: ${readableUrl(url.toString())}" + + if(session == null) "" else " (session)" + ) + if(body != null) connection.doOutput = true + + connection.requestMethod = method.requestName + + val previousProperties = connection.requestProperties + + for((k, v) in headers) { + connection.setRequestProperty(k, v) + println(" \u001B[96m$k\u001B[0m: \u001B[97m$v") + } + val contentType = body?.contentType + if(contentType != null) { + connection.setRequestProperty("Content-Type", contentType) + println(" \u001B[96mContent-Type\u001B[0m: \u001B[97m$contentType\u001b[0m (set by HttpBody)") + } + + for((k, v) in previousProperties) { + println(" \u001B[36m$k\u001B[0m: \u001B[37m$v") + } + + if(session != null) { + val cookies = session.cookieManager.get( + url.toURI(), headers.mapValues { listOf(it.value) } + ).values.singleOrNull() // returns "Cookie": [...] + + if(cookies?.isNotEmpty() == true) { + val cookieStr = cookies.fold(cookies.first()) { acc, entry -> "$acc; $entry" } + println(" \u001B[36mCookie\u001B[0m: \u001B[37m$cookieStr\u001b[0m (session)") + } + + if(session.keepAlive == true) { + connection.setRequestProperty("Connection", "keep-alive") + println(" \u001B[36mConnection\u001B[0m: \u001B[37mkeep-alive\u001b[0m (session)") + } + } + + + // connect + connection.connect() + + println("\u001B[0m") + + if(body != null) connection.outputStream.use { + if(body.debugAvailable) { + body.writeDebug(it) + } else { + val out = ByteArrayOutputStream() + body.write(out) + val array = out.toByteArray() + System.out.write(array) + it.write(array) + } + println() + } else { + println("(no body)") + } + + println() + + val response = HttpResultImpl(connection) + + println("\u001B[1;91m-> receive \u001B[93m${connection.headerFields[null]!![0]}\u001B[0m") + println(" \u001B[35m(message: '${connection.responseMessage}')") + for((k, v) in connection.headerFields) { + if(k == null) continue + println(" \u001B[96m$k\u001B[0m: \u001B[97m${v.joinToString()}") + } + println("\u001B[0m") + + if(response.responseCode in 200..299) { + val arr = response.rawResponse.readBytes() + val text = arr.toString(Charsets.UTF_8) + val count = text.count { it == '\n' } + if(count < 10) { + println(text) + } else { + val cut = text.splitToSequence('\n') + .joinToString(limit = 20, separator = "\n", truncated = "\n\u001b[90m ...\u001b[0m") + println(cut) + println() + } + object : FetchResult by response { + override val rawResponse = ByteArrayInputStream(arr) + } + } else { + println("(content: error)") + response + }.also { + println("------------------------") + } + } finally { + if(session != null) setThreadLocalCookieHandler(lastCookieManager) + } + } else { // without debug logging + val lastCookieManager = threadLocalCookieHandler + if(session != null) setThreadLocalCookieHandler(session.cookieManager) + + // open + try { + val connection = url.openConnection() as HttpURLConnection + + if(body != null) connection.doOutput = true + + connection.requestMethod = method.requestName + + for((k, v) in headers) { + connection.setRequestProperty(k, v) + } + val contentType = body?.contentType + if(contentType != null) connection.setRequestProperty("Content-Type", contentType) + if(session != null) { + if(session.keepAlive == true) { + connection.setRequestProperty("Connection", "keep-alive") + } + } + + // connect + connection.connect() + + if(body != null) connection.outputStream.use { body.write(it) } + + val response = HttpResultImpl(connection) + response.responseCode // preload some so that it utilizes cookie manager + + response + } finally { + setThreadLocalCookieHandler(lastCookieManager) + } + } + +} + + +val sFetchInterceptors = mutableListOf(HttpInterceptorImpl) + + +interface FetchBody + +fun interface HttpBody : FetchBody { fun write(out: OutputStream) fun writeDebug(out: OutputStream): Unit = error("debug not capable") @@ -20,7 +211,9 @@ fun interface HttpBody { } -enum class HttpMethod(val requestName: String) { +interface FetchMethod + +enum class HttpMethod(val requestName: String) : FetchMethod { get("GET"), head("HEAD"), post("POST"), @@ -39,7 +232,7 @@ interface FetchResult { fun close() } -private class FetchResultImpl(val connection: HttpURLConnection) : +private class HttpResultImpl(val connection: HttpURLConnection) : FetchResult, Closeable { override val responseCode get() = connection.responseCode override val responseCodeMessage: String get() = connection.responseMessage @@ -63,26 +256,25 @@ fun FetchResult.requireOk() { } -inline fun FetchResult.toJson(from: Json = Json.Default): T = - from.decodeFromString(value) +suspend inline fun FetchResult.toJson(from: Json = Json.Default): T = + withContext(Dispatchers.IO) { from.decodeFromString(getText()) } -fun FetchResult.toJson( +suspend fun FetchResult.toJson( serializer: KSerializer, from: Json = Json.Default -): T = from.decodeFromString(serializer, value) +): T = withContext(Dispatchers.IO) { from.decodeFromString(serializer, getText()) } -val FetchResult.value: String - get() { - val value = rawResponse.reader().readText() - rawResponse.close() - return value - } +suspend fun FetchResult.getText(): String = withContext(Dispatchers.IO) { + val value = rawResponse.reader().readText() + rawResponse.close() + value +} var sDebugFetch = false -private fun readableUrl(url: String): String { +fun readableUrl(url: String): String { val index = url.indexOf("?") if(index == -1) return url val link = url.substring(0, index) @@ -95,152 +287,29 @@ private fun readableUrl(url: String): String { } + ")" } -@Suppress("BlockingMethodInNonBlockingContext") suspend fun fetch( // for debug url: URL, - method: HttpMethod = HttpMethod.get, + method: FetchMethod? = null, headers: Map = emptyMap(), session: Session? = null, - body: HttpBody? = null -): FetchResult = withContext(Dispatchers.IO) { - if(sDebugFetch) { - println("") - - if(session != null) setThreadLocalCookieHandler(session.cookieManager) - - // open - val connection = url.openConnection() as HttpURLConnection - - println("\u001b[1;91m<- send HTTP \u001B[93m${HttpMethod.post}\u001b[0m: ${readableUrl(url.toString())}" + if(session == null) "" else " (session)") - if(body != null) connection.doOutput = true - - connection.requestMethod = method.requestName - - val previousProperties = connection.requestProperties - - for((k, v) in headers) { - connection.setRequestProperty(k, v) - println(" \u001B[96m$k\u001B[0m: \u001B[97m$v") - } - val contentType = body?.contentType - if(contentType != null) { - connection.setRequestProperty("Content-Type", contentType) - println(" \u001B[96mContent-Type\u001B[0m: \u001B[97m$contentType\u001b[0m (set by HttpBody)") - } - - for((k, v) in previousProperties) { - println(" \u001B[36m$k\u001B[0m: \u001B[37m$v") - } - - if(session != null) { - val cookies = session.cookieManager.get( - url.toURI(), headers.mapValues { listOf(it.value) } - ).values.single() // returns "Cookie": [...] - - if(cookies.isNotEmpty()) { - val cookieStr = cookies.fold(cookies.first()) { acc, entry -> "$acc; $entry" } - println(" \u001B[36mCookie\u001B[0m: \u001B[37m$cookieStr\u001b[0m (session)") - } - - if(session.keepAlive == true) { - connection.setRequestProperty("Connection", "keep-alive") - println(" \u001B[36mConnection\u001B[0m: \u001B[37mkeep-alive\u001b[0m (session)") - } - } - - - // connect - connection.connect() - - val response = FetchResultImpl(connection) - - println("\u001B[0m") - - if(body != null) connection.outputStream.use { - if(body.debugAvailable) { - body.writeDebug(it) - } else { - val out = ByteArrayOutputStream() - body.write(out) - val array = out.toByteArray() - System.out.write(array) - it.write(array) - } - println() - } else { - println("(no body)") - } - - println() - - println("\u001B[1;91m-> receive \u001B[93m${connection.headerFields[null]!![0]}\u001B[0m") - println(" \u001B[35m(message: '${connection.responseMessage}')") - for((k, v) in connection.headerFields) { - if(k == null) continue - println(" \u001B[96m$k\u001B[0m: \u001B[97m${v.joinToString()}") - } - println("\u001B[0m") - - if(response.responseCode in 200..299) { - val arr = response.rawResponse.readBytes() - val text = arr.toString(Charsets.UTF_8) - val count = text.count { it == '\n' } - if(count < 10) { - println(text) - } else { - val cut = text.splitToSequence('\n') - .joinToString(limit = 10, separator = "\n", truncated = "\n\u001B[90m ...") - println(cut) - println() - } - object : FetchResult by response { - override val rawResponse = ByteArrayInputStream(arr) - } - } else { - println("(content: error)") - response - }.also { - println("------------------------") - } - } else { // without debug logging - if(session != null) setThreadLocalCookieHandler(session.cookieManager) - - // open - val connection = url.openConnection() as HttpURLConnection - - if(body != null) connection.doOutput = true - - connection.requestMethod = method.requestName - - for((k, v) in headers) - connection.setRequestProperty(k, v) - val contentType = body?.contentType - if(contentType != null) connection.setRequestProperty("Content-Type", contentType) - if(session != null) { - if(session.keepAlive == true) connection.setRequestProperty("Connection", "keep-alive") - } - - // connect - connection.connect() - - val response = FetchResultImpl(connection) - - if(body != null) connection.outputStream.use { body.write(it) } - - response + body: FetchBody? = null +): FetchResult = withContext(Dispatchers.IO) main@{ + for(interceptor in sFetchInterceptors) { + val result = interceptor.intercept(url, method, headers, session, body) + if(result != null) return@main result } + error("couldn't find appropriate matcher") } -@Suppress("BlockingMethodInNonBlockingContext") suspend fun fetch( url: URL, method: HttpMethod = HttpMethod.get, headers: Map = emptyMap(), session: Session? = null, body: String -): FetchResult = fetch(url, method, headers, session) { +): FetchResult = fetch(url, method, headers, session, HttpBody { val writer = it.bufferedWriter() writer.write(body) writer.flush() -} +}) diff --git a/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/httpSession.kt b/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/httpSession.kt index 9e849a6e..c1e85d1f 100644 --- a/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/httpSession.kt +++ b/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/httpSession.kt @@ -3,7 +3,7 @@ package com.lhwdev.selfTestMacro import java.net.* -private val sCookieThreadLocal = ThreadLocal() +private val sCookieThreadLocal = ThreadLocal() object ThreadLocalCookieManager : CookieHandler() { @@ -17,10 +17,12 @@ object ThreadLocalCookieManager : CookieHandler() { } } -fun setThreadLocalCookieHandler(handler: CookieHandler) { +fun setThreadLocalCookieHandler(handler: CookieHandler?) { sCookieThreadLocal.set(handler) } +val threadLocalCookieHandler: CookieHandler? get() = sCookieThreadLocal.get() + @Suppress("unused") private val sDummyInitialization = run { diff --git a/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/utils.kt b/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/utils.kt index cb5e654b..5a5cf1ab 100644 --- a/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/utils.kt +++ b/api-base/src/jvmMain/kotlin/com/lhwdev/selfTestMacro/utils.kt @@ -2,12 +2,14 @@ package com.lhwdev.selfTestMacro +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import java.net.HttpCookie import java.net.URL import java.net.URLEncoder @@ -95,3 +97,85 @@ fun queryUrlParamsToString(params: Map) = "$k=${URLEncoder.encode(v, "UTF-8")}" } + +object HttpCookieSerializer : KSerializer { + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("java.net.HttpCookie") { + element("name", String.serializer().descriptor) + element("value", String.serializer().descriptor) + element("comment", String.serializer().descriptor, isOptional = true) + element("commentURL", String.serializer().descriptor, isOptional = true) + element("domain", String.serializer().descriptor) + element("maxAge", Long.serializer().descriptor) + element("path", String.serializer().descriptor) + element("portlist", String.serializer().descriptor, isOptional = true) + element("version", Int.serializer().descriptor) + element("secure", Boolean.serializer().descriptor) + element("discard", Boolean.serializer().descriptor) + } + + override fun serialize(encoder: Encoder, value: HttpCookie): Unit = encoder.encodeStructure(descriptor) { + val descriptor = descriptor + + encodeStringElement(descriptor, 0, value.name) + encodeStringElement(descriptor, 1, value.value) + if(value.comment != null) encodeStringElement(descriptor, 2, value.comment) + if(value.commentURL != null) encodeStringElement(descriptor, 3, value.commentURL) + encodeStringElement(descriptor, 4, value.domain) + encodeLongElement(descriptor, 5, value.maxAge) + encodeStringElement(descriptor, 6, value.path) + if(value.portlist != null) encodeStringElement(descriptor, 7, value.portlist) + encodeIntElement(descriptor, 8, value.version) + encodeBooleanElement(descriptor, 9, value.secure) + encodeBooleanElement(descriptor, 10, value.discard) + } + + @OptIn(ExperimentalSerializationApi::class) + override fun deserialize(decoder: Decoder): HttpCookie = decoder.decodeStructure(descriptor) { + // json do not support decodeSequentially() + val descriptor = descriptor + + var name = "" + var value = "" + var comment: String? = null + var commentURL: String? = null + var domain = "" + var maxAge = 0L + var path = "" + var portlist: String? = null + var version = 0 + var secure = false + var discard = false + + while(true) { + when(decodeElementIndex(descriptor)) { + CompositeDecoder.DECODE_DONE -> break + 0 -> name = decodeStringElement(descriptor, 0) + 1 -> value = decodeStringElement(descriptor, 1) + 2 -> comment = decodeStringElement(descriptor, 2) + 3 -> commentURL = decodeStringElement(descriptor, 3) + 4 -> domain = decodeStringElement(descriptor, 4) + 5 -> maxAge = decodeLongElement(descriptor, 5) + 6 -> path = decodeStringElement(descriptor, 6) + 7 -> portlist = decodeStringElement(descriptor, 7) + 8 -> version = decodeIntElement(descriptor, 8) + 9 -> secure = decodeBooleanElement(descriptor, 9) + 10 -> discard = decodeBooleanElement(descriptor, 10) + } + } + + val cookie = HttpCookie(name, value) + cookie.comment = comment + cookie.commentURL = commentURL + cookie.domain = domain + cookie.maxAge = maxAge + cookie.path = path + cookie.portlist = portlist + cookie.version = version + cookie.secure = secure + cookie.discard = discard + + cookie + } +} + diff --git a/api/build.gradle b/api/build.gradle index a8d5c2a1..cb840767 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -5,6 +5,7 @@ plugins { dependencies { implementation project(":api-base") + implementation project(":transkey") implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2" diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/findUser.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/findUser.kt index 101c1458..dfbb27f1 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/findUser.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/findUser.kt @@ -70,7 +70,7 @@ data class UserIdentifier( ) -suspend fun findUser(institute: InstituteInfo, request: GetUserTokenRequestBody): UserIdentifier = fetch( +suspend fun Session.findUser(institute: InstituteInfo, request: GetUserTokenRequestBody): UserIdentifier = fetch( institute.requestUrl["findUser"], method = HttpMethod.post, headers = sDefaultFakeHeader + mapOf("Content-Type" to ContentTypes.json), diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getInstituteData.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getInstituteData.kt index 78d9fb82..02291c0a 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getInstituteData.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getInstituteData.kt @@ -24,7 +24,7 @@ data class InstituteInfo( // 학교: lctnScCode=03&schulCrseScCode=4&orgName=...&loginType=school -suspend fun getSchoolData( +suspend fun Session.getSchoolData( regionCode: String, schoolLevelCode: String, name: String @@ -43,7 +43,7 @@ suspend fun getSchoolData( } // 대학: orgName=...&loginType=univ -suspend fun getUniversityData( +suspend fun Session.getUniversityData( name: String ): InstituteInfoResponse { val params = queryUrlParamsToString( @@ -55,7 +55,7 @@ suspend fun getUniversityData( } // 교육행정기관: orgName=...&loginType=office -suspend fun getOfficeData( +suspend fun Session.getOfficeData( name: String ): InstituteInfoResponse { val params = queryUrlParamsToString( @@ -70,7 +70,7 @@ suspend fun getOfficeData( } // 학원: lctnScCode=..&sigCode=....&orgName=...&isAcademySearch=true&loginType=office -suspend fun getAcademyData( +suspend fun Session.getAcademyData( regionCode: String, sigCode: String, name: String diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getUserGroup.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getUserGroup.kt index 7340eda3..dc93803a 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getUserGroup.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getUserGroup.kt @@ -16,7 +16,7 @@ data class User( ) -suspend fun getUserGroup(institute: InstituteInfo, token: UsersToken): List = fetch( +suspend fun Session.getUserGroup(institute: InstituteInfo, token: UsersToken): List = fetch( institute.requestUrl["selectUserGroup"], method = HttpMethod.post, headers = sDefaultFakeHeader + mapOf("Content-Type" to ContentTypes.json, "Authorization" to token.token), diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getUserInfo.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getUserInfo.kt index a6eed218..2368be81 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getUserInfo.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getUserInfo.kt @@ -52,7 +52,7 @@ data class UserInfo( * userPNo: "..." * wrongPassCnt: 0 */ -suspend fun getUserInfo(institute: InstituteInfo, user: User): UserInfo = fetch( +suspend fun Session.getUserInfo(institute: InstituteInfo, user: User): UserInfo = fetch( institute.requestUrl["getUserInfo"], method = HttpMethod.post, headers = sDefaultFakeHeader + mapOf( diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/registerServey.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/registerServey.kt index a3db8aed..f09c8cbd 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/registerServey.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/registerServey.kt @@ -70,7 +70,7 @@ data class SurveyData( val rspns14: String? = null, val rspns15: String? = null, @SerialName("upperToken") val userToken: UserToken, - @SerialName("upperUserNameEncpt") val userName: String + @SerialName("upperUserNameEncpt") val upperUserName: String ) @Serializable @@ -79,7 +79,7 @@ data class SurveyResult( // what is 'inveYmd'? ) -suspend fun registerSurvey( +suspend fun Session.registerSurvey( institute: InstituteInfo, user: User, surveyData: SurveyData diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/validatePassword.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/validatePassword.kt index c1831346..82a0b861 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/validatePassword.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/validatePassword.kt @@ -1,6 +1,7 @@ package com.lhwdev.selfTestMacro.api import com.lhwdev.selfTestMacro.* +import com.lhwdev.selfTestMacro.transkey.Transkey import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -9,6 +10,11 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json +import java.net.URL +import kotlin.random.Random + + +val transkeyUrl: URL = URL("https://hcs.eduro.go.kr/transkeyServlet") /* @@ -22,6 +28,7 @@ sealed class PasswordResult { @Serializable(UsersToken.Serializer::class) data class UsersToken(val token: String) : PasswordResult() { override val isSuccess get() = true + object Serializer : KSerializer { override val descriptor = PrimitiveSerialDescriptor(UsersToken::class.java.name, PrimitiveKind.STRING) override fun deserialize(decoder: Decoder) = UsersToken(decoder.decodeString()) @@ -56,6 +63,14 @@ data class PasswordWrong( val data: Data ) : PasswordResult() { override val isSuccess get() = false + + override fun toString(): String = when(errorCode) { + 1000 -> "비밀본호를 5회 틀리셔서 5분 후 재시도하실 수 있습니다." + 1001 -> "비밀번호가 맞지 않습니다. 현재 ${data.failedCount}회 실패하셨습니다." + 1003 -> "비밀번호가 초기화되었습니다. 다시 로그인하세요." + else -> "알 수 없는 오류: 에러코드 $errorCode (틀린 횟수: ${data.failedCount})" + } + @Serializable data class Data( @SerialName("failCnt") val failedCount: Int @@ -65,18 +80,69 @@ data class PasswordWrong( private val json = Json { ignoreUnknownKeys = true } -suspend fun validatePassword(institute: InstituteInfo, userIdentifier: UserIdentifier, password: String): PasswordResult { - val body = fetch( +suspend fun Session.validatePassword( + institute: InstituteInfo, + userIdentifier: UserIdentifier, + password: String +): PasswordResult { + val transkey = Transkey(this, transkeyUrl, Random) + + val keyPad = transkey.newKeypad( + keyType = "number", + name = "password", + inputName = "password", + fieldType = "password" + ) + + val encrypted = keyPad.encryptPassword(password) + + val hm = transkey.hmacDigest(encrypted.toByteArray()) + + val raonPassword = jsonString { + "raon" jsonArray { + addJsonObject { + "id" set "password" + "enc" set encrypted + "hmac" set hm + "keyboardType" set "number" + "keyIndex" set keyPad.keyIndex + "fieldType" set "password" + "seedKey" set transkey.crypto.encryptedKey + "initTime" set transkey.initTime + "ExE2E" set "false" + } + } + } + + val result = fetch( institute.requestUrl["validatePassword"], method = HttpMethod.post, - headers = sDefaultFakeHeader + mapOf("Content-Type" to ContentTypes.json, "Authorization" to userIdentifier.token.token), - body = """{"password": "${encrypt(password)}", "deviceUuid": ""}""" - ).value + headers = sDefaultFakeHeader + mapOf( + "Authorization" to userIdentifier.token.token, + "Accept" to "application/json, text/plain, */*" + ), + body = HttpBodies.jsonObject { + "password" set raonPassword + "deviceUuid" set "" + "makeSession" set true + } + ).getText() + + fun parseResultToken(): UsersToken { + val userToken = result.removeSurrounding("\"") + require(userToken.startsWith("Bearer")) { "Malformed users token $userToken" } + return UsersToken(userToken) + } + + if(result.startsWith('\"')) return try { + parseResultToken() + } catch(e: Throwable) { + json.decodeFromString(PasswordWrong.serializer(), result) + } + return try { - json.decodeFromString(PasswordWrong.serializer(), body) + json.decodeFromString(PasswordWrong.serializer(), result) } catch(e: Throwable) { - val userToken = body.removeSurrounding("\"") - require(userToken.startsWith("Bearer")) { userToken } - UsersToken(userToken) + parseResultToken() } } diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/selfTestUtils.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/selfTestUtils.kt index 479ca79c..ae755dc6 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/selfTestUtils.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/selfTestUtils.kt @@ -6,4 +6,4 @@ import com.lhwdev.selfTestMacro.api.JsonLoose import kotlinx.serialization.KSerializer -fun FetchResult.toJsonLoose(serializer: KSerializer) = toJson(serializer, JsonLoose) +suspend fun FetchResult.toJsonLoose(serializer: KSerializer) = toJson(serializer, JsonLoose) diff --git a/app/build.gradle b/app/build.gradle index 37fc2815..0bfcf166 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,8 +14,8 @@ android { applicationId "com.lhwdev.selfTestMacro" minSdkVersion 19 targetSdkVersion 31 - versionCode 1009 - versionName "2.9" + versionCode 1010 + versionName "2.10" multiDexEnabled true diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 51753871..1d8682cf 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -26,18 +26,10 @@ -dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations # kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer --keepclassmembers class kotlinx.serialization.json.** { - *** Companion; -} --keepclasseswithmembers class kotlinx.serialization.json.** { - kotlinx.serialization.KSerializer serializer(...); -} - --keep,includedescriptorclasses class com.lhwdev.selfTestMacro.**$$serializer { *; } # <-- change package name to your app's --keepclassmembers class com.lhwdev.selfTestMacro.** { # <-- change package name to your app's - *** Companion; -} --keepclasseswithmembers class com.lhwdev.selfTestMacro.** { # <-- change package name to your app's - kotlinx.serialization.KSerializer serializer(...); -} +#-keepclassmembers class kotlinx.serialization.json.** { +# *** Companion; +#} +#-keepclasseswithmembers class kotlinx.serialization.json.** { +# kotlinx.serialization.KSerializer serializer(...); +#} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9fe8173f..acc7d854 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,9 +6,9 @@ + diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/AlarmReceiver.kt b/app/src/main/java/com/lhwdev/selfTestMacro/AlarmReceiver.kt index 2656657e..87837deb 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/AlarmReceiver.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/AlarmReceiver.kt @@ -12,9 +12,10 @@ class AlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val result = goAsync() + val session = selfTestSession(context) runBlocking { // TODO: is this okay? - context.submitSuspend() + context.submitSuspend(session) result.finish() } diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/FirstActivity.kt b/app/src/main/java/com/lhwdev/selfTestMacro/FirstActivity.kt index 8eba5064..f0906734 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/FirstActivity.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/FirstActivity.kt @@ -5,8 +5,10 @@ import android.content.Intent import android.os.Bundle import android.text.InputFilter import android.view.ViewGroup +import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.ArrayAdapter +import android.widget.EditText import android.widget.Filter import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity @@ -24,6 +26,8 @@ class FirstActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val session = selfTestSession(this) + setContentView(R.layout.activity_first) window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) val pref = preferenceState @@ -99,11 +103,14 @@ class FirstActivity : AppCompatActivity() { scrollView.smoothScrollTo(0, scrollView.height) } - val data = getSchoolData( - regionCode = regionCode, - schoolLevelCode = levelCode.toString(), - name = nameString - ) + val data = tryAtMost(maxTrial = 3) { + session.getSchoolData( + regionCode = regionCode, + schoolLevelCode = levelCode.toString(), + name = nameString + ) + } + snackBar.dismiss() if(data.instituteList.isEmpty()) { showToast("학교를 찾을 수 없습니다. 이름을 바르게 입력했는지 확인해주세요.") @@ -165,7 +172,7 @@ class FirstActivity : AppCompatActivity() { lifecycleScope.launch main@{ // TODO: show progress try { - val userIdentifier = findUser( + val userIdentifier = session.findUser( instituteInfo, GetUserTokenRequestBody( institute = instituteInfo, @@ -177,6 +184,7 @@ class FirstActivity : AppCompatActivity() { val password = promptInput { edit, _ -> setTitle("비밀번호를 입력해주세요.") + edit.inputType = EditorInfo.TYPE_CLASS_NUMBER edit.filters = arrayOf(InputFilter { source, start, end, dest, destStart, destEnd -> val result = dest.replaceRange(destStart, destEnd, source.substring(start, end)) if(result.length > 4 || result.any { !it.isDigit() }) null @@ -185,20 +193,24 @@ class FirstActivity : AppCompatActivity() { } ?: return@main val token = catchErrorThanToast { - validatePassword(instituteInfo, userIdentifier, password) + tryAtMost(maxTrial = 3) { + session.validatePassword(instituteInfo, userIdentifier, password) + } } ?: return@main if(token is PasswordWrong) { - showToastSuspendAsync("잘못된 비밀번호입니다. 다시 시도해주세요. (${token.data.failedCount}회 틀림)") + showToastSuspendAsync(token.toString()) return@main } require(token is UsersToken) - val groups = getUserGroup(instituteInfo, token) + val groups = tryAtMost(maxTrial = 3) { + session.getUserGroup(instituteInfo, token) + } singleOfUserGroup(groups) ?: return@main // TODO: many users pref.institute = instituteInfo - pref.user = UserLoginInfo(userIdentifier, token) + pref.user = UserLoginInfo(userIdentifier, password, token) pref.setting = UserSetting( loginType = LoginType.school, // TODO region = sRegions.getValue(input_region.text.toString()), diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/MainActivity.kt b/app/src/main/java/com/lhwdev/selfTestMacro/MainActivity.kt index b2e55f50..7c768a94 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/MainActivity.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/MainActivity.kt @@ -36,12 +36,13 @@ const val IGNORE_BATTERY_OPTIMIZATION_REQUEST = 1001 @Suppress("SpellCheckingInspection") class MainActivity : AppCompatActivity() { - private var batteryOptimizationPromptShown = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val session = selfTestSession(this) + val pref = preferenceState val isFirst = pref.firstState == 0 @@ -65,11 +66,13 @@ class MainActivity : AppCompatActivity() { @SuppressLint("SetTextI18n") suspend fun updateCurrentState() = withContext(Dispatchers.IO) main@ { val institute = pref.institute!! - val user = pref.user!! + val user = pref.user!! // note: may change val detailedUserInfo = try { - val token = singleOfUserGroup(getUserGroup(institute, user.token)) ?: return@main - getUserInfo(institute, token) + val token = user.ensureTokenValid(session, institute, { pref.user = it }) { token -> + singleOfUserGroup(session.getUserGroup(institute, token)) ?: return@main + } + session.getUserInfo(institute, token) } catch(e: Throwable) { onError(e, "사용자 정보 불러오기") showToastSuspendAsync("사용자 정보를 불러오지 못했습니다.") @@ -135,7 +138,7 @@ class MainActivity : AppCompatActivity() { submit.setOnClickListener { lifecycleScope.launch { - submitSuspend(false) + submitSuspend(session, false) updateCurrentState() } } diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/MainApplication.kt b/app/src/main/java/com/lhwdev/selfTestMacro/MainApplication.kt deleted file mode 100644 index 4bca6c29..00000000 --- a/app/src/main/java/com/lhwdev/selfTestMacro/MainApplication.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.lhwdev.selfTestMacro - -import android.app.Application -import kotlinx.coroutines.DEBUG_PROPERTY_NAME -import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_OFF -import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON -import kotlinx.coroutines.runBlocking - - -@Suppress("unused") -class MainApplication : Application() { - override fun onCreate() { - super.onCreate() - sDebugFetch = isDebugEnabled - - // debug code - System.setProperty( - DEBUG_PROPERTY_NAME, - if(BuildConfig.DEBUG) DEBUG_PROPERTY_VALUE_ON else DEBUG_PROPERTY_VALUE_OFF - ) - - Thread.setDefaultUncaughtExceptionHandler { _, exception -> - runBlocking { - writeErrorLog(getErrorInfo(exception, "defaultUncaughtExceptionHandler")) - } - } - } -} diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/PersistentCookieStore.kt b/app/src/main/java/com/lhwdev/selfTestMacro/PersistentCookieStore.kt new file mode 100644 index 00000000..cb534304 --- /dev/null +++ b/app/src/main/java/com/lhwdev/selfTestMacro/PersistentCookieStore.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2015 Fran Montiel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.lhwdev.selfTestMacro + +import android.content.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.CookieStore +import java.net.HttpCookie +import java.net.URI +import java.net.URISyntaxException + + +@Serializable +data class SerializableHttpCookie(@Serializable(with = HttpCookieSerializer::class) val cookie: HttpCookie) + + + +class PersistentCookieStore(private val sharedPreferences: SharedPreferences) : CookieStore { + + init { + loadAllFromPersistence() + } + + // In memory + private var allCookies: MutableMap> = mutableMapOf() + + private fun loadAllFromPersistence() { + allCookies = HashMap() + + val allPairs: Map = sharedPreferences.all + for(entry: Map.Entry in allPairs.entries) { + val uriAndName = entry.key.split(SP_KEY_DELIMITER).toTypedArray() + try { + val uri = URI(uriAndName[0]) + val encodedCookie = entry.value as String + val cookie = Json.decodeFromString(SerializableHttpCookie.serializer(), encodedCookie).cookie + var targetCookies = allCookies[uri] + if(targetCookies == null) { + targetCookies = HashSet() + allCookies[uri] = targetCookies + } + // Repeated cookies cannot exist in persistence + // targetCookies.remove(cookie) + targetCookies.add(cookie) + } catch(e: URISyntaxException) { + Log.w(TAG, e) + } + } + } + + @Synchronized + override fun add(uri: URI, cookie: HttpCookie) { + val cookieUri = cookieUri(uri, cookie) + val targetCookies = allCookies[cookieUri] ?: run { + val cookies = HashSet() + allCookies[cookieUri] = cookies + cookies + } + targetCookies.remove(cookie) + targetCookies.add(cookie) + saveToPersistence(cookieUri, cookie) + } + + private fun saveToPersistence(uri: URI, cookie: HttpCookie): Unit = sharedPreferences.edit { + putString( + uri.toString() + SP_KEY_DELIMITER + cookie.name, + Json.encodeToString(SerializableHttpCookie.serializer(), SerializableHttpCookie(cookie)) + ) + } + + @Synchronized + override operator fun get(uri: URI): List { + return getValidCookies(uri) + } + + @Synchronized + override fun getCookies(): List { + val allValidCookies: MutableList = ArrayList() + + for(storedUri in allCookies.keys) { + allValidCookies.addAll(getValidCookies(storedUri)) + } + return allValidCookies + } + + private fun getValidCookies(uri: URI): List { + val targetCookies = mutableListOf() + // If the stored URI does not have a path then it must match any URI in + // the same domain + for(storedUri in allCookies.keys) { + // Check ith the domains match according to RFC 6265 + if(checkDomainsMatch(storedUri.host, uri.host)) { + // Check if the paths match according to RFC 6265 + if(checkPathsMatch(storedUri.path, uri.path)) { + targetCookies.addAll(allCookies[storedUri]!!) + } + } + } + + // Check it there are expired cookies and remove them + if(targetCookies.isNotEmpty()) { + val cookiesToRemoveFromPersistence = mutableListOf() + val it: MutableIterator = targetCookies.iterator() + while(it.hasNext()) { + val currentCookie: HttpCookie = it.next() + if(currentCookie.hasExpired()) { + cookiesToRemoveFromPersistence.add(currentCookie) + it.remove() + } + } + if(cookiesToRemoveFromPersistence.isNotEmpty()) { + removeFromPersistence(uri, cookiesToRemoveFromPersistence) + } + } + return targetCookies + } + + /* http://tools.ietf.org/html/rfc6265#section-5.1.3 + + A string domain-matches a given domain string if at least one of the + following conditions hold: + + o The domain string and the string are identical. (Note that both + the domain string and the string will have been canonicalized to + lower case at this point.) + + o All of the following conditions hold: + + * The domain string is a suffix of the string. + + * The last character of the string that is not included in the + domain string is a %x2E (".") character. + + * The string is a host name (i.e., not an IP address). */ + private fun checkDomainsMatch(cookieHost: String, requestHost: String): Boolean { + return requestHost == cookieHost || requestHost.endsWith(".$cookieHost") + } + + /* http://tools.ietf.org/html/rfc6265#section-5.1.4 + + A request-path path-matches a given cookie-path if at least one of + the following conditions holds: + + o The cookie-path and the request-path are identical. + + o The cookie-path is a prefix of the request-path, and the last + character of the cookie-path is %x2F ("/"). + + o The cookie-path is a prefix of the request-path, and the first + character of the request-path that is not included in the cookie- + path is a %x2F ("/") character. */ + private fun checkPathsMatch(cookiePath: String, requestPath: String): Boolean { + return requestPath == cookiePath || + requestPath.startsWith(cookiePath) && cookiePath[cookiePath.length - 1] == '/' || + requestPath.startsWith(cookiePath) && requestPath.substring(cookiePath.length)[0] == '/' + } + + private fun removeFromPersistence(uri: URI, cookiesToRemove: List) { + val editor: SharedPreferences.Editor = sharedPreferences.edit() + for(cookieToRemove: HttpCookie in cookiesToRemove) { + editor.remove(uri.toString() + SP_KEY_DELIMITER + cookieToRemove.name) + } + editor.apply() + } + + @Synchronized + override fun getURIs(): List = allCookies.keys.toList() + + @Synchronized + override fun remove(uri: URI, cookie: HttpCookie): Boolean { + val targetCookies: MutableSet? = allCookies[uri] + val cookieRemoved = targetCookies != null && targetCookies.remove(cookie) + if(cookieRemoved) { + removeFromPersistence(uri, cookie) + } + return cookieRemoved + } + + private fun removeFromPersistence(uri: URI, cookieToRemove: HttpCookie) { + val editor: SharedPreferences.Editor = sharedPreferences.edit() + editor.remove( + (uri.toString() + SP_KEY_DELIMITER + cookieToRemove.name) + ) + editor.apply() + } + + @Synchronized + override fun removeAll(): Boolean { + allCookies.clear() + removeAllFromPersistence() + return true + } + + private fun removeAllFromPersistence() { + sharedPreferences.edit().clear().apply() + } + + companion object { + private val TAG = PersistentCookieStore::class.simpleName + + // Persistence + private const val SP_COOKIE_STORE = "cookieStore" + private const val SP_KEY_DELIMITER = "|" // Unusual char in URL + + /** + * Get the real URI from the cookie "domain" and "path" attributes, if they + * are not set then uses the URI provided (coming from the response) + * + * @param uri + * @param cookie + * @return + */ + private fun cookieUri(uri: URI, cookie: HttpCookie): URI { + var cookieUri: URI = uri + if(cookie.domain != null) { + // Remove the starting dot character of the domain, if exists (e.g: .domain.com -> domain.com) + var domain: String = cookie.domain + if(domain[0] == '.') { + domain = domain.substring(1) + } + try { + cookieUri = URI( + if(uri.scheme == null) "http" else uri.scheme, domain, + if(cookie.path == null) "/" else cookie.path, null + ) + } catch(e: URISyntaxException) { + Log.w(TAG, e) + } + } + return cookieUri + } + } +} diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/selfTestUtils.kt b/app/src/main/java/com/lhwdev/selfTestMacro/selfTestUtils.kt index 04fe9e90..dd1e2420 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/selfTestUtils.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/selfTestUtils.kt @@ -11,29 +11,55 @@ import com.lhwdev.selfTestMacro.api.SurveyData import com.lhwdev.selfTestMacro.api.User import com.lhwdev.selfTestMacro.api.getUserGroup import com.lhwdev.selfTestMacro.api.registerSurvey +import java.net.CookieManager +import java.net.CookiePolicy import java.util.Calendar +fun selfTestSession(context: Context): Session { + return Session( + CookieManager( + PersistentCookieStore( + context.getSharedPreferences( + "cookie-persistent", + Context.MODE_PRIVATE + ) + ), + CookiePolicy.ACCEPT_ALL + ) + ) +} + + suspend fun Context.singleOfUserGroup(list: List) = if(list.size == 1) list.single() else { if(list.isEmpty()) showToastSuspendAsync("사용자를 찾지 못했습니다.") else showToastSuspendAsync("아직 여러명의 자가진단은 지원하지 않습니다.") null } -suspend fun Context.submitSuspend(notification: Boolean = true) { - val institute = preferenceState.institute!! - val loginInfo = preferenceState.user!! +suspend fun Context.submitSuspend(session: Session, notification: Boolean = true) { try { - val user = singleOfUserGroup(getUserGroup(institute, loginInfo.token)) ?: return - - val result = registerSurvey( - preferenceState.institute!!, - user, - SurveyData(userToken = user.token, userName = user.name) - ) - if(notification) showTestCompleteNotification(result.registerAt) - else { - showToastSuspendAsync("자가진단 제출 완료") + tryAtMost(maxTrial = 3) { + val institute = preferenceState.institute!! + val loginInfo = preferenceState.user!! // note: `preferenceStte.user` may change after val user = ... + + val user = loginInfo.ensureTokenValid( + session, institute, + onUpdate = { preferenceState.user = it } + ) { token -> + singleOfUserGroup(session.getUserGroup(institute, token)) ?: return + } + + val result = session.registerSurvey( + preferenceState.institute!!, + user, + SurveyData(userToken = user.token, upperUserName = user.name) + ) + println("selfTestMacro: submitSuspend=success") + if(notification) showTestCompleteNotification(result.registerAt) + else { + showToastSuspendAsync("자가진단 제출 완료") + } } } catch(e: Throwable) { showTestFailedNotification(e.stackTraceToString()) diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/utils.kt b/app/src/main/java/com/lhwdev/selfTestMacro/utils.kt index 8a569336..41574aa2 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/utils.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/utils.kt @@ -8,7 +8,6 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Resources import android.os.Handler -import android.util.Base64 import android.view.View import android.view.inputmethod.EditorInfo import android.widget.EditText @@ -34,6 +33,12 @@ import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty +@Suppress("unused") +private val dummyForInit = run { + // if(BuildConfig.DEBUG) sDebugFetch = true +} + + fun EditText.isEmpty() = text == null || text.isEmpty() @Serializable @@ -46,8 +51,41 @@ data class UserSetting( val studentBirth: String ) +inline fun tryAtMost(maxTrial: Int, onError: (th: Throwable) -> Unit = {}, block: () -> R): R { + var trialCount = 0 + while(true) { + try { + return block() + } catch(th: Throwable) { + trialCount++ + if(trialCount >= maxTrial) throw th + onError(th) + } + } +} + @Serializable -data class UserLoginInfo(val identifier: UserIdentifier, val token: UsersToken) +data class UserLoginInfo(val identifier: UserIdentifier, val password: String, val unstableToken: UsersToken) { + suspend inline fun ensureTokenValid( + session: Session, + instituteInfo: InstituteInfo, + onUpdate: (info: UserLoginInfo) -> Unit, + block: (token: UsersToken) -> R + ): R { + return tryAtMost( + maxTrial = 3, + onError = { th -> + // try once more with refresh token + val result = session.validatePassword(instituteInfo, identifier, password) + val unstableToken = result as? UsersToken ?: throw IllegalStateException("로그인 실패: $result", th) + onUpdate(copy(unstableToken = unstableToken)) + }, + block = { + block(unstableToken) + } + ) + } +} class PreferenceState(val pref: SharedPreferences) { init { @@ -55,6 +93,7 @@ class PreferenceState(val pref: SharedPreferences) { when(pref.getInt("lastVersion", -1)) { in -1..999 -> pref.edit { clear() } in 1000..1006 -> pref.edit { clear() } + in 1007..1009 -> pref.edit { clear() } BuildConfig.VERSION_CODE -> Unit // latest } @@ -204,6 +243,7 @@ suspend fun Context.promptDialog( cont.resume(result) } } + var dialog: AlertDialog? = null dialog = AlertDialog.Builder(this@promptDialog).apply { block { @@ -242,3 +282,5 @@ suspend fun Context.promptInput(block: AlertDialog.Builder.(edit: EditText, okay setNegativeButton("취소", null) block(view, okay) } + + diff --git a/build.gradle b/build.gradle index 45a969d1..5c1bab4c 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { maven { url 'https://dl.bintray.com/kotlin/kotlin-eap' } } dependencies { - classpath "com.android.tools.build:gradle:4.1.1" + classpath 'com.android.tools.build:gradle:4.2.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.2" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 93382a99..e7ee5239 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-all.zip diff --git a/transkey b/transkey index 198623c7..317396e4 160000 --- a/transkey +++ b/transkey @@ -1 +1 @@ -Subproject commit 198623c7aef0d34738babb63c4830f29957c7c89 +Subproject commit 317396e475b3b472cc687aced4ab0b6a74585ee3