code:
/*
 * 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.table;

import android.content.Context;
import androidx.cardview.widget.CardView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import godau.fynn.dsbdirect.R;
import godau.fynn.dsbdirect.Utility;
import org.sufficientlysecure.htmltextview.HtmlTextView;

import java.util.ArrayList;

public class Adapter extends ArrayAdapter<Entry> {

    private final Context mContext;
    private final ArrayList<Entry> mValues;

    public static final String LAYOUT_LIST = "list";
    public static final String LAYOUT_CARDS = "cards";
    public static final String LAYOUT_CARDS_REVERSED = "cards reversed";

    public Adapter(Context context, ArrayList<Entry> values) {
        super(context, R.layout.row_list,
                values // contents don't really matter
        );

        mContext = context;
        mValues = values;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        LayoutInflater inflater = (LayoutInflater) mContext
                .getSystemService(Context.LAYOUT_INFLATER_SERVICE);

        Entry entry = mValues.get(position);

        // which layout should be loaded?
        Utility u = new Utility(mContext);
        String layout = u.getSharedPreferences().getString("layout", LAYOUT_CARDS);

        // recycle stuff – I don't know how it works but it reduces lag
        View rowView;
        if (convertView != null) {
            rowView = convertView;
        } else {
            // load correct layout
            switch (layout) {
                case LAYOUT_LIST:
                    rowView = inflater.inflate(R.layout.row_list, parent, false);
                    break;
                case LAYOUT_CARDS_REVERSED:
                    rowView = inflater.inflate(R.layout.row_card_reversed, parent, false);
                    break;
                case LAYOUT_CARDS:
                default:
                    rowView = inflater.inflate(R.layout.row_card, parent, false);
                    break;
            }
        }

        // populate all fields
        fillField(entry.getAffectedClass(), (HtmlTextView) rowView.findViewById(R.id.affectedClass));
        fillField(entry.getLesson(), (HtmlTextView) rowView.findViewById(R.id.lesson));
        fillField(entry.getReplacementTeacher(), (HtmlTextView) rowView.findViewById(R.id.replacementTeacher));
        fillField(entry.getInfo(), (HtmlTextView) rowView.findViewById(R.id.info));

        // special code for cards
        if (layout.equals(LAYOUT_CARDS) || layout.equals(LAYOUT_CARDS_REVERSED)) {

            // remove divider
            ListView listView1 = (ListView) parent;
            listView1.setDivider(null);
            listView1.setDividerHeight(0);

            // color card according to status
            CardView cardView = rowView.findViewById(R.id.card);

            if (
                    entry.isHighlighted() ||
                            (entry.getAffectedClass().isEmpty() && entry.getLesson().isEmpty() && entry.getReplacementTeacher().isEmpty())) {
                // highlight card
                cardView.setCardBackgroundColor(u.getColorAccent());
            } else {
                // don't highlight card
                cardView.setCardBackgroundColor(u.getColorPrimary());
            }
        } else {
            // highlighting code for list

            int color = entry.isHighlighted() ? u.getColorAccent() : mContext.getResources().getColor(R.color.textOverWhite);

            ((HtmlTextView) rowView.findViewById(R.id.affectedClass)).setTextColor(color);
            ((HtmlTextView) rowView.findViewById(R.id.lesson)).setTextColor(color);
            ((HtmlTextView) rowView.findViewById(R.id.replacementTeacher)).setTextColor(color);
            ((HtmlTextView) rowView.findViewById(R.id.info)).setTextColor(color);
        }

        TextView dateView = rowView.findViewById(R.id.date);
        try {
            if (position != 0 && !mValues.get(position - 1).getDate().equals(entry.getDate())) {
                String date = u.formatDate(entry.getDate());
                dateView.setText(date);
                dateView.setVisibility(View.VISIBLE);
            } else {
                dateView.setVisibility(View.GONE);
            }
        } catch (NullPointerException e) {
            e.printStackTrace(); // Bad parser
        }

        return rowView;
    }

    private void fillField(String s, HtmlTextView v) {
        if (s != null && !s.isEmpty()) {
            v.setVisibility(View.VISIBLE);
            v.setHtml(s);
        } else {
            v.setVisibility(View.GONE);
        }
    }

    public void addAll(ArrayList<Entry> collection) {
        mValues.addAll(collection);
        notifyDataSetChanged();
    }

    public int size() {
        return mValues.size();
    }

    public Entry get(int index) {
        return mValues.get(index);
    }
}


code:
/*
 * 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.table;

import androidx.annotation.Nullable;
import godau.fynn.dsbdirect.Utility;
import godau.fynn.dsbdirect.manager.ShortcodeManager;

import java.util.Date;

public class Entry {
    private String mAffectedClass;
    private String mLesson;
    private String mReplacementTeacher;
    private String mInfo;
    private Date mDate;
    private boolean mHightlighted = false;

    public Entry(@Nullable String affectedClass, @Nullable String lesson, @Nullable String replacementTeacher,
                 @Nullable String info, @Nullable Date date, @Nullable ShortcodeManager shortcodeManager) {
        mAffectedClass = affectedClass;
        mLesson = lesson;
        mReplacementTeacher = replacementTeacher;
        mInfo = info;
        mDate = date;

        if (mAffectedClass == null) {
            mAffectedClass = "";
        }
        if (mLesson == null) {
            mLesson = "";
        } else {
            mLesson = mLesson.replaceAll(" ", "");
        }
        if (mReplacementTeacher == null) {
            mReplacementTeacher = "";
        } else {
            // If not null, apply shortcodes
            if (shortcodeManager != null) {
                mReplacementTeacher = shortcodeManager.replace(mReplacementTeacher);
            }
        }
        if (mInfo == null) {
            mInfo = "";
        }
    }

    public String getAffectedClass() {
        return mAffectedClass;
    }

    public void setAffectedClass(String affectedClass) {
        mAffectedClass = affectedClass;
    }

    public String getLesson() {
        return mLesson;
    }

    public void setLesson(String lesson) {
        mLesson = lesson;
    }

    public String getReplacementTeacher() {
        return mReplacementTeacher;
    }

    public void setReplacementTeacher(String replacementTeacher) {
        mReplacementTeacher = replacementTeacher;
    }

    public String getInfo() {
        return mInfo;
    }

    public void setInfo(String info) {
        mInfo = info;
    }

    public Date getDate() {
        return mDate;
    }

    public void setDate(Date date) {
        mDate = date;
    }

    public String getShareText(Utility u) {
        String text = Utility.smartConcatenate(new String[]{
                u.formatDate(mDate),
                mAffectedClass,
                mLesson,
                mReplacementTeacher,
                mInfo
        }, " · ");

        // There might be some html in there that needs to go away
        text = text.replaceAll("<br>", "\n");
        text = text.replaceAll("</*strike>", "~");

        return text;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Entry) {

            Entry entry = (Entry) obj;

            // Compare this entry to another entry
            return     entry.getAffectedClass().equals(getAffectedClass())
                    && entry.getLesson().equals(getLesson())
                    && entry.getReplacementTeacher().equals(getReplacementTeacher())
                    && entry.getInfo().equals(getInfo())
                    && entry.getDate().equals(getDate());

        } else {
            return super.equals(obj);
        }
    }

    public void setHighlighted() {
        mHightlighted = true;
    }

    public boolean isHighlighted() {
        return mHightlighted;
    }
}

repo_name: tomjschwanke/DSBDirect

license: mit


code:
/*
 * DSBDirect
 * Copyright (C) 2020 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.table;

import androidx.annotation.Nullable;

import java.io.Serializable;
import java.util.Date;

public class NewsItem extends NoticeBoardItem implements Serializable {

    private String message;

    public NewsItem(@Nullable String title, Date date, String message) {
        super(title, date);

        if (message.endsWith("\n"))
            message = message.substring(0, message.length() - 1);

        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}


code:
/*
* Notice for DSBDirect
* Copyright (C) 2019 Jasper Michalke <jasper.michalke@jasmich.ml>
* Created by Jasper Michalke <jasper.michalke@jasmich.de> under license EUPL 1.2.
 *
 * This software is not affiliated with heinekingmedia GmbH, the
 * developer of the DSB platform.
*/

package godau.fynn.dsbdirect.table

import godau.fynn.dsbdirect.table.reader.Reader
import java.io.Serializable
import java.util.*

class Notice(title: String?, date: Date?, var data: Array<String>, var conType: Int) : NoticeBoardItem(title, date), Serializable {

    fun isImage(): Boolean {
        return conType == Reader.CONTENT_IMAGE
    }
}


