diff --git a/api/build.gradle b/api/build.gradle index 38e2b0e0..0f14bf25 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -1,16 +1,13 @@ plugins { - id "java-library" id "kotlin" id "kotlinx-serialization" } dependencies { + implementation "org.bouncycastle:bcpkix-jdk15to18:1.66" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.4.0-M1" implementation "org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.0" - - // platform-specific - implementation "com.eclipsesource.j2v8:j2v8_win32_x86_64:2.2.1" } 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 d41c7cb3..1cb99967 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/findUser.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/findUser.kt @@ -13,9 +13,11 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import org.bouncycastle.jce.provider.BouncyCastleProvider import java.io.IOException import java.math.BigInteger import java.security.KeyFactory +import java.security.Security import java.security.spec.RSAPublicKeySpec import javax.crypto.Cipher @@ -66,10 +68,17 @@ suspend fun findUser(schoolInfo: SchoolInfo, request: GetUserTokenRequestBody) = lateinit var encodeBase64: (ByteArray) -> String +private val sProvider = run { + Security.removeProvider("BC") + val provider = BouncyCastleProvider() + Security.addProvider(provider) + provider +} + suspend fun encrypt(string: String): String = ioTask { val key = RSAPublicKeySpec(BigInteger("30718937712611605689191751047964347711554702318809238360089112453166217803395521606458190795722565177328746277011809492198037993902927400109154434682159584719442248913593972742086295960255192532052628569970645316811605886842040898815578676961759671712587342568414746446165948582657737331468733813122567503321355924190641302039446055143553127897636698729043365414410208454947672037202818029336707554263659582522814775377559532575089915217472518288660143323212695978110773753720635850393399040827859210693969622113812618481428838504301698541638186158736040620420633114291426890790215359085924554848097772407366395041461"), BigInteger("65537")) - val cipher = Cipher.getInstance("RSA") + val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding", sProvider) cipher.init(Cipher.PUBLIC_KEY, KeyFactory.getInstance("RSA").generatePublic(key)) encodeBase64(cipher.doFinal(string.toByteArray())) } diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getDetailedUserInfo.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getDetailedUserInfo.kt index aff670a1..8b0165c5 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getDetailedUserInfo.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/getDetailedUserInfo.kt @@ -14,12 +14,18 @@ private data class GetDetailedUserInfoRequestBody( @Serializable data class DetailedUserInfo( - @SerialName("userInfo") val userName: String, - @SerialName("registerYmd") val lastRegisterDate: String, - @SerialName("registerDtm") val lastRegisterAt: String, + @SerialName("userName") val userName: String, + @SerialName("orgCode") val schoolCode: String, + @SerialName("orgName") val schoolName: String, + @SerialName("registerYmd") val lastRegisterDate: String?, + @SerialName("registerDtm") val lastRegisterAt: String?, @SerialName("isHealthy") val isHealthy: Boolean, @SerialName("deviceUuid") val deviceUuid: String?, -) +) { + fun toUserInfoString() = "$userName($schoolName)" + fun toLastRegisterInfoString() = + "최근 자가진단: ${if(lastRegisterAt == null) "미참여" else ((if(isHealthy) "정상" else "유증상") + "($lastRegisterAt)")}" +} /* @@ -46,12 +52,18 @@ data class DetailedUserInfo( * userPNo: "..." * wrongPassCnt: 0 */ -suspend fun getDetailedUserInfo(schoolInfo: SchoolInfo, userInfo: UserInfo): DetailedUserInfo = ioTask { - fetch( - schoolInfo.requestUrl.child("getUserInfo"), - method = HttpMethod.post, - headers = sDefaultFakeHeader + mapOf("Content-Type" to ContentTypes.json, "Authorization" to userInfo.token.token), - body = Json.encodeToString(GetDetailedUserInfoRequestBody.serializer(), - GetDetailedUserInfoRequestBody(schoolInfo.code, userInfo.userId)) - ).toJsonLoose() -} +suspend fun getDetailedUserInfo(schoolInfo: SchoolInfo, userInfo: UserInfo): DetailedUserInfo = + ioTask { + fetch( + schoolInfo.requestUrl.child("getUserInfo"), + method = HttpMethod.post, + headers = sDefaultFakeHeader + mapOf( + "Content-Type" to ContentTypes.json, + "Authorization" to userInfo.token.token + ), + body = Json.encodeToString( + GetDetailedUserInfoRequestBody.serializer(), + GetDetailedUserInfoRequestBody(schoolInfo.code, userInfo.userId) + ) + ).toJsonLoose() + } 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 87632fbe..9de95106 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/registerServey.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/api/registerServey.kt @@ -54,8 +54,17 @@ data class SurveyData( @SerialName("upperUserNameEncpt") val userName: String ) +@Serializable +data class SurveyResult( + @SerialName("registerDtm") val registerAt: String + // what is 'inveYmd'? +) -suspend fun registerSurvey(schoolInfo: SchoolInfo, token: UserToken, surveyData: SurveyData) = ioTask { +suspend fun registerSurvey( + schoolInfo: SchoolInfo, + token: UserToken, + surveyData: SurveyData +): SurveyResult = ioTask { fetch( schoolInfo.requestUrlBase.child("registerServey"), method = HttpMethod.post, @@ -64,5 +73,5 @@ suspend fun registerSurvey(schoolInfo: SchoolInfo, token: UserToken, surveyData: "Authorization" to token.token ), body = Json { encodeDefaults = true }.encodeToString(SurveyData.serializer(), surveyData) - ).requireOk() + ).toJsonLoose() } diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/httpFetch.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/httpFetch.kt index ed206087..ed556063 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/httpFetch.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/httpFetch.kt @@ -1,10 +1,6 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) - package com.lhwdev.selfTestMacro import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.debug.DebugProbes import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -15,13 +11,6 @@ import java.net.URL import java.net.URLDecoder -@Suppress("unused") -val dummy: Unit = run { - DebugProbes.install() - DebugProbes.enableCreationStackTraces = true -} - - enum class HttpMethod(val requestName: String) { get("GET"), head("HEAD"), @@ -96,8 +85,6 @@ suspend fun DisposeScope.fetch( // for debug // open val connection = url.openConnection() as HttpURLConnection println("\u001b[1;91m<- send HTTP \u001B[93m${HttpMethod.post}\u001b[0m: ${readableUrl(url.toString())}") -// println(" * called from: ") -// println(DebugProbes.scopeToString(this)) if(body != null) connection.doOutput = true connection.requestMethod = method.requestName diff --git a/api/src/main/kotlin/com/lhwdev/selfTestMacro/utils.kt b/api/src/main/kotlin/com/lhwdev/selfTestMacro/utils.kt index 23be47b1..bdd3d577 100644 --- a/api/src/main/kotlin/com/lhwdev/selfTestMacro/utils.kt +++ b/api/src/main/kotlin/com/lhwdev/selfTestMacro/utils.kt @@ -3,6 +3,7 @@ package com.lhwdev.selfTestMacro import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.encoding.Decoder @@ -14,6 +15,7 @@ import java.net.URLEncoder fun URL.child(childPath: String) = URL(this, path.removeSuffix("/") + "/" + childPath) // our project is too small to use form like 'major.minor.bugFixes' +@Serializable data class Version(val major: Int, val minor: Int) : Comparable { override fun compareTo(other: Version) = if(major == other.major) minor - other.minor @@ -21,13 +23,13 @@ data class Version(val major: Int, val minor: Int) : Comparable { } - fun Version(string: String): Version { val split = string.split('.').map { it.toInt() } require(split.size == 2) return Version(split[0], split[1]) } +@Serializable data class VersionSpec(val from: Version, val to: Version) { operator fun contains(version: Version) = version in from..to } diff --git a/api/src/main/resources/jsencrypt.min.js b/api/src/main/resources/jsencrypt.min.js deleted file mode 100644 index a005a6f8..00000000 --- a/api/src/main/resources/jsencrypt.min.js +++ /dev/null @@ -1 +0,0 @@ -/*fake*/var window={};var navigator={appName:'nodejs'};!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e(t.JSEncrypt={})}(this,function(t){"use strict";var e="0123456789abcdefghijklmnopqrstuvwxyz";function a(t){return e.charAt(t)}function i(t,e){return t&e}function u(t,e){return t|e}function r(t,e){return t^e}function n(t,e){return t&~e}function s(t){if(0==t)return-1;var e=0;return 0==(65535&t)&&(t>>=16,e+=16),0==(255&t)&&(t>>=8,e+=8),0==(15&t)&&(t>>=4,e+=4),0==(3&t)&&(t>>=2,e+=2),0==(1&t)&&++e,e}function o(t){for(var e=0;0!=t;)t&=t-1,++e;return e}var h="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";function c(t){var e,i,r="";for(e=0;e+3<=t.length;e+=3)i=parseInt(t.substring(e,e+3),16),r+=h.charAt(i>>6)+h.charAt(63&i);for(e+1==t.length?(i=parseInt(t.substring(e,e+1),16),r+=h.charAt(i<<2)):e+2==t.length&&(i=parseInt(t.substring(e,e+2),16),r+=h.charAt(i>>2)+h.charAt((3&i)<<4));0<(3&r.length);)r+="=";return r}function f(t){var e,i="",r=0,n=0;for(e=0;e>2),n=3&s,r=1):1==r?(i+=a(n<<2|s>>4),n=15&s,r=2):2==r?(i+=a(n),i+=a(s>>2),n=3&s,r=3):(i+=a(n<<2|s>>4),i+=a(15&s),r=0))}return 1==r&&(i+=a(n<<2)),i}var l,p=function(t,e){return(p=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i])})(t,e)};var g,d=function(t){var e;if(void 0===l){var i="0123456789ABCDEF",r=" \f\n\r\t \u2028\u2029";for(l={},e=0;e<16;++e)l[i.charAt(e)]=e;for(i=i.toLowerCase(),e=10;e<16;++e)l[i.charAt(e)]=e;for(e=0;e>16,r[r.length]=n>>8&255,r[r.length]=255&n,s=n=0):n<<=6}}switch(s){case 1:throw new Error("Base64 encoding incomplete: at least 2 bits missing");case 2:r[r.length]=n>>10;break;case 3:r[r.length]=n>>16,r[r.length]=n>>8&255}return r},re:/-----BEGIN [^-]+-----([A-Za-z0-9+\/=\s]+)-----END [^-]+-----|begin-base64[^\n]+\n([A-Za-z0-9+\/=\s]+)====/,unarmor:function(t){var e=v.re.exec(t);if(e)if(e[1])t=e[1];else{if(!e[2])throw new Error("RegExp out of sync");t=e[2]}return v.decode(t)}},m=1e13,y=function(){function t(t){this.buf=[+t||0]}return t.prototype.mulAdd=function(t,e){var i,r,n=this.buf,s=n.length;for(i=0;ie&&(t=t.substring(0,e)+b),t}var w,D=function(){function i(t,e){this.hexDigits="0123456789ABCDEF",t instanceof i?(this.enc=t.enc,this.pos=t.pos):(this.enc=t,this.pos=e)}return i.prototype.get=function(t){if(void 0===t&&(t=this.pos++),t>=this.enc.length)throw new Error("Requesting byte offset "+t+" on a stream of length "+this.enc.length);return"string"==typeof this.enc?this.enc.charCodeAt(t):this.enc[t]},i.prototype.hexByte=function(t){return this.hexDigits.charAt(t>>4&15)+this.hexDigits.charAt(15&t)},i.prototype.hexDump=function(t,e,i){for(var r="",n=t;n>u&1?"1":"0";if(s.length>i)return n+E(s,i)}return n+s},i.prototype.parseOctetString=function(t,e,i){if(this.isASCII(t,e))return E(this.parseStringISO(t,e),i);var r=e-t,n="("+r+" byte)\n";(i/=2)i)return E(r,i);n=new y,s=0}}return 0>6,this.tagConstructed=0!=(32&e),this.tagNumber=31&e,31==this.tagNumber){for(var i=new y;e=t.get(),i.mulAdd(128,127&e),128&e;);this.tagNumber=i.simplify()}}return t.prototype.isUniversal=function(){return 0===this.tagClass},t.prototype.isEOC=function(){return 0===this.tagClass&&0===this.tagNumber},t}(),B=[2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97,101,103,107,109,113,127,131,137,139,149,151,157,163,167,173,179,181,191,193,197,199,211,223,227,229,233,239,241,251,257,263,269,271,277,281,283,293,307,311,313,317,331,337,347,349,353,359,367,373,379,383,389,397,401,409,419,421,431,433,439,443,449,457,461,463,467,479,487,491,499,503,509,521,523,541,547,557,563,569,571,577,587,593,599,601,607,613,617,619,631,641,643,647,653,659,661,673,677,683,691,701,709,719,727,733,739,743,751,757,761,769,773,787,797,809,811,821,823,827,829,839,853,857,859,863,877,881,883,887,907,911,919,929,937,941,947,953,967,971,977,983,991,997],A=(1<<26)/B[B.length-1],O=function(){function b(t,e,i){null!=t&&("number"==typeof t?this.fromNumber(t,e,i):null==e&&"string"!=typeof t?this.fromString(t,256):this.fromString(t,e))}return b.prototype.toString=function(t){if(this.s<0)return"-"+this.negate().toString(t);var e;if(16==t)e=4;else if(8==t)e=3;else if(2==t)e=1;else if(32==t)e=5;else{if(4!=t)return this.toRadix(t);e=2}var i,r=(1<>h)&&(n=!0,s=a(i));0<=o;)h>(h+=this.DB-e)):(i=this[o]>>(h-=e)&r,h<=0&&(h+=this.DB,--o)),0>24},b.prototype.shortValue=function(){return 0==this.t?this.s:this[0]<<16>>16},b.prototype.signum=function(){return this.s<0?-1:this.t<=0||1==this.t&&this[0]<=0?0:1},b.prototype.toByteArray=function(){var t=this.t,e=[];e[0]=this.s;var i,r=this.DB-t*this.DB%8,n=0;if(0>r)!=(this.s&this.DM)>>r&&(e[n++]=i|this.s<>(r+=this.DB-8)):(i=this[t]>>(r-=8)&255,r<=0&&(r+=this.DB,--t)),0!=(128&i)&&(i|=-256),0==n&&(128&this.s)!=(128&i)&&++n,(0=this.t?0!=this.s:0!=(this[e]&1<>n-a&u:(f=(t[p]&(1<>this.DB+n-a)),h=i;0==(1&f);)f>>=1,--h;if((n-=h)<0&&(n+=this.DB,--p),g)o[f].copyTo(s),g=!1;else{for(;1this.DB?(this[this.t-1]|=(o&(1<>this.DB-s):this[this.t-1]|=o<=this.DB&&(s-=this.DB))}8==i&&0!=(128&+t[0])&&(this.s=-1,0>r|o,o=(this[h]&n)<=this.t)e.t=0;else{var r=t%this.DB,n=this.DB-r,s=(1<>r;for(var o=i+1;o>r;0>=this.DB;if(t.t>=this.DB;r+=this.s}else{for(r+=this.s;i>=this.DB;r-=t.s}e.s=r<0?-1:0,r<-1?e[i++]=this.DV+r:0=e.DV&&(t[i+e.t]-=e.DV,t[i+e.t+1]=1)}0>this.F2:0),l=this.FV/f,p=(1<=i&&(this.dMultiply(r),this.dAddOffset(o,0),o=s=0))}0t&&this.subTo(b.ONE.shiftLeft(t-1),this);else{var r=[],n=7&t;r.length=1+(t>>3),e.nextBytes(r),0>=this.DB;if(t.t>=this.DB;r+=this.s}else{for(r+=this.s;i>=this.DB;r+=t.s}e.s=r<0?-1:0,0=this.DV;)this[e]-=this.DV,++e>=this.t&&(this[this.t++]=0),++this[e]}},b.prototype.multiplyLowerTo=function(t,e,i){var r=Math.min(this.t+t.t,e);for(i.s=0,i.t=r;0>1)&&(t=B.length);for(var n=M(),s=0;st&&n.subTo(b.ONE.shiftLeft(t-1),n),n.isProbablePrime(e)?setTimeout(function(){r()},0):setTimeout(s,0)};setTimeout(s,0)}else{var o=[],h=7&t;o.length=1+(t>>3),e.nextBytes(o),0>15,this.um=(1<>15)*this.mpl&this.um)<<15)&t.DM;for(t[i=e+this.m.t]+=this.m.am(0,r,t,e,0,this.m.t);t[i]>=t.DV;)t[i]-=t.DV,t[++i]++}t.clamp(),t.drShiftTo(this.m.t,t),0<=t.compareTo(this.m)&&t.subTo(this.m,t)},t.prototype.mulTo=function(t,e,i){t.multiplyTo(e,i),this.reduce(i)},t.prototype.sqrTo=function(t,e){t.squareTo(e),this.reduce(e)},t}(),P=function(){function t(t){this.m=t,this.r2=M(),this.q3=M(),O.ONE.dlShiftTo(2*t.t,this.r2),this.mu=this.r2.divide(t)}return t.prototype.convert=function(t){if(t.s<0||t.t>2*this.m.t)return t.mod(this.m);if(t.compareTo(this.m)<0)return t;var e=M();return t.copyTo(e),this.reduce(e),e},t.prototype.revert=function(t){return t},t.prototype.reduce=function(t){for(t.drShiftTo(this.m.t-1,this.r2),t.t>this.m.t+1&&(t.t=this.m.t+1,t.clamp()),this.mu.multiplyUpperTo(this.r2,this.m.t+1,this.q3),this.m.multiplyLowerTo(this.q3,this.m.t+1,this.r2);t.compareTo(this.r2)<0;)t.dAddOffset(1,this.m.t+1);for(t.subTo(this.r2,t);0<=t.compareTo(this.m);)t.subTo(this.m,t)},t.prototype.mulTo=function(t,e,i){t.multiplyTo(e,i),this.reduce(i)},t.prototype.sqrTo=function(t,e){t.squareTo(e),this.reduce(e)},t}();function M(){return new O(null)}function q(t,e){return new O(t,e)}"Microsoft Internet Explorer"==navigator.appName?(O.prototype.am=function(t,e,i,r,n,s){for(var o=32767&e,h=e>>15;0<=--s;){var a=32767&this[t],u=this[t++]>>15,c=h*a+u*o;n=((a=o*a+((32767&c)<<15)+i[r]+(1073741823&n))>>>30)+(c>>>15)+h*u+(n>>>30),i[r++]=1073741823&a}return n},w=30):"Netscape"!=navigator.appName?(O.prototype.am=function(t,e,i,r,n,s){for(;0<=--s;){var o=e*this[t++]+i[r]+n;n=Math.floor(o/67108864),i[r++]=67108863&o}return n},w=26):(O.prototype.am=function(t,e,i,r,n,s){for(var o=16383&e,h=e>>14;0<=--s;){var a=16383&this[t],u=this[t++]>>14,c=h*a+u*o;n=((a=o*a+((16383&c)<<14)+i[r]+n)>>28)+(c>>14)+h*u,i[r++]=268435455&a}return n},w=28),O.prototype.DB=w,O.prototype.DM=(1<>>16)&&(t=e,i+=16),0!=(e=t>>8)&&(t=e,i+=8),0!=(e=t>>4)&&(t=e,i+=4),0!=(e=t>>2)&&(t=e,i+=2),0!=(e=t>>1)&&(t=e,i+=1),i}O.ZERO=F(0),O.ONE=F(1);var K=function(){function t(){this.i=0,this.j=0,this.S=[]}return t.prototype.init=function(t){var e,i,r;for(e=0;e<256;++e)this.S[e]=e;for(e=i=0;e<256;++e)i=i+this.S[e]+t[e%t.length]&255,r=this.S[e],this.S[e]=this.S[i],this.S[i]=r;this.i=0,this.j=0},t.prototype.next=function(){var t;return this.i=this.i+1&255,this.j=this.j+this.S[this.i]&255,t=this.S[this.i],this.S[this.i]=this.S[this.j],this.S[this.j]=t,this.S[t+this.S[this.i]&255]},t}();var k,_,z=256,Z=null;if(null==Z){Z=[];var G=void(_=0);if(window.crypto&&window.crypto.getRandomValues){var $=new Uint32Array(256);for(window.crypto.getRandomValues($),G=0;G<$.length;++G)Z[_++]=255&$[G]}var Y=function(t){if(this.count=this.count||0,256<=this.count||z<=_)window.removeEventListener?window.removeEventListener("mousemove",Y,!1):window.detachEvent&&window.detachEvent("onmousemove",Y);else try{var e=t.x+t.y;Z[_++]=255&e,this.count+=1}catch(t){}};window.addEventListener?window.addEventListener("mousemove",Y,!1):window.attachEvent&&window.attachEvent("onmousemove",Y)}function J(){if(null==k){for(k=new K;_>6|192):(i[--e]=63&n|128,i[--e]=n>>6&63|128,i[--e]=n>>12|224)}i[--e]=0;for(var s=new X,o=[];2>3);if(null==e)return null;var i=this.doPublic(e);if(null==i)return null;var r=i.toString(16);return 0==(1&r.length)?r:"0"+r},t.prototype.setPrivate=function(t,e,i){null!=t&&null!=e&&0>1;this.e=parseInt(e,16);for(var n=new O(e,16);;){for(;this.p=new O(t-r,1,i),0!=this.p.subtract(O.ONE).gcd(n).compareTo(O.ONE)||!this.p.isProbablePrime(10););for(;this.q=new O(r,1,i),0!=this.q.subtract(O.ONE).gcd(n).compareTo(O.ONE)||!this.q.isProbablePrime(10););if(this.p.compareTo(this.q)<=0){var s=this.p;this.p=this.q,this.q=s}var o=this.p.subtract(O.ONE),h=this.q.subtract(O.ONE),a=o.multiply(h);if(0==a.gcd(n).compareTo(O.ONE)){this.n=this.p.multiply(this.q),this.d=n.modInverse(a),this.dmp1=this.d.mod(o),this.dmq1=this.d.mod(h),this.coeff=this.q.modInverse(this.p);break}}},t.prototype.decrypt=function(t){var e=q(t,16),i=this.doPrivate(e);return null==i?null:function(t,e){var i=t.toByteArray(),r=0;for(;r=i.length)return null;var n="";for(;++r>3)},t.prototype.generateAsync=function(t,e,n){var s=new X,o=t>>1;this.e=parseInt(e,16);var h=new O(e,16),a=this,u=function(){var e=function(){if(a.p.compareTo(a.q)<=0){var t=a.p;a.p=a.q,a.q=t}var e=a.p.subtract(O.ONE),i=a.q.subtract(O.ONE),r=e.multiply(i);0==r.gcd(h).compareTo(O.ONE)?(a.n=a.p.multiply(a.q),a.d=h.modInverse(r),a.dmp1=a.d.mod(e),a.dmq1=a.d.mod(i),a.coeff=a.q.modInverse(a.p),setTimeout(function(){n()},0)):setTimeout(u,0)},i=function(){a.q=M(),a.q.fromNumberAsync(o,1,s,function(){a.q.subtract(O.ONE).gcda(h,function(t){0==t.compareTo(O.ONE)&&a.q.isProbablePrime(10)?setTimeout(e,0):setTimeout(i,0)})})},r=function(){a.p=M(),a.p.fromNumberAsync(t-o,1,s,function(){a.p.subtract(O.ONE).gcda(h,function(t){0==t.compareTo(O.ONE)&&a.p.isProbablePrime(10)?setTimeout(i,0):setTimeout(r,0)})})};setTimeout(r,0)};setTimeout(u,0)},t.prototype.sign=function(t,e,i){var r=function(t,e){if(e=e?t:new Array(e-t.length+1).join("0")+t},this.getString=function(){return this.s},this.setString=function(t){this.hTLV=null,this.isModified=!0,this.s=t,this.hV=stohex(t)},this.setByDateValue=function(t,e,i,r,n,s){var o=new Date(Date.UTC(t,e-1,i,r,n,s,0));this.setByDate(o)},this.getFreshValueHex=function(){return this.hV}},tt.lang.extend(et.asn1.DERAbstractTime,et.asn1.ASN1Object),et.asn1.DERAbstractStructured=function(t){et.asn1.DERAbstractString.superclass.constructor.call(this),this.setByASN1ObjectArray=function(t){this.hTLV=null,this.isModified=!0,this.asn1Array=t},this.appendASN1Object=function(t){this.hTLV=null,this.isModified=!0,this.asn1Array.push(t)},this.asn1Array=new Array,void 0!==t&&void 0!==t.array&&(this.asn1Array=t.array)},tt.lang.extend(et.asn1.DERAbstractStructured,et.asn1.ASN1Object),et.asn1.DERBoolean=function(){et.asn1.DERBoolean.superclass.constructor.call(this),this.hT="01",this.hTLV="0101ff"},tt.lang.extend(et.asn1.DERBoolean,et.asn1.ASN1Object),et.asn1.DERInteger=function(t){et.asn1.DERInteger.superclass.constructor.call(this),this.hT="02",this.setByBigInteger=function(t){this.hTLV=null,this.isModified=!0,this.hV=et.asn1.ASN1Util.bigIntToMinTwosComplementsHex(t)},this.setByInteger=function(t){var e=new O(String(t),10);this.setByBigInteger(e)},this.setValueHex=function(t){this.hV=t},this.getFreshValueHex=function(){return this.hV},void 0!==t&&(void 0!==t.bigint?this.setByBigInteger(t.bigint):void 0!==t.int?this.setByInteger(t.int):"number"==typeof t?this.setByInteger(t):void 0!==t.hex&&this.setValueHex(t.hex))},tt.lang.extend(et.asn1.DERInteger,et.asn1.ASN1Object),et.asn1.DERBitString=function(t){if(void 0!==t&&void 0!==t.obj){var e=et.asn1.ASN1Util.newObject(t.obj);t.hex="00"+e.getEncodedHex()}et.asn1.DERBitString.superclass.constructor.call(this),this.hT="03",this.setHexValueIncludingUnusedBits=function(t){this.hTLV=null,this.isModified=!0,this.hV=t},this.setUnusedBitsAndHexValue=function(t,e){if(t<0||7 - \ No newline at end of file + diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/FirstActivity.kt b/app/src/main/java/com/lhwdev/selfTestMacro/FirstActivity.kt index e06db495..c33b5c66 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/FirstActivity.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/FirstActivity.kt @@ -1,16 +1,28 @@ package com.lhwdev.selfTestMacro +import android.content.DialogInterface +import android.content.Intent import android.os.Bundle import android.view.ViewGroup import android.widget.ArrayAdapter +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.res.ResourcesCompat import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.lhwdev.selfTestMacro.api.* import kotlinx.android.synthetic.main.activity_first.* import kotlinx.coroutines.launch +// TODO list: +// * use view binding +// * use preference fragment etc. +// * use better model, like MVVM. This page is small so whole code is not complicated, but +// on larger project, this imperative style lacks. + + class FirstActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -22,23 +34,26 @@ class FirstActivity : AppCompatActivity() { setSupportActionBar(toolbar) - var schoolInfo: com.lhwdev.selfTestMacro.api.SchoolInfo? = null + var schoolInfo: SchoolInfo? = null + + fun adapter(list: List) = + ArrayAdapter(this, android.R.layout.simple_list_item_1, list) + + input_loginType.setAdapter(adapter(listOf("학교"))) - spinner_region.adapter = - ArrayAdapter(this, android.R.layout.simple_list_item_1, sRegions.keys.toList()) - spinner_level.adapter = - ArrayAdapter(this, android.R.layout.simple_list_item_1, sSchoolLevels.keys.toList()) + input_region.setAdapter(adapter(sRegions.keys.toList())) + input_level.setAdapter(adapter(sSchoolLevels.keys.toList())) button_checkSchoolInfo.setOnClickListener { - if(spinner_region.selectedItemPosition == -1) { - showToast("시/도 정보를 입력해주세요") - return@setOnClickListener - } - - if(spinner_level.selectedItemPosition == -1) { - showToast("시/도 정보를 입력해주세요") - return@setOnClickListener - } +// if(input_region.) { +// showToast("시/도 정보를 입력해주세요") +// return@setOnClickListener +// } +// +// if(spinner_level.selectedItemPosition == -1) { +// showToast("시/도 정보를 입력해주세요") +// return@setOnClickListener +// } val schoolName = input_schoolName.text if(schoolName == null || schoolName.isEmpty()) { @@ -47,25 +62,49 @@ class FirstActivity : AppCompatActivity() { } val nameString = schoolName.toString() - val regionCode = sRegions[spinner_region.selectedItem]!! - val levelCode = sSchoolLevels[spinner_level.selectedItem]!! - + val regionCode = sRegions.getValue(input_region.text.toString()) + val levelCode = sSchoolLevels.getValue(input_level.text.toString()) + lifecycleScope.launch { - val data = com.lhwdev.selfTestMacro.api.getSchoolData(regionCode = regionCode, schoolLevelCode = levelCode.toString(), name = nameString) - if(data.schoolList.isEmpty()) { - showToast("학교를 찾을 수 없습니다. 이름을 바르게 입력했는지 확인해주세요.") - return@launch - } + val snackbar = + Snackbar.make(button_checkSchoolInfo, "잠시만 기다려주세요", Snackbar.LENGTH_INDEFINITE) + snackbar.show() - if(data.schoolList.size == 1) { - val school = data.schoolList[0] + fun selectSchool(school: SchoolInfo) { schoolInfo = school // ui interaction schoolName.clear() schoolName.append(school.name) - button_checkSchoolInfo.icon = ResourcesCompat.getDrawable(resources, R.drawable.ic_baseline_check_24, theme) + button_checkSchoolInfo.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_baseline_check_24, + theme + ) } + + val data = getSchoolData( + regionCode = regionCode, + schoolLevelCode = levelCode.toString(), + name = nameString, + loginType = LoginType.school /* TODO */ + ) + snackbar.dismiss() + if(data.schoolList.isEmpty()) { + showToast("학교를 찾을 수 없습니다. 이름을 바르게 입력했는지 확인해주세요.") + return@launch + } + + if(data.schoolList.size == 1) + selectSchool(data.schoolList[0]) + else AlertDialog.Builder(this@FirstActivity).apply { + setTitle("학교를 선택해주세요") + setItems( + data.schoolList.map { "${it.name}(${it.address})" }.toTypedArray(), + DialogInterface.OnClickListener { _, which -> + selectSchool(data.schoolList[which]) + }) + }.show() } } @@ -77,7 +116,7 @@ class FirstActivity : AppCompatActivity() { input_studentBirth.error = null } - button_done.setOnClickListener onClick@ { + button_done.setOnClickListener onClick@{ val school = schoolInfo ?: run { button_checkSchoolInfo.callOnClick() schoolInfo ?: return@onClick @@ -97,6 +136,9 @@ class FirstActivity : AppCompatActivity() { val name = input_studentName.text!!.toString() val birth = input_studentBirth.text!!.toString() + +// require(input_loginType.selectedItemPosition == 0) + if(birth.length != 6) { input_studentBirth.requestFocus() input_studentBirth.error = "생년월일을 6자리로 입력해주세요. (주민등록번호 앞 6자리, YYMMDD 형식)" @@ -104,8 +146,59 @@ class FirstActivity : AppCompatActivity() { } lifecycleScope.launch { - com.lhwdev.selfTestMacro.api.findUser() + // TODO: show progress + try { + val token = findUser( + school, + GetUserTokenRequestBody( + schoolInfo = school, + name = name, + birthday = birth, + loginType = LoginType.school /* TODO */ + ) + ) + val groups = getUserGroup(school, token) + if(groups.isEmpty()) { + showToastSuspendAsync("해당 정보의 학생을 찾지 못했습니다.") + return@launch + } + if(groups.size != 1) { + showToastSuspendAsync("여러명의 자가진단은 아직 지원하지 않습니다.") + } + val userInfo = groups.single() + + pref.school = school + pref.user = userInfo + pref.setting = UserSetting( + loginType = LoginType.school, // TODO + region = sRegions.getValue(input_region.text.toString()), + level = sSchoolLevels.getValue(input_level.text.toString()), + schoolName = school.name, + studentName = name, + studentBirth = birth + ) + + // success + finish() + + if(first) { + startActivity(Intent(this@FirstActivity, MainActivity::class.java)) + pref.firstState = 1 + } + } catch(e: Throwable) { + showToastSuspendAsync("잘못된 학생 정보입니다.") + } + } } + + pref.setting?.let { setting -> + input_loginType.setText("학교") // TODO + input_region.setText(sRegions.entries.first { it.value == setting.region}.key) + input_level.setText(sSchoolLevels.entries.first { it.value == setting.level }.key) + input_schoolName.setText(setting.schoolName) + input_studentName.setText(setting.studentName) + input_studentBirth.setText(setting.studentBirth) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/MainActivity.kt b/app/src/main/java/com/lhwdev/selfTestMacro/MainActivity.kt index eebc5b25..e0ffca73 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/MainActivity.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/MainActivity.kt @@ -9,6 +9,7 @@ import android.os.Bundle import android.os.PowerManager import android.provider.Settings import android.text.method.LinkMovementMethod +import android.util.Log import android.view.Menu import android.view.MenuItem import android.widget.TextView @@ -17,13 +18,14 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.text.HtmlCompat import androidx.lifecycle.lifecycleScope -import kotlinx.android.synthetic.main.activity_main.toolbar -import kotlinx.android.synthetic.main.content_main.submit -import kotlinx.android.synthetic.main.content_main.time +import com.lhwdev.selfTestMacro.api.getDetailedUserInfo +import kotlinx.android.synthetic.main.activity_main.* +import kotlinx.android.synthetic.main.content_main.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.json.JSONObject +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import java.net.URL @@ -58,7 +60,26 @@ class MainActivity : AppCompatActivity() { initializeNotificationChannel() checkNotice() - title = "자가진단: ${pref.user.userInfoToString()}" + @SuppressLint("SetTextI18n") + suspend fun updateCurrentState() = withContext(Dispatchers.IO) { + val detailedUserInfo = try { + getDetailedUserInfo(pref.school!!, pref.user!!) + } catch(e: Throwable) { + Log.e("hOI", null, e) + showToastSuspendAsync("사용자 정보를 불러오지 못했습니다.") + return@withContext + } + + withContext(Dispatchers.Main) { + @Suppress("SetTextI18n") + text_currentUserState.text = + detailedUserInfo.toUserInfoString() + "\n" + detailedUserInfo.toLastRegisterInfoString() + } + } + + lifecycleScope.launch { + updateCurrentState() + } val intent = createIntent() @@ -100,44 +121,58 @@ class MainActivity : AppCompatActivity() { submit.setOnClickListener { lifecycleScope.launch { - submitSuspend() + submitSuspend(false) + updateCurrentState() } } } + @Serializable + data class NotificationObject( + val notificationVersion: Int, + val entries: List + ) + + @Serializable + data class NotificationEntry( + val id: String, + val version: VersionSpec?, // null to all versions + val priority: Priority, + val title: String, + val message: String + ) { + enum class Priority { once, every } + } + private fun checkNotice() = lifecycleScope.launch(Dispatchers.IO) { try { - val versions = - JSONObject(URL("https://raw.githubusercontent.com/wiki/lhwdev/covid-selftest-macro/_notice.md").readText()) + val content = + URL("https://raw.githubusercontent.com/wiki/lhwdev/covid-selftest-macro/_notice").readText() - /* - * Spec: - * {"$version | all": {"id": "$id", "priority": "once | every", "title": "$title", "message": "$message"}} - * - * Version spec: 1.0 1.3..2.1 ..1.5 - */ + val notificationObject = Json { + ignoreUnknownKeys = true /* loose */ + }.decodeFromString(NotificationObject.serializer(), content) - val thisVersion = Version(BuildConfig.VERSION_NAME) + if(notificationObject.notificationVersion != 2) { + // incapable of displaying this + return@launch + } - for(key in versions.keys()) if(key == "all" || thisVersion in VersionSpec(key)) { - val notice = Notice(versions.getJSONObject(key)) - val show = when(notice.priority) { - Notice.Priority.once -> notice.id !in preferenceState.shownNotices - Notice.Priority.every -> true + for(entry in notificationObject.entries) { + val show = when(entry.priority) { + NotificationEntry.Priority.once -> entry.id !in preferenceState.shownNotices + NotificationEntry.Priority.every -> true } if(show) withContext(Dispatchers.Main) { AlertDialog.Builder(this@MainActivity).apply { - setTitle(notice.title) - val message = HtmlCompat.fromHtml(notice.message, 0) - setMessage(message) + setTitle(entry.title) + setMessage(HtmlCompat.fromHtml(entry.message, 0)) setPositiveButton("확인", null) }.show().apply { findViewById(android.R.id.message)!!.movementMethod = LinkMovementMethod.getInstance() } - - preferenceState.shownNotices += notice.id } } } catch(e: Exception) { @@ -161,7 +196,8 @@ class MainActivity : AppCompatActivity() { if(!batteryOptimizationPromptShown && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M - && !pwrm.isIgnoringBatteryOptimizations(name)) { + && !pwrm.isIgnoringBatteryOptimizations(name) + ) { AlertDialog.Builder(this).apply { setTitle("베터리 최적화 설정을 꺼야 알림 기능이 정상적으로 작동됩니다.") setPositiveButton("설정") { _, _ -> diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/MainApplication.kt b/app/src/main/java/com/lhwdev/selfTestMacro/MainApplication.kt new file mode 100644 index 00000000..6fd5ce7b --- /dev/null +++ b/app/src/main/java/com/lhwdev/selfTestMacro/MainApplication.kt @@ -0,0 +1,13 @@ +package com.lhwdev.selfTestMacro + +import androidx.multidex.MultiDexApplication + + +@Suppress("unused") +class MainApplication : MultiDexApplication() { + override fun onCreate() { + super.onCreate() + sDummyForInitialization + sDebugFetch = BuildConfig.DEBUG + } +} diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/notification.kt b/app/src/main/java/com/lhwdev/selfTestMacro/notification.kt index a60182a9..a426c5c5 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/notification.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/notification.kt @@ -7,14 +7,13 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import java.text.DateFormat -import java.util.Date object TestCompleteNotification { const val id = "com.lhwdev.selfTestMacro/selfTestCompleted" const val notificationId = 123 - const val name = "자가진단 완료" + const val failedName = "자가진단에 실패하였습니다." + const val successName = "자가진단을 완료하였습니다." const val description = "자가진단을 완료하면 알람을 표시합니다." val content = { time: String -> "자가진단을 ${time}에 완료했습니다" } @@ -28,7 +27,7 @@ fun Context.initializeNotificationChannel() { // selfTestCompleted val channel = NotificationChannel( TestCompleteNotification.id, - TestCompleteNotification.name, + TestCompleteNotification.successName, TestCompleteNotification.importance ).apply { description = TestCompleteNotification.description @@ -43,7 +42,7 @@ fun Context.initializeNotificationChannel() { fun Context.showTestCompleteNotification(time: String) { val builder = NotificationCompat.Builder(this, TestCompleteNotification.id).apply { setSmallIcon(R.drawable.ic_launcher_foreground) - setContentTitle(TestCompleteNotification.name) + setContentTitle(TestCompleteNotification.successName) setContentText(TestCompleteNotification.content(time)) priority = NotificationCompat.PRIORITY_DEFAULT } @@ -53,3 +52,17 @@ fun Context.showTestCompleteNotification(time: String) { notify(TestCompleteNotification.notificationId, builder.build()) } } + +fun Context.showTestFailedNotification(detailedMessage: String) { + val builder = NotificationCompat.Builder(this, TestCompleteNotification.id).apply { + setSmallIcon(R.drawable.ic_launcher_foreground) + setContentTitle(TestCompleteNotification.failedName) + setContentText(TestCompleteNotification.content(detailedMessage)) + priority = NotificationCompat.PRIORITY_DEFAULT + } + + with(NotificationManagerCompat.from(this)) { + // notificationId is a unique int for each notification that you must define + notify(TestCompleteNotification.notificationId, builder.build()) + } +} diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/selfTestUtils.kt b/app/src/main/java/com/lhwdev/selfTestMacro/selfTestUtils.kt index 8f147611..fe2c2339 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/selfTestUtils.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/selfTestUtils.kt @@ -1,4 +1,5 @@ @file:Suppress("SpellCheckingInspection") +@file:JvmName("AndroidSelfTestUtils") package com.lhwdev.selfTestMacro @@ -7,9 +8,30 @@ import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.util.Log +import com.lhwdev.selfTestMacro.api.SurveyData +import com.lhwdev.selfTestMacro.api.registerSurvey import java.util.Calendar +suspend fun Context.submitSuspend(notification: Boolean = true) { + try { + val user = preferenceState.user!! + val token = user.token + + val result = registerSurvey( + preferenceState.school!!, + token, + SurveyData(userToken = token.token, userName = user.name) + ) + if(notification) showTestCompleteNotification(result.registerAt) + else { + showToastSuspendAsync("자가진단 제출 완료") + } + } catch(e: Throwable) { + showTestFailedNotification(e.stackTraceToString()) + } +} + fun Context.updateTime(intent: PendingIntent) { val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager alarmManager.cancel(intent) diff --git a/app/src/main/java/com/lhwdev/selfTestMacro/utils.kt b/app/src/main/java/com/lhwdev/selfTestMacro/utils.kt index 8db11f0e..21b7734a 100644 --- a/app/src/main/java/com/lhwdev/selfTestMacro/utils.kt +++ b/app/src/main/java/com/lhwdev/selfTestMacro/utils.kt @@ -1,3 +1,5 @@ +@file:JvmName("AndroidUtils") + package com.lhwdev.selfTestMacro import android.app.PendingIntent @@ -5,18 +7,35 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Handler +import android.util.Base64 +import android.view.View import android.widget.EditText import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit +import com.google.android.material.snackbar.Snackbar +import com.lhwdev.selfTestMacro.api.LoginType +import com.lhwdev.selfTestMacro.api.SchoolInfo +import com.lhwdev.selfTestMacro.api.UserInfo +import com.lhwdev.selfTestMacro.api.encodeBase64 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.StringFormat import kotlinx.serialization.json.Json import org.json.JSONObject import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty +val sDummyForInitialization: Unit = run { + // NO_WRAP: this is where I was confused for a few days + encodeBase64 = { Base64.encodeToString(it, Base64.NO_WRAP) } +} + + // {"id": "$id", "priority": "once | every", "title": "$title", "message": "$message"} class Notice(obj: JSONObject) { enum class Priority { once, every } @@ -35,11 +54,21 @@ class Notice(obj: JSONObject) { fun EditText.isEmpty() = text == null || text.isEmpty() +@Serializable +data class UserSetting( + val loginType: LoginType, + val region: String, + val level: Int, + val schoolName: String, + val studentName: String, + val studentBirth: String +) + class PreferenceState(val pref: SharedPreferences) { init { // version migration when(pref.getInt("lastVersion", -1)) { - -1 -> pref.edit { clear() } + in -1..999 -> pref.edit { clear() } BuildConfig.VERSION_CODE -> Unit // latest } @@ -50,18 +79,9 @@ class PreferenceState(val pref: SharedPreferences) { var hour by pref.preferenceInt("hour", -1) var min by pref.preferenceInt("min", 0) - var userCache: TestUser? = null - - var user: TestUser - get() = userCache ?: run { - val value = Json.decodeFromString(TestUser.serializer(), pref.getString("user", null)!!) - userCache = value - value - } - set(value) { - userCache = value - pref.edit { putString("user", Json.encodeToString(TestUser.serializer(), value)) } - } + var user by pref.preferenceSerialized("userInfo", UserInfo.serializer()) + var school by pref.preferenceSerialized("schoolInfo", SchoolInfo.serializer()) + var setting by pref.preferenceSerialized("userSetting", UserSetting.serializer()) var shownNotices: Set get() = pref.getStringSet("shownNotices", setOf())!! @@ -93,6 +113,30 @@ fun SharedPreferences.preferenceString(key: String, defaultValue: String? = null getString(key, defaultValue) } +@OptIn(ExperimentalSerializationApi::class) +fun SharedPreferences.preferenceSerialized(key: String, serializer: KSerializer, formatter: StringFormat = Json) = + object : ReadWriteProperty { + var updated = false + var cache: T? = null + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { + cache = value + updated = true + edit { + if(value == null) remove(key) + else putString(key, formatter.encodeToString(serializer, value)) + } + } + + override fun getValue(thisRef: Any?, property: KProperty<*>): T? { + if(!updated) { + val string = getString(key, null) + cache = if(string == null) null else formatter.decodeFromString(serializer, string) + } + return cache + } + } + fun Context.prefMain() = getSharedPreferences("main", AppCompatActivity.MODE_PRIVATE) @@ -113,4 +157,8 @@ suspend fun Context.showToastSuspendAsync(message: String, isLong: Boolean = fal fun Context.showToast(message: String, isLong: Boolean = false) { Toast.makeText(this, message, if(isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show() -} \ No newline at end of file +} + +fun View.showSnackBar(message: String, duration: Int = 3000) { + Snackbar.make(this, message, duration).show() +} diff --git a/app/src/main/res/layout/activity_first.xml b/app/src/main/res/layout/activity_first.xml index 67a9cdb3..34e7517f 100644 --- a/app/src/main/res/layout/activity_first.xml +++ b/app/src/main/res/layout/activity_first.xml @@ -6,7 +6,7 @@ android:layout_height="match_parent" android:background="@color/design_default_color_background" tools:context=".FirstActivity" - tools:ignore="HardcodedText"> + tools:ignore="HardcodedText, LabelFor"> - - + + - + android:layout_margin="8dp"> + + - + + android:text="학교 선택" + android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" + android:textColor="?attr/colorPrimary" /> + + + + - + + + + android:layout_margin="8dp"> + + + + + app:hintEnabled="true" + android:layout_margin="8dp"> @@ -142,10 +171,11 @@ + app:counterMaxLength="6" + app:hintAnimationEnabled="true" + app:hintEnabled="true"> - - + +