[APIv2/Szkolny] Add Szkolny API and add getting shared events.

This commit is contained in:
Kacper Ziubryniewicz 2019-12-09 16:35:37 +01:00
parent d6f9b81de6
commit 40ba9e8434
20 changed files with 349 additions and 22 deletions

View File

@ -170,6 +170,9 @@ dependencies {
implementation 'com.github.kuba2k2:RecyclerTabLayout:700f980584' implementation 'com.github.kuba2k2:RecyclerTabLayout:700f980584'
implementation 'com.github.kuba2k2:Tachyon:551943a6b5' implementation 'com.github.kuba2k2:Tachyon:551943a6b5'
implementation "com.squareup.retrofit2:retrofit:${versions.retrofit}"
implementation "com.squareup.retrofit2:converter-gson:${versions.retrofit}"
} }
repositories { repositories {
mavenCentral() mavenCentral()

View File

@ -222,7 +222,7 @@ public class App extends androidx.multidex.MultiDexApplication implements Config
byte[] signatureBytes = signature.toByteArray(); byte[] signatureBytes = signature.toByteArray();
MessageDigest md = MessageDigest.getInstance("SHA"); MessageDigest md = MessageDigest.getInstance("SHA");
md.update(signatureBytes); md.update(signatureBytes);
this.signature = Base64.encodeToString(md.digest(), Base64.DEFAULT); this.signature = Base64.encodeToString(md.digest(), Base64.NO_WRAP);
//Log.d(TAG, "Signature is "+this.signature); //Log.d(TAG, "Signature is "+this.signature);
} }
} }

View File

