/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
Gobbetti, Daniel Hauck, Gabriele Monaco, José Rebelo, Petr Vaněk
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.externalevents;
import static androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Handler;
import android.provider.CalendarContract;
import android.widget.Toast;
import androidx.core.content.ContextCompat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.concurrent.TimeUnit;
import java.util.Hashtable;
import java.util.List;
import de.greenrobot.dao.query.QueryBuilder;
import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.entities.CalendarSyncState;
import nodomain.freeyourgadget.gadgetbridge.entities.CalendarSyncStateDao;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarEvent;
import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarManager;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class CalendarReceiver extends ContentObserver {
private static final Logger LOG = LoggerFactory.getLogger(CalendarReceiver.class);
private static final String ACTION_FORCE_SYNC = "FORCE_CALENDAR_SYNC";
private final Hashtable eventState = new Hashtable<>();
private final Context mContext;
private final GBDevice mGBDevice;
private final Handler mSyncHandler;
private final BroadcastReceiver mForceSyncReceiver;
private static class EventSyncState {
private int state;
private final CalendarEvent event;
EventSyncState(CalendarEvent event, int state) {
this.state = state;
this.event = event;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
public CalendarEvent getEvent() {
return event;
}
}
private static class EventState {
private static final int NOT_SYNCED = 0;
private static final int SYNCED = 1;
private static final int NEEDS_UPDATE = 2;
private static final int NEEDS_DELETE = 3;
}
public CalendarReceiver(final Context context, final GBDevice gbDevice) {
super(new Handler());
LOG.info("Created calendar receiver");
mContext = context;
mGBDevice = gbDevice;
mSyncHandler = new Handler();
mForceSyncReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context context, final Intent intent) {
LOG.info("Got force sync: {}", intent.getAction());
scheduleSync();
}
};
}
public GBDevice getGBDevice() {
return mGBDevice;
}
@Override
public void onChange(boolean selfChange, Uri uri) {
super.onChange(selfChange, uri);
LOG.info("Got calendar change: {}", uri);
scheduleSync();
}
public void scheduleSync() {
LOG.debug("Scheduling calendar sync");
mSyncHandler.removeCallbacksAndMessages(null);
mSyncHandler.postDelayed(this::syncCalendar, 2500L);
}
public void syncCalendar() {
List eventList = (new CalendarManager(mContext, mGBDevice.getAddress())).getCalendarEventList();
LOG.debug("Syncing {} calendar events", eventList.size());
syncCalendar(eventList);
}
public void syncCalendar(List eventList) {
try (DBHandler dbHandler = GBApplication.acquireDB()) {
DaoSession session = dbHandler.getDaoSession();
syncCalendar(eventList, session);
} catch (Exception e1) {
GB.toast("Database Error while syncing Calendar", Toast.LENGTH_SHORT, GB.ERROR, e1);
}
}
public void syncCalendar(List eventList, DaoSession session) {
LOG.info("Syncing with calendar.");
Hashtable eventTable = new Hashtable<>();
Long deviceId = DBHelper.getDevice(mGBDevice, session).getId();
QueryBuilder qb = session.getCalendarSyncStateDao().queryBuilder();
for (CalendarEvent e : eventList) {
long id = e.getId();
eventTable.put(id, e);
if (!eventState.containsKey(e.getId())) {
qb = session.getCalendarSyncStateDao().queryBuilder();
CalendarSyncState calendarSyncState = qb.where(qb.and(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId), CalendarSyncStateDao.Properties.CalendarEntryId.eq(id)))
.build().unique();
if (calendarSyncState == null) {
eventState.put(id, new EventSyncState(e, EventState.NOT_SYNCED));
LOG.info("event id={} is yet unknown to device id={}", id, deviceId);
} else if (calendarSyncState.getHash() == e.hashCode()) {
eventState.put(id, new EventSyncState(e, EventState.SYNCED));
LOG.info("event id={} is up to date on device id={}", id, deviceId);
} else {
eventState.put(id, new EventSyncState(e, EventState.NEEDS_UPDATE));
LOG.info("event id={} is not up to date on device id={}", id, deviceId);
}
}
}
// add all missing calendar ids on the device to sync status (so that they are deleted later)
List CalendarSyncStateList = qb.where(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId)).build().list();
for (CalendarSyncState CalendarSyncState : CalendarSyncStateList) {
if (!eventState.containsKey(CalendarSyncState.getCalendarEntryId())) {
eventState.put(CalendarSyncState.getCalendarEntryId(), new EventSyncState(null, EventState.NEEDS_DELETE));
LOG.info("insert null event for orphaned calendar id={} for device={}", CalendarSyncState.getCalendarEntryId(), mGBDevice.getName());
}
}
Enumeration ids = eventState.keys();
while (ids.hasMoreElements()) {
qb = session.getCalendarSyncStateDao().queryBuilder();
Long i = ids.nextElement();
EventSyncState es = eventState.get(i);
if (es == null) {
LOG.error("Failed to get event state for {}", i);
continue;
}
if (eventTable.containsKey(i)) {
if (es.getState() == EventState.SYNCED) {
if (!es.getEvent().equals(eventTable.get(i))) {
eventState.put(i, new EventSyncState(eventTable.get(i), EventState.NEEDS_UPDATE));
}
}
} else {
if (es.getState() == EventState.NOT_SYNCED) {
// delete for current device only
qb.where(qb.and(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId), CalendarSyncStateDao.Properties.CalendarEntryId.eq(i)))
.buildDelete().executeDeleteWithoutDetachingEntities();
eventState.remove(i);
} else {
es.setState(EventState.NEEDS_DELETE);
eventState.put(i, es);
}
}
updateEvents(deviceId, session);
}
}
private void updateEvents(Long deviceId, DaoSession session) {
Enumeration ids = eventState.keys();
while (ids.hasMoreElements()) {
Long i = ids.nextElement();
EventSyncState es = eventState.get(i);
if (es == null) {
LOG.error("Failed to get event state {} for sync", i);
continue;
}
int syncState = es.getState();
if (syncState == EventState.NOT_SYNCED || syncState == EventState.NEEDS_UPDATE) {
CalendarEvent calendarEvent = es.getEvent();
CalendarEventSpec calendarEventSpec = new CalendarEventSpec();
calendarEventSpec.id = i;
calendarEventSpec.title = calendarEvent.getTitle();
calendarEventSpec.allDay = calendarEvent.isAllDay();
calendarEventSpec.reminders = new ArrayList<>(calendarEvent.getRemindersAbsoluteTs());
calendarEventSpec.timestamp = calendarEvent.getBeginSeconds();
calendarEventSpec.durationInSeconds = calendarEvent.getDurationSeconds(); //FIXME: leads to problems right now
if (calendarEvent.isAllDay()) {
// As per the CalendarContract, for all-day events, the start timestamp is always in UTC
// and corresponds to the midnight boundary
final int numDays = (int) TimeUnit.DAYS.convert(
calendarEvent.getEnd() - calendarEvent.getBegin(),
TimeUnit.MILLISECONDS
);
calendarEventSpec.durationInSeconds = 24 * 60 * 60 * numDays;
}
calendarEventSpec.description = calendarEvent.getDescription();
calendarEventSpec.location = calendarEvent.getLocation();
calendarEventSpec.type = CalendarEventSpec.TYPE_UNKNOWN;
calendarEventSpec.calName = calendarEvent.getUniqueCalName();
calendarEventSpec.color = calendarEvent.getColor();
if (syncState == EventState.NEEDS_UPDATE) {
GBApplication.deviceService(mGBDevice).onDeleteCalendarEvent(CalendarEventSpec.TYPE_UNKNOWN, i);
}
GBApplication.deviceService(mGBDevice).onAddCalendarEvent(calendarEventSpec);
es.setState(EventState.SYNCED);
eventState.put(i, es);
// update db
session.insertOrReplace(new CalendarSyncState(null, deviceId, i, es.event.hashCode()));
} else if (syncState == EventState.NEEDS_DELETE) {
GBApplication.deviceService(mGBDevice).onDeleteCalendarEvent(CalendarEventSpec.TYPE_UNKNOWN, i);
eventState.remove(i);
// delete from db for current device only
QueryBuilder qb = session.getCalendarSyncStateDao().queryBuilder();
qb.where(qb.and(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId), CalendarSyncStateDao.Properties.CalendarEntryId.eq(i)))
.buildDelete().executeDeleteWithoutDetachingEntities();
}
}
}
public void registerBroadcastReceivers() {
mContext.getContentResolver().registerContentObserver(CalendarContract.Events.CONTENT_URI, true, this);
// Add a receiver to allow us to quickly force as calendar sync (without having to provide data)
ContextCompat.registerReceiver(mContext, mForceSyncReceiver, new IntentFilter(ACTION_FORCE_SYNC), RECEIVER_NOT_EXPORTED);
}
public void dispose() {
mContext.getContentResolver().unregisterContentObserver(this);
mContext.unregisterReceiver(mForceSyncReceiver);
mSyncHandler.removeCallbacksAndMessages(null);
}
public static void forceSync() {
final Intent intent = new Intent(ACTION_FORCE_SYNC);
intent.setPackage(BuildConfig.APPLICATION_ID);
GBApplication.getContext().sendBroadcast(intent);
}
}