/*
* ____.____ __.____ ___ _____
* | | |/ _| | \ / _ \ ______ ______
* | | < | | / / /_\ \\____ \\____ \
* /\__| | | \| | / / | \ |_> > |_> >
* \________|____|__ \______/ \____|__ / __/| __/
* \/ \/|__| |__|
*
* Copyright (c) 2014-2015 Paul "Marunjar" Pretsch
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
package org.voidsink.anewjkuapp.kusss;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.text.format.DateUtils;
import android.util.Log;
import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Calendar;
import org.jsoup.Connection;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.voidsink.anewjkuapp.analytics.Analytics;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.HttpCookie;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public class KusssHandler {
public static final String PATTERN_LVA_NR_WITH_DOT = "\\d{3}\\.\\w{3}";
public static final String PATTERN_LVA_NR = "\\d{3}\\w{3}";
public static final String PATTERN_TERM = "\\d{4}[swSW]";
public static final String PATTERN_LVA_NR_COMMA_TERM = "\\("
+ PATTERN_LVA_NR + "," + PATTERN_TERM + "\\)";
public static final String PATTERN_LVA_NR_SLASH_TERM = "\\("
+ PATTERN_LVA_NR + "\\/" + PATTERN_TERM + "\\)";
private static final String TAG = KusssHandler.class.getSimpleName();
private static final String URL_KUSSS_INDEX = "https://www.kusss.jku.at/kusss/index.action";
private static final String URL_MY_LVAS = "https://www.kusss.jku.at/kusss/assignment-results.action";
private static final String URL_GET_TERMS = "https://www.kusss.jku.at/kusss/listmystudentlvas.action";
private static final String URL_GET_ICAL = "https://www.kusss.jku.at/kusss/ical-multi-sz.action";
private static final String URL_MY_GRADES = "https://www.kusss.jku.at/kusss/gradeinfo.action";
private static final String URL_START_PAGE = "https://www.kusss.jku.at/kusss/studentwelcome.action";
private static final String URL_LOGOUT = "https://www.kusss.jku.at/kusss/logout.action";
private static final String URL_LOGIN = "https://www.kusss.jku.at/kusss/login.action";
private static final String URL_GET_NEW_EXAMS = "https://www.kusss.jku.at/kusss/szsearchexam.action";
private static final String URL_GET_EXAMS = "https://www.kusss.jku.at/kusss/szexaminationlist.action";
private static final String URL_SELECT_TERM = "https://www.kusss.jku.at/kusss/select-term.action";
private static final String SELECT_MY_LVAS = "body.intra > table > tbody > tr > td > table > tbody > tr > td.contentcell > div.contentcell > table > tbody > tr:has(td)";
private static final String SELECT_MY_GRADES = "body.intra > table > tbody > tr > td > table > tbody > tr > td.contentcell > div.contentcell > *";
private static final String SELECT_LOGOUT = "body > table > tbody > tr > td > div > ul > li > a[href*=logout.action]";
// private static final String SELECT_ACTUAL_EXAMS =
// "body.intra > table > tbody > tr > td > table > tbody > tr > td.contentcell > div.contentcell > div.tabcontainer > div.tabcontent > table > tbody > tr > td > form > table > tbody > tr:has(td)";
private static final String SELECT_NEW_EXAMS = "body.intra > table > tbody > tr > td > table > tbody > tr > td.contentcell > div.contentcell > div.tabcontainer > div.tabcontent > div.sidetable > form > table > tbody > tr:has(td)";
private static final String SELECT_EXAMS = "body.intra > table > tbody > tr > td > table > tbody > tr > td.contentcell > div.contentcell > div.tabcontainer > div.tabcontent > table > tbody > tr > td > form > table > tbody > tr:has(td)";
private static final String URL_MY_STUDIES = "https://www.kusss.jku.at/kusss/studentsettings.action";
private static final String SELECT_MY_STUDIES = "body.intra > table > tbody > tr > td > table > tbody > tr > td.contentcell > div.contentcell > div.tabcontainer > div.tabcontent > form > table > tbody > tr[class]:has(td)";
private static final int TIMEOUT_LOGIN = 15 * 1000; // 15s
private static final int TIMEOUT_SEARCH_EXAM_BY_LVA = 10 * 1000; //10s
private static final int DEFAULT_BUFFER_SIZE = 1024 * 4;
private static KusssHandler handler = null;
private final CookieManager mCookies;
private KusssHandler() {
this.mCookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
CookieHandler.setDefault(mCookies);
}
public static boolean isNetworkAvailable(Context context) {
try {
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
if (activeNetworkInfo != null && activeNetworkInfo.isConnected()) {
return true;
}
Log.i(TAG, "network not available");
} catch (Exception e) {
Log.w(TAG, "network not available", e);
}
return false;
}
public static synchronized KusssHandler getInstance() {
if (handler == null) {
synchronized (KusssHandler.class) {
if (handler == null) handler = new KusssHandler();
}
}
return handler;
}
public String getSessionIDFromCookie() {
try {
List<HttpCookie> cookies = mCookies.getCookieStore().get(
new URI("https://www.kusss.jku.at/"));
for (HttpCookie cookie : cookies) {
if (cookie.getName().equals("JSESSIONID")) {
return cookie.getValue();
}
}
return null;
} catch (URISyntaxException e) {
Log.e(TAG, "getSessionIDFromCookie", e);
return null;
}
}
public synchronized String login(Context c, String user, String password) {
if (user == null || password == null) {
return null;
}
if (!isNetworkAvailable(c)) {
return null;
}
try {
if ((user.length() > 0) && (user.charAt(0) != 'k')) {
user = "k" + user;
}
mCookies.getCookieStore().removeAll();
Jsoup.connect(URL_KUSSS_INDEX).timeout(TIMEOUT_LOGIN).followRedirects(true).get();
Connection.Response r = Jsoup.connect(URL_LOGIN).cookies(getCookieMap()).data("j_username", user).data("j_password", password).timeout(TIMEOUT_LOGIN).followRedirects(true).method(Connection.Method.POST).execute();
if (r.url() != null) {
r = Jsoup.connect(r.url().toString()).cookies(getCookieMap()).method(Connection.Method.GET).execute();
}
Document doc = r.parse();
String sessionId = getSessionIDFromCookie();
if (isLoggedIn(c, doc)) {
return sessionId;
}
if (isLoggedIn(c, sessionId)) {
return sessionId;
}
Log.w(TAG, "login failed: isLoggedIn=FALSE");
return null;
} catch (SocketTimeoutException e) {
// bad connection, timeout
Log.w(TAG, "login failed: connection timeout", e);
return null;
} catch (Exception e) {
Log.w(TAG, "login failed", e);
Analytics.sendException(c, e, true);
return null;
}
}
private Map<String, String> getCookieMap() {
Map<String, String> cookies = new HashMap<>();
for (HttpCookie cookie : mCookies.getCookieStore().getCookies()) {
cookies.put(cookie.getName(), cookie.getValue());
}
return cookies;
}
private String getCookieString() {
String cookies = "";
for (HttpCookie cookie : mCookies.getCookieStore().getCookies()) {
cookies += String.format("%s=%s;", cookie.getName(), cookie.getValue());
}
return cookies;
}
private void writeParams(URLConnection conn, String[] keys, String[] values)
throws IOException {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < keys.length; i++) {
builder.append(keys[i]);
builder.append("=");
builder.append(values[i]);
if (i < keys.length - 1) {
builder.append("&");
}
}
OutputStreamWriter wr = new OutputStreamWriter(conn.getOutputStream());
wr.write(builder.toString());
wr.flush();
}
public synchronized boolean logout(Context c) {
if (!isNetworkAvailable(c)) {
mCookies.getCookieStore().removeAll();
return true;
}
try {
Connection.Response r = Jsoup.connect(URL_LOGOUT).cookies(getCookieMap()).method(Connection.Method.GET).execute();
if (r == null) {
return false;
}
if (!isLoggedIn(c, (String) null)) {
mCookies.getCookieStore().removeAll();
return true;
}
return false;
} catch (Exception e) {
Log.w(TAG, "logout failed", e);
Analytics.sendException(c, e, true);
return true;
}
}
public synchronized boolean isLoggedIn(Context c, String sessionId) {
if (sessionId == null) {
return false;
}
if (!isNetworkAvailable(c)) {
return false;
}
try {
Document doc = Jsoup.connect(URL_START_PAGE).cookies(getCookieMap()).timeout(TIMEOUT_LOGIN).followRedirects(true).get();
return isLoggedIn(c, doc);
} catch (SocketTimeoutException e) {
// bad connection, timeout
return false;
} catch (IOException e) {
Log.e(TAG, "isLoggedIn", e);
Analytics.sendException(c, e, true);
return false;
}
}
private boolean isLoggedIn(Context c, Document doc) {
Elements logoutAction = doc.select(SELECT_LOGOUT);
return (logoutAction.size() > 0);
}
public synchronized boolean isAvailable(Context c, String sessionId,
String user, String password) {
return isNetworkAvailable(c) && (isLoggedIn(c, sessionId) || login(c, user, password) != null);
}
public static long copyStream(final InputStream input, final OutputStream output) throws IOException {
final byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
long count = 0;
int n = 0;
while (-1 != (n = input.read(buffer))) {
output.write(buffer, 0, n);
count += n;
}
return count;
}
public Calendar getLVAIcal(Context c, CalendarBuilder mCalendarBuilder) {
if (!isNetworkAvailable(c)) {
return null;
}
Calendar iCal;
ByteArrayOutputStream data = new ByteArrayOutputStream();
try {
URL url = new URL(URL_GET_ICAL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setDoOutput(true);
conn.setRequestProperty("Cookie", getCookieString());
conn.setConnectTimeout(5000);
conn.setReadTimeout(15000);
conn.setRequestMethod("POST");
writeParams(conn, new String[]{"selectAll"},
new String[]{"ical.category.mycourses"});
final String contentType = conn.getContentType();
if (contentType == null) {
conn.disconnect();
return null;
}
Log.d(TAG, String.format("getExamIcal: RequestMethod: %s", contentType));
if (!contentType.contains("text/calendar")) {
conn.disconnect();
return null;
}
final long length = copyStream(conn.getInputStream(), data);
conn.disconnect();
if (length > 0) {
iCal = mCalendarBuilder.build(new ByteArrayInputStream(getModifiedData(data)));
} else {
iCal = new Calendar();
}
} catch (ParserException e) {
Log.e(TAG, "getLVAIcal: " + data.toString(), e);
Analytics.sendException(c, e, true, data.toString());
iCal = null;
} catch (Exception e) {
Log.e(TAG, "getLVAIcal", e);
Analytics.sendException(c, e, true);
iCal = null;
}
return iCal;
}
private byte[] getModifiedData(ByteArrayOutputStream data) {
// replace crlf with \n, kusss ics uses lf only as content line separator
return data.toString().replace("\r\n", "\\n").getBytes();
}
public Calendar getExamIcal(Context c, CalendarBuilder mCalendarBuilder) {
if (!isNetworkAvailable(c)) {
return null;
}
Calendar iCal;
ByteArrayOutputStream data = new ByteArrayOutputStream();
try {
URL url = new URL(URL_GET_ICAL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setDoOutput(true);
conn.setRequestProperty("Cookie", getCookieString());
conn.setConnectTimeout(5000);
conn.setReadTimeout(15000);
conn.setRequestMethod("POST");
writeParams(conn, new String[]{"selectAll"},
new String[]{"ical.category.examregs"});
final String contentType = conn.getContentType();
if (contentType == null) {
conn.disconnect();
return null;
}
Log.d(TAG, String.format("getExamIcal: RequestMethod: %s", contentType));
if (!contentType.contains("text/calendar")) {
conn.disconnect();
return null;
}
final long length = copyStream(conn.getInputStream(), data);
conn.disconnect();
/*
AssetManager am = c.getAssets();
long length = copyStream(am.open("ical1.ics", AssetManager.ACCESS_STREAMING), data);
*/
if (length > 0) {
iCal = mCalendarBuilder.build(new ByteArrayInputStream(getModifiedData(data)));
} else {
iCal = new Calendar();
}
} catch (ParserException e) {
Log.e(TAG, "getExamIcal: " + data.toString(), e);
Analytics.sendException(c, e, true, data.toString());
iCal = null;
} catch (Exception e) {
Log.e(TAG, "getExamIcal", e);
Analytics.sendException(c, e, true);
iCal = null;
}
return iCal;
}
public Map<String, String> getTerms(Context c) {
if (!isNetworkAvailable(c)) {
return null;
}
Map<String, String> terms = new HashMap<>();
try {
Document doc = Jsoup.connect(URL_GET_TERMS).cookies(getCookieMap()).get();
Element termDropdown = doc.getElementById("term");
if (termDropdown != null) {
Elements termDropdownEntries = termDropdown
.getElementsByClass("dropdownentry");
for (Element termDropdownEntry : termDropdownEntries) {
terms.put(termDropdownEntry.attr("value"),
termDropdownEntry.text());
}
}
} catch (Exception e) {
Log.e(TAG, "getTerms", e);
Analytics.sendException(c, e, true);
return null;
}
return terms;
}
public boolean selectTerm(Context c, Term term) throws IOException {
if (!isNetworkAvailable(c)) {
return false;
}
Jsoup.connect(URL_SELECT_TERM)
.cookies(getCookieMap())
.data("term", term.toString())
.data("previousQueryString", "")
.data("reloadAction", "coursecatalogue-start.action").post();
//TODO: check document for successful selection of term
// if (!isSelected(doc, term)) {
// throw new IOException(String.format("selection of term failed: %s", term));
// }
return true;
}
public List<Course> getLvas(Context c, List<Term> terms) {
if (terms == null || terms.size() == 0) {
return null;
}
if (!isNetworkAvailable(c)) {
return null;
}
ArrayList<Course> courses = new ArrayList<>();
try {
Log.d(TAG, "getCourses");
for (Term term : terms) {
term.setLoaded(false); // init loaded flag
if (selectTerm(c, term)) {
Document doc = Jsoup.connect(URL_MY_LVAS).cookies(getCookieMap()).get();
if (isSelectable(c, doc, term)) {
if (isSelected(c, doc, term)) {
// .select("body.intra > table > tbody > tr > td > table > tbody > tr > td.contentcell > div.contentcell > table > tbody > tr");
Elements rows = doc.select(SELECT_MY_LVAS);
for (Element row : rows) {
Course course = new Course(c, term, row);
if (course.isInitialized()) {
courses.add(course);
}
}
term.setLoaded(true);
} else {
throw new IOException(String.format("term not selected: %s", term));
}
}
} else {
// break if selection failed
throw new IOException(String.format("cannot select term: %s", term));
}
}
if (courses.size() == 0) {
// break if no lvas found, a student without courses is a quite impossible case
return null;
}
} catch (Exception e) {
Analytics.sendException(c, e, true);
return null;
}
return courses;
}
private boolean isSelectable(Context c, Document doc, Term term) {
try {
Element termSelector = doc.getElementById("term");
if (termSelector == null) return false;
Elements selectable = termSelector.getElementsByAttributeValue("value", term.toString());
if (selectable.size() != 1) return false;
return true;
} catch (Exception e) {
Analytics.sendException(c, e, true);
return false;
}
}
private boolean isSelected(Context c, Document doc, Term term) {
try {
Elements terms = doc.getElementById("term").getElementsByAttribute(
"selected");
for (Element termEntry : terms) {
if (termEntry.attr("value").equals(term.toString())) {
return true;
}
}
} catch (Exception e) {
Analytics.sendException(c, e, true);
return false;
}
return false;
}
public List<Assessment> getAssessments(Context c) {
if (!isNetworkAvailable(c)) {
return null;
}
List<Assessment> grades = new ArrayList<>();
try {
Document doc = Jsoup.connect(URL_MY_GRADES).cookies(getCookieMap()).data("months", "0")
.get();
Elements rows = doc.select(SELECT_MY_GRADES);
AssessmentType type = null;
for (Element row : rows) {
if (row.tag().toString().equals("h3")) {
type = AssessmentType.parseGradeType(row.text());
} else if (row.tag().toString().equals("table")) {
Elements gradeRows = row
.select("tbody > tr[class]:has(td)");
for (Element gradeRow : gradeRows) {
Assessment grade = new Assessment(c, type, gradeRow);
if (grade.isInitialized()) {
grades.add(grade);
}
}
}
}
} catch (IOException e) {
Log.e(TAG, "getAssessments", e);
Analytics.sendException(c, e, true);
return null;
}
Log.d(TAG, grades.size() + " grades found");
return grades;
}
public List<Exam> getNewExams(Context c) {
if (!isNetworkAvailable(c)) {
return null;
}
List<Exam> exams = new ArrayList<>();
try {
Document doc = Jsoup.connect(URL_GET_NEW_EXAMS)
.cookies(getCookieMap())
.data("search", "true").data("searchType", "mylvas").get();
Elements rows = doc.select(SELECT_NEW_EXAMS);
int i = 0;
while (i < rows.size()) {
Element row = rows.get(i);
Exam exam = new Exam(c, row, true);
i++;
if (exam.isInitialized()) {
while (i < rows.size()
&& rows.get(i).attr("class")
.equals(row.attr("class"))) {
exam.addAdditionalInfo(rows.get(i));
i++;
}
exams.add(exam);
}
}
// add registered exams
loadExams(c, exams);
} catch (Exception e) {
Log.e(TAG, "getNewExams", e);
Analytics.sendException(c, e, true);
return null;
}
return exams;
}
public List<Exam> getNewExamsByCourseId(Context c, List<Course> courses, List<Term> terms) {
List<Exam> exams = new ArrayList<>();
try {
if (courses == null || courses.size() == 0) {
Log.d(TAG, "no lvas found, reload");
courses = getLvas(c, terms);
}
if (courses != null && courses.size() > 0) {
Map<String, Assessment> gradeCache = new HashMap<>();
List<Assessment> grades = getAssessments(c);
if (grades != null) {
for (Assessment grade : grades) {
if (!grade.getCourseId().isEmpty()) {
Assessment existing = gradeCache.get(grade.getCourseId());
if (existing != null) {
Log.d(TAG,
existing.getTitle() + " --> "
+ grade.getTitle());
}
gradeCache.put(grade.getCourseId(), grade);
}
}
}
for (Course course : courses) {
Assessment grade = gradeCache.get(course.getCourseId());
if (grade != null) {
if ((grade.getGrade() == Grade.G5)
|| (grade.getDate().getTime() > (System
.currentTimeMillis() - (182 * DateUtils.DAY_IN_MILLIS)))) {
Log.d(TAG,
"positive in last 6 Months: "
+ grade.getTitle());
grade = null;
}
}
if (grade == null) {
List<Exam> newExams = getNewExamsByCourseId(c,
course.getCourseId());
if (newExams != null) {
for (Exam newExam : newExams) {
if (newExam != null) {
exams.add(newExam);
}
}
}
}
}
}
// add registered exams
loadExams(c, exams);
} catch (Exception e) {
Log.e(TAG, "getNewExamsByCourseId", e);
Analytics.sendException(c, e, true);
return null;
}
return exams;
}
private List<Exam> getNewExamsByCourseId(Context c, String courseId) {
List<Exam> exams = new ArrayList<>();
try {
final SimpleDateFormat df = new SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN);
Log.d(TAG, "getNewExamsByCourseId: " + courseId);
Document doc = Jsoup
.connect(URL_GET_NEW_EXAMS)
.timeout(TIMEOUT_SEARCH_EXAM_BY_LVA)
.data("search", "true")
.data("searchType", "specific")
.data("searchDateFrom",
df.format(new Date(System.currentTimeMillis())))
.data("searchDateTo",
df.format(new Date(System.currentTimeMillis()
+ DateUtils.YEAR_IN_MILLIS)))
.data("searchLvaNr", courseId).data("searchLvaTitle", "")
.data("searchCourseClass", "").post();
Elements rows = doc.select(SELECT_NEW_EXAMS);
int i = 0;
while (i < rows.size()) {
Element row = rows.get(i);
Exam exam = new Exam(c, row, true);
i++;
if (exam.isInitialized()) {
while (i < rows.size()
&& rows.get(i).attr("class")
.equals(row.attr("class"))) {
exam.addAdditionalInfo(rows.get(i));
i++;
}
exams.add(exam);
}
}
} catch (IOException e) {
Log.e(TAG, "getNewExamsByCourseId", e);
Analytics.sendException(c, e, true);
exams = null;
}
return exams;
}
private void loadExams(Context c, List<Exam> exams) throws IOException {
if (!isNetworkAvailable(c)) {
return;
}
Log.d(TAG, "loadExams");
Document doc = Jsoup.connect(URL_GET_EXAMS).cookies(getCookieMap()).get();
Elements rows = doc.select(SELECT_EXAMS);
int i = 0;
while (i < rows.size()) {
Element row = rows.get(i);
Exam exam = new Exam(c, row, false);
i++;
if (exam.isInitialized()) {
while (i < rows.size()
&& rows.get(i).attr("class").equals(row.attr("class"))) {
exam.addAdditionalInfo(rows.get(i));
i++;
}
exams.add(exam);
}
}
}
public List<Curriculum> getCurricula(Context c) {
if (!isNetworkAvailable(c)) {
return null;
}
try {
List<Curriculum> mCurricula = new ArrayList<>();
Document doc = Jsoup.connect(URL_MY_STUDIES).cookies(getCookieMap()).get();
Elements rows = doc.select(SELECT_MY_STUDIES);
for (Element row : rows) {
Curriculum s = new Curriculum(c, row);
if (s.isInitialized()) {
mCurricula.add(s);
}
}
return mCurricula;
} catch (Exception e) {
Analytics.sendException(c, e, true);
return null;
}
}
}