123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685 |
- /*
- * DSBDirect
- * Copyright (C) 2019 Fynn Godau
- *
- * 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 <https://www.gnu.org/licenses/>.
- *
- * This software is not affiliated with heinekingmedia GmbH, the
- * developer of the DSB platform.
- */
- package godau.fynn.dsbdirect.manager;
- import android.content.Context;
- import android.content.SharedPreferences;
- import android.graphics.Bitmap;
- import android.graphics.BitmapFactory;
- import android.net.ConnectivityManager;
- import android.net.NetworkInfo;
- import androidx.annotation.NonNull;
- import androidx.annotation.Nullable;
- import android.util.Base64;
- import android.util.Log;
- import godau.fynn.dsbdirect.table.*;
- import org.json.JSONArray;
- import org.json.JSONException;
- import org.json.JSONObject;
- import org.jsoup.Connection;
- import org.jsoup.Jsoup;
- import org.jsoup.nodes.Document;
- import org.jsoup.nodes.Element;
- import org.jsoup.select.Elements;
- import java.io.*;
- import java.net.HttpURLConnection;
- import java.net.URISyntaxException;
- import java.net.URL;
- import java.net.URLEncoder;
- import java.text.SimpleDateFormat;
- import java.util.ArrayList;
- import java.util.Arrays;
- import java.util.Date;
- import java.util.HashSet;
- import java.util.List;
- import java.util.zip.GZIPInputStream;
- import java.util.zip.GZIPOutputStream;
- import godau.fynn.dsbdirect.Login;
- import godau.fynn.dsbdirect.QueryMetadata;
- import godau.fynn.dsbdirect.R;
- import godau.fynn.dsbdirect.Utility;
- import godau.fynn.dsbdirect.table.reader.Reader;
- public class DownloadManager {
- private Context mContext;
- public DownloadManager(Context context) {
- mContext = context;
- }
- /**
- * Make a GET request (synchronously)
- *
- * @param url URL to be requested
- * @param body Request body (JSON String)
- * @param requestMethod Usually either GET or POST
- * @return Response
- * @throws IOException If networking error or other IO exception
- */
- private @NonNull
- InputStream request(String url, @Nullable String body, String requestMethod) throws IOException {
- if (!isNetworkAvailable()) throw new IOException();
- // Encode the url correctly, as the file name part can contain Umlaute or other weird things
- String[] urlParts =
- url.split("/(?!.*/)"); // Matches only the last '/' character (using a negative lookahead)
- url = urlParts[0] + "/" + URLEncoder.encode(
- urlParts[1] // This is the part that has to be encoded correctly
- .replaceAll(
- "%20", " " /* Spaces are already encoded as "%20". Let's decode them quickly so we won't have
- * %20 encoded as something like "%2520"
- */
- ), "ISO-8859-1" // UTF-8 won't work here
- )
- .replaceAll(
- "\\+", "%20" /* Spaces are encoded again, but they are now '+' chars. That's unfortunately not
- * correct. Let's replace them with "%20"s.
- */
- );
- URL connectwat = new URL(url);
- HttpURLConnection urlConnection = (HttpURLConnection) connectwat.openConnection();
- urlConnection.setRequestMethod(requestMethod);
- // Add DNT header, as if it does anything
- urlConnection.addRequestProperty("DNT", "1");
- if (body != null) {
- // Get headers from sharedPreferences so they can be set through news
- HashSet queryHeaders = (HashSet) new Utility(mContext).getSharedPreferences()
- .getStringSet("queryHeaders", new HashSet<>(Arrays.asList(
- "Referer: https://www.dsbmobile.de/",
- "Content-Type: application/json;charset=utf-8"
- )));
- // Add each header to query
- for (Object header : queryHeaders) {
- String queryHeader = (String) header;
- // Split header into two parts
- String[] queryHeaderParts = queryHeader.split(": ");
- // Check whether header really is two parts
- if (queryHeaderParts.length != 2) {
- Log.e("DOWNLOADHEADER", "invalid header: " + queryHeader);
- continue;
- }
- // Add header to request
- urlConnection.setRequestProperty(queryHeaderParts[0], queryHeaderParts[1]);
- Log.d("DOWNLOADHEADER", queryHeader);
- }
- OutputStream outputStream = urlConnection.getOutputStream();
- OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
- outputStreamWriter.write(body);
- outputStreamWriter.flush();
- outputStreamWriter.close();
- outputStream.close();
- }
- urlConnection.connect();
- return new BufferedInputStream(urlConnection.getInputStream());
- }
- /**
- * Downloads a list of all contents currently available from DSB (synchronously)
- *
- * @param login Login to log in with
- * @return A ContentInformation object with all contents in DSB
- * @throws LoginFailureException If the credentials or the request in general are incorrect
- * @throws UnexpectedResponseException If response is invalid JSON
- * @throws IOException If request fails in general (networking error?)
- */
- public ContentInformation downloadContentInformation(Login login) throws IOException {
- JSONArray contentArray = downloadContentJSONArray(login);
- Table[] tables = null;
- List<Notice> notices = null;
- List<NewsItem> news = null;
- try {
- for (int i = 0; i < contentArray.length(); i++) {
- JSONObject contentObject = contentArray.getJSONObject(i);
- String contentName = contentObject.getString("Title");
- JSONArray childs = contentObject.getJSONObject("Root").getJSONArray("Childs");
- // It has been observed that News are before Pläne if they exist
- switch (contentName) {
- case "Pläne":
- tables = Reader.readTableList(childs);
- break;
- case "News":
- news = Reader.readNewsList(childs);
- break;
- case "Aushänge":
- notices = Reader.readNoticeList(childs);
- }
- }
- } catch (JSONException e) {
- // Response is invalid, throw further
- throw new UnexpectedResponseException(e.getCause());
- }
- return new ContentInformation(tables, notices, news);
- }
- /**
- * Downloads and returns the Childs JSON array of Inhalte from DSB (synchronously)
- *
- * @param login Login to log in with
- * @return JSON array containing the Childs of Inhalte of server's response
- * @throws LoginFailureException If the credentials or the request in general are incorrect
- * @throws UnexpectedResponseException If response is invalid JSON
- * @throws IOException If request fails in general (networking error?)
- */
- private JSONArray downloadContentJSONArray(Login login) throws IOException {
- Log.d("DOWNLOAD", "downloading data");
- if (!login.isNonZeroLength()) {
- // Empty credentials are not valid
- throw new LoginFailureException();
- }
- // Make request body
- JSONObject bodyObject;
- SharedPreferences sharedPreferences = new Utility(mContext).getSharedPreferences();
- try {
- // Query body base json might be overwritten by news, otherwise use hardcoded value
- String queryBodyBaseJson = sharedPreferences
- .getString("queryBodyBaseJson", mContext.getString(R.string.query_body_base_json));
- Log.d("DOWNLOADBASEJSON", queryBodyBaseJson);
- bodyObject = new JSONObject(queryBodyBaseJson);
- login.put(bodyObject);
- // Add things configurable through news
- boolean sendAppId = sharedPreferences.getBoolean("querySendAppId", true);
- boolean sendAndroidVersion = sharedPreferences.getBoolean("querySendAndroidVersion", true);
- boolean sendDeviceModel = sharedPreferences.getBoolean("querySendDeviceModel", true);
- boolean sendLanguage = sharedPreferences.getBoolean("querySendLanguage", true);
- boolean sendDate = sharedPreferences.getBoolean("querySendDate", false);
- boolean sendLastDate = sharedPreferences.getBoolean("querySendLastDate", false);
- // Generate AppId
- if (sendAppId) {
- bodyObject.put("AppId", QueryMetadata.getAppId());
- }
- // Attach random android version
- if (sendAndroidVersion) {
- bodyObject.put("OsVersion", QueryMetadata.getAndroidVersion());
- }
- // Attach random device name
- if (sendDeviceModel) {
- bodyObject.put("Device", QueryMetadata.getDeviceModel());
- }
- // Attach some language
- if (sendLanguage) {
- bodyObject.put("Language", QueryMetadata.getLanguage());
- }
- // Send date
- if (sendDate) {
- // Date should look like this: 2019-10-04T14:21:3728600
- SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss:SSSS000");
- String date = dateFormat.format(new Date());
- Log.d("DOWNLOADDATE", date);
- bodyObject.put("Date", date);
- // Send last date
- if (sendLastDate) {
- bodyObject.put("LastUpdate", sharedPreferences.getString("queryLastDate", ""));
- sharedPreferences.edit()
- .putString("queryLastDate", date)
- .apply();
- }
- }
- } catch (JSONException e) {
- e.printStackTrace();
- // Shouldn't happen! Throw further as IOException since we don't really know what happened anyway
- throw new IOException(e.getCause());
- }
- // Request url
- String url = getEndpoint(sharedPreferences.getInt("queryEndpoint", 0));
- Log.d("DOWNLOADENDPOINT", url);
- // Request
- String response = string(
- request(url, obfuscateQuery(bodyObject), "POST"),
- "UTF-8"
- );
- // If request is very invalid, there won't be any json in the response, only some plain text…
- if (response.equals("Unzulässige Anforderung")) {
- Log.d("DOWNLOAD", "request failed: " + bodyObject.toString() + " obfuscated to " + obfuscateQuery(bodyObject));
- throw new UnexpectedResponseException();
- }
- try {
- JSONObject responseBody = deobfuscateResponse(response);
- // Check result code
- int resultcode = responseBody.getInt("Resultcode");
- switch (resultcode) {
- case 0:
- // All is good, continue
- break;
- case 1:
- // Invalid credentials ("ResultStatusInfo": "Login fehlgeschlagen")
- throw new LoginFailureException();
- default:
- Log.d("DOWNLOAD", "unexpected Resultcode " + resultcode + ": " + response);
- throw new UnexpectedResponseException();
- }
- JSONArray resultMenuItems = responseBody.getJSONArray("ResultMenuItems");
- resultMenu:
- for (int i = 0; i < resultMenuItems.length(); i++) {
- JSONObject resultMenuItem = resultMenuItems.getJSONObject(i);
- String resultMenuItemTitle = resultMenuItem.getString("Title");
- // Just to be sure that we select Inhalte, in practice it has only been observed to be the first one
- if (resultMenuItemTitle.equals("Inhalte")) {
- return resultMenuItem.getJSONArray("Childs");
- }
- }
- // No Inhalte…?
- throw new UnexpectedResponseException("No Inhalte found");
- } catch (JSONException | EOFException e) {
- e.printStackTrace();
- // Response is invalid, throw further
- throw new UnexpectedResponseException(e.getCause());
- }
- }
- /**
- * Return the corresponding url for the endpoint id.
- *
- * @param id 0 (mobile) / 1 (web) / 2 (ihkmobile) / 3 (appihkbb)
- */
- private String getEndpoint(int id) throws IOException {
- switch (id) {
- case 0:
- default:
- return "https://app.dsbcontrol.de/JsonHandler.ashx/GetData";
- case 1:
- String webConfiguration = string(
- request("https://www.dsbmobile.de/scripts/configuration.js", null, "GET"),
- "UTF-8"
- );
- return "https://www.dsbmobile.de/" + webConfiguration.split("'")[3]; //bad solution, web endpoint obfuscated -> method outdated
- case 2:
- return "https://ihkmobile.dsbcontrol.de/new/JsonHandlerWeb.ashx/GetData";
- case 3:
- return "https://appihkbb.dsbcontrol.de/new/JsonHandlerWeb.ashx/GetData";
- }
- }
- /**
- * Downloads an html table file (synchronously)
- *
- * @param table The table which is to be downloaded
- * @param fileManager A file manager to be used to save the file
- * @return The downloaded html String
- * @throws IOException In case of networking error
- */
- public String downloadHtmlTable(final Table table, final FileManager fileManager) throws IOException { // TODO
- Log.d("DOWNLOAD", "downloading html file at " + table.getUri());
- // Request
- String html = string(request(table.getUri(), null, "GET"), "ISO-8859-1");
- // If these characters appear, the wrong encoding has been used
- if (html.contains("")) {
- try {
- html = new String(html.getBytes("ISO-8859-1"), "UTF-8");
- } catch (UnsupportedEncodingException e) {
- // So be it!
- e.printStackTrace();
- }
- }
- // Save file
- fileManager.saveFile(table, html);
- return html;
- }
- /**
- * Downloads a bitmap table file (synchronously)
- *
- * @param table The table which is to be downloaded
- * @param fileManager A file manager to be used to save the file
- * @return The bitmap
- * @throws IOException In case of networking error
- */
- public Bitmap downloadImageTable(final Table table, final FileManager fileManager) throws IOException { // TODO
- // We're doing bitmaps.
- Log.d("DOWNLOAD", "downloading image file at " + table.getUri());
- // Request bitmap
- InputStream inputStream = request(table.getUri(), null, "GET");
- // Create bitmap
- Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
- // Save bitmap
- fileManager.saveFile(table, bitmap);
- // Return bitmap
- return bitmap;
- }
- /**
- * Obfuscate query. The DSB server requires this.
- * <br/><br/>
- * Queries are "compressed" using gzip (saving less than half a kilobyte) and then encoded using base64.
- * The result of that is then again hidden inside some more JSON.
- *
- * @param query The JSON query you want to make
- * @return The body you will have to send to the server to execute the query
- */
- private String obfuscateQuery(JSONObject query) throws IOException {
- String queryString = query.toString();
- // Thanks, https://stackoverflow.com/a/6718707
- ByteArrayOutputStream outputStream = new ByteArrayOutputStream(queryString.length());
- GZIPOutputStream gzip = new GZIPOutputStream(outputStream);
- gzip.write(queryString.getBytes());
- gzip.close();
- byte[] gzipped = outputStream.toByteArray();
- outputStream.close();
- String encoded = Base64.encodeToString(gzipped, Base64.NO_WRAP); // Line wraps are useless here!
- return mContext.getString(R.string.query_body_outer_json, encoded);
- }
- /**
- * Deobfuscate response. The DSB server gives obfuscated responses.
- * <br/><br/>
- * Just the reverse of {@link #obfuscateQuery(JSONObject)}, except that "some more JSON" is different for responses
- * compared to queries.
- *
- * @param response The response the server gave you
- * @return The JSON hidden inside the response
- */
- private JSONObject deobfuscateResponse(String response) throws JSONException, IOException {
- JSONObject responseObject = new JSONObject(response);
- String encoded = responseObject.getString("d");
- byte[] gzipped = Base64.decode(encoded, Base64.DEFAULT);
- // Who knows how this works… thanks again, https://stackoverflow.com/a/6718707
- final int BUFFER_SIZE = 32;
- ByteArrayInputStream is = new ByteArrayInputStream(gzipped);
- GZIPInputStream gis = new GZIPInputStream(is, BUFFER_SIZE);
- StringBuilder stringBuilder = new StringBuilder();
- byte[] data = new byte[BUFFER_SIZE];
- int bytesRead;
- while ((bytesRead = gis.read(data)) != -1) {
- stringBuilder.append(new String(data, 0, bytesRead));
- }
- gis.close();
- is.close();
- return new JSONObject(stringBuilder.toString());
- }
- public JSONObject downloadUpdateCheck() throws IOException {
- Log.d("DOWNLOAD", "downloading update check");
- try {
- return new JSONObject(string(request(mContext.getString(R.string.uri_versioncheck), null, "GET"), "UTF-8"));
- } catch (JSONException e) {
- throw new UnexpectedResponseException(e.getCause());
- }
- }
- public JSONObject downloadNews() throws IOException {
- Log.d("DOWNLOAD", "downloading news");
- try {
- return new JSONObject(string(request(mContext.getString(R.string.uri_news), null, "GET"), "UTF-8"));
- } catch (JSONException e) {
- throw new UnexpectedResponseException(e.getCause());
- }
- }
- // Thanks, https://stackoverflow.com/a/35446009
- private String string(InputStream in, String charsetName) throws IOException {
- ByteArrayOutputStream result = new ByteArrayOutputStream();
- byte[] buffer = new byte[1024];
- int length;
- while ((length = in.read(buffer)) != -1) {
- result.write(buffer, 0, length);
- }
- return result.toString(charsetName);
- }
- /**
- * Uploads a url to the server at https://dsb.bixilon.de to ask the developer to develop a parser for it.
- * <br/><br/>The server code is available at <a href="https://notabug.org/fynngodau/dsbdirect-filedump/src/master/requestParser.php">fynngodau/dsbdirect-filedump</a>.
- *
- * @param url The url to upload. Must be at <a href="https://app.dsbcontrol.de">https://app.dsbcontrol.de</a>
- * @return whether the server returned 200 Success
- * @throws IOException in case of a network error
- */
- public boolean uploadParserRequest(String url) throws IOException {
- // request(…) returns an InputStream, not the response code as it is pretty much always 200, so we can't use it here
- if (!isNetworkAvailable()) {
- throw new IOException();
- }
- URL connectwat = new URL(mContext.getString(R.string.uri_requestparser));
- HttpURLConnection urlConnection = (HttpURLConnection) connectwat.openConnection();
- urlConnection.setRequestMethod("POST");
- OutputStream outputStream = urlConnection.getOutputStream();
- OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
- outputStreamWriter.write("url=" + url);
- outputStreamWriter.flush();
- outputStreamWriter.close();
- outputStream.close();
- urlConnection.connect();
- if (urlConnection.getResponseCode() == 200) return true;
- else return false;
- }
- private boolean isNetworkAvailable() {
- ConnectivityManager connectivityManager
- = (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
- NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
- return activeNetworkInfo != null && activeNetworkInfo.isConnected();
- }
- /**
- * Download all shortcodes from eltern-portal.org (synchronously)
- *
- * @param url URL to be requested
- * @param email Login Username (email)
- * @param password Password for login
- * @return Shortcode null on error, array on success
- * @throws IOException If networking error or other IO exception
- * @throws IllegalArgumentException If the provided url is incorrect
- * @throws LoginFailureException If the credentials were not correct
- */
- public Shortcode[] downloadShortcodesFromElternportal(String url, String email, String password)
- throws IOException, IllegalArgumentException, LoginFailureException {
- //is network available?
- if (!isNetworkAvailable()) throw new IOException();
- //attach an / if needed
- if (!url.endsWith("/"))
- url = url + "/";
- //check url
- if (!url.endsWith(".eltern-portal.org/")) {
- //url invalid
- throw new IllegalArgumentException();
- }
- if (url.startsWith("http://")) {
- url = url.replace("http://", "https://"); //force https
- }
- if (!url.toLowerCase().matches("^\\w+://.*")) {
- url = "https://" + url;
- }
- //check if url is parsable
- try {
- URL u = new URL(url);
- u.toURI();
- } catch (URISyntaxException e) {
- //nope. not valid
- e.printStackTrace();
- throw new IllegalArgumentException(e);
- }
- //obtain session key
- Connection.Response res = null;
- res = Jsoup.connect(url)
- .execute();
- //login with obtained session key
- res = Jsoup.connect(url + "includes/project/auth/login.php")
- .data("username", email, "password", password)
- .method(Connection.Method.POST)
- .cookies(res.cookies())
- .followRedirects(false)
- .execute();
- if (res.body().contains("Fehler bei der Anmeldung") || res.header("Location").contains(".eltern-portal.org/login?errno=1&username=")) {
- //fail: wrong session key or username/password wrong
- throw new LoginFailureException();
- }
- //get html of school information
- Document doc = Jsoup.connect(url + "service/schulinformationen")
- .cookies(res.cookies())
- .get();
- Elements e = doc.getElementById("asam_content").select("div[class=row m_bot]");
- boolean getthem = false; //there are not only shotcodes also school infos. Wait until shortcodes appear
- List<Shortcode> shortcodes = new ArrayList<Shortcode>();
- for (Element s : e) {
- String raw = s.select("div[class=col-md-4],div[class=col-md-6]").text();
- //empty line. skipping
- if (raw.equals(""))
- continue;
- if (raw.contains("Homepage")) { //Homepage is the last school info. Follows by shortcodes
- getthem = true;
- continue;
- }
- if (!getthem)
- continue;
- String split[] = raw.split(" ", 3); //short short n a m e
- shortcodes.add(new Shortcode(split[0], split[2]));
- }
- Shortcode array[] = new Shortcode[shortcodes.size()];
- array = shortcodes.toArray(array);
- return array;
- }
- /**
- * Holds information about all the content that is currently offered at the DSB. Notices
- * and news are treated as the same.
- */
- public static class ContentInformation implements Serializable {
- private Table[] tables;
- private ArrayList<NoticeBoardItem> notices;
- public ContentInformation(Table[] tables, List<Notice> notices, List<NewsItem> news) {
- this.tables = tables == null ? new Table[0] : tables;
- this.notices = new ArrayList<>();
- if (notices != null) this.notices.addAll(notices);
- if (news != null) this.notices.addAll(news);
- }
- public Table[] getTables() {
- return tables;
- }
- public ArrayList<NoticeBoardItem> getNotices() {
- return notices;
- }
- /**
- * @return Whether at least one notice board item is available.
- */
- public boolean hasNotices() {
- return notices.size() > 0;
- }
- }
- static public class UnexpectedResponseException extends IOException {
- public UnexpectedResponseException() {
- super();
- }
- public UnexpectedResponseException(Throwable cause) {
- super(cause);
- }
- public UnexpectedResponseException(String message) {
- super(message);
- }
- }
- /**
- * Possible causes: credentials incorrect, server rejected login due to an invalid request
- */
- static public class LoginFailureException extends UnexpectedResponseException {
- }
- static public class NoContentException extends UnexpectedResponseException {
- }
- }
|