code:
package godau.fynn.dsbdirect.table

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.Gravity
import androidx.cardview.widget.CardView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import com.squareup.picasso.Picasso
import godau.fynn.dsbdirect.R
import godau.fynn.dsbdirect.Utility
import godau.fynn.dsbdirect.ZoomImage
import humanize.Humanize
import humanize.time.TimeMillis
import org.sufficientlysecure.htmltextview.HtmlTextView
import java.lang.Error

/*
* NoticeAdapter
* Copyright (C) 2019 Jasper Michalke <jasper.michalke@jasmich.ml>
* Created by Jasper Michalke <jasper.michalke@jasmich.de> under license EUPL 1.2.
*
* Based on Adapter, 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.
*/

class NoticeAdapter(private val mContext: Context, private val mValues: ArrayList<NoticeBoardItem>, private val container : View)
    : ArrayAdapter<NoticeBoardItem>(mContext, R.layout.row_notice_card, mValues) {

    private lateinit var zoomImage: ZoomImage

    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val inflater = mContext
                .getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
        val notice = mValues[position]
        val u = Utility(mContext)

        // recycle convertView
        val rowView = convertView ?: inflater.inflate(R.layout.row_notice_card, parent, false);

        // fill title and date views with their contents
        fillField(notice.title, rowView.findViewById<View>(R.id.title) as HtmlTextView)
        fillField(Humanize.naturalTime(notice.date, TimeMillis.DAY), rowView.findViewById<View>(R.id.date) as HtmlTextView)

        val card = rowView.findViewById<CardView>(R.id.card)

        // color card
        card.setCardBackgroundColor(u.colorPrimary)

        // remove OnClickListener from card
        card.setOnClickListener(null)

        // remove existing views from recycled view
        val contentHolderView = rowView.findViewById<LinearLayout>(R.id.content)
        contentHolderView.removeAllViews()

        val textView = rowView.findViewById<TextView>(R.id.textContent)

        if (notice is Notice && notice.isImage()) {

            contentHolderView.minimumHeight = u.dpToPx(200)
            textView.visibility = View.GONE

            // add each image to view
            for (imageUrl in notice.data) {
                val preview = ImageView(mContext)

                preview.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, u.dpToPx(200)).apply {
                    gravity = Gravity.CENTER
                }
                preview.adjustViewBounds = true
                preview.scaleType = ImageView.ScaleType.CENTER_CROP

                try {
                    Picasso.get().load(imageUrl).into(preview)
                } catch (e: Error) {
                    e.printStackTrace()
                }

                ZoomImage(preview, container.findViewById(R.id.expanded_image), container, imageUrl)

                contentHolderView.addView(preview)
            }
        } else if (notice is Notice && notice.data.size == 1) {
            // Allow user to open content externally

            textView.visibility = View.VISIBLE
            textView.setTextColor(mContext.resources.getColor(R.color.textSecondaryOverPrimary))
            textView.text = mContext.getString(R.string.notice_board_content_not_displayable_openable)

            card.setOnClickListener {
                val intent = Intent(Intent.ACTION_VIEW, Uri.parse(notice.data[0]))
                mContext.startActivity(intent)
            }
        } else if (notice is NewsItem) {
            // display news

            contentHolderView.minimumHeight = 0

            textView.visibility = View.VISIBLE
            textView.setTextColor(mContext.resources.getColor(R.color.textOverPrimary))
            textView.text = notice.message
        } else {
            // not displayable content and more than one – don't offer opening externally and wait for users to complain

            textView.visibility = View.VISIBLE
            textView.setTextColor(mContext.resources.getColor(R.color.textSecondaryOverPrimary))
            textView.text = mContext.getString(R.string.notice_board_content_not_displayable)
        }

        // remove divider
        val listView = parent as ListView
        listView.divider = null
        listView.dividerHeight = 0

        return rowView
    }

    private fun fillField(s: String?, v: HtmlTextView) {
        if (s != null && !s.isEmpty()) {
            v.visibility = View.VISIBLE
            v.setHtml(s)
        } else {
            v.visibility = View.GONE
        }
    }

    fun addAll(collection: ArrayList<NoticeBoardItem>) {
        mValues.addAll(collection)
        notifyDataSetChanged()
    }

    fun size(): Int {
        return mValues.size
    }

    operator fun get(index: Int): NoticeBoardItem {
        return mValues[index]
    }

}


code:
/*
 * DSBDirect
 * Copyright (C) 2020 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.table;

import androidx.annotation.Nullable;

import java.io.Serializable;
import java.util.Date;

/**
 * A notice board item is a notice or news item that will appear on the notice board.
 */
public abstract class NoticeBoardItem implements Serializable {

    protected @Nullable
    String title;
    protected Date date;

    public NoticeBoardItem(@Nullable String title, Date date) {
        this.title = title;
        this.date = date;
    }
}