@ -15,6 +15,8 @@ import android.text.*
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.StrikethroughSpan import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Base64.NO_WRAP
import android.util.Base64.encodeToString
import android.util.LongSparseArray import android.util.LongSparseArray
import android.util.SparseArray import android.util.SparseArray
import android.util.TypedValue import android.util.TypedValue
@ -34,15 +36,20 @@ import im.wangchao.mhttp.Response
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.RequestBody
import okio.Buffer
import pl.szczodrzynski.edziennik.data.db.modules.profiles.Profile import pl.szczodrzynski.edziennik.data.db.modules.profiles.Profile
import pl.szczodrzynski.edziennik.data.db.modules.teachers.Teacher import pl.szczodrzynski.edziennik.data.db.modules.teachers.Teacher
import pl.szczodrzynski.edziennik.data.db.modules.teams.Team import pl.szczodrzynski.edziennik.data.db.modules.teams.Team
import pl.szczodrzynski.edziennik.utils.models.Time import pl.szczodrzynski.edziennik.utils.models.Time
import pl.szczodrzynski.navlib.getColorFromAttr
import pl.szczodrzynski.navlib.getColorFromRes import pl.szczodrzynski.navlib.getColorFromRes
import java.math.BigInteger
import java.security.MessageDigest
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.zip.CRC32 import java.util.zip.CRC32
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
fun List<Teacher>.byId(id: Long) = firstOrNull { it.id == id } fun List<Teacher>.byId(id: Long) = firstOrNull { it.id == id }
@ -358,6 +365,28 @@ fun String.crc32(): Long {
return crc.value return crc.value
} }
fun String.hmacSHA1(password: String): String {
val key = SecretKeySpec(password.toByteArray(), "HmacSHA1")
val mac = Mac.getInstance("HmacSHA1").apply {
init(key)
update(this@hmacSHA1.toByteArray())
}
return encodeToString(mac.doFinal(), NO_WRAP)
}
fun String.md5(): String {
val md = MessageDigest.getInstance("MD5")
return BigInteger(1, md.digest(toByteArray())).toString(16).padStart(32, '0')
}
fun RequestBody.bodyToString(): String {
val buffer = Buffer()
writeTo(buffer)
return buffer.readUtf8()
}
fun Long.formatDate(format: String = "yyyy-MM-dd HH:mm:ss"): String = SimpleDateFormat(format).format(this) fun Long.formatDate(format: String = "yyyy-MM-dd HH:mm:ss"): String = SimpleDateFormat(format).format(this)
fun CharSequence?.asColoredSpannable(colorInt: Int): Spannable { fun CharSequence?.asColoredSpannable(colorInt: Int): Spannable {

View File

@ -15,12 +15,10 @@ import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.api.v2.events.* import pl.szczodrzynski.edziennik.api.v2.events.*
import pl.szczodrzynski.edziennik.api.v2.events.requests.ServiceCloseRequest import pl.szczodrzynski.edziennik.api.v2.events.requests.ServiceCloseRequest
import pl.szczodrzynski.edziennik.api.v2.events.requests.TaskCancelRequest import pl.szczodrzynski.edziennik.api.v2.events.requests.TaskCancelRequest
import pl.szczodrzynski.edziennik.api.v2.events.task.EdziennikTask import pl.szczodrzynski.edziennik.api.v2.events.task.*
import pl.szczodrzynski.edziennik.api.v2.events.task.ErrorReportTask
import pl.szczodrzynski.edziennik.api.v2.events.task.IApiTask
import pl.szczodrzynski.edziennik.api.v2.events.task.NotifyTask
import pl.szczodrzynski.edziennik.api.v2.interfaces.EdziennikCallback import pl.szczodrzynski.edziennik.api.v2.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.api.v2.models.ApiError import pl.szczodrzynski.edziennik.api.v2.models.ApiError
import pl.szczodrzynski.edziennik.data.db.modules.profiles.ProfileFull
import pl.szczodrzynski.edziennik.utils.Utils.d import pl.szczodrzynski.edziennik.utils.Utils.d
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -41,8 +39,8 @@ class ApiService : Service() {
private val app by lazy { applicationContext as App } private val app by lazy { applicationContext as App }
private val finishingTaskQueue = mutableListOf( private val finishingTaskQueue = mutableListOf(
NotifyTask(), ServerSyncTask(),
ErrorReportTask() NotifyTask()
) )
private val taskQueue = mutableListOf<IApiTask>() private val taskQueue = mutableListOf<IApiTask>()
private val errorList = mutableListOf<ApiError>() private val errorList = mutableListOf<ApiError>()
@ -63,6 +61,8 @@ class ApiService : Service() {
private var lastEventTime = System.currentTimeMillis() private var lastEventTime = System.currentTimeMillis()
private var taskCancelTries = 0 private var taskCancelTries = 0
private val syncingProfiles = mutableListOf<ProfileFull>()
/* ______ _ _ _ _ _____ _ _ _ _ /* ______ _ _ _ _ _____ _ _ _ _
| ____| | | (_) (_) | / ____| | | | | | | | ____| | | (_) (_) | / ____| | | | | | |
| |__ __| |_____ ___ _ __ _ __ _| | __ | | __ _| | | |__ __ _ ___| | __ | |__ __| |_____ ___ _ __ _ __ _| | __ | | __ _| | | |__ __ _ ___| | __
@ -156,11 +156,14 @@ class ApiService : Service() {
// post an event // post an event
EventBus.getDefault().post(ApiTaskStartedEvent(taskProfileId, task.profile)) EventBus.getDefault().post(ApiTaskStartedEvent(taskProfileId, task.profile))
task.profile?.let { syncingProfiles.add(it) }
try { try {
when (task) { when (task) {
is EdziennikTask -> task.run(app, taskCallback) is EdziennikTask -> task.run(app, taskCallback)
is NotifyTask -> task.run(app, taskCallback) is NotifyTask -> task.run(app, taskCallback)
is ErrorReportTask -> task.run(app, taskCallback, notification, errorList) is ErrorReportTask -> task.run(app, taskCallback, notification, errorList)
is ServerSyncTask -> task.run(app, syncingProfiles, taskCallback)
} }
} catch (e: Exception) { } catch (e: Exception) {
taskCallback.onError(ApiError(TAG, EXCEPTION_API_TASK).withThrowable(e)) taskCallback.onError(ApiError(TAG, EXCEPTION_API_TASK).withThrowable(e))

View File

@ -39,7 +39,7 @@ open class EdziennikTask(override val profileId: Int, val request: Any) : IApiTa
taskName = app.getString(R.string.edziennik_notification_api_first_login_title) taskName = app.getString(R.string.edziennik_notification_api_first_login_title)
} else { } else {
// get the requested profile and login store // get the requested profile and login store
val profile = app.db.profileDao().getByIdNow(profileId) val profile = app.db.profileDao().getFullByIdNow(profileId)
this.profile = profile this.profile = profile
if (profile == null) { if (profile == null) {
return return

View File

@ -11,11 +11,11 @@ import android.os.Build.VERSION_CODES.O
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.App import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.api.v2.ApiService import pl.szczodrzynski.edziennik.api.v2.ApiService
import pl.szczodrzynski.edziennik.data.db.modules.profiles.Profile import pl.szczodrzynski.edziennik.data.db.modules.profiles.ProfileFull
abstract class IApiTask(open val profileId: Int) { abstract class IApiTask(open val profileId: Int) {
var taskId: Int = 0 var taskId: Int = 0
var profile: Profile? = null var profile: ProfileFull? = null
var taskName: String? = null var taskName: String? = null
/** /**
@ -39,4 +39,4 @@ abstract class IApiTask(open val profileId: Int) {
override fun toString(): String { override fun toString(): String {
return "IApiTask(profileId=$profileId, taskId=$taskId, profile=$profile, taskName=$taskName)" return "IApiTask(profileId=$profileId, taskId=$taskId, profile=$profile, taskName=$taskName)"
} }
} }

View File

@ -7,6 +7,9 @@ package pl.szczodrzynski.edziennik.api.v2.events.task
import pl.szczodrzynski.edziennik.App import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.api.v2.interfaces.EdziennikCallback import pl.szczodrzynski.edziennik.api.v2.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.api.v2.szkolny.SzkolnyApi
import pl.szczodrzynski.edziennik.data.db.modules.metadata.Metadata
import pl.szczodrzynski.edziennik.data.db.modules.profiles.ProfileFull
class ServerSyncTask : IApiTask(-1) { class ServerSyncTask : IApiTask(-1) {
override fun prepare(app: App) { override fun prepare(app: App) {
@ -17,9 +20,25 @@ class ServerSyncTask : IApiTask(-1) {
} }
fun run(app: App, taskCallback: EdziennikCallback) { fun run(app: App, profiles: List<ProfileFull>, taskCallback: EdziennikCallback) {
val api = SzkolnyApi(app, profiles)
val events = api.getEvents()
if (events.isNotEmpty()) {
app.db.eventDao().addAll(events)
app.db.metadataDao().addAllIgnore(events.map { event ->
Metadata(
event.profileId,
Metadata.TYPE_EVENT,
event.id,
event.seen,
event.notified,
event.addedDate
)
})
}
taskCallback.onCompleted() taskCallback.onCompleted()
} }
} }

View File

@ -0,0 +1,99 @@
/*
* Copyright (c) Kacper Ziubryniewicz 2019-12-8
*/
package pl.szczodrzynski.edziennik.api.v2.szkolny
import android.os.Build
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.BuildConfig
import pl.szczodrzynski.edziennik.api.v2.szkolny.adapter.DateAdapter
import pl.szczodrzynski.edziennik.api.v2.szkolny.adapter.TimeAdapter
import pl.szczodrzynski.edziennik.api.v2.szkolny.interceptor.SignatureInterceptor
import pl.szczodrzynski.edziennik.api.v2.szkolny.request.ServerSyncRequest
import pl.szczodrzynski.edziennik.data.db.modules.events.EventFull
import pl.szczodrzynski.edziennik.data.db.modules.profiles.ProfileFull
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.Time
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import java.util.concurrent.TimeUnit.SECONDS
class SzkolnyApi(val app: App, val profiles: List<ProfileFull>) {
private var api: SzkolnyService
init {
val okHttpClient: OkHttpClient = app.http.newBuilder()
.followRedirects(true)
.callTimeout(30, SECONDS)
.addInterceptor(SignatureInterceptor(app))
.build()
val gsonConverterFactory = GsonConverterFactory.create(
GsonBuilder()
.setLenient()
.registerTypeAdapter(Date::class.java, DateAdapter())
.registerTypeAdapter(Time::class.java, TimeAdapter())
.create())
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl("https://api.szkolny.eu/")
.addConverterFactory(gsonConverterFactory)
.client(okHttpClient)
.build()
api = retrofit.create()
}
fun getEvents(): List<EventFull> {
val teams = app.db.teamDao().allNow
val response = api.serverSync(ServerSyncRequest(
deviceId = app.deviceId,
device = ServerSyncRequest.Device(
osType = "Android",
osVersion = Build.VERSION.RELEASE,
hardware = "${Build.MANUFACTURER} ${Build.MODEL}",
pushToken = app.config.sync.tokenApp,
appVersion = BuildConfig.VERSION_NAME,
appType = BuildConfig.BUILD_TYPE,
appVersionCode = BuildConfig.VERSION_CODE,
syncInterval = app.config.sync.interval
),
userCodes = profiles.map { it.usernameId },
users = profiles.map { profile ->
ServerSyncRequest.User(
profile.usernameId,
profile.studentNameLong ?: "",
profile.studentNameShort ?: "",
profile.loginStoreType,
teams.filter { it.profileId == profile.id }.map { it.code }
)
}
)).execute().body()
val events = mutableListOf<EventFull>()
response?.data?.events?.forEach { event ->
teams.filter { it.code == event.teamCode }.forEach { team ->
val profile = profiles.firstOrNull { it.id == team.profileId }
events.add(event.apply {
profileId = team.profileId
teamId = team.id
addedManually = true
seen = profile?.empty ?: false
notified = profile?.empty ?: false
if (profile?.usernameId == event.sharedBy) sharedBy = "self"
})
}
}
return events
}
}

View File

@ -0,0 +1,18 @@
/*
* Copyright (c) Kacper Ziubryniewicz 2019-12-8
*/
package pl.szczodrzynski.edziennik.api.v2.szkolny
import pl.szczodrzynski.edziennik.api.v2.szkolny.request.ServerSyncRequest
import pl.szczodrzynski.edziennik.api.v2.szkolny.response.ApiResponse
import pl.szczodrzynski.edziennik.api.v2.szkolny.response.ServerSyncResponse
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.POST
interface SzkolnyService {
@POST("appSync")
fun serverSync(@Body request: ServerSyncRequest): Call<ApiResponse<ServerSyncResponse>>
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) Kacper Ziubryniewicz 2019-12-8
*/
package pl.szczodrzynski.edziennik.api.v2.szkolny.adapter
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
import pl.szczodrzynski.edziennik.utils.models.Date
class DateAdapter : TypeAdapter<Date>() {
override fun write(writer: JsonWriter?, value: Date?) {}
override fun read(reader: JsonReader?): Date? {
if (reader?.peek() == JsonToken.NULL) {
reader.nextNull()
return null
}
return reader?.nextInt()?.let { Date.fromValue(it) }
}
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) Kacper Ziubryniewicz 2019-12-8
*/
package pl.szczodrzynski.edziennik.api.v2.szkolny.adapter
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
import pl.szczodrzynski.edziennik.utils.models.Time
class TimeAdapter : TypeAdapter<Time>() {
override fun write(writer: JsonWriter?, value: Time?) {}
override fun read(reader: JsonReader?): Time? {
if (reader?.peek() == JsonToken.NULL) {
reader.nextNull()
return null
}
return reader?.nextInt()?.let { Time.fromValue(it) }
}
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (c) Kacper Ziubryniewicz 2019-12-8
*/
package pl.szczodrzynski.edziennik.api.v2.szkolny.interceptor
import okhttp3.Interceptor
import okhttp3.Response
import pl.szczodrzynski.edziennik.*
class SignatureInterceptor(val app: App) : Interceptor {
companion object {
private const val API_KEY = "szkolny_android_42a66f0842fc7da4e37c66732acf705a"
private const val API_PASSWORD = "HodrJ+6OAl9zqlK1IlYBUg=="
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val timestamp = currentTimeUnix()
val body = request.body()?.bodyToString() ?: ""
val url = request.url().toString()
return chain.proceed(
request.newBuilder()
.header("X-ApiKey", API_KEY)
.header("X-AppVersion", BuildConfig.VERSION_CODE.toString())
.header("X-Timestamp", timestamp.toString())
.header("X-Signature", sign(timestamp, body, url))
.build())
}
private fun sign(timestamp: Long, body: String, url: String): String {
val content = timestamp.toString().md5() + body.md5() + url.md5()
val password = API_PASSWORD + BuildConfig.VERSION_CODE.toString() + app.signature
return content.hmacSHA1(password)
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright (c) Kacper Ziubryniewicz 2019-12-8
*/
package pl.szczodrzynski.edziennik.api.v2.szkolny.request
data class ServerSyncRequest(
val deviceId: String,
val device: Device? = null,
val userCodes: List<String>,
val users: List<User>? = null
) {
data class Device(
val osType: String,
val osVersion: String,
val hardware: String,
val pushToken: String?,
val appVersion: String,
val appType: String,
val appVersionCode: Int,
val syncInterval: Int
)
data class User(
val userCode: String,
val studentName: String,
val studentNameShort: String,
val loginType: Int,
val teamCodes: List<String>
)
}

View File

@ -0,0 +1,16 @@
/*
* Copyright (c) Kacper Ziubryniewicz 2019-12-8
*/
package pl.szczodrzynski.edziennik.api.v2.szkolny.response
data class ApiResponse<T> (
val success: Boolean,
val errors: List<Error>? = null,
val data: T? = null
) {
data class Error (val code: String, val reason: String)
}

View File

@ -0,0 +1,9 @@
/*
* Copyright (c) Kacper Ziubryniewicz 2019-12-8
*/
package pl.szczodrzynski.edziennik.api.v2.szkolny.response
import pl.szczodrzynski.edziennik.data.db.modules.events.EventFull
data class ServerSyncResponse(val events: List<EventFull>)

View File

@ -10,6 +10,7 @@ public class EventFull extends Event {
public String subjectShortName = ""; public String subjectShortName = "";
public String teamName = ""; public String teamName = "";
public String teamCode = null;
// metadata // metadata
public boolean seen; public boolean seen;

View File

@ -223,13 +223,13 @@ class ProfileFull : Profile {
} }
fun loginStoreType(): String { fun loginStoreType(): String {
when (loginStoreType) { return when (loginStoreType) {
LOGIN_TYPE_MOBIDZIENNIK -> return "LOGIN_TYPE_MOBIDZIENNIK" LOGIN_TYPE_MOBIDZIENNIK -> "LOGIN_TYPE_MOBIDZIENNIK"
LOGIN_TYPE_LIBRUS -> return "LOGIN_TYPE_LIBRUS" LOGIN_TYPE_LIBRUS -> "LOGIN_TYPE_LIBRUS"
LOGIN_TYPE_IUCZNIOWIE -> return "LOGIN_TYPE_IDZIENNIK" LOGIN_TYPE_IUCZNIOWIE -> "LOGIN_TYPE_IDZIENNIK"
LOGIN_TYPE_VULCAN -> return "LOGIN_TYPE_VULCAN" LOGIN_TYPE_VULCAN -> "LOGIN_TYPE_VULCAN"
LOGIN_TYPE_DEMO -> return "LOGIN_TYPE_DEMO" LOGIN_TYPE_DEMO -> "LOGIN_TYPE_DEMO"
else -> return "LOGIN_TYPE_UNKNOWN" else -> "LOGIN_TYPE_UNKNOWN"
} }
} }

View File

@ -31,6 +31,9 @@ public abstract class TeamDao {
@Query("SELECT * FROM teams WHERE profileId = :profileId ORDER BY teamType, teamName ASC") @Query("SELECT * FROM teams WHERE profileId = :profileId ORDER BY teamType, teamName ASC")
public abstract List<Team> getAllNow(int profileId); public abstract List<Team> getAllNow(int profileId);
@Query("SELECT * FROM teams ORDER BY teamType, teamName ASC")
public abstract List<Team> getAllNow();
@Query("SELECT * FROM teams WHERE profileId = :profileId AND teamType = 1") @Query("SELECT * FROM teams WHERE profileId = :profileId AND teamType = 1")
public abstract LiveData<Team> getClass(int profileId); public abstract LiveData<Team> getClass(int profileId);

View File

@ -103,6 +103,13 @@ public class Time implements Comparable<Time> {
} }
} }
public static Time fromValue(int value) {
int hours = value / 10000;
int minutes = (value - hours * 10000) / 100;
int seconds = (value - hours * 10000 - minutes * 100);
return new Time(hours, minutes, seconds);
}
public long getInMillis() { public long getInMillis() {
Calendar c = Calendar.getInstance(); Calendar c = Calendar.getInstance();
c.set(2000, 0, 1, hour, minute, second); c.set(2000, 0, 1, hour, minute, second);

View File

@ -47,7 +47,9 @@ buildscript {
navlib : "8b31921697", navlib : "8b31921697",
gifdrawable : "1.2.15" gifdrawable : "1.2.15",
retrofit : '2.6.2'
] ]
} }