public static boolean exists(Context context, Type type, String filename) {
    DocumentFile grantedDirectory = DocumentFile.fromTreeUri(context, Basics.getOrCreateInstance(context).getDocumentTree());
    if (grantedDirectory != null) {
        DocumentFile subDirectory = find(grantedDirectory, true, type.getSubdirectoryName());
        if (subDirectory != null) {
            DocumentFile file = find(subDirectory, false, filename);
            return file != null;
        }
    }
    return false;
} public class BackupFileInfo {

    private static final String BACKUP_FILE_OLD = "backup.csv";
    private static final String BACKUP_FILE_EVENTS = "backup.events.csv";
    private static final String BACKUP_FILE_TARGETS = "backup.targets.csv";
    private static final String BACKUP_FILE_PREFERENCES = "backup.preferences.csv";

    private String eventsBackupFile;
    private String targetsBackupFile;
    private final String preferencesBackupFile = BACKUP_FILE_PREFERENCES;
    private DocumentTreeStorage.Type type;

    private BackupFileInfo() {}

    public String getEventsBackupFile() {
        return eventsBackupFile;
    }
    public String getTargetsBackupFile() {
        return targetsBackupFile;
    }
    public String getPreferencesBackupFile() {
        return preferencesBackupFile;
    }

    public DocumentTreeStorage.Type getType() {
        return type;
    }

    @NonNull @Override
    public String toString() {
        return eventsBackupFile
            + "\n" + targetsBackupFile
            + "\n" + preferencesBackupFile;
    }

    public String listAvailable(Context context) {
        // TODO use StringJoiner
        String separator = "";
        StringBuilder sb = new StringBuilder();

        if (exists(context, type, eventsBackupFile)) {
            sb.append(eventsBackupFile);
            separator = "\n";
        }
        if (exists(context, type, targetsBackupFile)) {
            sb.append(separator).append(targetsBackupFile);
            separator = "\n";
        }
        if (exists(context, type, preferencesBackupFile)) {
            sb.append(separator).append(preferencesBackupFile);
        }

        return sb.toString();
    }

    /**
     * Get backup files
     * @param existing search for existing files
     */
    public static BackupFileInfo getBackupFiles(Context context, boolean existing, boolean isAutomaticBackup) {
        BackupFileInfo info = new BackupFileInfo();
        info.eventsBackupFile = BACKUP_FILE_EVENTS;
        info.targetsBackupFile = BACKUP_FILE_TARGETS;
        info.type = isAutomaticBackup
                ? DocumentTreeStorage.Type.AUTOMATIC_BACKUP
                : DocumentTreeStorage.Type.MANUAL_BACKUP;

        if (existing && !exists(context, info.type, info.eventsBackupFile)) {
            if (exists(context, info.type, BACKUP_FILE_OLD)) {
                info.eventsBackupFile = BACKUP_FILE_OLD;
            }
        }

        if (!existing
                || exists(context, info.type, info.eventsBackupFile)
                || exists(context, info.type, info.targetsBackupFile)
                || exists(context, info.type, info.preferencesBackupFile)) {
            return info;
        } else {
            // no backup file exists
            return null;
        }
    }

    /**
     * Get backup files
     * @param existing search for existing files
     */
    public static BackupFileInfo getBackupFiles(Context context, boolean existing) {
        return getBackupFiles(context, existing, false);
    }
} public class DAO {

	// TODO use prepared statements as described here: http://stackoverflow.com/questions/7255574

	private volatile SQLiteDatabase db;
	private final MySQLiteHelper dbHelper;
	private final Context context;
	private final WorkTimeTrackerBackupManager backupManager;
	private final Basics basics;

	private static final Pattern RESTORE_PATTERN = Pattern.compile(";");

	/**
	 * Constructor
	 */
	public DAO(Context context) {
		this.context = context;
		dbHelper = new MySQLiteHelper(context);
		backupManager = new WorkTimeTrackerBackupManager(context);
		basics = Basics.getOrCreateInstance(context);
	}

	/**
	 * Open the underlying database. Implicitly called before any database operation.
	 */
	private synchronized void open() throws SQLException {
		if(db == null || !db.isOpen()) {
			db = dbHelper.getWritableDatabase();
		}
	}

	/**
	 * Close the underlying database.
	 */
	public synchronized void close() {
		dbHelper.close();
	}

	// =======================================================

	private static final String[] TASK_FIELDS = { TASK_ID, TASK_NAME, TASK_ACTIVE, TASK_ORDERING, TASK_DEFAULT };

	private Task cursorToTask(Cursor cursor) {
		Task task = new Task();
		task.setId(cursor.getInt(0));
		task.setName(cursor.getString(1));
		task.setActive(cursor.getInt(2));
		task.setOrdering(cursor.getInt(3));
		task.setIsDefault(cursor.getInt(4));
		return task;
	}

	private ContentValues taskToContentValues(Task task) {
		ContentValues ret = new ContentValues();
		ret.put(TASK_NAME, task.getName());
		ret.put(TASK_ACTIVE, task.getActive());
		ret.put(TASK_ORDERING, task.getOrdering());
		ret.put(TASK_DEFAULT, task.getIsDefault());
		return ret;
	}

	/**
	 * Insert a new task.
	 *
	 * @param task
	 *            the task to add
	 * @return the newly created task as read from the database (complete with ID)
	 */
	public synchronized Task insertTask(Task task) {
		open();
		ContentValues args = taskToContentValues(task);
		long insertId = db.insert(TASK, null, args);
		// now fetch the newly inserted row and return it as Task object
		List<Task> created = getTasksWithConstraint(TASK_ID + "=" + insertId);
		dataChanged();
		return created.get(0);
	}

	/**
	 * Get all tasks.
	 *
	 * @return all existing tasks
	 */
	public List<Task> getAllTasks() {
		return getTasksWithConstraint(null);
	}

	/**
	 * Get all active tasks.
	 *
	 * @return all existing tasks that are active at the moment
	 */
	public List<Task> getActiveTasks() {
		return getTasksWithConstraint(TASK_ACTIVE + "!=0");
	}

	/**
	 * Get the default task.
	 *
	 * @return the default task or {@code null} (if no task was marked as default or if the default task is deactivated)
	 */
	public Task getDefaultTask() {
		List<Task> tasks = getTasksWithConstraint(TASK_ACTIVE + "!=0 AND " + TASK_DEFAULT + "!=0");
		return tasks.isEmpty() ? null : tasks.get(0);
	}

	/**
	 * Get the task with a specific ID.
	 *
	 * @param id
	 *            the ID
	 * @return the task or {@code null} if the specified ID does not exist
	 */
	public Task getTask(Integer id) {
		List<Task> tasks = getTasksWithConstraint(TASK_ID + "=" + id);
		return tasks.isEmpty() ? null : tasks.get(0);
	}

	/**
	 * Get the first task with a specific name.
	 *
	 * @param name
	 *            the name
	 * @return the task (first if more than one exist) or {@code null} if the specified name does not exist at all
	 */
	public Task getTask(String name) {
		List<Task> tasks = getTasksWithConstraint(TASK_NAME + "=\"" + name + "\"");
		return tasks.isEmpty() ? null : tasks.get(0);
	}

	/**
	 * Return if the task with the given ID is used in an event.
	 */
	public synchronized boolean isTaskUsed(Integer id) {
		Cursor cursor = db.query(EVENT, new String[] { "count(*)" }, EVENT_TASK + " = " + id, null,
			null, null, null, null);
		cursor.moveToFirst();
		int count = 0;
		if (!cursor.isAfterLast()) {
			count = cursor.getInt(0);
		}
		cursor.close();
		return count > 0;
	}

	private synchronized List<Task> getTasksWithConstraint(String constraint) {
		open();
		List<Task> ret = new ArrayList<>();
		// TODO sort tasks by TASK_ORDERING when the UI supports manual ordering of tasks
		Cursor cursor = db.query(TASK, TASK_FIELDS, constraint, null, null, null, TASK_NAME);
		cursor.moveToFirst();
		while (!cursor.isAfterLast()) {
			Task task = cursorToTask(cursor);
			ret.add(task);
			cursor.moveToNext();
		}
		cursor.close();
		return ret;
	}

	/**
	 * Update a task.
	 *
	 * @param task
	 *            the task to update - the ID has to be set!
	 * @return the task as newly read from the database
	 */
	public synchronized Task updateTask(Task task) {
		open();
		ContentValues args = taskToContentValues(task);
		db.update(TASK, args, TASK_ID + "=" + task.getId(), null);
		// now fetch the newly updated row and return it as Task object
		List<Task> updated = getTasksWithConstraint(TASK_ID + "=" + task.getId() + "");
		dataChanged();
		return updated.get(0);
	}

	/**
	 * Remove a task.
	 *
	 * @param task
	 *            the task to delete - the ID has to be set!
	 * @return {@code true} if successful, {@code false} if not
	 */
	public synchronized boolean deleteTask(Task task) {
		open();
		final boolean result = db.delete(TASK, TASK_ID + "=" + task.getId(), null) > 0;
		dataChanged();
		return result;
	}


	// =======================================================

	private static final String[] EVENT_FIELDS = { EVENT_ID, EVENT_TIME, EVENT_ZONE_OFFSET, EVENT_TYPE, EVENT_TASK, EVENT_TEXT };
	private static final String[] MAX_EVENT_FIELDS = { EVENT_ID, "max(" + EVENT_TIME + ")", EVENT_ZONE_OFFSET, EVENT_TYPE,
		EVENT_TASK, EVENT_TEXT };

	private Event cursorToEvent(Cursor cursor) {
		Event event = new Event();
		event.setId(cursor.getInt(0));

		Instant instant = Instant.ofEpochSecond(cursor.getLong(1));
		ZoneOffset zoneOffset = ZoneOffset.ofTotalSeconds(cursor.getInt(2) * 60);
		event.setDateTime(instant.atOffset(zoneOffset));

		event.setType(cursor.getInt(3));
		event.setTask(cursor.getInt(4));
		event.setText(cursor.getString(5));
		return event;
	}

	private ContentValues eventToContentValues(Event event) {
		ContentValues ret = new ContentValues();

		OffsetDateTime dateTime = event.getDateTime();
		ret.put(EVENT_TIME, dateTime.toEpochSecond());
		ret.put(EVENT_ZONE_OFFSET, dateTime.getOffset().getTotalSeconds() / 60);

		ret.put(EVENT_TYPE, event.getType());
		ret.put(EVENT_TASK, event.getTask());
		ret.put(EVENT_TEXT, event.getText());
		return ret;
	}

	/**
	 * Insert a new event.
	 *
	 * @param event
	 *            the event to add
	 * @return the newly created event as read from the database (complete with ID)
	 */
	public synchronized Event insertEvent(Event event) {
		open();
		ContentValues args = eventToContentValues(event);
		long insertId = db.insert(EVENT, null, args);
		// now fetch the newly created row and return it as Event object
		List<Event> created = getEventsWithConstraint(EVENT_ID + "=" + insertId);
		if (created.size() > 0) {
			dataChanged();
			return created.get(0);
		} else {
			return null;
		}
	}

	/**
	 * Return all events - attention: this may be slow if many events exist!
	 */
	public List<Event> getAllEvents() {
		return getEventsWithConstraint(null);
	}

	/**
	 * Return the events that are in the specified time frame.
	 */
	public List<Event> getEvents(Instant beginOfTimeFrame, Instant endOfTimeFrame) {
		return getEventsWithConstraint(EVENT_TIME + " >= \"" + beginOfTimeFrame.getEpochSecond()
			+ "\" AND " + EVENT_TIME + " < \"" + endOfTimeFrame.getEpochSecond() + "\"");
	}

	/**
	 * Return all events in a certain week.
	 *
	 * @param week
	 *            the week in which the events are searched - the ID has to be set!
	 */
	public List<Event> getEventsInWeek(Week week, ZoneId zoneId) {
		// FIXME
		if (week == null) {
			return Collections.emptyList();
		} else {
			Instant instantA = week.getStart().atStartOfDay(zoneId).toInstant();
			Instant instantB = week.getEnd().atTime(LocalTime.MAX).atZone(zoneId).toInstant();

			return getEvents(instantA, instantB);
		}
	}

	/**
	 * Return all events on a certain day.
	 *
	 * @param date
	 *            the day on which the events are searched
	 */
	public List<Event> getEventsOnDay(ZonedDateTime date) {
		return getEventsOnDayBefore(date.with(LocalTime.MAX));
	}

	/**
	 * Return all events on a certain day before the given timestamp.
	 *
	 * @param date the day on which the events are searched
	 */
	public List<Event> getEventsOnDayBefore(ZonedDateTime date) {
		long instantA = date.with(LocalTime.MIN).toEpochSecond();
		long instantB = date.toEpochSecond();

		return getEventsWithConstraint(EVENT_TIME + " >= \"" + instantA +
			"\" AND " + EVENT_TIME + " < \"" + instantB + "\"");
	}

	/**
	 * Return all events on a certain day up to and including the given timestamp.
	 *
	 * @param date the day on which the events are searched
	 */
	public List<Event> getEventsOnDayUpTo(ZonedDateTime date) {
		long instantA = date.with(LocalTime.MIN).toEpochSecond();
		long instantB = date.toEpochSecond();

		return getEventsWithConstraint(EVENT_TIME + " >= \"" + instantA +
				"\" AND " + EVENT_TIME + "<= \"" + instantB + "\"");
	}

	/**
	 * Return all events on a certain day after the given timestamp.
	 *
	 * @param date the day on which the events are searched
	 */
	public List<Event> getEventsOnDayAfter(ZonedDateTime date) {
		long instantA = date.toEpochSecond();
		long instantB = date.with(LocalTime.MAX).toEpochSecond();


		return getEventsWithConstraint(EVENT_TIME + " > \"" + instantA +
				"\" AND " + EVENT_TIME + "<= \"" + instantB + "\"");
	}

	/**
	 * Fetch a specific event.
	 *
	 * @param id
	 *            the ID of the event
	 * @return the event, or {@code null} if the id does not exist
	 */
	public Event getEvent(Integer id) {
		List<Event> event = getEventsWithParameters(EVENT_FIELDS, EVENT_ID + " = " + id, false, true);
		// if event is empty, then there is no such event in the database
		return event.isEmpty() ? null : event.get(0);
	}

	/**
	 * Return the last event before a certain date and time or {@code null} if there is no such event.
	 *
	 * @param dateTime
	 *            the date and time before which the event is searched
	 */
	public Event getLastEventBefore(OffsetDateTime dateTime) {
		List<Event> lastEvent = getEventsWithParameters(EVENT_FIELDS, EVENT_TIME + " < \""
			+ dateTime.toEpochSecond() + "\"", true, true);
		// if lastEvent is empty, then there is no such event in the database
		return lastEvent.isEmpty() ? null : lastEvent.get(0);
	}

	/**
	 * Return the last event before a certain date and time (including the hour and minute given!)
	 * or {@code null} if there is no such event.
	 *
	 * @param dateTime
	 *            the date and time before which the event is searched
	 */
	public Event getLastEventUpTo(OffsetDateTime dateTime) {
		List<Event> lastEvent = getEventsWithParameters(EVENT_FIELDS, EVENT_TIME + " <= \""
			+ dateTime.toEpochSecond() + "\"", true, true);
		// if lastEvent is empty, then there is no such event in the database
		return lastEvent.isEmpty() ? null : lastEvent.get(0);
	}

	/**
	 * Return the first event after a certain date and time or {@code null} if there is no such event.
	 *
	 * @param dateTime
	 *            the date and time after which the event is searched
	 */
	public Event getFirstEventAfter(OffsetDateTime dateTime) {
		List<Event> firstEvent = getEventsWithParameters(EVENT_FIELDS, EVENT_TIME + " > \""
			+ dateTime.toEpochSecond() + "\"", false, true);
		// if firstEvent is empty, then there is no such event in the database
		return firstEvent.isEmpty() ? null : firstEvent.get(0);
	}

	/**
	 * Return the first event with the given type on a certain day after a certain time
	 * or {@code null} if there is no such event.
	 *
	 * @param dateTime
	 *            the date and time after which the event is searched
	 */
	public Event getFirstEventAfterWithType(ZonedDateTime dateTime, TypeEnum type) {
		long instantA = dateTime.toEpochSecond();

		String constraint =
				EVENT_TIME + " > \"" + instantA + "\" AND " + EVENT_TYPE + " = " + type.toString();

		List<Event> firstEvent = getEventsWithParameters(EVENT_FIELDS, constraint, false, true);

		// if firstEvent is empty, then there is no such event in the database
		return firstEvent.isEmpty() ? null : firstEvent.get(0);
	}

	/**
	 * Return the first recorded event or {@code null} if no event exists.
	 */
	public Event getFirstEvent() {
		List<Event> firstEvent = getEventsWithParameters(EVENT_FIELDS, null, false, true);
		// if firstEvent is empty, then there is no event in the database
		return firstEvent.isEmpty() ? null : firstEvent.get(0);
	}

	/**
	 * Return the last recorded event or {@code null} if no event exists.
	 */
	public Event getLatestEvent() {
		List<Event> latestEvent = getEventsWithParameters(MAX_EVENT_FIELDS, null, false, true);
		// if latestEvent is empty, then there is no event in the database
		return latestEvent.isEmpty() ? null : latestEvent.get(0);
	}

	private synchronized List<Event> getEventsWithParameters(String[] fields, String constraint,
			boolean descending,
		boolean limitedToOne) {
		open();
		List<Event> ret = new ArrayList<>();
		Cursor cursor = db.query(EVENT, fields, constraint, null, null, null, EVENT_TIME + (descending ? " desc" : "")
			+ "," + EVENT_ID + (descending ? " desc" : ""), (limitedToOne ? "1" : null));
		cursor.moveToFirst();
		while (!cursor.isAfterLast()) {
			Event event = cursorToEvent(cursor);
			ret.add(event);
			cursor.moveToNext();
		}
		cursor.close();
		return ret;
	}

	private List<Event> getEventsWithConstraint(String constraint) {
		return getEventsWithParameters(EVENT_FIELDS, constraint, false, false);
	}

	/**
	 * Update an event.
	 *
	 * @param event
	 *            the event to update - the ID has to be set!
	 * @return the event as newly read from the database
	 */
	public synchronized Event updateEvent(Event event) {
		open();
		ContentValues args = eventToContentValues(event);
		db.update(EVENT, args, EVENT_ID + "=" + event.getId(), null);
		// now fetch the newly updated row and return it as Event object
		List<Event> updated = getEventsWithConstraint(EVENT_ID + "=" + event.getId());
		dataChanged();
		return updated.get(0);
	}

	/**
	 * Remove an event.
	 *
	 * @param event
	 *            the event to delete - the ID has to be set!
	 * @return {@code true} if successful, {@code false} if not
	 */
	public synchronized boolean deleteEvent(Event event) {
		open();
		final boolean result = db.delete(EVENT, EVENT_ID + "=" + event.getId(), null) > 0;
		dataChanged();
		return result;
	}

	private synchronized boolean deleteAll() {
		open();
		boolean result = db.delete(TASK, null, null) > 0;
		result |= db.delete(EVENT, null, null) > 0;
		dataChanged();
		return result;
	}

	public synchronized Cursor getAllEventsAndTasks() {
		open();
		final String querySelectPart = "SELECT"
			+ " " + MySQLiteHelper.EVENT + "." + MySQLiteHelper.EVENT_ID + " AS eventId"
			+ ", " + MySQLiteHelper.EVENT_TYPE
			+ ", " + MySQLiteHelper.EVENT_TIME
			+ ", " + MySQLiteHelper.EVENT_ZONE_OFFSET
			+ ", " + MySQLiteHelper.EVENT_TASK
			+ ", " + MySQLiteHelper.EVENT_TEXT
			+ ", " + MySQLiteHelper.TASK + "." + MySQLiteHelper.TASK_ID + " AS taskId"
			+ ", " + MySQLiteHelper.TASK_NAME
			+ ", " + MySQLiteHelper.TASK_ACTIVE
			+ ", " + MySQLiteHelper.TASK_ORDERING
			+ ", " + MySQLiteHelper.TASK_DEFAULT;

		// this is a FULL OUTER JOIN.
		// see http://stackoverflow.com/questions/1923259/full-outer-join-with-sqlite
		final String query = ""
			+ querySelectPart
			+ " FROM"
			+ " " + MySQLiteHelper.EVENT
			+ " LEFT JOIN"
			+ " " + MySQLiteHelper.TASK
			+ " ON "
			+ " taskId = " + MySQLiteHelper.EVENT_TASK

			+ " UNION ALL "

			+ querySelectPart
			+ " FROM"
			+ " " + MySQLiteHelper.TASK
			+ " LEFT JOIN"
			+ " " + MySQLiteHelper.EVENT
			+ " ON "
			+ " taskId = " + MySQLiteHelper.EVENT_TASK

			+ " WHERE"
			+ " eventId IS NULL"

			+ " ORDER BY"
			+ " eventId";

		return db.rawQuery(query, new String[] {});
	}

	// ---------------------------------------------------------------------------------------------
	// backup/restore methods for Google servers
	// ---------------------------------------------------------------------------------------------
	public long getLastDbModification() {
		final File dbFile = context.getDatabasePath(MySQLiteHelper.DATABASE_NAME);
		return dbFile.lastModified();
	}

	/**
	 * Called internally by the data base methods where data is changed.
	 */
	private void dataChanged() {
		backupManager.dataChanged();
	}

	// ---------------------------------------------------------------------------------------------
	// backup/restore methods
	// ---------------------------------------------------------------------------------------------
	public void backupEventsToWriter(final Writer writer) throws IOException {
		final String eol = System.getProperty("line.separator");
		final Cursor cur = getAllEventsAndTasks();
		cur.moveToFirst();

		final int eventIdCol = cur.getColumnIndex("eventId");
		final int eventTypeCol = cur.getColumnIndex(MySQLiteHelper.EVENT_TYPE);
		final int eventTimeCol = cur.getColumnIndex(MySQLiteHelper.EVENT_TIME);
		final int eventZoneCol = cur.getColumnIndex(MySQLiteHelper.EVENT_ZONE_OFFSET);
		final int eventTaskCol = cur.getColumnIndex(MySQLiteHelper.EVENT_TASK);
		final int eventTextCol = cur.getColumnIndex(MySQLiteHelper.EVENT_TEXT);

		final int taskIdCol = cur.getColumnIndex("taskId");
		final int taskNameCol = cur.getColumnIndex(MySQLiteHelper.TASK_NAME);
		final int taskActiveCol = cur.getColumnIndex(MySQLiteHelper.TASK_ACTIVE);
		final int taskOrderingCol = cur.getColumnIndex(MySQLiteHelper.TASK_ORDERING);
		final int taskDefaultCol = cur.getColumnIndex(MySQLiteHelper.TASK_DEFAULT);

		writer.write(
			MySQLiteHelper.EVENT_TYPE
				+ ";" + MySQLiteHelper.EVENT_TIME
				+ ";" + MySQLiteHelper.EVENT_TASK
				+ ";" + MySQLiteHelper.EVENT_TEXT
				+ ";taskId"
				+ ";" + MySQLiteHelper.TASK_NAME
				+ ";" + MySQLiteHelper.TASK_ACTIVE
				+ ";" + MySQLiteHelper.TASK_ORDERING
				+ ";" + MySQLiteHelper.TASK_DEFAULT
				+ eol);

		final StringBuilder buf = new StringBuilder();
		while (!cur.isAfterLast()) {
			if (!cur.isNull(eventIdCol)) {
				buf.append(TypeEnum.byValue(cur.getInt(eventTypeCol)).getReadableName());
				buf.append(";");

				Instant instant = Instant.ofEpochSecond(cur.getLong(eventTimeCol));
				ZoneOffset offset = ZoneOffset.ofTotalSeconds(cur.getInt(eventZoneCol) * 60);
				buf.append(instant.atOffset(offset).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
				buf.append(";");

				buf.append(cur.getInt(eventTaskCol));
				buf.append(";");

				String eventComment = cur.getString(eventTextCol);
				buf.append(eventComment == null ? "" : eventComment);
				buf.append(";");
			} else {
				// this is a task that has no events
				buf.append(";;;;");
			}
			if (!cur.isNull(taskIdCol)) {
				buf.append(cur.getInt(taskIdCol));
				buf.append(";");
				buf.append(cur.getString(taskNameCol));
				buf.append(";");
				buf.append(cur.getInt(taskActiveCol));
				buf.append(";");
				buf.append(cur.getInt(taskOrderingCol));
				buf.append(";");
				buf.append(cur.getInt(taskDefaultCol));
				buf.append(";");
			} else {
				// this is an event that has no task (TypeEnum.CLOCK_OUT)
				buf.append(";;;;;");
			}
			buf.append(eol);
			writer.write(buf.toString());
			buf.setLength(0);
			cur.moveToNext();
		}
		cur.close();
	}

	private static final int INDEX_EVENT_TYPE = 0;
	private static final int INDEX_EVENT_TIME = 1;
	private static final int INDEX_EVENT_TASK = 2;
	private static final int INDEX_EVENT_TEXT = 3;
	private static final int INDEX_TASK_ID = 4;
	private static final int INDEX_TASK_NAME = 5;
	private static final int INDEX_TASK_ACTIVE = 6;
	private static final int INDEX_TASK_ORDERING = 7;
	private static final int INDEX_TASK_DEFAULT = 8;

	/** ONLY FOR PARSING! reads new and old backup timestamp format */
	private static final DateTimeFormatter BACKUP_DATETIME_PARSER = new DateTimeFormatterBuilder()
		.parseCaseInsensitive()
		.append(DateTimeFormatter.ISO_LOCAL_DATE)
		.optionalStart()
		.appendLiteral('T')
		.optionalEnd()
		.optionalStart()
		.appendLiteral(' ')
		.optionalEnd()
		.append(DateTimeFormatter.ISO_LOCAL_TIME)
		.optionalStart()
		.appendOffsetId()
		.optionalEnd()
		.toFormatter()
		.withResolverStyle(ResolverStyle.STRICT)
		.withChronology(IsoChronology.INSTANCE);

	public void restoreEventsFromReader(final BufferedReader reader) throws IOException {
        final String eol = System.getProperty("line.separator");
        final TimerManager timerManager = Basics.getInstance().getTimerManager();

        deleteAll();

		reader.readLine();	// skip header

        String line;
        // cache values
        final String clockInReadableName = TypeEnum.CLOCK_IN.getReadableName();
        final String clockOutNowReadableName = TypeEnum.CLOCK_OUT_NOW.getReadableName();

		while ((line = reader.readLine()) != null) {
            final String[] columns = RESTORE_PATTERN.split(line, -1);
            try {
                if (columns.length > 8 && columns[INDEX_TASK_ID].length() > 0) {
                    final int taskId = Integer.parseInt(columns[INDEX_TASK_ID]);
                    if (getTask(taskId) == null) {
                        final Task task = new Task(
                                taskId,
                                columns[INDEX_TASK_NAME],
                                Integer.parseInt(columns[INDEX_TASK_ACTIVE]),
                                Integer.parseInt(columns[INDEX_TASK_ORDERING]),
                                Integer.parseInt(columns[INDEX_TASK_DEFAULT])
                        );
                        final ContentValues args = taskToContentValues(task);
                        args.put(MySQLiteHelper.TASK_ID, taskId);
                        db.insert(TASK, null, args);
                    }
				}

                if (columns.length > 2 && columns[INDEX_EVENT_TYPE].length() > 0) {
					OffsetDateTime dateTime = parseOffsetDateTime(timerManager, columns[INDEX_EVENT_TIME]);
					final TypeEnum typeEnum;
                    if (clockInReadableName.equalsIgnoreCase(columns[INDEX_EVENT_TYPE])) {
                        typeEnum = TypeEnum.CLOCK_IN;
                    } else if (clockOutNowReadableName.equalsIgnoreCase(columns[INDEX_EVENT_TYPE])) {
                        typeEnum = TypeEnum.CLOCK_OUT_NOW;
                    } else {
                        typeEnum = TypeEnum.CLOCK_OUT;
                    }
                    timerManager.createEvent(dateTime,
                            Integer.parseInt(columns[INDEX_EVENT_TASK]),
                            typeEnum,
                            columns.length > INDEX_EVENT_TEXT ?
                                    columns[INDEX_EVENT_TEXT] : "", true);
                }
            } catch (NumberFormatException e) {
                // ignore rest of current row
            }
        }
	}

	@VisibleForTesting
	static OffsetDateTime parseOffsetDateTime(TimerManager timerManager, String timestamp) {
		TemporalAccessor eventTimestamp = BACKUP_DATETIME_PARSER.parse(timestamp);
		if (!eventTimestamp.isSupported(ChronoField.OFFSET_SECONDS)) {
			LocalDateTime localDateTime = LocalDateTime.from(eventTimestamp);
			return localDateTime
				.atOffset(timerManager.getHomeTimeZoneOffset(localDateTime));
		} else {
			return OffsetDateTime.from(eventTimestamp);
		}
	}


	// =======================================================

	private static final String[] TARGET_FIELDS = { TARGET_ID, TARGET_TIME, TARGET_TYPE, TARGET_VALUE, TARGET_TEXT };

	private Target cursorToTarget(Cursor cursor) {
		Target target = new Target();
		target.setId(cursor.getInt(0));
		target.setDate(LocalDate.ofEpochDay(cursor.getLong(1)));
		target.setType(cursor.getInt(2));
		target.setValue(cursor.getInt(3));
		target.setComment(cursor.getString(4));
		return target;
	}

	private ContentValues targetToContentValues(Target target) {
		ContentValues ret = new ContentValues();
		ret.put(TARGET_TIME, target.getDate().toEpochDay());
		ret.put(TARGET_TYPE, target.getType());
		ret.put(TARGET_VALUE, target.getValue());
		ret.put(TARGET_TEXT, target.getComment());
		return ret;
	}


	/**
	 * Insert a new target.
	 *
	 * @param target the target to add
	 * @return the newly created target as read from the database (complete with ID)
	 */
	public synchronized Target insertTarget(Target target) {
		open();
		ContentValues args = targetToContentValues(target);
		long insertId = db.insert(TARGET, null, args);

		// now fetch the newly created row and return it as Target object
		List<Target> created = getTargetsWithConstraint(TARGET_ID + "=" + insertId);
		if (created.size() > 0) {
			//dataChanged();    // TODO
			return created.get(0);
		} else {
			return null;
		}
	}

	private List<Target> getTargetsWithConstraint(String constraint) {
		open();
		List<Target> ret = new ArrayList<>();

		Cursor cursor = db.query(TARGET, TARGET_FIELDS, constraint, null, null, null, TARGET_TIME);
		cursor.moveToFirst();

		while (!cursor.isAfterLast()) {
			Target target = cursorToTarget(cursor);
			ret.add(target);
			cursor.moveToNext();
		}
		cursor.close();

		return ret;
	}

	public Target getDayTarget(LocalDate day) {
		List<Target> ret = getTargetsWithConstraint(TARGET_TIME + "=" + day.toEpochDay() +
				" AND " + TARGET_TYPE + "<=" + TargetEnum.DAY_IGNORE.getValue());

		if (ret.size() == 1) {
			Logger.debug("Got day target: {}", ret.get(0));

			return ret.get(0);
		} else {
			return null;
		}
	}

	/**
	 * Update an target.
	 *
	 * @param target the target to update - the ID has to be set!
	 * @return the target as newly read from the database
	 */
	public synchronized Target updateTarget(Target target) {
		open();
		ContentValues args = targetToContentValues(target);
		db.update(TARGET, args, TARGET_ID + "=" + target.getId(), null);

		// now fetch the newly updated row and return it as Target object
		List<Target> updated = getTargetsWithConstraint(TARGET_ID + "=" + target.getId());
		//dataChanged(); // TODO
		return updated.get(0);
	}

	/**
	 * Remove an target.
	 *
	 * @param target the target to delete - the ID has to be set!
	 * @return {@code true} if successful, {@code false} if not
	 */
	public boolean deleteTarget(Target target) {
		open();
		final boolean result = db.delete(TARGET, TARGET_ID + "=" + target.getId(), null) > 0;
		//dataChanged(); // TODO
		return result;
	}

	private synchronized boolean deleteAllTargets() {
		open();
		boolean result = db.delete(TARGET, null, null) > 0;
		dataChanged();
		return result;
	}

	public synchronized Cursor getAllTargets() {
		open();
		final String query = "SELECT"
				+ " "  + TARGET_TIME
				+ ", " + TARGET_TYPE
				+ ", " + TARGET_VALUE
				+ ", " + TARGET_TEXT
				+ " FROM " + MySQLiteHelper.TARGET
				+ " ORDER BY " + TARGET_TIME;

		return db.rawQuery(query, new String[] {});
	}

	public List<Target> getTargets(Instant from, Instant to) {
		return getTargetsWithConstraint(TARGET_TIME + " >= "
			+ ChronoUnit.DAYS.between(Instant.EPOCH, from) +
			" AND " + TARGET_TIME + " <= "
			+ ChronoUnit.DAYS.between(Instant.EPOCH, to));
	}


	// ---------------------------------------------------------------------------------------------
	// backup/restore methods
	// ---------------------------------------------------------------------------------------------
	public void backupTargetsToWriter(final Writer writer) throws IOException {
		final String eol = System.getProperty("line.separator");
		final Cursor cur = getAllTargets();
		cur.moveToFirst();

		final int targetTimeCol = cur.getColumnIndex(TARGET_TIME);
		final int targetTypeCol = cur.getColumnIndex(TARGET_TYPE);
		final int targetValueCol = cur.getColumnIndex(TARGET_VALUE);
		final int targetTextCol = cur.getColumnIndex(TARGET_TEXT);

		writer.write(
				TARGET_TIME
						+ ";" + TARGET_TYPE
						+ ";" + TARGET_VALUE
						+ ";" + TARGET_TEXT
						+ eol);

		final StringBuilder buf = new StringBuilder();
		while (!cur.isAfterLast()) {
			LocalDate date = LocalDate.ofEpochDay(cur.getLong(targetTimeCol));
			buf.append(date.format(DateTimeFormatter.ISO_DATE));
			buf.append(";");

			buf.append(TargetEnum.byValue(cur.getInt(targetTypeCol)).toString());
			buf.append(";");

			buf.append(cur.getInt(targetValueCol));
			buf.append(";");

			String targetComment = cur.getString(targetTextCol);
			buf.append(targetComment == null ? "" : targetComment);
			buf.append(eol);

			writer.write(buf.toString());
			buf.setLength(0);
			cur.moveToNext();
		}
		cur.close();
	}

	private static final int INDEX_TARGET_TIME = 0;
	private static final int INDEX_TARGET_TYPE = 1;
	private static final int INDEX_TARGET_VALUE = 2;
	private static final int INDEX_TARGET_TEXT = 3;

	public void restoreTargetsFromReader(final BufferedReader reader) throws IOException {
		deleteAllTargets();

		reader.readLine();	// skip header
		String line;
		while ((line = reader.readLine()) != null) {
			final String[] columns = RESTORE_PATTERN.split(line, -1);

			try {
				if (columns.length >= 4 && columns[INDEX_TARGET_TIME].length() > 0) {

					final Target target = new Target(0,
							TargetEnum.byName(columns[INDEX_TARGET_TYPE]).getValue(),
							Integer.parseInt(columns[INDEX_TARGET_VALUE]),
							LocalDate.parse(columns[INDEX_TARGET_TIME]),
							columns[INDEX_TARGET_TEXT]
					);
					final ContentValues args = targetToContentValues(target);
					db.insert(TARGET, null, args);
				} else {
					Logger.debug("could not restore target line: {}", line);
				}
			} catch (NumberFormatException e) {
				// ignore rest of current row
			}
		}
	}


	// =======================================================

	private static final String[] CACHE_FIELDS = {CACHE_DATE, CACHE_WORKED, CACHE_TARGET};

	private CalcCacheEntry cursorToCache(Cursor cursor) {
		CalcCacheEntry cache = new CalcCacheEntry();
		cache.setDateFromId(cursor.getLong(0));
		cache.setWorked(cursor.getLong(1));
		cache.setTarget(cursor.getLong(2));
		return cache;
	}

	private ContentValues cacheToContentValues(CalcCacheEntry cache) {
		ContentValues ret = new ContentValues();
		ret.put(CACHE_DATE, cache.getDateAsId());
		ret.put(CACHE_WORKED, cache.getWorked());
		ret.put(CACHE_TARGET, cache.getTarget());
		return ret;
	}


	/**
	 * Insert a new target.
	 *
	 * @param cache the cache entry to add
	 * @return the newly created cache entry as read from the database (complete with ID)
	 */
	public synchronized CalcCacheEntry insertCache(CalcCacheEntry cache) {
		open();
		ContentValues args = cacheToContentValues(cache);
		long insertId = db.insert(CACHE, null, args);

		// now fetch the newly created row and return it as Target object
		return getCacheWithConstraint(CACHE_DATE + "=" + insertId);
	}


	private CalcCacheEntry getCacheWithConstraint(String constraint) {
		open();

		CalcCacheEntry ret = null;

		Cursor cursor = db.query(CACHE, CACHE_FIELDS, constraint, null, null, null, CACHE_DATE + " DESC", "1");
		cursor.moveToFirst();

		if (!cursor.isAfterLast()) {
			ret = cursorToCache(cursor);
		}

		cursor.close();

		return ret;
	}

	public CalcCacheEntry getCacheAt(LocalDate date) {
		return getCacheWithConstraint(CACHE_DATE + "<=" + date.toEpochDay());
	}

	/**
	 * Remove cache entries.
	 *
	 * @param date the date from which to delete, deletes everything if {@code null}
	 * @return {@code true} if successful, {@code false} if not
	 */
	public boolean deleteCacheFrom(LocalDate date) {
		open();

		if (date != null) {
			return db.delete(CACHE, CACHE_DATE + ">=" + date.toEpochDay(), null) > 0;
		} else {
			return db.delete(CACHE, null, null) > 0;
		}
	}


	public void executePendingMigrations() {
		open();

		SQLiteStatement s = db.compileStatement("SELECT count(*) FROM sqlite_master WHERE type=\"table\" AND name=\"" + EVENT_V1 + "\"");

		if (s.simpleQueryForLong() > 0) {
			Logger.debug("Starting upgrade activity.");
			//close();

			Intent i = new Intent(context, UpgradeActivity.class);
			if (!(context instanceof Activity)) {
				i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
			}
			context.startActivity(i);
		}
	}

	/*
	Migrate events to v2
	 */
	public void migrateEventsToV2(ZoneId zoneId, MigrationCallback callback) {
		new MigrateEventsV2(zoneId, callback).execute();
	}

    private class MigrateEventsV2 extends AsyncTask<Void, Integer, Void> {

		private final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSS");
		private final int TYPE_FLEX = 2;

		private final ZoneId zoneId;
		private final WeakReference<MigrationCallback> callback;

		private MigrateEventsV2(ZoneId zoneId, MigrationCallback callback) {
			this.zoneId = zoneId;
			this.callback = new WeakReference<>(callback);
		}

		@Override
		protected Void doInBackground(Void... voids) {
			open();

			long numEntries = DatabaseUtils.queryNumEntries(db, EVENT_V1);
			long count = 0;

			Logger.debug("Migrating {} rows.", numEntries);

			db.beginTransaction();
			try {
				Cursor cursor = db.query(EVENT_V1, null, null, null,
						null, null, EVENT_TIME + "," + EVENT_ID, null);
				cursor.moveToFirst();

				while (!cursor.isAfterLast()) {
					publishProgress((int)((count * 100) / numEntries));

					int type = cursor.getInt(2);
					LocalDateTime eventDateTime =
							LocalDateTime.parse(cursor.getString(3), DATETIME_FORMAT);

					String text = cursor.getString(5);

					Integer task = null;

					if (type == TYPE_FLEX) {
						// migrate flex event to target table, time -> target work time for the day
						LocalTime localTime = eventDateTime.toLocalTime();
						long targetTime = localTime.getHour() * 60 + localTime.getMinute();

						ContentValues cv = new ContentValues();
						cv.put(TARGET_TIME, eventDateTime.toLocalDate().toEpochDay());
						cv.put(TARGET_TYPE,TargetEnum.DAY_SET.getValue());
						cv.put(TARGET_VALUE, targetTime);
						cv.put(TARGET_TEXT, text);

						db.insert(TARGET, null, cv);

					} else {
						if (!cursor.isNull(4)) {
							task = cursor.getInt(4);
						}

						// migrate local datetime assuming home time zone
						OffsetDateTime newTime = eventDateTime.atZone(zoneId).toOffsetDateTime();

						// entry in new event table
						ContentValues cv = new ContentValues();
						cv.put(EVENT_TIME, newTime.toInstant().getEpochSecond());
						cv.put(EVENT_ZONE_OFFSET, newTime.getOffset().getTotalSeconds() / 60);
						cv.put(EVENT_TYPE, type);
						cv.put(EVENT_TASK, task);
						cv.put(EVENT_TEXT, text);

						db.insert(EVENT, null, cv);
					}

					count++;
					cursor.moveToNext();
				}
				cursor.close();

				// for now only rename the old event table
				db.execSQL("ALTER TABLE " + EVENT_V1 + " RENAME TO " + EVENT_V1 + "_mig");

				db.setTransactionSuccessful();
			} finally {
				db.endTransaction();
			}

			return null;
		}

		@Override
		protected void onProgressUpdate(Integer... values) {
			MigrationCallback cb = callback.get();

			if (cb != null) {
				cb.onProgressUpdate(values[0]);
			}
		}

		@Override
		protected void onPostExecute(Void v) {
			Logger.debug("Migration done.");

			MigrationCallback cb = callback.get();
			if (cb != null) {
				cb.migrationDone();
			}
		}
	}
} public class Task extends Base implements Comparable<Task> {
	private Integer id = null;
	private String name = null;
	private Integer active = null;
	private Integer ordering = null;
	private Integer isDefault = null;

	public Task() {
		// do nothing
	}

	public Task(Integer id, String name, Integer active, Integer ordering, Integer isDefault) {
		this.id = id;
		this.name = name;
		this.active = active;
		this.ordering = ordering;
		this.isDefault = isDefault;
	}

	public Integer getId() {
		return id;
	}

	public String getName() {
		return name;
	}

	public Integer getActive() {
		return active;
	}

	public void setId(Integer id) {
		this.id = id;
	}

	public void setName(String name) {
		this.name = name;
	}

	public void setActive(Integer active) {
		this.active = active;
	}

	public Integer getOrdering() {
		return ordering;
	}

	public void setOrdering(Integer ordering) {
		this.ordering = ordering;
	}

	public Integer getIsDefault() {
		return isDefault;
	}

	public void setIsDefault(Integer isDefault) {
		this.isDefault = isDefault;
	}

	@Override
	public int compareTo(Task another) {
		return compare(getName(), another.getName(), compare(getId(), another.getId(), 0));
	}

	/**
	 * This is used e.g. by an ArrayAdapter in a ListView and it is also useful for debugging.
	 * 
	 * @see org.zephyrsoft.trackworktime.model.Base#toString()
	 */
	@Override
	public String toString() {
		return getName() + (isDefault != null && isDefault.equals(1) ? " *" : "");
	}

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((id == null) ? 0 : id.hashCode());
		return result;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Task other = (Task) obj;

		if (id == null) {
			return other.id == null;
		} else {
			return id.equals(other.id);
		}
	}
} public class Week extends Base implements Comparable<Week> {
	private final LocalDate startDay;

	public Week(LocalDate date) {
		// TODO consider locale
		startDay = date.with(DayOfWeek.MONDAY);
	}

	public Week(long epochDay) {
		startDay = LocalDate.ofEpochDay(epochDay);
	}

	public LocalDate getStart() {
		return startDay;
	}

	public LocalDate getEnd() {
		// TODO consider locale
		return startDay.with(DayOfWeek.SUNDAY);
	}

	public long toEpochDay() {
		return startDay.toEpochDay();
	}

	public Week plusWeeks(long weeksToAdd) {
		return new Week(startDay.plusWeeks(weeksToAdd));
	}

	public boolean isInWeek(LocalDate date) {
		return !date.isBefore(startDay) && !date.isAfter(getEnd());
	}

	@Override
	public int compareTo(Week another) {
		//return compare(getStart(), another.getStart(), compare(getId(), another.getId(), 0));
		return compare(getStart(), another.getStart(), 0);
	}

	/**
	 * This is used e.g. by an ArrayAdapter in a ListView and it is also useful for debugging.
	 *
	 * @see Base#toString()
	 */
	@Override
	public String toString() {
		return startDay.toString();
	}
} public enum Key {

	HOME_TIME_ZONE("keyHomeTimezone", DataType.TIMEZONEID, null, R.string.homeTimezone),

	ENABLE_FLEXI_TIME("keyEnableFlexiTime", DataType.BOOLEAN, null, R.string.enableFlexiTime),
	FLEXI_TIME_START_VALUE("keyFlexiTimeStartValue", DataType.HOUR_MINUTE, ENABLE_FLEXI_TIME,
		R.string.flexiTimeStartValue),
	FLEXI_TIME_TARGET("keyFlexiTimeTarget", DataType.HOUR_MINUTE, ENABLE_FLEXI_TIME, R.string.flexiTimeTarget),
	FLEXI_TIME_DAY_MONDAY("keyFlexiTimeDayMonday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.mondayLong),
	FLEXI_TIME_DAY_TUESDAY("keyFlexiTimeDayTuesday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.tuesdayLong),
	FLEXI_TIME_DAY_WEDNESDAY("keyFlexiTimeDayWednesday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.wednesdayLong),
	FLEXI_TIME_DAY_THURSDAY("keyFlexiTimeDayThursday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.thursdayLong),
	FLEXI_TIME_DAY_FRIDAY("keyFlexiTimeDayFriday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.fridayLong),
	FLEXI_TIME_DAY_SATURDAY("keyFlexiTimeDaySaturday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.saturdayLong),
	FLEXI_TIME_DAY_SUNDAY("keyFlexiTimeDaySunday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.sundayLong),
	FLEXI_TIME_RESET_INTERVAL("keyFlexiTimeResetInterval", DataType.ENUM_NAME,
			ENABLE_FLEXI_TIME, R.string.flexiTimeResetInterval),

	FLATTENING_ENABLED("keyFlatteningEnabled", DataType.BOOLEAN, null, R.string.flatteningEnabled),
	SMALLEST_TIME_UNIT("keySmallestTimeUnit", DataType.INTEGER, FLATTENING_ENABLED, R.string.smallestTimeUnit),

	LOCATION_BASED_TRACKING_ENABLED("keyLocationBasedTrackingEnabled", DataType.BOOLEAN, null,
		R.string.enableLocationBasedTracking),
	LOCATION_BASED_TRACKING_VIBRATE("keyLocationBasedTrackingVibrate", DataType.BOOLEAN,
		LOCATION_BASED_TRACKING_ENABLED, R.string.locationBasedTrackingVibrate),
	LOCATION_BASED_TRACKING_LATITUDE("keyLocationBasedTrackingLatitude", DataType.DOUBLE,
		LOCATION_BASED_TRACKING_ENABLED, R.string.workplaceLatitude),
	LOCATION_BASED_TRACKING_LONGITUDE("keyLocationBasedTrackingLongitude", DataType.DOUBLE,
		LOCATION_BASED_TRACKING_ENABLED, R.string.workplaceLongitude),
	LOCATION_BASED_TRACKING_TOLERANCE("keyLocationBasedTrackingTolerance", DataType.INTEGER,
		LOCATION_BASED_TRACKING_ENABLED, R.string.trackingTolerance),
	LOCATION_BASED_TRACKING_IGNORE_BEFORE_EVENTS("keyLocationBasedTrackingIgnoreBeforeEvents",
		DataType.INTEGER_OR_EMPTY, LOCATION_BASED_TRACKING_ENABLED, R.string.ignoreBefore),
	LOCATION_BASED_TRACKING_IGNORE_AFTER_EVENTS("keyLocationBasedTrackingIgnoreAfterEvents", DataType.INTEGER_OR_EMPTY,
		LOCATION_BASED_TRACKING_ENABLED, R.string.ignoreAfter),

	AUTO_PAUSE_ENABLED("keyAutoPauseEnabled", DataType.BOOLEAN, null, R.string.autoPauseEnabled),
	AUTO_PAUSE_BEGIN("keyAutoPauseBegin", DataType.TIME, AUTO_PAUSE_ENABLED, R.string.autoPauseBegin),
	AUTO_PAUSE_END("keyAutoPauseEnd", DataType.TIME, AUTO_PAUSE_ENABLED, R.string.autoPauseEnd),

	NOTIFICATION_ENABLED("keyNotificationEnabled", DataType.BOOLEAN, null, R.string.notificationEnabled),
	NOTIFICATION_USES_FLEXI_TIME_AS_TARGET("keyNotificationUsesFlexiTimeAsTarget", DataType.BOOLEAN,
		NOTIFICATION_ENABLED, R.string.notificationUsesFlexiTimeAsTarget),
	FLEXI_TIME_TO_ZERO_ON_EVERY_DAY("keyFlexiTimeToZeroOnEveryDay", DataType.BOOLEAN, NOTIFICATION_ENABLED,
		R.string.flexiTimeToZeroOnEveryDay),
	NOTIFICATION_ON_PEBBLE("keyPebbleNotification", DataType.BOOLEAN, null, R.string.pebbleNotification),

	WIFI_BASED_TRACKING_ENABLED("keyWifiBasedTrackingEnabled", DataType.BOOLEAN, null, R.string.enableWifiBasedTracking),
	WIFI_BASED_TRACKING_VIBRATE("keyWifiBasedTrackingVibrate", DataType.BOOLEAN, WIFI_BASED_TRACKING_ENABLED,
		R.string.wifiBasedTrackingVibrate),
	WIFI_BASED_TRACKING_SSID("keyWifiBasedTrackingSSID", DataType.SSID, WIFI_BASED_TRACKING_ENABLED,
		R.string.workplaceWifiSSID),

	AUTOMATIC_TRACKING_METHODS_GENERATE_EVENTS_SEPARATELY(
		"keyEachTrackingMethodGeneratesEventsSeparately", DataType.BOOLEAN, null,
		R.string.methodsGenerateEventsSeparately),

	REPORT_LAST_RANGE("keyReportLastUsedRange", DataType.INTEGER, null, null),
	REPORT_LAST_UNIT("keyReportLastUsedUnit", DataType.INTEGER, null, null),
	REPORT_LAST_GROUPING("keyReportLastUsedGrouping", DataType.INTEGER, null, null);

	private final String name;
	private final DataType dataType;
	private final Key parent;
	private final Integer readableNameResourceId;

	Key(String name, DataType dataType, Key parent, Integer readableNameResourceId) {
		this.name = name;
		this.dataType = dataType;
		this.parent = parent;
		this.readableNameResourceId = readableNameResourceId;
	}

	/**
	 * Get the name for use in SharedPreferences.getXXX(name, ...).
	 */
	public String getName() {
		return name;
	}

	public DataType getDataType() {
		return dataType;
	}

	public Key getParent() {
		return parent;
	}

	public Integer getReadableNameResourceId() {
		return readableNameResourceId;
	}

	public static Key getKeyWithName(String name) {
		for (Key key : values()) {
			if (key.getName().equals(name)) {
				return key;
			}
		}
		return null;
	}

	public static Set<Key> getChildKeys(Key parentKey) {
		Set<Key> ret = new HashSet<>();
		for (Key key : values()) {
			if (key.getParent() == parentKey) {
				ret.add(key);
			}
		}
		return ret;
	}

} public class TimerManager {

	private final DAO dao;
	private final SharedPreferences preferences;
	private final Context context;

	/**
	 * Constructor
	 */
	public TimerManager(DAO dao, SharedPreferences preferences, Context context) {
		this.dao = dao;
		this.preferences = preferences;
		this.context = context;
	}

	public ZoneId getHomeTimeZone() {
		String homeTimeZone = preferences.getString(Key.HOME_TIME_ZONE.getName(),  null);

		if (homeTimeZone == null) {
			// TODO start activity to select time zone?
			return ZoneId.systemDefault();
		} else {
			return ZoneId.of(homeTimeZone);
		}
	}

	public ZoneOffset getHomeTimeZoneOffset(LocalDateTime localDateTime) {
		return getHomeTimeZone().getRules().getOffset(localDateTime);
	}

	public FlexiReset getFlexiReset() {
		return FlexiReset.loadFromPreferences(preferences);
	}

	public boolean insertDefaultWorkTimes(LocalDate from, LocalDate to, Integer taskId, String text) {
		try {
			LocalDate running = from;

			while (!running.isAfter(to)) {
				// clock-in at start of day (00:00) in home time zone
				// TODO current time zone?

				DayOfWeek weekDay = running.getDayOfWeek();
				int workDuration = getNormalWorkDurationFor(weekDay);
				if (workDuration > 0) {
					OffsetDateTime clockInTime = running.atStartOfDay(getHomeTimeZone()).toOffsetDateTime();
					createEvent(clockInTime, taskId, TypeEnum.CLOCK_IN, text);

					OffsetDateTime clockOutTime = clockInTime.plusMinutes(workDuration);
					if (isAutoPauseApplicable(clockOutTime)) {
						long pauseDuration = getAutoPauseDuration();
						clockOutTime = clockOutTime.plusMinutes(pauseDuration);
					}
					createEvent(clockOutTime, null, TypeEnum.CLOCK_OUT, null);
				}

				running = running.plusDays(1);
			}
			return true;
		} catch(Exception e) {
			return false;
		}
	}

	/**
	 * Checks the current state.
	 *
	 * @return {@code true} if currently clocked in, {@code false} otherwise
	 */
	public boolean isTracking() {
		Event latestEvent = dao.getLastEventUpTo(OffsetDateTime.now());
		return latestEvent != null && latestEvent.getType().equals(TypeEnum.CLOCK_IN.getValue());
	}

	/**
	 * Returns {@code true} if the options are set in a way that an event is in the defined time before/after an
	 * existing event (not counting CLOCK_OUT_NOW).
	 */
	public boolean isInIgnorePeriodForLocationBasedTracking() {
		OffsetDateTime now = OffsetDateTime.now();

		// get first event AFTER now, subtract the minutes to ignore before events and check if the result is BEFORE now
		Event firstAfterNow = dao.getFirstEventAfter(now);
		String ignoreBeforeString = preferences.getString(Key.LOCATION_BASED_TRACKING_IGNORE_BEFORE_EVENTS.getName(),
			"0");
		int ignoreBefore = 0;
		try {
			ignoreBefore = Integer.parseInt(ignoreBeforeString);
		} catch (NumberFormatException nfe) {
			Logger.warn("illegal value - ignore before events: {}", ignoreBeforeString);
		}
		if (firstAfterNow != null) {
			OffsetDateTime firstAfterNowTime = firstAfterNow.getDateTime();

			if (firstAfterNowTime.minusMinutes(ignoreBefore).isBefore(now)) {
				return true;
			}
		}
		// get the last event BEFORE now, add the minutes to ignore after events and check if the result is AFTER now
		Event lastBeforeNow = dao.getLastEventBefore(OffsetDateTime.now());
		String ignoreAfterString = preferences
			.getString(Key.LOCATION_BASED_TRACKING_IGNORE_AFTER_EVENTS.getName(), "0");
		int ignoreAfter = 0;
		try {
			ignoreAfter = Integer.parseInt(ignoreAfterString);
		} catch (NumberFormatException nfe) {
			Logger.warn("illegal value - ignore after events: {}", ignoreAfterString);
		}
		if (lastBeforeNow != null) {
			OffsetDateTime lastBeforeNowTime = lastBeforeNow.getDateTime();

			return lastBeforeNowTime.plusMinutes(ignoreAfter).isAfter(now);
		}

		return false;
	}

	/**
	 * Returns the currently active task or {@code null} if tracking is disabled at the moment.
	 */
	public Task getCurrentTask() {
		Event latestEvent = dao.getLastEventBefore(OffsetDateTime.now());
		if (latestEvent != null && latestEvent.getType().equals(TypeEnum.CLOCK_IN.getValue())) {
			return dao.getTask(latestEvent.getTask());
		} else {
			return null;
		}
	}

	/**
	 * Returns the default task or {@code null} if no task is configured.
	 */
	public Task getDefaultTask() {
		return dao.getDefaultTask();
	}

	/**
	 * Either starts tracking (from non-tracked time) or changes the task and/or text (from already tracked time).
	 * If the task is {@code null}, the default task is taken. If there is no default task, no task will be taken.
	 *
	 * @param minutesToPredate
	 *            how many minutes in the future should the event be
	 * @param selectedTask
	 *            the task for which the time shall be tracked
	 * @param text
	 *            free text to describe in detail what was done
	 */
	public void startTracking(int minutesToPredate, Task selectedTask, String text) {
		Task taskToLink = selectedTask;
		if (taskToLink == null) {
			taskToLink = dao.getDefaultTask();
		}
		createEvent(minutesToPredate, (taskToLink == null ? null : taskToLink.getId()), TypeEnum.CLOCK_IN, text);
		Basics.getInstance().safeCheckExternalControls();
	}

	/**
	 * Stops tracking time.
	 *
	 * @param minutesToPredate
	 *            how many minutes in the future should the event be
	 */
	public void stopTracking(int minutesToPredate) {
		createEvent(minutesToPredate, null, TypeEnum.CLOCK_OUT, null);
		Basics.getInstance().safeCheckExternalControls();
	}

	/**
	 * Is the event a clock-in event? {@code null} as argument will return {@code false}.
	 */
	public static boolean isClockInEvent(Event event) {
		return event != null && event.getType().equals(TypeEnum.CLOCK_IN.getValue());
	}

	/**
	 * Is the event a clock-out event? {@code null} as argument will return {@code false}.
	 */
	public static boolean isClockOutEvent(Event event) {
		return event != null
			&& (event.getType().equals(TypeEnum.CLOCK_OUT.getValue()) || event.getType().equals(
				TypeEnum.CLOCK_OUT_NOW.getValue()));
	}

	/**
	 * Calculate a time sum for a given period.
	 */
	public long calculateTimeSum(LocalDate date, PeriodEnum periodEnum) {
		// TODO restructure for clarity!
		// TODO improve performance
		if (periodEnum != PeriodEnum.ALL_TIME) {
			Logger.debug("calculating time sum for {} containing {}", periodEnum.name(), date);
		} else {
			Logger.debug("calculation time sum for all time");
		}

		TimeInfo infoStart;
		TimeInfo infoEnd;

		switch (periodEnum) {
			case DAY: {
				TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, date, true);
				timeCalc.calculatePeriod(PeriodEnum.DAY, false);
				return timeCalc.getTimeWorked();
			}

			case WEEK: {
				TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, date, true);
				timeCalc.calculatePeriod(PeriodEnum.WEEK, false);
				return timeCalc.getTimeWorked();
			}

			case MONTH:
				infoStart = getTimesAt(date.with(firstDayOfMonth()));

				LocalDate endDate = date.with(date.with(TemporalAdjusters.firstDayOfNextMonth()));
				if (endDate.isAfter(LocalDate.now())) {
					endDate = LocalDate.now().plusDays(1);
				}

				infoEnd = getTimesAt(endDate);
				break;
			case ALL_TIME:
				// FIXME better solution
				infoStart = getTimesAt(null);
				infoEnd = getTimesAt(LocalDate.now().plusDays(1));
				break;
			default:
				throw new IllegalArgumentException("unknown period type");
		}

		return (int) (infoEnd.getActual() - infoStart.getActual());
	}

	/**
	 * Get the remaining time for today (in minutes). Takes into account the target work time for
	 * the week and also if this is the last day in the working week.
	 */
	public Integer getMinutesRemaining() {
		boolean toZeroEveryDay = preferences.getBoolean(Key.FLEXI_TIME_TO_ZERO_ON_EVERY_DAY.getName(),
			false);
		OffsetDateTime dateTime = OffsetDateTime.now();
		DayOfWeek weekDay = dateTime.getDayOfWeek();
		if (isWorkDay(weekDay)) {
			int minutesRemaining = 0;
			Logger.debug("isAutoPauseEnabled={}", isAutoPauseEnabled());
			Logger.debug("isAutoPauseTheoreticallyApplicable={}", isAutoPauseTheoreticallyApplicable(dateTime));
			Logger.debug("isAutoPauseApplicable={}", isAutoPauseApplicable(dateTime));
			if (isAutoPauseEnabled() && isAutoPauseTheoreticallyApplicable(dateTime)
					&& !isAutoPauseApplicable(dateTime)) {
				// auto-pause is necessary, but was NOT already taken into account by calculatePeriod():
				Logger.debug("auto-pause is necessary, but was NOT already taken into account by calculateTimeSum()");
				minutesRemaining += getAutoPauseDuration();
			}

			if (!isFollowedByWorkDay(weekDay) || toZeroEveryDay) {
				// reach zero today
				TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, dateTime.toLocalDate(), true);
				timeCalc.calculatePeriod(PeriodEnum.DAY, true);

				minutesRemaining += (int)-timeCalc.getBalance();
			} else {
				// not the last work day of the week, distribute remaining work time
				TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, dateTime.toLocalDate(), true);
				timeCalc.calculatePeriod(PeriodEnum.WEEK, false);

				long remainingWeekPerDay = -timeCalc.getBalance() / (timeCalc.getFutureWorkDays() + 1);
				long remainingToday      = -timeCalc.getCurrentDayBalance() + minutesRemaining;

				minutesRemaining = (int)Math.min(remainingToday,remainingWeekPerDay);
			}

			Logger.debug("minutesRemaining={}", minutesRemaining);

			return minutesRemaining;
		} else {
			return null;
		}
	}

	/**
	 * Get the time info at the start of the specific day.
	 */
	public synchronized TimeInfo getTimesAt(LocalDate targetDate) {
		Logger.debug("Calculating times at {}", targetDate);

		TimeInfo ret = new TimeInfo();
		LocalDate startDate;
		CalcCacheEntry cache = null;

		Event event = dao.getFirstEvent();
		if (event == null) {
			return ret;
		}

		if (targetDate != null) {
			cache = dao.getCacheAt(targetDate);
		}

		if (cache == null) {
			Logger.debug("No cache for date {}", targetDate);

			// Start value assumed at start of week
			startDate =  DateTimeUtil.getWeekStart(event.getDateTime().toLocalDate());

			String startValueString = preferences.getString(Key.FLEXI_TIME_START_VALUE.getName(), "0:00");
			int startValue = parseHoursMinutesString(startValueString);

			ret.setActual(startValue);
		} else {
			startDate = cache.getDate();
			Logger.debug("Cache entry found for date {}: {}", startDate, cache.getWorked());

			ret.setActual(cache.getWorked());
			ret.setTarget(cache.getTarget());
		}

		if (targetDate == null || !startDate.isBefore(targetDate)) {
			return ret;
		}

		Logger.debug("Start sum: {}", formatTime(ret.getBalance()));

		long iter = DAYS.between(startDate, targetDate);
		Logger.debug("Date range to calculate: {} -> {}", startDate, targetDate);
		Logger.debug("Number of days to calculate: {}", iter);

		TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, startDate,true);
		timeCalc.setStartSums(ret);

		// FIXME background task
		LocalDate currentDate = startDate;
		iter = 0;
		while (currentDate.isBefore(targetDate)) {
			timeCalc.calculateNextDay();

			Logger.debug("Sum at {}: {} = {} - {}", currentDate, timeCalc.getBalance(), timeCalc.getTotalTimeWorked(), timeCalc.getTotalTarget());


			currentDate = currentDate.plusDays(1);

			// save checkpoint in cache on Mondays and first day of month,
			// data from the **beginning** of the day (= 0:00)
			// currentDay is one ahead, so if it is today, we can already write the cache

			if (!currentDate.isAfter(LocalDate.now()) &&
					(currentDate.getDayOfWeek() == DayOfWeek.MONDAY || currentDate.getDayOfMonth() == 1)) {
				Logger.debug("Saving checkpoint for date: {}", currentDate.format(DateTimeFormatter.ISO_LOCAL_DATE));

				CalcCacheEntry cacheEntry = new CalcCacheEntry(currentDate, timeCalc.getTotalTimeWorked(), timeCalc.getTotalTarget());
				Logger.debug("Data: {}", cacheEntry);

				dao.insertCache(cacheEntry);
			}

			iter++;
		}
		Logger.debug("Calculated {} days", iter);

		if (timeCalc.withFlexiTime()) {
			Logger.debug("Calculated flexi time:       {}", timeCalc.getBalance());
		}

		ret.setActual(timeCalc.getTotalTimeWorked());
		ret.setTarget(timeCalc.getTotalTarget());

		Logger.debug("DONE getTimesAt({}): actual={}, target={}",
				targetDate, ret.getActual(), ret.getTarget());
		Logger.debug("--");

		return ret;
	}

	/**
	 * Parse a value of hours and minutes (positive or negative).
	 */
	public static int parseHoursMinutesString(String hoursMinutes) {
		if (hoursMinutes != null) {
			String[] startValueArray = hoursMinutes.replaceAll("[- ]", "").split("[:.]");
			int hours = Integer.parseInt(startValueArray[0]);
			int minutes = startValueArray.length > 1 ? Integer.parseInt(startValueArray[1]) : 0;

			if (hoursMinutes.trim().startsWith("-")) {
				return -1 * (hours * 60 + minutes);
			} else {
				return hours * 60 + minutes;
			}
		}
		return 0;
	}

	/**
	 * Get the normal work time (in minutes) for a specific week day.
	 */
	public int getNormalWorkDurationFor(DayOfWeek weekDay) {
		if (isWorkDay(weekDay)) {
			String targetValueString = preferences.getString(Key.FLEXI_TIME_TARGET.getName(), "0:00");
			targetValueString = DateTimeUtil.refineHourMinute(targetValueString);
			int targetValue = parseHoursMinutesString(targetValueString);
			BigDecimal minutes = new BigDecimal(targetValue).divide(new BigDecimal(countWorkDays()),
				RoundingMode.HALF_UP);
			return minutes.intValue();
		} else {
			return 0;
		}
	}

	public int countWorkDays() {
		int ret = 0;
		for (DayOfWeek day : DayOfWeek.values()) {
			if (isWorkDay(day)) {
				ret++;
			}
		}
		return ret;
	}

	/**
	 * Is the next day after the given day marked as work day?
	 */
	private boolean isFollowedByWorkDay(DayOfWeek day) {
		DayOfWeek nextDay = day.plus(1);
		return isWorkDay(nextDay);
	}

	/**
	 * Is this a work day?
	 */
	public boolean isWorkDay(DayOfWeek weekDay) {
		Key key;
		switch (weekDay) {
			case MONDAY:
				key = Key.FLEXI_TIME_DAY_MONDAY;
				break;
			case TUESDAY:
				key = Key.FLEXI_TIME_DAY_TUESDAY;
				break;
			case WEDNESDAY:
				key = Key.FLEXI_TIME_DAY_WEDNESDAY;
				break;
			case THURSDAY:
				key = Key.FLEXI_TIME_DAY_THURSDAY;
				break;
			case FRIDAY:
				key = Key.FLEXI_TIME_DAY_FRIDAY;
				break;
			case SATURDAY:
				key = Key.FLEXI_TIME_DAY_SATURDAY;
				break;
			case SUNDAY:
				key = Key.FLEXI_TIME_DAY_SUNDAY;
				break;
			default:
				throw new IllegalArgumentException("unknown weekday");
		}
		return preferences.getBoolean(key.getName(), false);
	}

	/**
	 * Create a new event at current time + the given minute amount.
	 *
	 * @param minutesToPredate
	 *            how many minutes to add to "now"
	 * @param taskId
	 *            the task id (may be {@code null})
	 * @param type
	 *            the type
	 * @param text
	 *            the text (may be {@code null})
	 */
	public void createEvent(int minutesToPredate, Integer taskId, TypeEnum type, String text) {
		if (minutesToPredate < 0) {
			throw new IllegalArgumentException("no negative minute amount allowed");
		}
		ZonedDateTime targetTime = ZonedDateTime.now().plusMinutes(minutesToPredate);
		createEvent(targetTime.toOffsetDateTime(), taskId, type, text);
	}

	/**
	 * Create a new event at the given time.
	 *
	 * @param dateTime
	 *            the time for which the new event should be created
	 * @param taskId
	 *            the task id (may be {@code null})
	 * @param type
	 *            the type
	 * @param text
	 *            the text (may be {@code null})
	 */
	public void createEvent(OffsetDateTime dateTime, Integer taskId, TypeEnum type, String text) {
		createEvent(dateTime, taskId, type, text, false);
	}

	/**
	 * Create a new event at the given time.
	 *
	 * @param dateTime
	 *            the time for which the new event should be created
	 * @param taskId
	 *            the task id (may be {@code null})
	 * @param type
	 *            the type
	 * @param text
	 *            the text (may be {@code null})
	 * @param insertedByRestore
	 *            true if the event is inserted by a restore. In that case, auto pause and refresh of notifications are
	 *            suppressed.
	 */
	public void createEvent(OffsetDateTime dateTime, Integer taskId, TypeEnum type, String text, boolean insertedByRestore) {
		if (dateTime == null) {
			throw new IllegalArgumentException("date/time has to be given");
		}
		if (type == null) {
			throw new IllegalArgumentException("type has to be given");
		}

		if (!insertedByRestore && type == TypeEnum.CLOCK_OUT) {
			tryToInsertAutoPause(dateTime);
		}

		Event event = new Event(null, taskId, type.getValue(), dateTime, text);
		Logger.debug("TRACKING: {} @ {} taskId={} text={}", type.name(), dateTime, taskId, text);
		dao.insertEvent(event);
		dao.deleteCacheFrom(event.getDateTime().toLocalDate());

		if (!insertedByRestore) {
			Basics.getInstance().safeCheckExternalControls();
		}
	}

	// TODO General invalidate function (possibly with notification)
	public void invalidateCacheFrom(OffsetDateTime date) {
		// convert date to home time zone
		LocalDate dbDate = date.atZoneSameInstant(getHomeTimeZone()).toLocalDate();
		dao.deleteCacheFrom(dbDate);
	}

	public void invalidateCacheFrom(LocalDate date) {
		dao.deleteCacheFrom(date);
	}

	/**
	 * Create a new NON-PERSISTENT (!) event of the type CLOCK_OUT_NOW.
	 */
	public static Event createClockOutNowEvent() {
		return new Event(null, null, TypeEnum.CLOCK_OUT_NOW.getValue(), OffsetDateTime.now(), null);
	}

	private void tryToInsertAutoPause(OffsetDateTime dateTime) {
		if (isAutoPauseEnabled() && isAutoPauseApplicable(dateTime)) {
			// insert auto-pause events
			OffsetDateTime begin = dateTime.with(getAutoPauseBegin());
			OffsetDateTime end   = dateTime.with(getAutoPauseEnd());
			Logger.debug("inserting auto-pause, begin={}, end={}", begin, end);

			Event lastBeforePause = dao.getLastEventBefore(begin);
			createEvent(begin, null,TypeEnum.CLOCK_OUT, null);
			createEvent(end, (lastBeforePause == null ? null : lastBeforePause.getTask()), TypeEnum.CLOCK_IN,
				(lastBeforePause == null ? null : lastBeforePause.getText()));
		} else {
			Logger.debug("NOT inserting auto-pause");
		}
	}

	/**
	 * Determines if the auto-pause mechanism is enabled.
	 */
	public boolean isAutoPauseEnabled() {
		return preferences.getBoolean(Key.AUTO_PAUSE_ENABLED.getName(), false);
	}

	/**
	 * Determines if the auto-pause can be applied to the given day.
	 */
	public boolean isAutoPauseApplicable(OffsetDateTime dateTime) {
		OffsetDateTime end = dateTime.with(getAutoPauseEnd());
		// auto-pause is theoretically applicable
		return isAutoPauseTheoreticallyApplicable(dateTime)
			// given time is after auto-pause end, so auto-pause should really be applied
			&& dateTime.isAfter(end);
	}

	private boolean isAutoPauseTheoreticallyApplicable(OffsetDateTime date) {
		OffsetDateTime begin = date.with(getAutoPauseBegin());
		OffsetDateTime end   = date.with(getAutoPauseEnd());

		if (begin.isBefore(end)) {
			Event lastEventBeforeBegin = dao.getLastEventBefore(begin);
			Event lastEventBeforeEnd = dao.getLastEventBefore(end);
				// is clocked in before begin
			return lastEventBeforeBegin != null && lastEventBeforeBegin.getType().equals(TypeEnum.CLOCK_IN.getValue())
				// no event is in auto-pause interval
				&& lastEventBeforeBegin.getId().equals(lastEventBeforeEnd.getId());
		} else {
			// begin is equal to end or (even worse) begin is after end => no auto-pause
			return false;
		}
	}

	/**
	 * Calculates the begin of the auto-pause
	 */
	public LocalTime getAutoPauseBegin() {
		return DateTimeUtil.parseTime(getAutoPauseData(Key.AUTO_PAUSE_BEGIN.getName(), "23.59"));
	}

	/**
	 * Calculates the end of the auto-pause
	 */
	public LocalTime getAutoPauseEnd() {
		return DateTimeUtil.parseTime(getAutoPauseData(Key.AUTO_PAUSE_END.getName(), "00.00"));
	}

	/**
	 * Calculates the length of the auto-pause for the given day (in minutes).<br>
	 * Note: does *not* account for time changeovers
	 */
	public long getAutoPauseDuration() {
		return TimerManager.timeDiff(getAutoPauseBegin(), getAutoPauseEnd());
	}

	private String getAutoPauseData(String key, String defaultTime) {
		String ret = preferences.getString(key, defaultTime);
		ret = DateTimeUtil.refineTime(ret);
		return ret;
	}

	// ======== registration of automatic work time tracking methods ========

	public void activateTrackingMethod(TrackingMethod method) {
		Collection<TrackingMethod> activeMethods = readCurrentlyActiveTrackingMethods();
		if (!activeMethods.contains(method)) {
			activeMethods.add(method);
			writeCurrentlyActiveTrackingMethods(activeMethods);
		}
	}

	public void deactivateTrackingMethod(TrackingMethod method) {
		Collection<TrackingMethod> activeMethods = readCurrentlyActiveTrackingMethods();
		if (activeMethods.contains(method)) {
			activeMethods.remove(method);
			writeCurrentlyActiveTrackingMethods(activeMethods);
		}
	}

	private Collection<TrackingMethod> readCurrentlyActiveTrackingMethods() {
		String activeMethodsString = preferences.getString(context.getString(R.string.keyActiveMethods), "");
		String[] activeMethodsStrings = StringUtils.split(activeMethodsString, ',');
		Collection<TrackingMethod> result = new ArrayList<>(activeMethodsStrings.length);
		for (String activeMethodString : activeMethodsStrings) {
			result.add(TrackingMethod.valueOf(activeMethodString));
		}
		return result;
	}

	private void writeCurrentlyActiveTrackingMethods(Collection<TrackingMethod> methods) {
		StringBuilder result = new StringBuilder();
		for (TrackingMethod method : methods) {
			if (result.length() > 0) {
				result.append(",");
			}
			result.append(method.name());
		}
		final Editor editor = preferences.edit();
		editor.putString(context.getString(R.string.keyActiveMethods), result.toString());
		editor.commit();
	}

	public boolean clockInWithTrackingMethod(TrackingMethod method) {
		boolean currentlyClockedInWithMethod = getTrackingMethodClockInState(method);
		if (getTrackingMethodsGenerateEventsSeparately()) {
			Logger.debug("clocking in with method {} forcibly", method.name());
			return setTrackingMethodClockInStateForcibly(method, true);
		} else if (currentlyClockedInWithMethod) {
			Logger.debug("already clocked in with method {}", method.name());
			return false;
		} else {
			Logger.debug("clocking in with method {}", method.name());
			return setTrackingMethodClockInState(method, true);
		}
	}

	public boolean clockOutWithTrackingMethod(TrackingMethod method) {
		boolean currentlyClockedInWithMethod = getTrackingMethodClockInState(method);
		if (getTrackingMethodsGenerateEventsSeparately()) {
			Logger.debug("clocking out with method {} forcibly", method.name());
			return setTrackingMethodClockInStateForcibly(method, false);
		} else if (!currentlyClockedInWithMethod) {
			Logger.debug("not clocked in with method {}", method.name());
			return false;
		} else {
			Logger.debug("clocking out with method {}", method.name());
			return setTrackingMethodClockInState(method, false);
		}
	}

	private boolean getTrackingMethodClockInState(TrackingMethod method) {
		return preferences.getBoolean(context.getString(method.getPreferenceKeyId()), false);
	}

	private boolean getTrackingMethodsGenerateEventsSeparately() {
		return preferences
			.getBoolean(context.getString(R.string.keyEachTrackingMethodGeneratesEventsSeparately), false);
	}

	private boolean setTrackingMethodClockInState(TrackingMethod method, boolean state) {
		final Editor editor = preferences.edit();
		editor.putBoolean(context.getString(method.getPreferenceKeyId()), state);
		editor.commit();
		return createEventIfNecessary(method, state);
	}

	private boolean setTrackingMethodClockInStateForcibly(TrackingMethod method, boolean state) {
		final Editor editor = preferences.edit();
		editor.putBoolean(context.getString(method.getPreferenceKeyId()), state);
		editor.commit();
		return createEventForcibly(method, state);
	}

	private boolean createEventIfNecessary(TrackingMethod method, boolean state) {
		if (state) {
			// method is clocked in now - should we generate a clock-in event?
			if (!isClockedInWithAnyOtherTrackingMethod(method) && !isTracking()) {
				// we are not clocked in already by hand or by another method, so generate an event (first method
				// clocking in)
				startTracking(0, null, null);
				Logger.debug("method {}: started tracking", method);
				return true;
			} else {
				Logger.debug("method {}: NOT started tracking (was not first method or already clocked in manually)",
					method);
				return false;
			}
		} else {
			// method is clocked out now - should we generate a clock-out event?
			if (!isClockedInWithAnyOtherTrackingMethod(method) && isTracking()) {
				// we are not clocked in by hand or by another method, so generate an event (last method clocking out)
				stopTracking(0);
				Logger.debug("method {}: stopped tracking", method);
				return true;
			} else {
				Logger.debug("method {}: NOT stopped tracking (was not last method or already clocked out manually)",
					method);
				return false;
			}
		}
	}

	private boolean createEventForcibly(TrackingMethod method, boolean state) {
		if (state) {
			// method is clocked in now - should we generate a clock-in event?
			if (!isTracking()) {
				startTracking(0, null, null);
				Logger.debug("method {}: started tracking forcibly", method);
				return true;
			} else {
				Logger.debug("method {}: NOT started tracking forcibly (already clocked in)",
					method);
				return false;
			}
		} else {
			// method is clocked out now - should we generate a clock-out event?
			if (isTracking()) {
				stopTracking(0);
				Logger.debug("method {}: stopped tracking forcibly", method);
				return true;
			} else {
				Logger.debug("method {}: NOT stopped tracking forcibly (already clocked out)",
					method);
				return false;
			}
		}
	}

	private boolean isClockedInWithAnyOtherTrackingMethod(TrackingMethod methodToIgnore) {
		Collection<TrackingMethod> activeMethods = readCurrentlyActiveTrackingMethods();
		for (TrackingMethod method : activeMethods) {
			if (method.equals(methodToIgnore)) {
				continue;
			}
			if (isClockedInWithTrackingMethod(method)) {
				return true;
			}
		}
		return false;
	}

	private boolean isClockedInWithTrackingMethod(TrackingMethod method) {
		return getTrackingMethodClockInState(method);
	}

	public static String formatTime(long time) {
		String sgn = (time < 0) ? "-" : "";
		return String.format(Locale.US, "%s%02d:%02d", sgn, Math.abs(time / 60), Math.abs(time % 60));
	}

	public static long timeDiff(OffsetDateTime startTime, OffsetDateTime endTime) {
		return MINUTES.between(startTime.truncatedTo(MINUTES), endTime.truncatedTo(MINUTES));
	}

	public static long timeDiff(LocalTime startTime, LocalTime endTime) {
		return MINUTES.between(startTime.truncatedTo(MINUTES), endTime.truncatedTo(MINUTES));
	}
} public class BackupUtil {

    private BackupUtil() {
        // only static usage
    }

    public static Boolean doBackup(Context context, BackupFileInfo info) {
        try {
            DAO dao = Basics.getOrCreateInstance(context).getDao();
            SharedPreferences preferences = Basics.getOrCreateInstance(context).getPreferences();

            DocumentTreeStorage.writing(context, info.getType(), info.getPreferencesBackupFile(), outputStream -> {
                try (Writer writer = new OutputStreamWriter(outputStream);
                     BufferedWriter output = new BufferedWriter(writer)) {
                    PreferencesUtil.writePreferences(preferences, output);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });

            DocumentTreeStorage.writing(context, info.getType(), info.getEventsBackupFile(), outputStream -> {
                try (Writer writer = new OutputStreamWriter(outputStream);
                     BufferedWriter output = new BufferedWriter(writer)) {
                    dao.backupEventsToWriter(output);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });

            DocumentTreeStorage.writing(context, info.getType(), info.getTargetsBackupFile(), outputStream -> {
                try (Writer writer = new OutputStreamWriter(outputStream);
                     BufferedWriter output = new BufferedWriter(writer)) {
                    dao.backupTargetsToWriter(output);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });

            return true;
        } catch (Exception e) {
            Logger.warn(e, "problem while writing backup");
            return false;
        }
    }

    public static Boolean doRestore(Context context, BackupFileInfo info) {
        try {
            DAO dao = Basics.getOrCreateInstance(context).getDao();
            SharedPreferences preferences = Basics.getOrCreateInstance(context).getPreferences();

            if (exists(context, info.getType(), info.getPreferencesBackupFile())) {
                DocumentTreeStorage.reading(context, info.getType(), info.getPreferencesBackupFile(),
                    reader -> {
                        try (final BufferedReader input = new BufferedReader(reader)) {
                            PreferencesUtil.readPreferences(preferences, input);
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }
                    });
            }

            if (exists(context, info.getType(), info.getEventsBackupFile())) {
                DocumentTreeStorage.reading(context, info.getType(), info.getEventsBackupFile(),
                    reader -> {
                        try (final BufferedReader input = new BufferedReader(reader)) {
                            dao.restoreEventsFromReader(input);
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }
                    });
            }

            if (exists(context, info.getType(), info.getTargetsBackupFile())) {
                DocumentTreeStorage.reading(context, info.getType(), info.getTargetsBackupFile(),
                    reader -> {
                        try (final BufferedReader input = new BufferedReader(reader)) {
                            dao.restoreTargetsFromReader(input);
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }
                    });
            }
            return true;
        } catch (Exception e) {
            Logger.warn(e, "problem while restoring backup");
            return false;
        }
    }

} public class DateTimeUtil {
	private static final DateTimeFormatter LOCALIZED_DATE = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM);
	private static final DateTimeFormatter LOCALIZED_DATE_SHORT = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT);
	private static final DateTimeFormatter LOCALIZED_TIME = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
	private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
	private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss");
	private static final DateTimeFormatter LOCALIZED_DAY_AND_DATE = new DateTimeFormatterBuilder()
			.appendPattern("eeee")
			.appendLiteral(", ")
			.appendLocalized(FormatStyle.SHORT, null)
			.toFormatter();

	/** E.g. Fri, 12.9 */
	private static final DateTimeFormatter LOCALIZED_DAY_AND_SHORT_DATE =
			createLocalizedDayAndShortDateFormat();

	private static DateTimeFormatter createLocalizedDayAndShortDateFormat() {
		String shortDate = DateTimeFormatterBuilder.getLocalizedDateTimePattern(
				FormatStyle.SHORT,
				null,
				IsoChronology.INSTANCE,
				Locale.getDefault())
				// remove year and some year-separators (at least in german dates, the dot after the month has to exist)
				.replaceAll("[ /-]? *[yY]+ *[ 年/.-]?", "");
		String pattern = "eee, " + shortDate;
		return DateTimeFormatter.ofPattern(pattern);
	}

	/**
	 * Determines if the given {@link LocalDateTime} is in the future.
	 */
	public static boolean isInFuture(LocalDateTime dateTime) {
		return dateTime.isAfter(LocalDateTime.now());
	}

	public static boolean isInFuture(OffsetDateTime dateTime) {
		return dateTime.isAfter(OffsetDateTime.now());
	}

	/**
	 * Determines if the given {@link LocalDateTime} is in the past.
	 */
	public static boolean isInPast(LocalDateTime dateTime) {
		return dateTime.isBefore(LocalDateTime.now());
	}

	public static boolean isInPast(OffsetDateTime dateTime) {
		return dateTime.isBefore(OffsetDateTime.now());
	}

	public static LocalDate getWeekStart(LocalDate date) {
		return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
	}

	public static LocalDateTime getWeekStart(LocalDateTime dateTime) {
		return dateTime.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
	}

	public static ZonedDateTime getWeekStart(ZonedDateTime dateTime) {
		return dateTime.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
	}

	/**
	 * Formats a {@link OffsetDateTime} to a String.
	 *
	 * @param dateTime the input (may not be null)
	 * @return the String which corresponds to the given input
	 */
	public static String formatLocalizedDateTime(OffsetDateTime dateTime) {
		return formatLocalizedDate(dateTime.toLocalDate()) + " / " + formatLocalizedTime(dateTime);
	}

	/**
	 * Formats a {@link LocalDate} to a String.
	 *
	 * @param date the input (may not be null)
	 * @return the String which corresponds to the given input
	 */
	public static String formatLocalizedDate(LocalDate date) {
		return date.format(LOCALIZED_DATE);
	}

	public static String formatLocalizedDateShort(TemporalAccessor temporal) {
		return LOCALIZED_DATE_SHORT.format(temporal);
	}

	/**
	 * Formats a {@link TemporalAccessor} to a String, containing only hours and minutes.
	 *
	 * @param temporal the input (may not be null)
	 * @return the String which corresponds to the given input
	 */
	public static String formatLocalizedTime(TemporalAccessor temporal) {
		return LOCALIZED_TIME.format(temporal);
	}

	/**
	 * Formats a {@link TemporalAccessor} to a String.
	 *
	 * @param date the input (may not be null)
	 * @return the String which corresponds to the given input. E.g. "Friday, 22.2.2222".
	 */
	public static String formatLocalizedDayAndDate(TemporalAccessor date) {
		String dateString = LOCALIZED_DAY_AND_DATE.format(date);
		return StringUtils.capitalize(dateString);
	}

	public static String formatLocalizedDayAndShortDate(TemporalAccessor date) {
		String dateString = LOCALIZED_DAY_AND_SHORT_DATE.format(date)
				// remove possible abbreviation point ("Mo., 27.09." (german) or "Lun., 27/09" (french) looks odd):
				.replaceAll("^(\\p{Alpha}+)\\., ", "$1, ");
		return StringUtils.capitalize(dateString);
	}

	/**
	 * Formats a {@link ZonedDateTime} to a String.
	 *
	 * @param dateTime the input (may not be null)
	 * @return the String which corresponds to the given input
	 */
	public static String dateToULString(ZonedDateTime dateTime) {
		return dateTime.format(DATE);
	}

	/**
	 * Parse a time string to a LocalTime
	 *
	 * @param timeString a String which contains the hour and minute only, e.g. "14:30"
	 * @return a LocalTime which represents the given time
	 */
	public static LocalTime parseTime(String timeString) {
		return LocalTime.parse(refineTime(timeString));
	}

	/**
	 * Prepare the a user-entered time string to suffice for {@link #parseTime}.
	 */
	public static String refineTime(String timeString) {
		String ret = refineHourMinute(timeString);
		// append seconds
		ret += ":00";
		return ret;
	}

	/**
	 * Prepare the a user-entered time string to represent "hours:minutes".
	 * TODO DateTimeFormatterBuilder
	 */
	public static String refineHourMinute(String timeString) {
		if (timeString == null || timeString.isEmpty()) {
			// empty means midnight
			return "00:00";
		}
		String ret = timeString.replace('.', ':');
		// cut off seconds if present
		ret = ret.replaceAll("^(\\d\\d?):(\\d\\d?):.*$", "$1:$2");
		// fix only one digit as hour
		ret = ret.replaceAll("^(\\d):", "0$1:");
		// fix only one digit as minute
		ret = ret.replaceAll(":(\\d)$", ":0$1");
		return ret;
	}

	/**
	 * Return the given number left-padded with 0 if applicable.
	 */
	public static String padToTwoDigits(int number) {
		if (number < 0) {
			throw new IllegalArgumentException("number has to be >= 0");
		} else if (number < 10) {
			return "0" + number;
		} else {
			return String.valueOf(number);
		}
	}

	public static long dateToEpoch(ZonedDateTime accessor) {
		return Instant.from(accessor).toEpochMilli();
	}

	public static boolean isDurationValid(String value) {
		String[] pieces = value.split("[:.]");
		if (pieces.length == 2) {
			try {
				Integer.parseInt(pieces[0]);
				Integer.parseInt(pieces[1]);
				return true;
			} catch (NumberFormatException e) {
				// ignore and return false
			}
		}
		return false;
	}

	public static String formatDuration(Integer duration) {
		if (duration != null) {
			return String.format(Locale.US, "%d:%02d", duration / 60, duration % 60);
		} else {
			return "0:00";
		}
	}

	public static String timestampNow() {
		return LocalDateTime.now().format(TIMESTAMP);
	}
} public class ExternalNotificationManager {

	private final Context context;
	private final SharedPreferences preferences;
	private final Vibrator vibratorService;

	/**
	 * Create the manager.
	 */
	public ExternalNotificationManager(Context context, SharedPreferences preferences) {
		this.context = context;
		this.preferences = preferences;
		vibratorService = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
	}

	/**
	 * Vibrate a specified pattern (once, do not repeat it).
	 * 
	 * @see Vibrator#vibrate(long[], int)
	 */
	public void vibrate(long[] pattern) {
		vibratorService.vibrate(pattern, -1);
	}

	public void notifyPebble(String message) {
		try {
			if (preferences.getBoolean(Key.NOTIFICATION_ON_PEBBLE.getName(), false)) {
				final Intent i = new Intent("com.getpebble.action.SEND_NOTIFICATION");

				final Map<String, String> data = new HashMap<>();
				data.put("title", "Track Work Time");
				data.put("body", message);
				final JSONObject jsonData = new JSONObject(data);
				final String notificationData = new JSONArray().put(jsonData).toString();

				i.putExtra("messageType", "PEBBLE_ALERT");
				i.putExtra("sender", "PebbleKit Android");
				i.putExtra("notificationData", notificationData);
				context.sendBroadcast(i);
			}
		} catch (Exception e) {
			Logger.warn(e, "problem while notifying via Pebble");
		}
	}
} public class FileUtil {

	public static void copy(InputStream in, OutputStream out) throws IOException {
		byte[] buffer = new byte[1024];
		int read;
		while((read = in.read(buffer)) != -1) {
			out.write(buffer, 0, read);
		}
	}
} public class PermissionsUtil {

    /**
     * @return the permissions which are not granted currently in order to enable tracking by location and/or Wi-Fi
     */
    public static Set<String> missingPermissionsForTracking(Context context) {
        Set<String> permissionsToRequest = new HashSet<>();

        List<String> locationPermissions = Arrays.asList(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION);
        for (String permission : locationPermissions) {
            if (ActivityCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) {
                permissionsToRequest.addAll(locationPermissions);
                break;
            }
        }

        // beginning with API version 30 (Android R), also asking for ACCESS_BACKGROUND_LOCATION
        // in parallel results in no permission request shown at all!
        if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
            addPermissionIfNotGranted(Manifest.permission.ACCESS_BACKGROUND_LOCATION, permissionsToRequest, context);
        }

        addPermissionIfNotGranted(Manifest.permission.CHANGE_WIFI_STATE, permissionsToRequest, context);
        addPermissionIfNotGranted(Manifest.permission.ACCESS_WIFI_STATE, permissionsToRequest, context);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            addPermissionIfNotGranted(Manifest.permission.FOREGROUND_SERVICE, permissionsToRequest, context);
        }
        return permissionsToRequest;
    }

    public static boolean isBackgroundPermissionMissing(Context context) {
        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
            && ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED;
    }

    private static void addPermissionIfNotGranted(String permission, Set<String> permissionsToRequest, Context context) {
        if (ActivityCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) {
            permissionsToRequest.add(permission);
        }
    }

    /**
     * @return the permissions which were not granted in the diven results
     * @see android.app.Activity#onRequestPermissionsResult(int, String[], int[])
     */
    public static List<String> notGrantedPermissions(String[] permissions, int[] grantResults) {
        List<String> result = new ArrayList<>();
        if (permissions == null || grantResults == null) {
            return result;
        }
        for (int i = 0; i < grantResults.length; i++) {
            if (grantResults[i] != PackageManager.PERMISSION_GRANTED) {
                result.add(permissions[i]);
            }
        }
        return result;
    }

    public static void askForLocationPermission(Context context, Runnable positiveConsequence, Runnable negativeConsequence) {
        new AlertDialog.Builder(context)
            .setTitle(context.getString(R.string.locationPermissionsRequestTitle))
            .setMessage(context.getString(R.string.locationPermissionsRequestText)
                + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
                ? context.getString(R.string.locationPermissionsRequestTextSupplementForAPI29)
                : "")
                + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
                ? String.format(context.getString(R.string.locationPermissionsRequestTextSupplementForAPI30),
                    context.getPackageManager().getBackgroundPermissionOptionLabel())
                : ""))
            .setNegativeButton(android.R.string.cancel, (dialog, which) -> {
                Basics.getOrCreateInstance(context).disableLocationBasedTracking();
                Basics.getOrCreateInstance(context).disableWifiBasedTracking();
                negativeConsequence.run();
            })
            .setPositiveButton(android.R.string.ok, (dialog, which) -> positiveConsequence.run())
            .create()
            .show();
    }

    public static void askForDocumentTreePermission(Context context,
                                                    int textResourceId,
                                                    Runnable positiveConsequence,
                                                    Runnable negativeConsequence) {
        new AlertDialog.Builder(context)
            .setTitle(context.getString(R.string.documentTreePermissionsRequestTitle))
            .setMessage(context.getString(textResourceId))
            .setPositiveButton(android.R.string.ok, (dialog, which) -> positiveConsequence.run())
            .setNegativeButton(R.string.notNow, (dialog, which) -> negativeConsequence.run())
            .create()
            .show();
    }

    public void openSystemSettingsForApp(Context context) {
        Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        Uri uri = Uri.fromParts("package", context.getPackageName(), null);
        intent.setData(uri);
        context.startActivity(intent);
    }

} public class PreferencesUtil {

	private static final String PREF_FIELD_SEPARATOR = ";";
	private static final String PREF_TYPE_STRING = "string";
	private static final String PREF_TYPE_STRINGSET = "stringset";
	private static final String PREF_TYPE_BOOLEAN = "boolean";
	private static final String PREF_TYPE_INTEGER = "integer";
	private static final String PREF_TYPE_FLOAT = "float";
	private static final String PREF_TYPE_LONG = "long";
	private static final String NEWLINE = "\n";

	/**
	 * Execute all checks for the options and disable any sections that contain illegal options or option combinations.
	 *
	 * @return the number of sections that were disabled because of failed checks
	 */
	public static int checkAllPreferenceSections() {
		SharedPreferences preferences = Basics.getInstance().getPreferences();
		int disabledSections = 0;
		for (String key : preferences.getAll().keySet()) {
			Key sectionToDisable = PreferencesUtil.check(preferences, key);
			if (PreferencesUtil.getBooleanPreference(preferences, sectionToDisable)) {
				Logger.warn("option {} is invalid => disabling option {}", key, sectionToDisable.getName());
				disabledSections++;

				// deactivate the section
				PreferencesUtil.disablePreference(preferences, sectionToDisable);
			}
		}

		return disabledSections;
	}

	/**
	 * Set a boolean preference to false. Used primarily to disable a section.
	 */
	@SuppressLint("ApplySharedPref")
	public static void disablePreference(SharedPreferences sharedPreferences, Key toDisable) {
		SharedPreferences.Editor editor = sharedPreferences.edit();
		editor.putBoolean(toDisable.getName(), false);
		editor.commit();
	}

	/**
	 * Get the value of the indicated preference. Defaults to false if the preference does not exist.
	 */
	public static boolean getBooleanPreference(SharedPreferences sharedPreferences, Key key) {
		return key != null && sharedPreferences != null && sharedPreferences.getBoolean(key.getName(), false);
	}

	/**
	 * Check a preference value in combination with the other values.
	 *
	 * @param sharedPreferences
	 *            where to get all preferences from
	 * @param keyName
	 *            which one to check
	 * @return the {@link Key} belonging to the illegal section (this boolean preference should be disabled
	 *         subsequently) or {@code null} if all is OK";"
	 */
	public static Key check(SharedPreferences sharedPreferences, String keyName) {
		Key key = Key.getKeyWithName(keyName);
		if (key != null) {
			return check(sharedPreferences, key);
		} else {
			return null;
		}
	}

	/**
	 * Check a preference value in combination with the other values.
	 *
	 * @param sharedPreferences
	 *            where to get all preferences from
	 * @param key
	 *            which one to check
	 * @return the {@link Key} belonging to the illegal section (this boolean preference should be disabled
	 *         subsequently) or {@code null} if all is OK
	 */
	public static Key check(SharedPreferences sharedPreferences, Key key) {
		// only two levels planned - either a pref is a parent or a child, it cannot be both!
		boolean isParent = (key.getParent() == null);

		Key keyToDisableIfInvalid;
		boolean isValid = true;
		if (isParent) {
			keyToDisableIfInvalid = key;
			Set<Key> keysToCheck = Key.getChildKeys(key);
			for (Key keyToCheck : keysToCheck) {
				isValid = keyToCheck.getDataType().validateFromSharedPreferences(sharedPreferences,
					keyToCheck.getName())
					&& Checks.executeFor(keyToCheck, sharedPreferences);
				if (!isValid) {
					break;
				}
			}
		} else {
			keyToDisableIfInvalid = key.getParent();
			isValid = key.getDataType().validateFromSharedPreferences(sharedPreferences, key.getName())
				&& Checks.executeFor(key, sharedPreferences);
		}

		return isValid ? null : keyToDisableIfInvalid;
	}

	/**
	 * writes the current preferences including their values to the given writer
	 * @see #readPreferences(SharedPreferences, BufferedReader)
	 */
	@SuppressWarnings({"unchecked"})
	public static void writePreferences(SharedPreferences preferences, BufferedWriter output) throws IOException {
		for (Map.Entry<String, ?> entry : preferences.getAll().entrySet()) {
			if (entry.getValue() instanceof String) {
				output.append(entry.getKey()).append(PREF_FIELD_SEPARATOR)
					.append(PREF_TYPE_STRING).append(PREF_FIELD_SEPARATOR)
					.append((String)entry.getValue()).append(NEWLINE);
			} else if (entry.getValue() instanceof Set) {
				Set<String> value = (Set<String>) entry.getValue();
				String separator = findSeparator(value);
				output.append(entry.getKey()).append(PREF_FIELD_SEPARATOR)
					.append(PREF_TYPE_STRINGSET).append(PREF_FIELD_SEPARATOR)
					.append(separator).append(PREF_FIELD_SEPARATOR);
				boolean isFirst = true;
				for (String v : value) {
					if (isFirst) {
						isFirst = false;
					} else {
						output.append(separator);
					}
					output.append(v);
				}
				output.append(NEWLINE);
			} else if (entry.getValue() instanceof Boolean) {
				output.append(entry.getKey()).append(PREF_FIELD_SEPARATOR)
					.append(PREF_TYPE_BOOLEAN).append(PREF_FIELD_SEPARATOR)
					.append(((Boolean)entry.getValue()).toString()).append(NEWLINE);
			} else if (entry.getValue() instanceof Integer) {
				output.append(entry.getKey()).append(PREF_FIELD_SEPARATOR)
					.append(PREF_TYPE_INTEGER).append(PREF_FIELD_SEPARATOR)
					.append(((Integer)entry.getValue()).toString()).append(NEWLINE);
			} else if (entry.getValue() instanceof Float) {
				output.append(entry.getKey()).append(PREF_FIELD_SEPARATOR)
					.append(PREF_TYPE_FLOAT).append(PREF_FIELD_SEPARATOR)
					.append(((Float)entry.getValue()).toString()).append(NEWLINE);
			} else if (entry.getValue() instanceof Long) {
				output.append(entry.getKey()).append(PREF_FIELD_SEPARATOR)
					.append(PREF_TYPE_LONG).append(PREF_FIELD_SEPARATOR)
					.append(((Long)entry.getValue()).toString()).append(NEWLINE);
			} else {
				throw new IllegalStateException("unknown preference type: " + entry.getValue());
			}
		}
	}

	private static String findSeparator(Set<String> set) {
		for (String possibleSeparator : new String[] {",", "|", "!", "$", "%", "#", "~", "+", ":",
			"!§&%$", "&§%$}!,.-#+"}) {
			if (!anyContains(set, possibleSeparator)) {
				return possibleSeparator;
			}
		}
		throw new IllegalStateException("could not find a separator");
	}

	private static boolean anyContains(Set<String> set, String testedSeparator) {
		for (String str : set) {
			if (str.contains(testedSeparator)) {
				return true;
			}
		}
		return false;
	}

    /**
     * reads the keys and values from the given reader and saves them as preferences
     * @see #writePreferences(SharedPreferences, BufferedWriter)
     */
	@SuppressLint("ApplySharedPref")
	public static void readPreferences(SharedPreferences preferences, BufferedReader input) throws IOException {
	    String line;
		SharedPreferences.Editor editor = preferences.edit();
		// remove all entries
		editor.clear();
		// now add the entries from the reader - there is no try/catch
		// around the put method calls intentionally so either all or none are changed
		while ((line = input.readLine()) != null) {
			String[] parts = line.split(PREF_FIELD_SEPARATOR, 3);
			switch (parts[1]) {
				case PREF_TYPE_STRING:
					editor.putString(parts[0], parts[2]);
					break;
				case PREF_TYPE_STRINGSET:
					String[] separatorAndValues = parts[2].split(PREF_FIELD_SEPARATOR, 2);
					String[] values = separatorAndValues[1].split(separatorAndValues[0]);
					Set<String> valueSet = new HashSet<>(Arrays.asList(values));
					editor.putStringSet(parts[0], valueSet);
					break;
				case PREF_TYPE_BOOLEAN:
					editor.putBoolean(parts[0], Boolean.parseBoolean(parts[2]));
					break;
				case PREF_TYPE_INTEGER:
					editor.putInt(parts[0], Integer.parseInt(parts[2]));
					break;
				case PREF_TYPE_FLOAT:
					editor.putFloat(parts[0], Float.parseFloat(parts[2]));
					break;
				case PREF_TYPE_LONG:
					editor.putLong(parts[0], Long.parseLong(parts[2]));
					break;
				default:
					throw new IllegalStateException("unknown preference type " + parts[1]
						+ " for preference " + parts[0]);
			}
        }
		// if no exception until here, make changes permanent
		editor.commit();
	}
} public class WeekAdapter extends RecyclerView.Adapter<WeekTimesViewHolder> {
	
	private final WeekStateLoaderManager weekStateLoaderManager;
	private final LayoutParams LAYOUT_PARAMS = new LayoutParams(MATCH_PARENT, MATCH_PARENT);
	private final OnDayClickListener onDayClickListener;
	private final OnClickListener onClickListener;

	public WeekAdapter(@NonNull WeekStateLoaderManager weekStateLoaderManager,
			@Nullable OnDayClickListener onDayClickListener,
			@Nullable OnClickListener onClickListener) {
		this.weekStateLoaderManager = weekStateLoaderManager;
		this.onDayClickListener = onDayClickListener;
		this.onClickListener = onClickListener;
		setHasStableIds(true);
	}

	@NonNull @Override
	public WeekTimesViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
		Context context = parent.getContext();
		WeekTimesView weekTimesView = createView(context);
		return new WeekTimesViewHolder(weekTimesView);
	}

	private WeekTimesView createView(Context context) {
		WeekTimesView weekTimesView = new WeekTimesView(context);
		weekTimesView.setLayoutParams(LAYOUT_PARAMS);
		weekTimesView.setOnDayClickListener(onDayClickListener);		
		weekTimesView.setOnClickListener(onClickListener);
		return weekTimesView;
	}

	@Override
	public void onBindViewHolder(@NonNull WeekTimesViewHolder holder, int position) {
		Week week = WeekIndexConverter.getWeekForIndex(position);
		int requestId = position;
		// Cancel request before starting new one. It's possible same week is still being loaded,
		// but holder hasn't been recycled yet.
		weekStateLoaderManager.cancelRequest(requestId);
		LiveData<WeekState> weekState = weekStateLoaderManager.requestWeekState(week, requestId);
		holder.bind(weekState);
	}

	@Override
	public void onViewRecycled(@NonNull WeekTimesViewHolder holder) {
		super.onViewRecycled(holder);
		int position = holder.getAdapterPosition();
		if(position != NO_POSITION) {
			weekStateLoaderManager.cancelRequest(position);
		}
		holder.recycle();
	}

	@Override
	public int getItemCount() {
		return Integer.MAX_VALUE;
	}

	@Override
	public long getItemId(int position) {
		return position;
	}
} public class WeekIndexConverter {
	private static final LocalDate epochDate = LocalDate.ofEpochDay(0);

	public static @NonNull Week getWeekForIndex(@IntRange(from=0) int weekIndex) {
		if(weekIndex < 0) {
			throw new IllegalArgumentException("Week index should be positive");
		}
		
		return new Week(epochDate.plusWeeks(weekIndex));
	}

	public static @IntRange(from=0) int getIndexForDate(LocalDate date) {
		return (int) ChronoUnit.WEEKS.between(epochDate, date.with(DayOfWeek.MONDAY)) + 1;
	}

	public static @NonNull Week getWeekForDate(LocalDate date) {
		return new Week(date);
	}
} public class WeekStateCalculatorFactory {

	private final @NonNull Context context;
	private final @NonNull DAO dao;
	private final @NonNull TimerManager timerManager;
	private final @NonNull SharedPreferences preferences;

	public WeekStateCalculatorFactory(@NonNull Context context, @NonNull DAO dao,
			@NonNull TimerManager timerManager, @NonNull SharedPreferences preferences) {
		this.context = context;
		this.dao = dao;
		this.timerManager = timerManager;
		this.preferences = preferences;
	}

	public @NonNull WeekStateCalculator createForWeek(@NonNull Week week) {
		return new WeekStateCalculator(context, dao, timerManager, preferences, week);
	}

} public class WeekStateLoaderFactory {

	private final WeekStateCalculatorFactory weekStateCalculatorFactory;

	public WeekStateLoaderFactory(@NonNull WeekStateCalculatorFactory weekStateCalculatorFactory) {
		this.weekStateCalculatorFactory = weekStateCalculatorFactory;
	}

	public @NonNull WeekStateLoader create(@NonNull Week week,
			@NonNull Consumer<WeekState> onLoadedCallback) {
		WeekStateCalculator weekStateCalculator = weekStateCalculatorFactory.createForWeek(week);
		return new WeekStateLoader(weekStateCalculator, onLoadedCallback);
	}

} public class WeekStateLoaderManager {

	private static final Executor threadPool = AsyncTask.THREAD_POOL_EXECUTOR;

	private final SparseArray<WeekStateLoader> weekStateLoaders = new SparseArray<>();
	private final WeekStateLoaderFactory weekStateLoaderFactory;

	public WeekStateLoaderManager(@NonNull WeekStateLoaderFactory weekStateLoaderFactory) {
		this.weekStateLoaderFactory = weekStateLoaderFactory;
	}

	/**
	 * Request {@link WeekState}, that will be calculated async.
	 * @param week week to calculate {@link WeekState} for
	 * @param requestId request identifier, to identify specific async loader
	 * @return data reference, that will be updated, once {@link WeekState} is ready
	 */
	public @NonNull LiveData<WeekState> requestWeekState(@NonNull Week week, int requestId) {
		MutableLiveData<WeekState> weekStateLiveData = new MutableLiveData<>();
		WeekStateLoader loader = createLoader(week, requestId, weekStateLiveData);
		registerLoader(loader, requestId);
		executeRequest(requestId);
		return weekStateLiveData;
	}

	private WeekStateLoader createLoader(Week week, int requestId,
			MutableLiveData<WeekState> weekStateData) {
		return weekStateLoaderFactory.create(week, weekState -> {
			weekStateData.postValue(weekState);
			cancelRequest(requestId);
		});
	}

	private void registerLoader(@NonNull WeekStateLoader weekStateLoader, int requestId) {
		checkRequestId(requestId);
		weekStateLoaders.put(requestId, weekStateLoader);
	}

	private void checkRequestId(int requestId) {
		if(weekStateLoaders.get(requestId) != null) {
			throw new RuntimeException("Duplicate request id: " + requestId);
		}
	}

	private void executeRequest(int requestId) {
		WeekStateLoader loader = getLoader(requestId);
		loader.executeOnExecutor(threadPool);
	}

	/**
	 * Cancel specific loading request
	 * @param requestId request identifier, to identify async loader, that will be stopped
	 */
	public void cancelRequest(int requestId) {
		WeekStateLoader loader = getLoader(requestId);
		if(loader != null) {
			loader.cancel(true);
			removeLoader(requestId);
		}
	}

	private WeekStateLoader getLoader(int requestId) {
		return weekStateLoaders.get(requestId);
	}

	private void removeLoader(int requestId) {
		weekStateLoaders.remove(requestId);
	}

} public class WeekTimesView extends LinearLayout {
	
	private WeekState weekState;
	
	private TableLayout weekTable = null;
	private WeekTableBinding binding;

	public WeekTimesView(@NonNull Context context) {
		super(context);
		startLayoutLoading();
	}

	private void startLayoutLoading() {
		new AsyncLayoutInflater(getContext()).inflate(R.layout.week_table,this, ((view, resid, parent) -> {
			binding = WeekTableBinding.bind(view);
			parent.addView(binding.getRoot());
			onViewReady();
		}));
	}

	private void onViewReady() {
		weekTable = binding.weekTable;
		
		if(isDataSet()) {
			loadWeekState();
		}
	}

	/** Interface for callback when clicking on day **/
	public interface OnDayClickListener {
		void onClick(View v, DayOfWeek day);
	}
	private OnDayClickListener onDayClickListener;
	public void setOnDayClickListener(OnDayClickListener onDayClickListener) {
		this.onDayClickListener = onDayClickListener;
	}
	

	private boolean isDataSet() {
		return weekState != null;
	}

	public void clearWeekState() {
		setWeekState(new WeekState());
	}

	public void setWeekState(@NonNull WeekState weekState) {
		this.weekState = weekState;
		if(isViewReady()) {
			loadWeekState();
		}
	}

	private boolean isViewReady() {
		return weekTable != null;
	}

	private void loadWeekState() {
		if(!isDataSet()) {
			Logger.warn("Loading weekState when data was not set");
			return;
		}
		
		binding.topLeftCorner.setText(weekState.topLeftCorner);

		for (DayOfWeek day : DayOfWeek.values()) {
			DayRowState currentWeekRow = weekState.getRowForDay(day);

			TableRow tableRow = (TableRow) weekTable.getChildAt(day.getValue());
			setWeekRow(currentWeekRow, tableRow, day);
			setRowHighlighting(currentWeekRow, tableRow, day.getValue() % 2 == 1);
		}
		
		setSummaryRow(weekState.totals, (TableRow) weekTable.getChildAt(8));
		
		binding.targetLabel.setOnClickListener(v -> {
			if (onDayClickListener != null) {
				onDayClickListener.onClick(v, null);
			}
		});
	}
	
	private void setWeekRow(DayRowState dayRowState, TableRow tableRow, DayOfWeek day) {
		TextView textView;
		
		textView = getTableCell(tableRow, 0);
		textView.setText(dayRowState.label);
		setColorAccording(dayRowState.labelHighlighted, textView);
		textView.setOnClickListener(v -> {
			if (onDayClickListener != null) {
				onDayClickListener.onClick(v, day);
			}
		});
		
		getTableCell(tableRow, 1).setText(dayRowState.in);
		getTableCell(tableRow, 2).setText(dayRowState.out);
		
		textView = getTableCell(tableRow, 3);
		textView.setText(dayRowState.worked);

		getTableCell(tableRow, 4).setText(dayRowState.flexi);
	}

	private void setColorAccording(WeekState.HighlightType type, TextView textView) {
		switch (type) {
			case NONE:
				textView.setTextColor(getResources().getColor(R.color.date_regular_work));
				break;
			case REGULAR_FREE:
				textView.setTextColor(getResources().getColor(R.color.date_regular_free));
				break;
			case FREE:
				textView.setTextColor(getResources().getColor(R.color.date_free));
				break;
			case CHANGED_TARGET_TIME:
				textView.setTextColor(getResources().getColor(R.color.date_target_changed));
				break;
			default:
				throw new IllegalStateException("unknown highlight type " + type);
		}
	}
	
	private void setSummaryRow(SummaryRowState summaryRowState, TableRow tableRow) {
		getTableCell(tableRow, 0).setText(summaryRowState.label);
		getTableCell(tableRow, 1).setText(summaryRowState.worked);
		getTableCell(tableRow, 2).setText(summaryRowState.flexi);
	}

	private void setRowHighlighting(DayRowState dayRowState, TableRow tableRow,  boolean isLight) {
		int unhighlightedDrawable = isLight ? R.drawable.table_row : 0;
		
		tableRow.setBackgroundResource(dayRowState.highlighted
				? R.drawable.table_row_highlighting : unhighlightedDrawable);
	}	
	
	private TextView getTableCell(TableRow tableRow, int index) {
		return (TextView) tableRow.getChildAt(index);
	}
}
import static org.zephyrsoft.trackworktime.DocumentTreeStorage.exists;
import static java.lang.Math.abs;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.PersistableBundle;
import android.preference.PreferenceManager;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.FileProvider;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.navigation.NavigationView;
import org.pmw.tinylog.Logger;
import org.threeten.bp.DayOfWeek;
import org.threeten.bp.LocalDate;
import org.zephyrsoft.trackworktime.backup.BackupFileInfo;
import org.zephyrsoft.trackworktime.database.DAO;
import org.zephyrsoft.trackworktime.databinding.ActivityMainBinding;
import org.zephyrsoft.trackworktime.model.Task;
import org.zephyrsoft.trackworktime.model.Week;
import org.zephyrsoft.trackworktime.options.Key;
import org.zephyrsoft.trackworktime.timer.TimerManager;
import org.zephyrsoft.trackworktime.util.BackupUtil;
import org.zephyrsoft.trackworktime.util.DateTimeUtil;
import org.zephyrsoft.trackworktime.util.ExternalNotificationManager;
import org.zephyrsoft.trackworktime.util.FileUtil;
import org.zephyrsoft.trackworktime.util.PermissionsUtil;
import org.zephyrsoft.trackworktime.util.PreferencesUtil;
import org.zephyrsoft.trackworktime.weektimes.WeekAdapter;
import org.zephyrsoft.trackworktime.weektimes.WeekIndexConverter;
import org.zephyrsoft.trackworktime.weektimes.WeekStateCalculatorFactory;
import org.zephyrsoft.trackworktime.weektimes.WeekStateLoaderFactory;
import org.zephyrsoft.trackworktime.weektimes.WeekStateLoaderManager;
import org.zephyrsoft.trackworktime.weektimes.WeekTimesView;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Set;
		// make entry visible
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
			binding.navView.getMenu().findItem(R.id.nav_ignore_battery_optimizations).setVisible(true);
		}

		ActionBar actionBar = getSupportActionBar();
		if (actionBar != null) {
			actionBar.setDisplayHomeAsUpEnabled(true);
			actionBar.setHomeButtonEnabled(true);
		}

		binding.navView.setNavigationItemSelectedListener(this);

		// bind click listener for navigation buttons (may not be visible)
		binding.main.today.setOnClickListener(v -> recenterWeek());
		binding.main.previous.setOnClickListener(v -> changeDisplayedWeek(-1));
		binding.main.next.setOnClickListener(v -> changeDisplayedWeek(1));


		initWeekPager(savedInstanceState);

		Button clockInButton = binding.main.clockInButton;
		clockInButton.setOnClickListener(v -> clockInAction(0));
		clockInButton.setOnLongClickListener(v -> {
            Intent i = new Intent(getApplicationContext(), TimeAheadActivity.class);
            String typeString;
            if (timerManager.isTracking()) {
                typeString = getString(R.string.clockInChange);
            } else {
                typeString = getString(R.string.clockIn);
            }
            i.putExtra(Constants.TYPE_EXTRA_KEY, 0);
            i.putExtra(Constants.TYPE_STRING_EXTRA_KEY, typeString);
            startActivity(i);
            return true;
        });

		Button clockOutButton = binding.main.clockOutButton;
		clockOutButton.setOnClickListener(v -> clockOutAction(0));
		clockOutButton.setOnLongClickListener(v -> {
            Intent i = new Intent(getApplicationContext(), TimeAheadActivity.class);
            String typeString;
            if (timerManager.isTracking()) {
                typeString = getString(R.string.clockOut);
                i.putExtra(Constants.TYPE_EXTRA_KEY, 1);
                i.putExtra(Constants.TYPE_STRING_EXTRA_KEY, typeString);
                startActivity(i);
            }
            return true;
        });

		// delegate the rest of the work to onResume()
		reloadTasksOnResume = true;

		// check options for logical errors
		checkAllOptions();

		boolean playServicesAvailable = false;
		try {
			PackageInfo info = getPackageManager().getPackageInfo("com.google.android.gms", 0);
			if (info != null) {
				playServicesAvailable = true;
			}
		} catch (Exception e) {
			// do nothing
		}
		if (playServicesAvailable && !preferences.getBoolean(getString(R.string.keyBackupSettingAsked), false)) {
			@SuppressLint("ApplySharedPref") DialogInterface.OnClickListener dialogClickListener = (dialog, which) -> {
                final Editor editor = preferences.edit();
                switch (which) {
                    case DialogInterface.BUTTON_POSITIVE:
                        editor.putBoolean(getText(R.string.keyBackupEnabled) + "", true);
                        break;

                    case DialogInterface.BUTTON_NEGATIVE:
                        editor.putBoolean(getText(R.string.keyBackupEnabled) + "", false);
                        break;
                }
                editor.putBoolean(getString(R.string.keyBackupSettingAsked), true);
                editor.commit();
            };
			AlertDialog.Builder builder = new AlertDialog.Builder(this);
			builder.setMessage(R.string.backup_on_google_servers)
				.setPositiveButton(R.string.yes, dialogClickListener)
				.setNegativeButton(R.string.no, dialogClickListener).show();
		}
	}

	private void initWeekPager(@Nullable Bundle state) {
		ViewPager2 weekPager = binding.main.week;

		weekPager.setOffscreenPageLimit(1);
		initWeekPagerAdapter();
		initWeekPagerAnimation();
		initWeekPagerPosition(state);

		weekPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
			@Override
			public void onPageSelected(int selectedWeekIndex) {
				super.onPageSelected(selectedWeekIndex);
				refreshRecenterMenuItem();
			}
		});
	}

	private void initWeekPagerAdapter() {
		WeekStateLoaderManager weekStateLoaderManager = createWeekLoaderManger();
		View.OnClickListener weekClickListener = v -> showEventList();
		WeekTimesView.OnDayClickListener dayClickListener = (v, day) -> setTarget(day);

		weekAdapter = new WeekAdapter(weekStateLoaderManager, dayClickListener, weekClickListener);
		binding.main.week.setAdapter(weekAdapter);
	}

	private WeekStateLoaderManager createWeekLoaderManger() {
		WeekStateLoaderFactory weekStateLoaderFactory = createWeekLoaderFactory();
		return new WeekStateLoaderManager(weekStateLoaderFactory);
	}

	private WeekStateLoaderFactory createWeekLoaderFactory() {
		WeekStateCalculatorFactory weekStateCalculatorFactory = new WeekStateCalculatorFactory(