code:
/*
 * 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.table;

import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.job.JobParameters;
import android.app.job.JobService;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.os.Build;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import android.util.Log;
import godau.fynn.dsbdirect.Login;
import godau.fynn.dsbdirect.R;
import godau.fynn.dsbdirect.Utility;
import godau.fynn.dsbdirect.activity.MainActivity;
import godau.fynn.dsbdirect.manager.DownloadManager;
import godau.fynn.dsbdirect.manager.FileManager;
import godau.fynn.dsbdirect.manager.LoginManager;
import godau.fynn.dsbdirect.table.reader.Reader;
import humanize.Humanize;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Calendar;

import static android.os.Build.VERSION_CODES.LOLLIPOP;

@RequiresApi(LOLLIPOP)
public class PollingService extends JobService {
    private static final String CHANNEL = "plan_update";

    private static final int NOTIFICATION_ID = 70700707; // A random number my brother told me when I asked him for one

    private SharedPreferences mSharedPreferences;
    private DownloadManager mDownloadManager;

    private Utility u;

    private Login login;

    @Override
    public boolean onStartJob(JobParameters parameters) {

        Log.d("POLLING", "polling now");

        // Doesn't seem useful to me, but otherwise we get a NetworkOnMainThreadException
        new Thread(new Runnable() {
            @Override
            public void run() {
                u = new Utility(PollingService.this);
                mSharedPreferences = u.getSharedPreferences();
                MainActivity.filterEnabled = mSharedPreferences.getBoolean("filter", false);

                LoginManager loginManager = new LoginManager(PollingService.this);
                login = loginManager.getActiveLogin();

                mDownloadManager = new DownloadManager(PollingService.this);

                try {

                    onTimetableListDownloaded(mDownloadManager.downloadContentInformation(login).getTables());

                } catch (IOException e) {
                    e.printStackTrace();
                }

                // Schedule next polling
                u.schedulePolling();
            }
        }).start();

        return true;
    }

    /**
     * To be called after the timetable list has been downloaded.
     *
     * @param tables List of timetables
     */
    private void onTimetableListDownloaded(Table[] tables) {

        try {
            ArrayList<Entry> entries = new ArrayList<>();

            FileManager fileManager;
            boolean download = mSharedPreferences.getBoolean("autoDownload", true);
            if (download) {
                fileManager = new FileManager(PollingService.this);
            } else {
                fileManager = null;
            }

            // Process every table
            for (int i = 0; i < tables.length; i++) {
                Table table = tables[i];

                // Only parse if requirements are met
                if (table.isHtml()
                        && mSharedPreferences.getBoolean("parse", true)
                        && download) {

                    String html = fileManager.getHtmlTable(table, mDownloadManager);
                    Reader reader = u.getReader(html, login.getId());
                    entries.addAll(reader.filterToday(
                            reader.filterUserFilters(
                                    reader.read(), PollingService.this
                            )
                    ));

                    // Create notification after last iteration
                    if (i == tables.length - 1) {
                        createParsedNotification(table, entries);
                    }

                } else {
                    // Parsing is impossible, contents don't matter

                    // Download anyway if automatic download is enabled
                    if (download) {
                        fileManager.getTable(table, mDownloadManager);
                    }

                    if (i == tables.length - 1) {
                        // Notify about this table if new
                        createNotification(table);
                    }

                }
            }

        } catch (IOException  e) {
            e.printStackTrace();
        }

    }

    @Override
    public boolean onStopJob(JobParameters params) {
        return false;
    }

    private void createParsedNotification(Table table, ArrayList<Entry> entries) {
        // The entries have finally been received and could be parsed

        SharedPreferences sharedPreferences = u.getSharedPreferences();

        // Create notification text
        StringBuilder text = new StringBuilder();

        if (entries.size() == 0) {
            // There are no entries

            // Get configure behaviour
            boolean displayEmptyNotifications = sharedPreferences.getBoolean("displayEmpty", false);
            if (displayEmptyNotifications) {
                text.append(getString(R.string.notification_plan_html_updates_empty));
            } else {
                // Cancel notification creation
                return;
            }
        } else {
            // Some entries are present
            for (int i = 0; i < entries.size(); i++) {
                Entry entry = entries.get(i);

                text.append(Utility.smartConcatenate(
                        new String[]{
                                entry.getAffectedClass(),
                                entry.getLesson(),
                                entry.getReplacementTeacher(),
                                entry.getInfo()
                        }, " · "
                ));

                if (i != entries.size() - 1) {
                    // This is not the last iteration yet
                    text.append("\n");
                }

            }
        }

        // Only notify if text is different from the last notification

        String lastText = sharedPreferences.getString("lastNotification", "Attention: the developer is programming late at night again");

        if (text.toString().equals(lastText)
                && sharedPreferences.getBoolean(Utility.SUPER_SECRET_SETTING_NOTIFY_ABOUT_NOTHING_NEW, false) == false // For debugging
        ) {
            // Notification already displayed
            Log.d("POLLINGNOTIFICATION", "notification not displayed because of text match (" + text.toString() + ")");
            return;
        }

        // Notification can be displayed

        // Store text to shared preferences
        sharedPreferences.edit().putString("lastNotification", text.toString()).apply();

        // Create content text / summary
        String contentText;
        if (sharedPreferences.getBoolean(Utility.SUPER_SECRET_SETTING_TEXT_AS_CONTENT_TEXT, false)) { // Developer has special needs, so he implemented a super secret setting
            contentText = text.toString().replaceAll("\n", " – ");
        } else {
            contentText = Humanize.pluralize(
                    getString(R.string.notification_plan_updates_text_pluralize_entries_one),
                    getString(R.string.notification_plan_updates_text_pluralize_entries_many),
                    getString(R.string.notification_plan_html_updates_empty),
                    entries.size());
        }

        // Create notification channel because API 26 demands it
        createNotificationChannel();

        // Expire notification at 0 o'clock
        Calendar timeoutAt = Calendar.getInstance();
        timeoutAt.add(Calendar.DAY_OF_YEAR, 1);
        Utility.zeroOClock(timeoutAt);
        // Timeout is then minus now
        long timeoutAfter = timeoutAt.getTimeInMillis() - Calendar.getInstance().getTimeInMillis();

        // Create notification
        NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, new NotificationCompat.Builder(PollingService.this, CHANNEL)
                .setSmallIcon(R.drawable.ic_notification)
                .setContentTitle(getString(R.string.notification_plan_html_updates_title))
                .setContentText(contentText)
                .setContentIntent(PendingIntent.getActivity(this, 2201,
                        new Intent(PollingService.this, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT))
                .setStyle(
                        new NotificationCompat.BigTextStyle().bigText(text.toString().replaceAll("</?s(trike)*>", "~")
                        .replaceAll("</?br/?>", " ")))
                .setColorized(true)
                .setColor(Color.argb(255, 63, 81, 181))
                .setTimeoutAfter(timeoutAfter)
                .setAutoCancel(false) // Parsed notifications might still be valuable after they had been opened
                .build()
        );
    }

    private void createNotification(Table table) {

        SharedPreferences sharedPreferences = u.getSharedPreferences();

        String uri = table.getUri();

        // Only notify if image is different than last time

        String lastUri = sharedPreferences.getString("lastNotification", "Attention: the developer is programming late at night again");

        if (uri.equals(lastUri)
                && sharedPreferences.getBoolean(Utility.SUPER_SECRET_SETTING_NOTIFY_ABOUT_NOTHING_NEW, false) == false // For debugging
        ) {
            // Notification already displayed
            Log.d("POLLINGNOTIFICATION", "notification not displayed because of uri match (" + uri + ")");
            return;
        }

        // Notification can be displayed

        // Store text to shared preferences
        sharedPreferences.edit().putString("lastNotification", uri).apply();

        // Create notification channel
        createNotificationChannel();

        // Create notification
        NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, new NotificationCompat.Builder(PollingService.this, CHANNEL)
                .setSmallIcon(R.drawable.ic_notification)
                .setContentTitle(getString(R.string.notification_plan_updates_title))
                .setContentIntent(PendingIntent.getActivity(this, 2202,
                        new Intent(PollingService.this, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT))
                .setColorized(true)
                .setColor(Color.argb(255, 63, 81, 181))
                .setAutoCancel(true) // "New plan available" notification should disappear once the new plan has been seen
                .build()
        );

    }

    private void createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            int importance = NotificationManager.IMPORTANCE_DEFAULT;
            NotificationChannel channel = new NotificationChannel(CHANNEL, getString(R.string.notification_channel_plan_updates), importance);
            channel.setDescription(getString(R.string.notification_channel_plan_updates_description));

            NotificationManager notificationManager = getSystemService(NotificationManager.class);
            notificationManager.createNotificationChannel(channel);
        }
    }

    // Methods for getting the next polling time

    public static long getNextPollingTime(SharedPreferences sharedPreferences) {
        return getNextPollingTime(sharedPreferences, Calendar.getInstance());
    }

    public static long getNextPollingTime(SharedPreferences sharedPreferences, Calendar now) {

        Calendar calendar = Calendar.getInstance();
        calendar.setTimeInMillis(now.getTimeInMillis());

        String pollingIntervalString = sharedPreferences.getString("pollingInterval", "5");

        try {
            calendar.add(Calendar.MINUTE, Integer.parseInt(pollingIntervalString));

            // If super secret setting for polling on weekends is not enabled, don't poll on weekends
            if (sharedPreferences.getBoolean(Utility.SUPER_SECRET_SETTING_POLL_ON_WEEKENDS, false) == false) {

                // If this is a weekend, go to next Monday
                switch (calendar.get(Calendar.DAY_OF_WEEK)) {
                    case Calendar.SATURDAY:
                        // If this a Saturday, go to Monday zero o' clock
                        calendar.add(Calendar.DAY_OF_MONTH, 2);
                        Utility.zeroOClock(calendar);
                        break;
                    case Calendar.SUNDAY:
                        // If this is a Sunday, go to tomorrow zero o' clock
                        calendar.add(Calendar.DAY_OF_MONTH, 1);
                        Utility.zeroOClock(calendar);
                }

            }

            // If this is late at night, go to the morning
            if (calendar.get(Calendar.HOUR_OF_DAY) < 5) {
                Utility.zeroOClock(calendar);
                calendar.set(Calendar.HOUR_OF_DAY, 5);
            }

            Log.d("POLLINGTIME", calendar.getTimeInMillis() + ", which is in " + (calendar.getTimeInMillis() - now.getTimeInMillis()));

            return calendar.getTimeInMillis();

        } catch (NumberFormatException e) {
            // There was no int, whatever
        }
        return 0;
    }

    public static long getNextPollingTimeDelay(SharedPreferences sharedPreferences) {
        return getNextPollingTimeDelay(sharedPreferences, Calendar.getInstance());
    }

    public static long getNextPollingTimeDelay(SharedPreferences sharedPreferences, Calendar now) {
        long nextTime = getNextPollingTime(sharedPreferences, now);
        return nextTime - now.getTimeInMillis();
    }
}


code:
package godau.fynn.dsbdirect.table;

import javax.annotation.Nullable;

/**
 * Stores a shortcode to be replaced with a proper teacher name
 */
public class Shortcode {

    /**
     * Short code and corresponding teacher's name
     */
    private String shortcode, teacherName;

    public Shortcode(String shortcode, String teacherName) throws IllegalArgumentException {

        // Check for forbidden characters
        checkForIllegalCharacters(shortcode);
        checkForIllegalCharacters(teacherName);

        this.shortcode = shortcode;
        this.teacherName = teacherName;
    }

    /**
     * Deserialize a serialized shortcode
     * @param serialized A pair of shortcode and teacherName separated by a → character
     */
    public Shortcode(String serialized) throws IllegalArgumentException {
        String[] split = serialized.split("→");

        // Check whether amount is correct
        if (split.length != 2) {
            throw new IllegalArgumentException("serialized string does not contain exactly one '→' char: " + serialized);
        }

        // Check for → and \n chars
        checkForIllegalCharacters(split[0]);
        checkForIllegalCharacters(split[1]);

        shortcode = split[0];
        teacherName = split[1];

    }

    /**
     * Check whether a given String contains characters that break things
     * @param s The String to be checked
     * @throws IllegalArgumentException if s contains a '→' (breaks serialization) or a newline character (breaks storage)
     */
    private void checkForIllegalCharacters(String s) {
        if (s.contains("→") || s.contains("\n")) {
            throw new IllegalArgumentException("Neither shortcode nor teacher name may contain a '→' or newline character");
        }
    }

