/* Copyright (C) 2022-2024 José Rebelo This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.util.calendar; import android.content.ContentUris; import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; import android.net.Uri; import android.provider.CalendarContract; import android.provider.CalendarContract.Instances; import android.provider.ContactsContract; import android.text.format.Time; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Calendar; import java.util.Comparator; import java.util.Date; import java.util.GregorianCalendar; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Set; import java.util.TimeZone; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public class CalendarManager { private static final Logger LOG = LoggerFactory.getLogger(CalendarManager.class); // needed for pebble: time, duration, layout, reminders, actions // layout: type, title, subtitle, body (max 512), tinyIcon, smallIcon, largeIcon //further: primaryColor, secondaryColor, backgroundColor, headings, paragraphs, lastUpdated // taken from: https://developer.getpebble.com/guides/timeline/pin-structure/ // needed for MiBand: // time private static final String[] EVENT_INSTANCE_PROJECTION = new String[]{ Instances._ID, Instances.BEGIN, Instances.END, Instances.DURATION, Instances.TITLE, Instances.DESCRIPTION, Instances.EVENT_LOCATION, Instances.ORGANIZER, Instances.CALENDAR_DISPLAY_NAME, CalendarContract.Calendars.ACCOUNT_NAME, Instances.CALENDAR_COLOR, Instances.ALL_DAY, Instances.EVENT_ID, //needed for reminders CalendarContract.Calendars.ACCOUNT_TYPE, Instances.CALENDAR_ID, Instances.RRULE }; private final String deviceAddress; private final Context mContext; public CalendarManager(final Context context, final String deviceAddress) { this.mContext = context; this.deviceAddress = deviceAddress; loadCalendarsBlackList(); } public List getCalendarEventList() { loadCalendarsBlackList(); final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress); final Prefs prefs = new Prefs(sharedPrefs); int lookaheadDays = Math.max(1, prefs.getInt("calendar_lookahead_days", 7)); final List calendarEventList = getCalendarEvents(lookaheadDays); if (sharedPrefs.getBoolean("sync_birthdays", false)) { calendarEventList.addAll(getBirthdays(lookaheadDays)); calendarEventList.sort(Comparator.comparingInt(CalendarEvent::getBeginSeconds)); } return calendarEventList; } public List getCalendarEvents(final int lookaheadDays) { final List calendarEventList = new ArrayList<>(); Calendar cal = GregorianCalendar.getInstance(); long dtStart = cal.getTimeInMillis(); cal.add(Calendar.DATE, lookaheadDays); long dtEnd = cal.getTimeInMillis(); Uri.Builder eventsUriBuilder = Instances.CONTENT_URI.buildUpon(); ContentUris.appendId(eventsUriBuilder, dtStart); ContentUris.appendId(eventsUriBuilder, dtEnd); Uri eventsUri = eventsUriBuilder.build(); try (Cursor evtCursor = mContext.getContentResolver().query(eventsUri, EVENT_INSTANCE_PROJECTION, null, null, Instances.BEGIN + " ASC")) { if (evtCursor == null || evtCursor.getCount() == 0) { return calendarEventList; } while (evtCursor.moveToNext()) { long start = evtCursor.getLong(evtCursor.getColumnIndexOrThrow(Instances.BEGIN)); long end = evtCursor.getLong(evtCursor.getColumnIndexOrThrow(Instances.END)); if (end == 0) { LOG.info("no end time, will parse duration string"); Time time = new Time(); //FIXME: deprecated FTW time.parse(evtCursor.getString(evtCursor.getColumnIndexOrThrow(Instances.DURATION))); end = start + time.toMillis(false); } CalendarEvent calEvent = new CalendarEvent( start, end, evtCursor.getLong(evtCursor.getColumnIndexOrThrow(Instances._ID)), evtCursor.getString(evtCursor.getColumnIndexOrThrow(Instances.TITLE)), evtCursor.getString(evtCursor.getColumnIndexOrThrow(Instances.DESCRIPTION)), evtCursor.getString(evtCursor.getColumnIndexOrThrow(Instances.EVENT_LOCATION)), evtCursor.getString(evtCursor.getColumnIndexOrThrow(Instances.CALENDAR_DISPLAY_NAME)), evtCursor.getString(evtCursor.getColumnIndexOrThrow(CalendarContract.Calendars.ACCOUNT_NAME)), evtCursor.getInt(evtCursor.getColumnIndexOrThrow(Instances.CALENDAR_COLOR)), !evtCursor.getString(evtCursor.getColumnIndexOrThrow(Instances.ALL_DAY)).equals("0"), evtCursor.getString(evtCursor.getColumnIndexOrThrow(Instances.ORGANIZER)), evtCursor.getString(evtCursor.getColumnIndexOrThrow(CalendarContract.Calendars.ACCOUNT_TYPE)), evtCursor.getString(evtCursor.getColumnIndexOrThrow(Instances.CALENDAR_ID)), evtCursor.getString(evtCursor.getColumnIndexOrThrow(Instances.RRULE)) ); // Query reminders for this event final Cursor reminderCursor = mContext.getContentResolver().query( CalendarContract.Reminders.CONTENT_URI, null, CalendarContract.Reminders.EVENT_ID + " = ?", new String[]{String.valueOf(evtCursor.getLong(evtCursor.getColumnIndexOrThrow(Instances.EVENT_ID)))}, null ); if (reminderCursor != null && reminderCursor.getCount() > 0) { final List reminders = new ArrayList<>(); while (reminderCursor.moveToNext()) { int minutes = reminderCursor.getInt(reminderCursor.getColumnIndexOrThrow(CalendarContract.Reminders.MINUTES)); int method = reminderCursor.getInt(reminderCursor.getColumnIndexOrThrow(CalendarContract.Reminders.METHOD)); LOG.debug("Reminder Method: {}, Minutes: {}", method, minutes); if (method == 1) //METHOD_ALERT reminders.add(calEvent.getBegin() - minutes * 60 * 1000L); } reminderCursor.close(); calEvent.setRemindersAbsoluteTs(reminders); } if (!calendarIsBlacklisted(calEvent.getUniqueCalName())) { calendarEventList.add(calEvent); } else { LOG.debug("calendar {} skipped because it's blacklisted", calEvent.getUniqueCalName()); } } return calendarEventList; } catch (final Exception e) { LOG.error("could not query calendar, permission denied?", e); return calendarEventList; } } public List getBirthdays(final int lookaheadDays) { final String[] projection = new String[]{ ContactsContract.CommonDataKinds.Event.CONTACT_ID, ContactsContract.CommonDataKinds.Event.START_DATE, ContactsContract.CommonDataKinds.Event.DISPLAY_NAME }; final String selection = ContactsContract.Data.MIMETYPE + " = ? AND " + ContactsContract.CommonDataKinds.Event.TYPE + " = ?"; final String[] selectionArgs = new String[]{ ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE, String.valueOf(ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY) }; final List birthdays = new LinkedList<>(); final LocalDate maxDate = LocalDate.now().plusDays(lookaheadDays); try (Cursor birthdayCursor = mContext.getContentResolver().query(ContactsContract.Data.CONTENT_URI, projection, selection, selectionArgs, ContactsContract.CommonDataKinds.Event.START_DATE + " ASC")) { if (birthdayCursor == null || birthdayCursor.getCount() == 0) { return birthdays; } while (birthdayCursor.moveToNext()) { final String contactId = birthdayCursor.getString(birthdayCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Event.CONTACT_ID)); final String birthdayStr = birthdayCursor.getString(birthdayCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Event.START_DATE)); final String displayName = birthdayCursor.getString(birthdayCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Event.DISPLAY_NAME)); final LocalDate birthday = parseBirthday(birthdayStr); if (birthday == null || birthday.isAfter(maxDate)) { continue; } // Follow the same logic as CalendarContract - all day events have the start // timestamp at the UTC midnight boundary final long startTimestampUtc = DateTimeUtils.dayStartUtc(birthday).getTime(); birthdays.add(new CalendarEvent( startTimestampUtc, startTimestampUtc + 86400000L - 1L, contactId.hashCode(), mContext.getString(R.string.contact_birthday, displayName), null, null, mContext.getString(R.string.birthdays), mContext.getString(R.string.pref_contacts_title), 0, true, null, CalendarContract.ACCOUNT_TYPE_LOCAL, null, null )); } } catch (final Exception e) { LOG.error("could not query birthdays, permission denied?", e); } return birthdays; } private LocalDate parseBirthday(final String birthdayStr) { final LocalDate birthday; final LocalDate now = LocalDate.now(); try { if (birthdayStr.startsWith("--")) { // MM-DD final String monthDay = birthdayStr.substring(2); birthday = LocalDate.parse(now.getYear() + "-" + monthDay, DateTimeFormatter.ofPattern("yyyy-MM-dd")); } else { birthday = LocalDate.parse(birthdayStr, DateTimeFormatter.ISO_LOCAL_DATE).withYear(now.getYear()); } } catch (final DateTimeParseException e) { LOG.error("Failed to parse birthday {}", birthdayStr, e); return null; } if (birthday.isAfter(now) || birthday.isEqual(now)) { return birthday; } return birthday.plusYears(1); } private static HashSet calendars_blacklist = null; public boolean calendarIsBlacklisted(String calendarUniqueName) { if (calendars_blacklist == null) { LOG.warn("calendarIsBlacklisted: calendars_blacklist is null!"); } return calendars_blacklist != null && calendars_blacklist.contains(calendarUniqueName); } public void setCalendarsBlackList(Set calendarNames) { if (calendarNames == null) { LOG.info("Set null apps_notification_blacklist"); calendars_blacklist = new HashSet<>(); } else { calendars_blacklist = new HashSet<>(calendarNames); } LOG.info("New calendars_blacklist has {} entries", calendars_blacklist.size()); saveCalendarsBlackList(); } public void addCalendarToBlacklist(String calendarUniqueName) { if (calendars_blacklist.add(calendarUniqueName)) { LOG.info("Blacklisted calendar {}", calendarUniqueName); saveCalendarsBlackList(); } else { LOG.warn("Calendar {} already blacklisted!", calendarUniqueName); } } public void removeFromCalendarBlacklist(String calendarUniqueName) { calendars_blacklist.remove(calendarUniqueName); LOG.info("Unblacklisted calendar {}", calendarUniqueName); saveCalendarsBlackList(); } private void loadCalendarsBlackList() { SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress); LOG.info("Loading calendars_blacklist"); calendars_blacklist = (HashSet) sharedPrefs.getStringSet(GBPrefs.CALENDAR_BLACKLIST, null); if (calendars_blacklist == null) { calendars_blacklist = new HashSet<>(); } LOG.info("Loaded calendars_blacklist has {} entries", calendars_blacklist.size()); } private void saveCalendarsBlackList() { final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress); LOG.info("Saving calendars_blacklist with {} entries", calendars_blacklist.size()); SharedPreferences.Editor editor = sharedPrefs.edit(); if (calendars_blacklist.isEmpty()) { editor.putStringSet(GBPrefs.CALENDAR_BLACKLIST, null); } else { Prefs.putStringSet(editor, GBPrefs.CALENDAR_BLACKLIST, calendars_blacklist); } editor.apply(); } }