File indexing completed on 2024-11-24 04:42:07

0001 /*
0002     SPDX-FileCopyrightText: 2022 Volker Krause <vkrause@kde.org>
0003     SPDX-License-Identifier: LGPL-2.0-or-later
0004 */
0005 
0006 #include "androidicalconverter.h"
0007 
0008 #include "android/eventdata.h"
0009 
0010 #include <KAndroidExtras/CalendarContract>
0011 #include <KAndroidExtras/JniArray>
0012 
0013 #include <KCalendarCore/ICalFormat>
0014 
0015 #include <QDebug>
0016 #include <QJsonArray>
0017 #include <QJsonDocument>
0018 #include <QTimeZone>
0019 
0020 #include <libical/ical.h>
0021 
0022 namespace ical {
0023     using property_ptr = std::unique_ptr<icalproperty, decltype(&icalproperty_free)>;
0024 }
0025 
0026 KCalendarCore::Event::Ptr AndroidIcalConverter::readEvent(const JniEventData& data)
0027 {
0028     if (!KAndroidExtras::Jni::handle(data).isValid()) {
0029         return nullptr;
0030     }
0031 
0032     KCalendarCore::ICalFormat format;
0033     KCalendarCore::Event::Ptr ev(new KCalendarCore::Event);
0034     qDebug() << data.title;
0035 
0036     ev->setSummary(data.title);
0037     ev->setLocation(data.location);
0038     ev->setDescription(data.description);
0039     ev->setDtStart(QDateTime::fromMSecsSinceEpoch(data.dtStart).toTimeZone(QTimeZone(QString(data.startTimezone).toUtf8())));
0040     ev->setDtEnd(QDateTime::fromMSecsSinceEpoch(data.dtEnd).toTimeZone(QTimeZone(QString(data.endTimezone).toUtf8())));
0041     ev->setAllDay(data.allDay);
0042     ev->setUid(data.uid2445);
0043 
0044     const QString duration = data.duration;
0045     if (!duration.isEmpty()) {
0046         ev->setDuration(format.durationFromString(duration));
0047     }
0048 
0049     if (!QString(data.originalId).isEmpty()) {
0050         ev->setRecurrenceId(QDateTime::fromMSecsSinceEpoch(data.instanceId));
0051     }
0052 
0053     const jint accessLevel = data.accessLevel;
0054     if (accessLevel == KAndroidExtras::EventsColumns::ACCESS_PRIVATE) { // ### we probably want to cache the constants?
0055         ev->setSecrecy(KCalendarCore::Event::SecrecyPrivate);
0056     } else if (accessLevel == KAndroidExtras::EventsColumns::ACCESS_CONFIDENTIAL) {
0057         ev->setSecrecy(KCalendarCore::Event::SecrecyConfidential);
0058     }
0059 
0060     const jint availability = data.availability;
0061     if (availability == KAndroidExtras::EventsColumns::AVAILABILITY_FREE) {
0062         ev->setTransparency(KCalendarCore::Event::Transparent);
0063     }
0064 
0065     KCalendarCore::Person organizer;
0066     organizer.setEmail(data.organizer);
0067 
0068     // recurrence rules
0069     const QString rrule = data.rrule;
0070     if (!rrule.isEmpty()) {
0071         auto r = new KCalendarCore::RecurrenceRule;
0072         format.fromString(r, rrule);
0073         ev->recurrence()->addRRule(r);
0074     }
0075     if (ev->allDay()) {
0076         ev->recurrence()->setRDates(AndroidIcalConverter::readRDates<QDate>(data.rdate));
0077     } else {
0078         ev->recurrence()->setRDateTimes(AndroidIcalConverter::readRDates<QDateTime>(data.rdate));
0079     }
0080 
0081     const QString exrule = data.exrule;
0082     if (!exrule.isEmpty()) {
0083         auto r = new KCalendarCore::RecurrenceRule;
0084         format.fromString(r, exrule);
0085         ev->recurrence()->addExRule(r);
0086     }
0087     if (ev->allDay()) {
0088         ev->recurrence()->setExDates(AndroidIcalConverter::readRDates<QDate>(data.exdate));
0089     } else {
0090         ev->recurrence()->setExDateTimes(AndroidIcalConverter::readRDates<QDateTime>(data.exdate));
0091     }
0092 
0093     // attendees
0094     const KAndroidExtras::Jni::Array<JniAttendeeData> attendeesData = data.attendees;
0095     for (const JniAttendeeData &attendeeData : attendeesData) {
0096         ev->addAttendee(AndroidIcalConverter::readAttendee(attendeeData));
0097     }
0098 
0099     // alarms
0100     const KAndroidExtras::Jni::Array<JniReminderData> remindersData = data.reminders;
0101     for (const JniReminderData &reminderData : remindersData) {
0102         ev->addAlarm(AndroidIcalConverter::readAlarm(reminderData, ev.data()));
0103     }
0104 
0105     // extended properties
0106     const KAndroidExtras::Jni::Array<JniExtendedPropertyData> extProperties = data.extendedProperties;
0107     for (const JniExtendedPropertyData &extProperty : extProperties) {
0108         if (QString(extProperty.name) == QLatin1StringView("vnd.android.cursor.item/vnd.ical4android.unknown-property")) {
0109             // DAVx⁵ extended properties: the actual name/value pair is a JSON array in the property value
0110             const auto a = QJsonDocument::fromJson(QString(extProperty.value).toUtf8()).array();
0111             if (a.size() != 2) {
0112                 qWarning() << "Invalid DAVx⁵ extended property data:" << extProperty.value;
0113                 continue;
0114             }
0115             AndroidIcalConverter::addExtendedProperty(ev.data(), a[0].toString(), a[1].toString());
0116         } else {
0117             qInfo() << "Unhandled extended property:" << extProperty.name << extProperty.value;
0118         }
0119     }
0120 
0121     ev->resetDirtyFields();
0122     return ev;
0123 }
0124 
0125 JniEventData AndroidIcalConverter::writeEvent(const KCalendarCore::Event::Ptr &event)
0126 {
0127     KCalendarCore::ICalFormat format;
0128 
0129     JniEventData data;
0130     data.title = event->summary();
0131     data.location = event->location();
0132     data.description = event->description();
0133     data.dtStart = event->dtStart().toMSecsSinceEpoch();
0134     data.startTimezone = QString::fromUtf8(event->dtStart().timeZone().id());
0135     data.dtEnd = event->dtEnd().toMSecsSinceEpoch();
0136     data.endTimezone = QString::fromUtf8(event->dtEnd().timeZone().id());
0137     data.allDay = event->allDay();
0138     data.uid2445 = event->uid();
0139 
0140     if (!event->duration().isNull()) {
0141         data.duration = format.toString(event->duration());
0142     }
0143 
0144     if (event->recurrenceId().isValid()) {
0145         data.originalId = event->uid();
0146         data.instanceId = event->recurrenceId().toMSecsSinceEpoch();
0147     }
0148 
0149     switch (event->secrecy()) {
0150         case KCalendarCore::Event::SecrecyPrivate:
0151             data.accessLevel = KAndroidExtras::EventsColumns::ACCESS_PRIVATE;
0152             break;
0153         case KCalendarCore::Event::SecrecyConfidential:
0154             data.accessLevel = KAndroidExtras::EventsColumns::ACCESS_CONFIDENTIAL;
0155             break;
0156         case KCalendarCore::Event::SecrecyPublic:
0157             data.accessLevel = KAndroidExtras::EventsColumns::ACCESS_PUBLIC;
0158             break;
0159     }
0160 
0161     switch (event->transparency()) {
0162         case KCalendarCore::Event::Transparent:
0163             data.availability = KAndroidExtras::EventsColumns::AVAILABILITY_FREE;
0164             break;
0165         case KCalendarCore::Event::Opaque:
0166             data.availability = KAndroidExtras::EventsColumns::AVAILABILITY_BUSY;
0167             break;
0168     }
0169 
0170     data.organizer = event->organizer().email();
0171 
0172     // recurrence rules
0173     if (!event->recurrence()->rRules().isEmpty()) {
0174         data.rrule = format.toString(event->recurrence()->rRules().at(0)); // TODO what about more than one rule?
0175     }
0176     if (event->allDay() && !event->recurrence()->rDates().isEmpty()) {
0177         data.rdate = writeRDates(event->recurrence()->rDates());
0178     } else if (!event->allDay() && !event->recurrence()->rDateTimes().isEmpty()) {
0179         data.rdate = writeRDates(event->recurrence()->rDateTimes());
0180     }
0181     if (!event->recurrence()->exRules().isEmpty()) {
0182         data.exrule = format.toString(event->recurrence()->exRules().at(0)); // TODO what about more than one rule?
0183     }
0184     if (event->allDay() && !event->recurrence()->exDates().isEmpty()) {
0185         data.rdate = writeRDates(event->recurrence()->exDates());
0186     } else if (!event->allDay() && !event->recurrence()->exDateTimes().isEmpty()) {
0187         data.rdate = writeRDates(event->recurrence()->exDateTimes());
0188     }
0189 
0190     // attendees
0191     if (!event->attendees().isEmpty()) {
0192         const auto attendees = event->attendees();
0193         KAndroidExtras::Jni::Array<JniAttendeeData> attendeeData(attendees.size());
0194         for (int i = 0; i < attendees.size(); ++i) {
0195             attendeeData[i] = writeAttendee(attendees.at(i));
0196         }
0197         data.attendees = attendeeData;
0198     }
0199 
0200     // alarms
0201     if (!event->alarms().isEmpty()) {
0202         const auto alarms = event->alarms();
0203         KAndroidExtras::Jni::Array<JniReminderData> reminders(alarms.size());
0204         for (int i = 0; i < alarms.size(); ++i) {
0205             reminders[i] = writeAlarm(alarms.at(i));
0206         }
0207         data.reminders = reminders;
0208     }
0209 
0210     // extended properties
0211     const auto properties = writeExtendedProperties(event.data());
0212     if (!properties.empty()) {
0213         KAndroidExtras::Jni::Array<JniExtendedPropertyData> propData(properties.size());
0214         for (std::size_t i = 0; i < properties.size(); ++i) {
0215             propData[i] = properties[i];
0216         }
0217         data.extendedProperties = propData;
0218     }
0219 
0220     return data;
0221 }
0222 
0223 KCalendarCore::Alarm::Ptr AndroidIcalConverter::readAlarm(const JniReminderData &data, KCalendarCore::Incidence *parent)
0224 {
0225     KCalendarCore::Alarm::Ptr alarm(new KCalendarCore::Alarm(parent));
0226     alarm->setStartOffset(KCalendarCore::Duration(-data.minutes * 60, KCalendarCore::Duration::Seconds));
0227     if (data.method == KAndroidExtras::RemindersColumns::METHOD_EMAIL || data.method == KAndroidExtras::RemindersColumns::METHOD_SMS) {
0228         alarm->setType(KCalendarCore::Alarm::Email);
0229     } else {
0230         alarm->setType(KCalendarCore::Alarm::Display);
0231     }
0232     return alarm;
0233 }
0234 
0235 JniReminderData AndroidIcalConverter::writeAlarm(const KCalendarCore::Alarm::Ptr &alarm)
0236 {
0237     JniReminderData data;
0238     data.minutes = -alarm->startOffset().asSeconds() / 60;
0239     switch (alarm->type()) {
0240         case KCalendarCore::Alarm::Audio:
0241         case KCalendarCore::Alarm::Display:
0242         case KCalendarCore::Alarm::Procedure:
0243         case KCalendarCore::Alarm::Invalid:
0244             data.method = KAndroidExtras::RemindersColumns::METHOD_ALERT;
0245             break;
0246         case KCalendarCore::Alarm::Email:
0247             data.method = KAndroidExtras::RemindersColumns::METHOD_EMAIL;
0248             break;
0249     }
0250     return data;
0251 }
0252 
0253 KCalendarCore::Attendee AndroidIcalConverter::readAttendee(const JniAttendeeData& data)
0254 {
0255     KCalendarCore::Attendee attendee(data.name, data.email);
0256 
0257     // attendee role (### doesn't map properly, what to do about the remaining values?)
0258     const jint relationship = data.relationship;
0259     if (relationship == KAndroidExtras::AttendeesColumns::RELATIONSHIP_NONE) {
0260         attendee.setRole(KCalendarCore::Attendee::NonParticipant);
0261     } else if (relationship == KAndroidExtras::AttendeesColumns::RELATIONSHIP_ORGANIZER) {
0262         attendee.setRole(KCalendarCore::Attendee::Chair);
0263     }
0264 
0265     // attendee status
0266     const jint status = data.status;
0267     if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_ACCEPTED) {
0268         attendee.setStatus(KCalendarCore::Attendee::Accepted);
0269     } else if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_DECLINED) {
0270         attendee.setStatus(KCalendarCore::Attendee::Declined);
0271     } else if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_INVITED) {
0272         attendee.setStatus(KCalendarCore::Attendee::NeedsAction);
0273     } else if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_NONE) {
0274         attendee.setStatus(KCalendarCore::Attendee::None);
0275     } else if (status == KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_TENTATIVE) {
0276         attendee.setStatus(KCalendarCore::Attendee::Tentative);
0277     }
0278 
0279     // attendee type (### doesn't perfectly map either, and partly maps to role?)
0280     const jint type = data.type;
0281     if (type == KAndroidExtras::AttendeesColumns::TYPE_REQUIRED) {
0282         attendee.setRole(KCalendarCore::Attendee::ReqParticipant);
0283     } else if (type == KAndroidExtras::AttendeesColumns::TYPE_OPTIONAL) {
0284         attendee.setRole(KCalendarCore::Attendee::OptParticipant);
0285     } else if (type == KAndroidExtras::AttendeesColumns::TYPE_NONE) {
0286         attendee.setRole(KCalendarCore::Attendee::NonParticipant);
0287     } else if (type == KAndroidExtras::AttendeesColumns::TYPE_RESOURCE) {
0288         attendee.setCuType(KCalendarCore::Attendee::Resource);
0289     }
0290 
0291     return attendee;
0292 }
0293 
0294 JniAttendeeData AndroidIcalConverter::writeAttendee(const KCalendarCore::Attendee &attendee)
0295 {
0296     JniAttendeeData data;
0297     data.name = attendee.name();
0298     data.email = attendee.email();
0299 
0300     switch (attendee.cuType()) {
0301         case KCalendarCore::Attendee::Individual:
0302         case KCalendarCore::Attendee::Group:
0303         case KCalendarCore::Attendee::Unknown:
0304             data.relationship = KAndroidExtras::AttendeesColumns::RELATIONSHIP_ATTENDEE;
0305             break;
0306         case KCalendarCore::Attendee::Room:
0307         case KCalendarCore::Attendee::Resource:
0308             data.type = KAndroidExtras::AttendeesColumns::TYPE_RESOURCE;
0309             break;
0310     }
0311 
0312     switch (attendee.role()) {
0313         case KCalendarCore::Attendee::ReqParticipant:
0314             data.type = KAndroidExtras::AttendeesColumns::TYPE_REQUIRED;
0315             break;
0316         case KCalendarCore::Attendee::OptParticipant:
0317             data.type = KAndroidExtras::AttendeesColumns::TYPE_OPTIONAL;
0318             break;
0319         case KCalendarCore::Attendee::NonParticipant:
0320             data.type = KAndroidExtras::AttendeesColumns::TYPE_NONE;
0321             break;
0322         case KCalendarCore::Attendee::Chair:
0323             // TODO?
0324             break;
0325     }
0326 
0327     switch (attendee.status()) {
0328         case KCalendarCore::Attendee::NeedsAction:
0329             data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_INVITED;
0330             break;
0331         case KCalendarCore::Attendee::Accepted:
0332             data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_ACCEPTED;
0333             break;
0334         case KCalendarCore::Attendee::Declined:
0335             data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_DECLINED;
0336             break;
0337         case KCalendarCore::Attendee::Tentative:
0338             data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_TENTATIVE;
0339             break;
0340         case KCalendarCore::Attendee::Delegated:
0341         case KCalendarCore::Attendee::InProcess:
0342         case KCalendarCore::Attendee::Completed:
0343         case KCalendarCore::Attendee::None:
0344             data.status = KAndroidExtras::AttendeesColumns::ATTENDEE_STATUS_NONE;
0345             break;
0346     }
0347 
0348     return data;
0349 }
0350 
0351 void AndroidIcalConverter::addExtendedProperty(KCalendarCore::Incidence *incidence, const QString &name, const QString &value)
0352 {
0353     const QByteArray propString = name.toUtf8() + ':' + value.toUtf8();
0354     ical::property_ptr p(icalproperty_new_from_string(propString.constData()), &icalproperty_free);
0355     if (!p) {
0356         qWarning() << "Failed to parse extended property:" << name << value;
0357         return;
0358     }
0359 
0360     // ### we can probably reuse large parts if icalformat_p.cpp with a bit of refactoring after moving to KCalendarCore
0361     switch (icalproperty_isa(p.get())) {
0362         case ICAL_CREATED_PROPERTY:
0363         {
0364             icaldatetimeperiodtype tp;
0365             tp.time = icalproperty_get_created(p.get());
0366             incidence->setCreated(QDateTime({tp.time.year, tp.time.month, tp.time.day}, {tp.time.hour, tp.time.minute, tp.time.second}, Qt::UTC));
0367             break;
0368         }
0369         case ICAL_GEO_PROPERTY:
0370         {
0371             icalgeotype geo = icalproperty_get_geo(p.get());
0372             incidence->setGeoLatitude(geo.lat);
0373             incidence->setGeoLongitude(geo.lon);
0374             break;
0375         }
0376         case ICAL_PRIORITY_PROPERTY:
0377             incidence->setPriority(icalproperty_get_priority(p.get()));
0378             break;
0379         case ICAL_X_PROPERTY:
0380             incidence->setNonKDECustomProperty(name.toUtf8(), value);
0381             break;
0382         default:
0383             qWarning() << "Unhandled property type:" << name << value;
0384     }
0385 }
0386 
0387 static JniExtendedPropertyData writeExtendedProperty(const QString &name, const QString &value)
0388 {
0389     JniExtendedPropertyData data;
0390     data.name = QStringLiteral("vnd.android.cursor.item/vnd.ical4android.unknown-property");
0391 
0392     QJsonArray array;
0393     array.push_back(name);
0394     array.push_back(value);
0395     data.value = QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact));
0396 
0397     return data;
0398 }
0399 
0400 static JniExtendedPropertyData writeExtendedProperty(const ical::property_ptr &prop)
0401 {
0402     const auto name = QString::fromUtf8(icalproperty_kind_to_string(icalproperty_isa(prop.get())));
0403     const auto icalValue = icalproperty_get_value(prop.get());
0404     const auto value = QString::fromUtf8(icalvalue_as_ical_string(icalValue));
0405     return writeExtendedProperty(name, value);
0406  }
0407 
0408 std::vector<JniExtendedPropertyData> AndroidIcalConverter::writeExtendedProperties(const KCalendarCore::Incidence *incidence)
0409 {
0410     std::vector<JniExtendedPropertyData> props;
0411 
0412     if (incidence->created().isValid()) {
0413         const auto dt = incidence->created().toUTC();
0414         icaltimetype t = icaltime_null_time();
0415         t.year = dt.date().year();
0416         t.month = dt.date().month();
0417         t.day = dt.date().day();
0418         t.hour = dt.time().hour();
0419         t.minute = dt.time().minute();
0420         t.second = dt.time().second();
0421         t.zone = nullptr; // zone is NOT set
0422         t = icaltime_convert_to_zone(t, icaltimezone_get_utc_timezone());
0423         ical::property_ptr p(icalproperty_new_created(t), &icalproperty_free);
0424         props.push_back(::writeExtendedProperty(p));
0425     }
0426 
0427     if (incidence->hasGeo()) {
0428         icalgeotype geo;
0429         geo.lat = incidence->geoLatitude();
0430         geo.lon = incidence->geoLongitude();
0431         ical::property_ptr p(icalproperty_new_geo(geo), &icalproperty_free);
0432         props.push_back(::writeExtendedProperty(p));
0433     }
0434 
0435     // TODO all other properties not included in the standard fields
0436 
0437     const auto customProps = incidence->customProperties();
0438     for (auto it = customProps.begin(); it != customProps.end(); ++it) {
0439         props.push_back(::writeExtendedProperty(QString::fromUtf8(it.key()), it.value()));
0440     }
0441     return props;
0442 }
0443 
0444 template<typename T>
0445 QList<T> AndroidIcalConverter::readRDates(const QString &data)
0446 {
0447     if (data.isEmpty()) {
0448         return {};
0449     }
0450 
0451     const auto pos = data.indexOf(QLatin1Char(';'));
0452     QTimeZone tz;
0453     QStringView listData;
0454     if (pos > 0) {
0455         listData = QStringView(data).mid(pos + 1);
0456         tz = QTimeZone(QStringView(data).left(pos).toUtf8());
0457     } else {
0458         listData = QStringView(data);
0459         tz = QTimeZone::utc();
0460     }
0461 
0462     const auto list = listData.split(QLatin1Char(','));
0463     QList<T> result;
0464     result.reserve(list.size());
0465     for (const auto &s : list) {
0466         T value;
0467         if constexpr (std::is_same_v<QDate, T>) {
0468             value = QDate::fromString(s.toString(), QStringLiteral("yyyyMMdd"));
0469         } else {
0470             if (s.endsWith(QLatin1Char('Z'))) {
0471                 value = QDateTime::fromString(s.toString(), QStringLiteral("yyyyMMddThhmmssZ"));
0472                 value.setTimeZone(QTimeZone::utc());
0473             } else {
0474                 value = QDateTime::fromString(s.toString(), QStringLiteral("yyyyMMddThhmmss"));
0475                 value.setTimeZone(tz);
0476             }
0477         }
0478         if (!value.isValid()) {
0479             qWarning() << "Failed to parse RDATE data:" << data;
0480             return {};
0481         }
0482         result.push_back(value);
0483     }
0484 
0485     return result;
0486 }
0487 
0488 QString AndroidIcalConverter::writeRDates(const QList<QDate> &rdates)
0489 {
0490     QString result;
0491     result.reserve((rdates.size() * 9) - 1);
0492     for (const auto &rdate : rdates) {
0493         if (!result.isEmpty()) {
0494             result += QLatin1Char(',');
0495         }
0496         result += rdate.toString(QStringLiteral("yyyyMMdd"));
0497     }
0498     return result;
0499 }
0500 
0501 QString AndroidIcalConverter::writeRDates(const QList<QDateTime> &rdates)
0502 {
0503     if (rdates.isEmpty()) {
0504         return {};
0505     }
0506 
0507     QString result;
0508     const auto tz = rdates.at(0).timeZone();
0509     const auto isUtc = tz == QTimeZone::utc();
0510     if (!isUtc) {
0511         result += QString::fromUtf8(tz.id()) + QLatin1Char(';');
0512     }
0513 
0514     result.reserve(result.size() + (rdates.size() * (isUtc ? 14 : 13)) - 1);
0515     for (int i = 0; i < rdates.size(); ++i) {
0516         if (i > 0) {
0517             result += QLatin1Char(',');
0518         }
0519         result += rdates.at(i).toTimeZone(tz).toString(isUtc ? QStringLiteral("yyyyMMddThhmmssZ") : QStringLiteral("yyyyMMddThhmmss"));
0520     }
0521 
0522     return result;
0523 }