    /**
     * Replace shortcodes with teacher names
     * @param s The String in which a shortcode might appear that has to be replaced
     * @return The String where shortcode is replaced with teacher name
     */
    public String replace(String s) {
        return s.replaceAll("\\b" + shortcode + "\\b", teacherName);
    }

    /**
     * @param s The String in which a shortcode might appear
     * @return Whether the String contains the shortcode
     */
    public boolean containedIn(String s) {
        return s.contains(shortcode);
    }

    /**
     * Serialize to "shortcode→teacherName" to store in sharedPreferences
     * @return serialized String
     */
    public String serialize() {
        return shortcode + "→" + teacherName;
    }

    /**
     * @return a String that displays this shortcode
     */
    public String getDisplayName() {
        return shortcode + " → " + teacherName;
    }

    public String getFrom() {
        return shortcode;
    }

    public String getTo() {
        return teacherName;
    }

    public void setFrom(String shortcode) {
        this.shortcode = shortcode;
    }

    public void setTo(String teacherName) {
        this.teacherName = teacherName;
    }

    @Override
    public boolean equals(@Nullable Object obj) {
        if (obj instanceof Shortcode) {
            return ((Shortcode) obj).serialize().equals(serialize());
        }
        return super.equals(obj);
    }
}


code:
/*
 * 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.table;

import java.io.Serializable;
import java.util.Date;

import static godau.fynn.dsbdirect.table.reader.Reader.*;

public class Table implements Serializable {
    private String uri;
    private Date publishedTime;
    private int contentType;
    private String title;

    public Table(String uri, Date publishedTime, int contentType, String title) {
        this.uri = uri;
        this.publishedTime = publishedTime;
        this.contentType = contentType;
        this.title = title;
    }

    public Table(String uri, Date publishedTime, boolean isHtml, String title) {
        this.uri = uri;
        this.publishedTime = publishedTime;
        if (isHtml) {
            contentType = CONTENT_HTML;
        } else {
            contentType = CONTENT_IMAGE;
        }
        this.title = title;
    }

    public Date getPublishedDate() {
        return publishedTime;
    }

    public String getUri() {
        return uri;
    }

    public boolean isHtml() {
        return contentType == CONTENT_HTML || contentType == CONTENT_HTML_MYSTERY;
    }

    /**
     * Don't use this method to verify if a content is HTML, use {@link #isHtml()} for that
     * @return The content type – either CONTENT_HTML, CONTENT_HTML_MYSTERY or CONTENT_IMAGE.
     */
    public int getContentType() {
        return contentType;
    }

    public String getTitle() {
        return title;
    }
}


code:
package godau.fynn.dsbdirect.table.reader;

import godau.fynn.dsbdirect.table.Entry;

import javax.annotation.Nullable;
import java.util.ArrayList;

public class Dummy extends Reader {
    public Dummy() {
        super(null, null);
    }

    @Override
    public void addEntries() {

        // For issue #33 https://notabug.org/fynngodau/DSBDirect/issues/33
        addEntry("10a · D", "3", null, "E47 · statt Mi", null);
        addEntry("10a · D", "4", null, "E47 · statt Mi", null);
        addEntry("10b · F 2. FS", "5", null, "U5 · Raumänderung", null);
        addEntry("10b · F 2. FS", "6", null, "U5 · Raumänderung", null);
        addEntry("10c", "5", null, "--- · fällt aus", null);
        addEntry("10d · M", "3", null, "E44 · statt Mi", null);
    }

    @Nullable
    @Override
    public String getSchoolName() {
        String[] names = {"Baumschule", "Dummydatengymnasium", "Max-Mustermann-Realschule"};

        int rand = (int) (Math.random() * names.length); // Cut off decimals

        return names[rand];
    }
}


code:
package godau.fynn.dsbdirect.table.reader;

import android.content.Context;
import android.util.Log;
import godau.fynn.dsbdirect.Utility;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

import java.util.Arrays;
import java.util.Date;
import java.util.EnumMap;
import java.util.Map;

import static godau.fynn.dsbdirect.table.reader.FlexibleReader.MasterTableColumn.*;

/**
 * FlexibleReader is used for implementing the concept of a master table
 * more easily. A (theoretical) master table has columns for every column
 * that could appear in a substitution plan and is then used to construct
 * an entry with the four fields DSBDirect uses. Therefore, columns found
 * in a substitution plan file are assigned to their corresponding master
 * table columns.
 */
public abstract class FlexibleReader extends Reader {

    enum MasterTableColumn {
        UNDEFINED,
        CLASS,
        SUBJECT,
        LESSON,
        TYPE,
        TEACHER,
        ROOM,
        OLD_SUBJECT,
        OLD_TEACHER,
        OLD_CLASS,
        OLD_TIME,
        INFO
    }

    public FlexibleReader(String html, Context context) {
        super(html, context);
    }

    private MasterTableColumn[] columns;

    /**
     * Creates a mapping of actual columns to master columns stored by
     * calling {@link #getMasterTableColumn(String)} for each column and
     * stores the result in {@link #columns}.
     */
    protected final void setMasterTableColumns(String[] definitions) {

        MasterTableColumn[] columns = new MasterTableColumn[definitions.length];

        // Get column of each master table from subclass
        for (int i = 0; i < definitions.length; i++) {
            columns[i] = getMasterTableColumn(definitions[i]);
        }

        this.columns = columns;
    }

    /**
     * Gets the text String from each Element and calls {@link #setMasterTableColumns(String[])}
     * @param definingElements Should contain one element for each column of
     *                         the table (likely {@code td} elements)
     */
    protected final void setMasterTableColumns(Elements definingElements) {
        setMasterTableColumns(stringsOfElements(definingElements));
    }

    /**
     * @return What master table column the column with this title belongs to, or,
     * in other words, a mapping of this column title to its {@code MasterTableColumn}
     */
    protected abstract MasterTableColumn getMasterTableColumn(String definition);

    /**
     * This method is marked as deprecated to inform you that you probably don't
     * want to use it, as you should call {@link #constructEntry(Object[], Date)}
     * with the list of Strings as you found them in your table.
     * @return the array of master table column assignments
     */
    @Deprecated
    protected MasterTableColumn[] getMasterTableColumns() {
        return columns;
    }

    /**
     * Maps each array element to the corresponding master table column,
     * then calls {@link #constructEntry(Map, Date)}.
     * @param <T> The type of array that row is – if it is an Element, its
     *           text will be used; otherwise, its contents will be
     *           received using toString().
     */
    protected final <T> void constructEntry(T[] row, Date date) {

        verifyMasterTableConfiguration(row);

        Map<MasterTableColumn, String> masterRow = new EnumMap<>(MasterTableColumn.class);

        for (int i = 0; i < row.length; i++) {
            if (row[i] instanceof Element)
                masterRow.put(columns[i], ((Element) row[i]).text());
            else masterRow.put(columns[i], row[i].toString());
        }

        constructEntry(masterRow, date);
    }

    /**
     * Converts {@code elements} to an array, then calls
     * {@link #constructEntry(Object[], Date)}.
     */
    protected final void constructEntry(Elements elements, Date date) {
        /* elements.toArray() return an Object[], but constructEntry(T[], Date)
         * will test whether each of the array's item is an instance of Element
         * separately.
         */

        constructEntry(elements.toArray(), date);
    }

    /**
     * Takes a map that corresponds to a row in the column and builds an Entry for it.
     * @param masterRow Mapping of {@code MasterTableColumns} to their corresponding Strings
     */
    protected final void constructEntry(Map<MasterTableColumn, String> masterRow, Date date) {

        String classString = ratherThisThanThat(masterRow.get(CLASS), masterRow.get(OLD_CLASS));
        String subject = ratherThisThanThat(masterRow.get(SUBJECT), masterRow.get(OLD_SUBJECT));

        String combinedClassString = Utility.smartConcatenate(new String[]{classString, subject}, " · ");

        String lesson = masterRow.get(LESSON);

        String teacher = ratherThisThanThat(masterRow.get(TEACHER), masterRow.get(OLD_TEACHER));

        String info = Utility.smartConcatenate(new String[]{
                masterRow.get(TYPE), masterRow.get(OLD_TIME),
                masterRow.get(ROOM), masterRow.get(INFO)
        }, " · ");

        addEntry(combinedClassString, lesson, teacher, info, date);
    }

    /**
     * Prefers to return string1, but returns string2 surrounded by strike tags if string1 is null, empty or just dashes.
     */
    private String ratherThisThanThat(String string1, String string2) {

        final String DASHES_REGEX = "-+(?!.)"; // matches one or more "-" if nothing else follows it

        if (string1 != null && !string1.isEmpty() && !string1.matches(DASHES_REGEX)) {
            return string1;
        } else if (string2 != null) { // Don't concatenate around with null

            return "<strike>" + string2 + "</strike>";
        } else {
            return null;
        }
    }

    /**
     * @return An array of Strings with one String for the content of each element in e
     */
    protected final String[] stringsOfElements(Elements e) {
        Object[] strings = e.eachText().toArray();
        // "Cast" to String[]
        return Arrays.copyOf(strings, strings.length, String[].class);
    }

    /**
     * Prevent raw NullPointerException and ArrayIndexOutOfBoundsException and give
     * hopefully useful feedback instead.
     * @param row An array that contains elements that are part of a row that is supposed
     *            to be the same length as the master table configuration.
     */
    private void verifyMasterTableConfiguration(Object[] row) {

        if (columns == null) {
            throw new MasterTableColumnsNotMappedException();
        } else
            MasterTableColumnAmountMismatchException.test(row.length, columns.length);
    }

    private static class MasterTableColumnsNotMappedException extends NullPointerException {
        public MasterTableColumnsNotMappedException() {
            super("The master table columns aren't set yet! You must first call setMasterTableColumns(…).");
        }
    }

    private static class MasterTableColumnAmountMismatchException extends ArrayIndexOutOfBoundsException {
        public MasterTableColumnAmountMismatchException(int rowLength, int columnLength) {
            super("Your row has " + rowLength + " column" + (rowLength == 1? "" : "s") +
                    ", but your master table only has assignments for " + columnLength +
                    ". Please ensure you have last called setMasterTableColumns(…) for the " +
                    "table you are trying to add Entries for!");
        }

        static void printMismatchWarning(int rowLength, int columnLength) {
            Log.w("FLEXREADER", "Your row, which has " + rowLength +" column" +
                    (rowLength == 1? "" : "s") + ", is shorter than the array of " +
                    "assignments that was generated when you called " +
                    "setMasterTableColumns(…), which only has " + columnLength +
                    " items. Please ensure you have last called setMasterTableColumns(…) " +
                    "for the table you are trying to add Entries for!"
            );
        }

        /**
         * Deals with mismatching combinations of rowLength and columnLength by either
         * throwing an exception if an ArrayOutOfBoundsException would normally occur, or
         * by printing a warning message.
         */
        static void test(int rowLength, int columnLength) {
            if (rowLength > columnLength) {
                throw new MasterTableColumnAmountMismatchException(rowLength, columnLength);
            } else if (rowLength < columnLength) {
                MasterTableColumnAmountMismatchException.printMismatchWarning(rowLength, columnLength);
            }
        }
    }
}


code:
/*
 * DSBDirect
 * Copyright (C) 2019 Jasper Michalke <jasper.michalke@jasmich.ml>
 *
 * 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.table.reader;

import android.content.Context;
import android.util.Log;
import godau.fynn.dsbdirect.table.Entry;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

import javax.annotation.Nullable;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class GoetheHamburg extends Reader {

    private String teacher = "";

    /**
     * Every class is just listed once even if there are several substitutions
     */
    private String lastClass = "";

    public GoetheHamburg(String html, Context context) {
        super(html, context);

    }

    @Override
    public void addEntries() {

        Document document = Jsoup.parse(html);

        Date date = null;

        Elements divs = document.getElementsByTag("div");

        for (Element div : divs) {

            // Is this the beginning of a new day?
            if (div.hasClass("dayHeader")) {
                try {
                    String dateString = div.text().replaceAll(", \\w+", "");
                    date = new SimpleDateFormat("dd.MM.yyyy").parse(dateString);
                } catch (ParseException e) {
                    date = new Date();
                    e.printStackTrace();
                }
            } else
                // Is this a div containing a table?
                if (div.getElementsByTag("table").size() > 0) {

                    Elements classBlocks = div.select("table");

                    Elements rows = classBlocks.select("tr");

                    for (Element row :
                            rows) {
                        readRow(row, date);
                    }
                }
        }

    }

    private void readRow(Element row, Date date) {
        try {

            String infoText;
            String affectedClass;
            try {
                infoText = row.select("td").get(1).text();
                affectedClass = row.select("td").get(0).text();
                lastClass = affectedClass;
            } catch (Exception e) {
                infoText = row.select("td").get(0).text();
                Log.d("Multi row", infoText);
                affectedClass = lastClass;
            }

            String lesson = "";
            Pattern pattern = Pattern.compile("(\\d.*) Std");
            Matcher matcher = pattern.matcher(infoText);
            if (matcher.find()) lesson = matcher.group(1).replaceAll("\\.", "");
            ;

            teacher = "";
            pattern = Pattern.compile("bei ([A-ZÄÖÜ]{3}, [A-ZÄÖÜ]{3}|[A-ZÄÖÜ]{3})");
            matcher = pattern.matcher(infoText);
            if (matcher.find()) teacher = matcher.group(1);

            String subject = "";
            pattern = Pattern.compile("\\d.* Std. (\\w+)");
            matcher = pattern.matcher(infoText);
            if (matcher.find() && !matcher.group(1).equals("bei")) subject = matcher.group(1) + " ";

            String info = "";
            String[] regex = {"im Raum ([HVGFT]\\d{3}|ESH)", "statt bei [A-ZÄÖÜ]{3}", "\\(.*\\)", "(f.llt aus|Vtr. ohne Lehrer)"};
            for (int i = 0; i < regex.length; i++) {
                pattern = Pattern.compile(regex[i]);
                matcher = pattern.matcher(infoText);
                if (matcher.find()) info = matcher.group(0);
            }

            info = subject + info;

            if (!info.contains("im Raum")) {
                pattern = Pattern.compile("im Raum ([HVGFT]\\d{3}|ESH)");
                matcher = pattern.matcher(infoText);
                if (matcher.find()) info += " (" + matcher.group(1) + ")";
            }

            addEntry(affectedClass, lesson, teacher, info, date);
        } catch (Exception e) {
            Log.d("GOETHE", "Error in row: " + row.text());
            addEntry(null, null, null, row.text(), date);
        }
    }

    @Nullable
    @Override
    public String getSchoolName() {
        return null;
    }
}


code:
/*
 * 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.table.reader;

import android.content.Context;
import android.content.SharedPreferences;

import androidx.annotation.NonNull;

import godau.fynn.dsbdirect.Utility;
import godau.fynn.dsbdirect.activity.MainActivity;
import godau.fynn.dsbdirect.manager.ShortcodeManager;
import godau.fynn.dsbdirect.table.Entry;
import godau.fynn.dsbdirect.table.NewsItem;
import godau.fynn.dsbdirect.table.Notice;
import godau.fynn.dsbdirect.table.Table;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import javax.annotation.Nullable;

import java.util.*;

import static android.content.Context.MODE_PRIVATE;

public abstract class Reader {
    // Reader is just used as a synonym for Parser

    /* from configuration.js:
        Dsbmobile.SPOTTYPS = {
            NONE:   0,
            FOLDER: 1,
            SYNC:   2,
            HTML:   3,
            IMG:    4,
            NEWS:   5,
            URL:    6,
            VIDEO:  7,
            PDF:    8,
        };
     */

    /**
     * Not for actual content, mustn't appear in Table objects as only their children are read out
     * in {@link godau.fynn.dsbdirect.table.reader.Reader}
     */
    private static final int CONTENT_ROOT = 0;
    /**
     * Unknown what this is good for, therefore a mystery
     */
    private static final int CONTENT_MYSTERY = 1;
    /**
     * Not for actual content, might hold further tables or notices in its children. Mustn't become an object
     */
    private static final int CONTENT_PARENT = 2;
    /**
     * HTML content
     */
    public static final int CONTENT_HTML = 3;
    /**
     * Image content
     */
    public static final int CONTENT_IMAGE = 4;
    /**
     * Text content
     */
    private static final int CONTENT_TEXT = 5;
    /**
     * Also HTML content…? We don't know the difference to {@link #CONTENT_HTML}, so it's a mystery
     */
    public static final int CONTENT_HTML_MYSTERY = 6;

    protected String html;
    private ShortcodeManager shortcodeManager = null;
    /**
     * Entries are centrally managed by Reader, to which they are added with
     * {@link #addEntry(String, String, String, String, Date)} and then returned by {@link #read()}.
     */
    private ArrayList<Entry> entries = new ArrayList<>();

    public Reader(String html, @Nullable Context context) {

        // Leave brs be
        html = html.replaceAll("(?i)
", "<br>");

        // Leave strikes be
        html = html.replaceAll("(?i)<s(trike)*>", "<strike>");
        html = html.replaceAll("(?i)</s(trike)*>", "</strike>");

        this.html = html;
        if (context != null) {
            shortcodeManager = new ShortcodeManager(context);
        }
    }

    /**
     * Reads the provided HTML file and returns all contained entries while removing duplicates.
     */
    public final ArrayList<Entry> read() {
        addEntries();
        return entries;
    }

    /**
     * Adds all entries contained in the {@link #html} by calling
     * {@link #addEntry(String, String, String, String, Date)} for each entry.
     */
    public abstract void addEntries();

    /**
     * @see #entries
     */
    protected final void addEntry(@Nullable String affectedClass, @Nullable String lesson, @Nullable String replacementTeacher,
                            @Nullable String info, @Nullable Date date) {

        Entry e = new Entry(affectedClass, lesson, replacementTeacher, info, date, shortcodeManager);

        // Test whether entry already exists
        for (Entry existingEntry :
                entries) {
            if (existingEntry.equals(e)) {
                return;
            }
        }

        entries.add(e);
    }

    /**
     * @return The name of the school that this substitution plan belongs to, or
     * {@code null} if not available.
     */
    public abstract @Nullable String getSchoolName();

    public final ArrayList<Entry> filterUserFilters(@NonNull ArrayList<Entry> entries, Context context) {
        // get sharedPreferences
        final SharedPreferences sharedPreferences = context.getSharedPreferences("default", MODE_PRIVATE);

        if (!MainActivity.filterEnabled || !sharedPreferences.getBoolean("filter", false)) {
            // Filters are disabled
            return entries;
        }

        String number = sharedPreferences.getString("number", "");
        String letter = sharedPreferences.getString("letter", "");

        String[] classThings = new String[]{number, letter};

        String name = sharedPreferences.getString("name", "");

        String[] teacherThings = new String[]{name};

        Set<String> courses = sharedPreferences.getStringSet("courses", new TreeSet<String>());
        String[] courseThings = courses.toArray(new String[courses.size()]);

        boolean courseFilterActive = courses.size() > 0 && !(courseThings[0].equals("") && courses.size() == 1);

        // Declare adjectives
        boolean displayGeneral = sharedPreferences.getBoolean("displayGeneral", true);
        boolean classFilterActive = !letter.isEmpty() || !number.isEmpty();
        boolean teacherFilterActive = !name.isEmpty();

        // Determine whether past should be filtered
        boolean filterPast = sharedPreferences.getBoolean("filterPast", true);

        if (!classFilterActive && !teacherFilterActive && !filterPast && !courseFilterActive) { // Let's not filter when no filter is enabled
            return entries;
        }

        for (int i = entries.size(); i > 0; i--) {
            Entry e = entries.get(i - 1);

            if (filterPast) {
                // Declare past adjective

                Calendar zeroOClockCalendar = Utility.zeroOClock(Calendar.getInstance());

                long today = zeroOClockCalendar.getTimeInMillis();
                if (e.getDate() != null) {
                    long entry = e.getDate().getTime();

                    // Entry is in the past if it was before today
                    boolean past = entry < today;

                    if (past) {
                        entries.remove(i - 1);

                        // Don't check other criteria as the entry is already gone
                        continue;
                    }
                }

                if (!classFilterActive && !teacherFilterActive && !courseFilterActive) {
                    // If only filtering past, don't check other criteria
                    continue;
                }

            }

            // Declare more adjectives

            boolean unterrichtsfrei = e.getInfo().contains("Unterrichtsfrei") || e.getInfo().contains("Unterrichtsende") || e.getInfo().contains("Unterrichtsschluss");

            boolean classInClass = thingsInString(e.getAffectedClass(), classThings, true, false);
            boolean classInInfo = thingsInString(e.getInfo(), classThings, false, false);

            boolean affectedClassEmpty = e.getAffectedClass().isEmpty();

            boolean teacherAppears = thingsInString(e.getReplacementTeacher(), teacherThings, true, false) // Teacher in teacher
                    || thingsInString(e.getInfo(), teacherThings, false, false); // Teacher in info

            boolean courseAppears = thingsInString(e.getInfo(), courseThings, true, true)
                    || thingsInString(e.getAffectedClass(), courseThings, true, true);

            boolean infoOnly = affectedClassEmpty && e.getLesson().isEmpty() && e.getReplacementTeacher().isEmpty() && !e.getInfo().isEmpty();

            int matchesFilters = 0;

            // first possibility: Unterrichtsfrei / Unterrichtsende / Unterrichtsschluss
            if (unterrichtsfrei) {
                // pass, everyone is interested in that
                matchesFilters++;
            }

            // second possibility: class appears or contains
            if (

                    (classInClass // test class in affected class
                            || (classInInfo && affectedClassEmpty) // test class in info if no affected class
                    ) && classFilterActive

            ) {
                // pass
                matchesFilters++;
            }

            // third possibility: teacher appears
            if (teacherAppears && teacherFilterActive) {
                // pass
                matchesFilters++;
            }

            // fourth possibility: course appears
            if (courseAppears && courseFilterActive) {
                // pass
                matchesFilters++;
            }

            // fifth possibility: info only
            if (infoOnly && displayGeneral) {
                // pass
                matchesFilters++;
            }

            if (matchesFilters <= 0) {
                // failed to match any filters
                entries.remove(i - 1);
            } else if (matchesFilters >= 2) {
                // matched multiple filters; highlight
                e.setHighlighted();
            }
        }

        /*/ fill in data from sharedPreferences
        inputNumber.setText();
        inputLetter.setText(sharedPreferences.getString("letter", ""));
        inputName.setText(sharedPreferences.getString("name", ""));*/

        return entries;
    }

    /**
     * @param string     String that might contain the things.
     * @param things     Things that the String could contain.
     * @param ignoreCase Only some comparisons can be case-sensitive.
     *                   For details see #19
     * @param or         If true, an or condition is applied instead of an and condition.
     * @return Whether the String contains all or, if or is true, one of the things. If no things are given, false
     * is returned.
     */
    private boolean thingsInString(String string, String[] things, boolean ignoreCase, boolean or) {

        if (ignoreCase) {
            string = string.toLowerCase();

            for (int i = 0; i < things.length; i++) {
                things[i] = things[i].toLowerCase();
            }
        }

        String[] parts = string
                .replaceAll("</?s(trike)*>", "~") /* Bad to have strikethrough tags while filtering
                                                   * ("6d" would contain an 'e')
                                                   */
                .split("·");

        for (String part : parts) {
            /* If or is false, okay will be true at first and set to false once a thing is reached that part doesn't contain.
             * If or is true, okay will be false at first and set to true once a thing is reached that part does contain.
             * For a more detailed explanation, see #33
             */

            boolean okay = !or;
            for (String thing : things) {
                if (part.contains(thing) == or) {
                    okay = or;
                }
            }

            if (okay) {
                return true;
            }

        }

        // None of the parts contain all (or one of) the things
        return false;
    }

    public final ArrayList<Entry> filterToday(@NonNull ArrayList<Entry> entries) {
        for (int i = entries.size(); i > 0; i--) {
            Entry entry = entries.get(i - 1);

            Date entryDate = entry.getDate();

            Calendar entryCalendar = Calendar.getInstance();
            entryCalendar.setTime(entryDate);

            Calendar todayCalendar = Calendar.getInstance();

            int entryDay = entryCalendar.get(Calendar.DAY_OF_YEAR);
            int todayDay = todayCalendar.get(Calendar.DAY_OF_YEAR);

            int entryYear = entryCalendar.get(Calendar.YEAR);
            int todayYear = todayCalendar.get(Calendar.YEAR);

            boolean yearMatches = entryYear == todayYear;
            boolean dayMatches = entryDay == todayDay;

            boolean sameDay = yearMatches && dayMatches;

            if (!sameDay) {
                entries.remove(entry);
            }
        }

        return entries;
    }

    public static Table[] readTableList(JSONArray jsonArray, @Nullable String title) throws JSONException {
        ArrayList
 tables = new ArrayList<>();

        parsing:
        for (int i = 0; i < jsonArray.length(); i++) {
            JSONObject jsonTable = jsonArray.getJSONObject(i);

            int contentType = jsonTable.getInt("ConType");
            // We can only handle html and images at the moment
            if (contentType == CONTENT_HTML || contentType == CONTENT_HTML_MYSTERY || contentType == CONTENT_IMAGE) {

                String url = jsonTable.getString("Detail");

                // Test whether url is duplicate
                for (Table t :
                        tables) {
                    if (t.getUri().equals(url)) {
                        continue parsing;
                    }
                }
                if (title == null) {
                    title = jsonTable.getString("Title");
                }

                Date publishedTime = Utility.parseLastUpdatedDate(jsonTable.getString("Date"));

                // Not a duplicate; add
                tables.add(new Table(url, publishedTime, contentType, title));
            }

            // Recursion! Add all "Childs" of this table to tables
            Collections.addAll(tables, readTableList(jsonTable.getJSONArray("Childs"), jsonTable.getString("Title")));

        }
        return tables.toArray(new Table[0]);
    }

    public static Table[] readTableList(JSONArray jsonArray) throws JSONException {
        return readTableList(jsonArray, null);
    }

    public static ArrayList<Notice> readNoticeList(JSONArray jsonArray) throws JSONException {
        ArrayList<Notice> notices = new ArrayList<>();

        for (int i = 0; i < jsonArray.length(); i++) {
            JSONObject noticeJson = jsonArray.getJSONObject(i);

            // Get image URLs

            JSONArray images = noticeJson.getJSONArray("Childs");

            if (images.length() < 1) continue;

            String[] imageUrls = new String[images.length()];

            for (int j = 0; j < images.length(); j++) {
                imageUrls[j] = images.getJSONObject(j).getString("Detail");
            }

            notices.add(new Notice(noticeJson.getString("Title"),
                    Utility.parseLastUpdatedDate(noticeJson.getString("Date")),
                    imageUrls,
                    // Assuming that all notices in a category have the same ConType
                    images.getJSONObject(0).getInt("ConType"))
            );
        }

        return notices;
    }

    public static ArrayList<NewsItem> readNewsList(JSONArray jsonArray) throws JSONException {
        ArrayList<NewsItem> news = new ArrayList<>();

        for (int i = 0; i < jsonArray.length(); i++) {
            JSONObject newsJson = jsonArray.getJSONObject(i);

            news.add(new NewsItem(newsJson.getString("Title"),
                    Utility.parseLastUpdatedDate(newsJson.getString("Date")),
                    newsJson.getString("Detail"))
            );
        }

        return news;
    }
}


code:
package godau.fynn.dsbdirect.table.reader;

import android.content.Context;
import androidx.annotation.Nullable;
import godau.fynn.dsbdirect.Utility;

public abstract class ReaderFactory {

    // List of parser names

    public static final String NULL = "automatic";
    public static final String UNTIS = "untis";
    public static final String UNTIS4 = "untis4"; // technically a SUPER SECRET SETTING
    public static final String ROENTGEN = "roentgen";
    public static final String GOETHE = "goethe";
    public static final String WHITE_YELLOW = "whiteyellow";
    public static final String DUMMY = "dummy"; // SUPER SECRET SETTING for debugging

    /**
     * Get parser for this html by id and user preference.
     * @param html The html the parser is for
     * @param id Auth id
     * @return A parser set by the user or figured out using id in {@link String getParserIdentifier(id)} or
     * identified through checking the html file for ads.
     */
    public static @Nullable
    Reader getReader(String html, String id, Context context) {

        // User preference overwrite hardcoded preference overwrite dynamic decision
        switch (getParserIdentifier(id, html, context)) {
            case UNTIS:
                return new Untis(html, context);
            case UNTIS4:
                return new Untis4(html, context);
            case ROENTGEN:
                return new Roentgen(html, context);
            case GOETHE:
                return new GoetheHamburg(html, context);
            case WHITE_YELLOW:
                return new WhiteYellow(html, context);
            case DUMMY:
                return new Dummy();
            case NULL:
            default:
                return null;
        }
    }

    /**
     * Get user preference or, if user has no preference, decide based on hardcoded ids or autodetect ads.
     * @param id id to base decision on if user has not configured a preference
     * @return The string name of the parser
     */
    private static String getParserIdentifier(String id, String html, Context context) {

        // Get user setting

        String userSetting = getParserUserSetting(context);
        // NULL means that the preference is "Automatic"
        if (!userSetting.equals(NULL)) {
            return userSetting;
            /* Note that this does not verify the user's preference; if an invalid preference has been
             * configured, it won't be overwritten by the hardcoded id preference or the dynamic decision.
             */
        }

        // Hardcoded preference

        switch (id) {
            case "162161": // Alexander-von-Humboldt-Gymnasium Schweinfurt
                return UNTIS;
            case "219261": // Röntgen-Gymnasium Würzburg
                return ROENTGEN;
            case "189802": // Goethe-Gymnasium Hamburg
                return GOETHE;
            case "177841":
                return WHITE_YELLOW;
        }

        // Dynamic decision: try to recognize substitution plan by an ad it contains

        if (html.contains("Untis Stundenplan Software")) {
            return UNTIS;
        } else if (html.contains("HeinekingMedia Vertretungsplan")) {
            return GOETHE;
        }

        // Nothing found? Then we can't parse this, sorry.
        return NULL;
    }

    /**
     * @return The parser option configured in user's shared preferences
     */
    private static String getParserUserSetting(Context context) {
        return new Utility(context).getSharedPreferences().getString("parser", NULL);
    }
}


code:
/*
 * 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.table.reader;

import android.content.Context;
import android.os.Handler;
import android.util.Log;
import godau.fynn.dsbdirect.Utility;
import godau.fynn.dsbdirect.table.Entry;

import java.text.ParseException;
import java.util.ArrayList;
import java.util.Queue;

public class ReaderRunnable implements Runnable {

    private String data;
    private String credentialsId;
    private Handler errorHandler;
    private Handler dataProcessedHandler;
    private Context context;
    private ArrayList<Entry> result = new ArrayList<>();
    private Queue<Runnable> tasks;

    public ReaderRunnable(Context context, String data, String credentialsId, Queue<Runnable> tasks) {
        super();

        this.data = data;
        this.context = context;
        this.credentialsId = credentialsId;
        this.tasks = tasks;
    }

    public void addHandlers(Handler dataProcessedHandler, Handler errorHandler) {
        this.dataProcessedHandler = dataProcessedHandler;
        this.errorHandler = errorHandler;
    }

    private Reader reader;

    @Override
    public void run() {

        Utility u = new Utility(context);

        try {
            reader = u.getReader(data, credentialsId);
            final ArrayList<Entry> entries;

            entries = reader.filterUserFilters(reader.read(), context);
            Log.d("ENTRIES", String.valueOf(entries.size()));

            result = entries;

            dataProcessedHandler.sendEmptyMessage(0);

        } catch (NullPointerException | NumberFormatException | ArrayIndexOutOfBoundsException e) {
            e.printStackTrace();
            errorHandler.sendEmptyMessage(0);
        }

        // Remove self (first one in queue)
        tasks.remove();

        // Run next task if possible
        if (tasks.size() > 0) {
            tasks.element().run();
        }

    }

    public ArrayList<Entry> getResult() {
        return result;
    }

    public String getSchoolName() {
        return reader.getSchoolName();
    }
}


code:
/*
 * 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.table.reader;

import android.content.Context;
import godau.fynn.dsbdirect.table.Entry;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Date;

public class Roentgen extends Reader {

    public Roentgen(String html, Context context) {
        super(html, context);

    }

    @Override
    public void addEntries() {

        Document document = Jsoup.parse(html);

        // Find date

        String dateString = document.selectFirst("h2").text().replaceAll("Vertretungsplan für \\w+, ", "");
        String[] dateDigits = dateString.split("\\.");
        Date date = new Date(Integer.parseInt(dateDigits[2]) - 1900 /* years start at 1900 */, Integer.parseInt(dateDigits[1]) - 1 /* months start with 0 */,
                Integer.parseInt(dateDigits[0])); // because SimpleDateTime did deliver good results

        // Prepare parsing tables

        Elements h4Elements = document.select("h4");
        String[] h4Strings = new String[h4Elements.size()];

        int substitutionTableIndex = 0;

        for (int i = 0; i < h4Elements.size(); i++) {
            h4Strings[i] = h4Elements.get(i).text();

            if (h4Strings[i].equals("Vertretungen:")) {
                substitutionTableIndex = i;
            }
        }

        Elements tables = document.getElementsByTag("table");

        // Add substitutions

        Element substitutionTable = tables.get(substitutionTableIndex);
        Elements rows = substitutionTable.select("tr");

        String teacher = "";

        for (Element row :
                rows) {
            ArrayList<String> rowList = new ArrayList<>();

            Elements columns = row.select("td");
            for (Element column :
                    columns) {
                rowList.add(column.text());
            }

            String newTeacher = row.select("th").text();
            if (newTeacher.length() > 0) {
                teacher = newTeacher;
            }

            if (rowList.size() >= 4) {
                addEntry(rowList.get(1).replace("(", " ("), rowList.get(0), teacher, combine(rowList.get(2), rowList.get(3)), date);
            }
        }

        if (tables.size() > h4Strings.length) {
            // Add general announcements

            Element generalTable = tables.get(tables.size() - 1 /* we don't want to be off by one */);

            Elements generalTableRows = generalTable.select("th");

            for (Element row :
                    generalTableRows) {
                addEntry("", "", "", row.text(), date);
            }

        }
    }

    @Nullable
    @Override
    public String getSchoolName() {
        return null;
    }

    private String combine(String s, String s1) {
        if (s.length() != 0 && s1.length() != 0) {
            return s + " – " + s1;
        } else if (s.length() == 0 && s1.length() != 0) {
            return s1;
        } else if (s.length() != 0 && s1.length() == 0) {
            return s;
        } else {
            return "";
        }
    }
}


code:
/*
 * 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.table.reader;

import android.content.Context;
import android.util.Log;
import godau.fynn.dsbdirect.Utility;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;

import static godau.fynn.dsbdirect.table.reader.FlexibleReader.MasterTableColumn.*;

public class Untis extends FlexibleReader {

    public Untis(String html, Context context) {
        super(html, context);
    }

    private Document d;

    @Override
    public void addEntries() {

        if (d == null) {
            d = Jsoup.parse(html);
        }

        // Tables are inside center tags
        Elements centers = d.getElementsByTag("center");

        // Every other center contains an advertisement for Untis
        for (int centerIndex = 0; centerIndex < centers.size(); centerIndex += 2) {

            Element center = centers.get(centerIndex);

            // Get which date this center is about
            String dateString = center.selectFirst("div").text();

            Date date;
            try {
                date = new SimpleDateFormat("dd.MM.yyyy").parse(dateString);
            } catch (ParseException e) {
                e.printStackTrace();
                date = null;
            }

            // Get info box, if present
            Elements infoTables = center.getElementsByClass("info");
            if (infoTables.size() != 0) {
                Element infoTableBody = infoTables.first().getElementsByTag("tbody").first();

                Elements infoTableTrs = infoTableBody.getElementsByTag("tr");

                /* First tr will (probably) contain "Nachrichten zum Tag" headline, but we check it anyway because
                 * the headline is inside a th tag, not a td tag.
                 */
                for (Element tr : infoTableTrs) {

                    Elements tds = tr.getElementsByTag("td");

                    if (tds.size() > 0 && !isUselessLine(tds.first().text())) {
                        // Construct an entry for this line

                        // If there are two columns: separate them with a ':'
                        addEntry(null, null, null,
                                Utility.smartConcatenate(tds.toArray(), ": "),
                                date
                        );
                    }

                }

            }

            // Get main table
            Elements mainTables = center.getElementsByClass("mon_list"); // There should be exactly one
            if (mainTables.size() > 0) {
                Element mainTableBody = mainTables.first().getElementsByTag("tbody").first();

                if (mainTableBody == null) {
                    // No actual table yet on this page?
                    continue;
                }

                Elements mainTableTrs = mainTableBody.getElementsByTag("tr");

                /* Test whether definitions will be in the first or the second row –
                 * some schools have configured a weird header that spans two lines.
                 */
                boolean secondRowIsDefinition = !(mainTableTrs.size() > 1 &&
                        (mainTableTrs.get(1).hasClass("list odd")
                                || mainTableTrs.get(1).hasClass("list even")
                        ));

                if (!secondRowIsDefinition)
                    setMasterTableColumns(mainTableTrs.first().getElementsByTag("th"));
                else {
                    // The second row has to be considered
                    Elements line0 = mainTableTrs.get(0).getElementsByTag("th");
                    Elements line1 = mainTableTrs.get(1).getElementsByTag("th");

                    String[] definitions = new String[line0.size()];
                    for (int i = 0; i < line0.size(); i++) {
                        definitions[i] = Utility.smartConcatenate(
                                new String[]{line0.get(i).text(), line1.get(i).text()}, "\n"
                                );
                    }

                    setMasterTableColumns(definitions);
                }

                // Guess the purpose of a potential inline header
                MasterTableColumn inlineHeaderColumn = guessInlineHeaderColumn(getMasterTableColumns());

                String inlineHeader = null;

                // Get every row
                row:
                for (int trIndex = (secondRowIsDefinition? 1 : 0) + 1; trIndex < mainTableTrs.size(); trIndex++) {
                    Element tr = mainTableTrs.get(trIndex);

                    Elements tds = tr.getElementsByTag("td");
                    Map<MasterTableColumn, String> masterRow = new EnumMap<>(MasterTableColumn.class);

                    // Get value from every column
                    for (int tdIndex = 0; tdIndex < tds.size(); tdIndex++) {
                        Element td = tds.get(tdIndex);

                        String s = td.text();

                        if (td.hasClass("inline_header")) {
                            // This is an inline header
                            inlineHeader = s;
                            continue row;
                        } else {

                            if (s.matches("<s(trike)*>.+</s(trike)*>\\?.*")) {
                                // See https://notabug.org/fynngodau/DSBDirect/issues/58
                                s = s.split("\\?")[1];
                            }

                            masterRow.put(getMasterTableColumns()[tdIndex], s);
                        }
                    }

                    // Copy inline header to master row
                    if (inlineHeader != null) {
                        masterRow.put(inlineHeaderColumn, inlineHeader);
                    }

                    constructEntry(masterRow, date);

                }

            }

        }

    }

    @Override
    public String getSchoolName() {

        try {

            if (d == null) {
                d = Jsoup.parse(html);
            }

            Element monHead = d.getElementsByClass("mon_head").first();
            Element p = monHead.getElementsByTag("tbody").first()
                    .getElementsByTag("tr").first()
                    .getElementsByAttributeValue("align", "right").first()
                    .getElementsByTag("p").first();

            return p.textNodes().get(0).text();
        } catch (NullPointerException e) {
            e.printStackTrace();
            return null;
        }
    }

    @Override
    protected MasterTableColumn getMasterTableColumn(String definition) {

        // Handle two-line rows
        if (definition.contains("\n")) {
            /* Precedence for two-line rows:
             * 1. Separate lines with ' '
             * 2. Separate lines not at all (e.g. Entfall-Fach)
             * 3. Leave out first line
             * 4. Leave out second line
             */
            String[] definitionLines = definition.split("\n");

            // Ensure there are only two lines
            if (definitionLines.length != 2)
                Log.e("UNTISREAD", "Unexpectedly, the following column definition has " + definitionLines.length
                        + " instead of 2 lines – only the first two lines will be considered: " + definition);

            MasterTableColumn result =
                    getMasterTableColumn(definitionLines[0] + ' ' + definitionLines[1]);
            if (result == UNDEFINED) result =
                    getMasterTableColumn(definitionLines[0] + definitionLines[1]);
            if (result == UNDEFINED) result =
                    getMasterTableColumn(definitionLines[1]);
            if (result == UNDEFINED) result =
                    getMasterTableColumn(definitionLines[0]);
            return result;
        }

        switch (definition) {
            case "Klasse(n)":
            case "Klasse":
                return CLASS;
            case "Fach":
                return SUBJECT;
            case "Stunde":
            case "Std.":
            case "Std":
                return LESSON;
            case "Art":
                return TYPE;
            case "Vertreter":
            case "Lehrk.":
            case "neuer Lehrer":
                return TEACHER;
            case "Raum":
                return ROOM;
            case "(Fach)":
            case "Entfall-Fach":
                return OLD_SUBJECT;
            case "(Lehrer)":
            case "(Lehrk.)":
            case "Entfall-Lehrer":
            case "Vertretung für":
                return OLD_TEACHER;
            case "(Klasse(n))":
                return OLD_CLASS;
            case "Vertretungs-Text":
            case "Vertr.Text":
            case "Text":
            case "Bemerkungen und Hinweise":
                return INFO;
            case "Vertr. von":
            case "Info":
                // Apparently this represents a point in time at which the lesson was originally supposed to be held
                return OLD_TIME;
            default:
                return UNDEFINED;
        }
    }

    private MasterTableColumn guessInlineHeaderColumn(MasterTableColumn[] columns) {
        if (!contains(columns, CLASS)) return CLASS;
        else if (!contains(columns, TEACHER)) return TEACHER;
        else return UNDEFINED;
    }

    private static <T> boolean contains(T[] is, T i) {
        for (T isi :
                is) {
            if (isi == i) return true;
        }
        return false;
    }

    private boolean isUselessLine(String string) {
        String[] uselessLines = {"Abwesende Klassen", "Abwesende Lehrer", "Blockierte Räume",
                "Betroffene Klassen", "Betroffene Lehrer", "Betroffene Räume"};

        for (String useless :
                uselessLines) {
            if (string.contains(useless)) {
                // If it contains something useless, this line is useless
                return true;
            }
        }

        // It didn't contain anything useless and thus is not useless
        return false;
    }
}