diff --git a/CodenameOne/src/com/codename1/calendar/CalDavAuthentication.java b/CodenameOne/src/com/codename1/calendar/CalDavAuthentication.java new file mode 100644 index 00000000000..7370efe108a --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalDavAuthentication.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.security.Hash; +import com.codename1.util.AsyncResource; +import com.codename1.util.Base64; +import com.codename1.util.StringUtil; +import com.codename1.util.SuccessCallback; +import java.util.HashMap; +import java.util.Map; + +/// App-owned CalDAV authentication strategy. Built-in strategies cover Basic, +/// OAuth Bearer, and RFC 7616 Digest authentication. +public abstract class CalDavAuthentication { + + public abstract AsyncResource authorization(String method, String uri, String challenge, boolean forceRefresh); + + public static CalDavAuthentication basic(final String username, final String password) { + if (username == null || password == null) { + throw new IllegalArgumentException("username and password required"); + } + return new CalDavAuthentication() { + + @Override + public AsyncResource authorization(String method, String uri, String challenge, boolean forceRefresh) { + return complete("Basic " + Base64.encodeNoNewline(StringUtil.getBytes(username + ":" + password))); + } + }; + } + + public static CalDavAuthentication bearer(final CalendarTokenProvider tokens, final String... scopes) { + if (tokens == null) { + throw new IllegalArgumentException("token provider required"); + } + return new CalDavAuthentication() { + + @Override + public AsyncResource authorization(String method, String uri, String challenge, boolean forceRefresh) { + final AsyncResource out = new AsyncResource(); + tokens.getToken(scopes, forceRefresh).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarAuthToken token) { + out.complete("Bearer " + token.getAccessToken()); + } + }).except(new SuccessCallback() { + + @Override + public void onSucess(Throwable error) { + out.error(error); + } + }); + return out; + } + }; + } + + public static CalDavAuthentication digest(final String username, final String password) { + if (username == null || password == null) { + throw new IllegalArgumentException("username and password required"); + } + return new Digest(username, password); + } + + private static final class Digest extends CalDavAuthentication { + + private final String username; + + private final String password; + + private int nonceCount; + + Digest(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public synchronized AsyncResource authorization(String method, String uri, String challenge, boolean forceRefresh) { + if (challenge == null || !challenge.toLowerCase().startsWith("digest ")) { + return complete(null); + } + Map p = parse(challenge.substring(7)); + String realm = p.get("realm"); + String nonce = p.get("nonce"); + String opaque = p.get("opaque"); + String qop = selectQop(p.get("qop")); + if (realm == null || nonce == null) { + return failed(new CalendarException(CalendarError.AUTHENTICATION_REQUIRED, "Invalid Digest challenge")); + } + String algorithm = p.get("algorithm"); + if (algorithm != null && !"MD5".equalsIgnoreCase(algorithm) && !"MD5-sess".equalsIgnoreCase(algorithm)) { + return failed(new CalendarException(CalendarError.NOT_SUPPORTED, "Digest algorithm " + algorithm + " is not supported")); + } + String nc = hex(++nonceCount, 8); + String cnonce = md5(String.valueOf(System.currentTimeMillis()) + ":" + nonceCount).substring(0, 16); + String ha1 = md5(username + ":" + realm + ":" + password); + if ("MD5-sess".equalsIgnoreCase(algorithm)) { + ha1 = md5(ha1 + ":" + nonce + ":" + cnonce); + } + String ha2 = md5(method + ":" + uri); + String response = qop == null ? md5(ha1 + ":" + nonce + ":" + ha2) : md5(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2); + StringBuilder out = new StringBuilder("Digest username=\"").append(escape(username)).append("\", realm=\"").append(escape(realm)).append("\", nonce=\"").append(escape(nonce)).append("\", uri=\"").append(escape(uri)).append("\", response=\"").append(response).append('"'); + if (algorithm != null) { + out.append(", algorithm=").append(algorithm); + } + if (opaque != null) { + out.append(", opaque=\"").append(escape(opaque)).append('"'); + } + if (qop != null) { + out.append(", qop=").append(qop).append(", nc=").append(nc).append(", cnonce=\"").append(cnonce).append('"'); + } + return complete(out.toString()); + } + } + + private static Map parse(String value) { + Map out = new HashMap(); + int i = 0; + while (i < value.length()) { + while (i < value.length() && (value.charAt(i) == ' ' || value.charAt(i) == ',')) { + i++; + } + int eq = value.indexOf('=', i); + if (eq < 0) { + break; + } + String key = value.substring(i, eq).trim().toLowerCase(); + i = eq + 1; + String data; + if (i < value.length() && value.charAt(i) == '"') { + i++; + StringBuilder b = new StringBuilder(); + while (i < value.length()) { + char c = value.charAt(i++); + if (c == '"') { + break; + } + if (c == '\\' && i < value.length()) { + c = value.charAt(i++); + } + b.append(c); + } + data = b.toString(); + } else { + int comma = value.indexOf(',', i); + if (comma < 0) { + comma = value.length(); + } + data = value.substring(i, comma).trim(); + i = comma; + } + out.put(key, data); + } + return out; + } + + private static String selectQop(String qop) { + if (qop == null) { + return null; + } + for (String v : CalendarDateUtil.split(qop, ',')) { + if ("auth".equalsIgnoreCase(v.trim())) { + return "auth"; + } + } + return null; + } + + private static String md5(String value) { + Hash hash = Hash.create(Hash.MD5); + hash.update(StringUtil.getBytes(value)); + byte[] bytes = hash.digest(); + StringBuilder out = new StringBuilder(); + for (byte b : bytes) { + out.append(hex(b & 255, 2)); + } + return out.toString(); + } + + private static String hex(int value, int width) { + String s = Integer.toHexString(value); + while (s.length() < width) { + s = "0" + s; + } + return s; + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static AsyncResource complete(T value) { + AsyncResource out = new AsyncResource(); + out.complete(value); + return out; + } + + private static AsyncResource failed(Throwable error) { + AsyncResource out = new AsyncResource(); + out.error(error); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalDavCalendarSource.java b/CodenameOne/src/com/codename1/calendar/CalDavCalendarSource.java new file mode 100644 index 00000000000..37b4c5d588e --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalDavCalendarSource.java @@ -0,0 +1,512 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Standards-based CalDAV source. Pass the account's calendar-home URL; the +/// source discovers collections below it and uses RFC 6578 sync tokens when +/// the server advertises them. +public class CalDavCalendarSource extends CalendarSource { + + private final String homeUrl; + + private final CalDavAuthentication authentication; + + private final CalendarHttpTransport transport; + + public CalDavCalendarSource(String id, String displayName, String calendarHomeUrl, CalDavAuthentication authentication) { + this(id, displayName, calendarHomeUrl, authentication, null); + } + + public CalDavCalendarSource(String id, String displayName, String calendarHomeUrl, CalDavAuthentication authentication, CalendarHttpTransport transport) { + super(id, displayName); + if (calendarHomeUrl == null || authentication == null) { + throw new IllegalArgumentException("calendar home URL and authentication required"); + } + this.homeUrl = calendarHomeUrl.endsWith("/") ? calendarHomeUrl : calendarHomeUrl + "/"; + this.authentication = authentication; + this.transport = transport == null ? new DefaultCalendarHttpTransport() : transport; + } + + @Override + public CalendarCapabilities getCapabilities() { + return CalendarCapabilities.of(CalendarCapability.READ_CALENDARS, CalendarCapability.READ_EVENTS, CalendarCapability.WRITE_EVENTS, CalendarCapability.DELETE_EVENTS, CalendarCapability.READ_TASKS, CalendarCapability.WRITE_TASKS, CalendarCapability.DELETE_TASKS, CalendarCapability.RECURRENCE, CalendarCapability.ATTENDEES_READ, CalendarCapability.ATTENDEES_WRITE, CalendarCapability.ALARMS, CalendarCapability.ATTACHMENTS, CalendarCapability.CONFERENCING, CalendarCapability.DELTA_SYNC, CalendarCapability.OFFLINE_MUTATIONS); + } + + @Override + public CalendarAuthorizationStatus getAuthorizationStatus(CalendarAccess access) { + return CalendarAuthorizationStatus.NOT_DETERMINED; + } + + @Override + public AsyncResource requestAuthorization(CalendarAccess access) { + final AsyncResource out = new AsyncResource(); + request("PROPFIND", homeUrl, "", "application/xml", headers("Depth", "0")).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + out.complete(CalendarAuthorizationStatus.FULL); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource> listCalendars(final CalendarInfo.ContentType type, String pageToken) { + final AsyncResource> out = new AsyncResource>(); + String body = ""; + request("PROPFIND", homeUrl, body, "application/xml", headers("Depth", "1")).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + List items = new ArrayList(); + for (String response : elements(r.getBody(), "response")) { + String href = xmlValue(response, "href"); + String components = xmlElement(response, "supported-calendar-component-set"); + if (href == null || xmlElement(response, "calendar") == null) { + continue; + } + boolean events = components == null || components.toUpperCase().indexOf("VEVENT") >= 0; + boolean tasks = components != null && components.toUpperCase().indexOf("VTODO") >= 0; + if ((type == CalendarInfo.ContentType.EVENTS && !events) || (type == CalendarInfo.ContentType.TASKS && !tasks)) { + continue; + } + items.add(new CalendarInfo().setId(resolve(href)).setSourceId(getId()).setName(xmlValue(response, "displayname")).setContentType(type).setCapabilities(getCapabilities()).putProviderData("ctag", xmlValue(response, "getctag")).putProviderData("syncToken", xmlValue(response, "sync-token"))); + } + out.complete(new CalendarPage(items, null, null)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource> queryEvents(final CalendarQuery query) { + final AsyncResource> out = new AsyncResource>(); + if (query == null || query.getCalendarId() == null) { + return fail(out, "calendarId required"); + } + String body = query.getSyncToken() == null ? calendarQuery("VEVENT", query) : syncQuery(query.getSyncToken()); + request("REPORT", query.getCalendarId(), body, "application/xml", headers("Depth", "1")).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + List items = new ArrayList(); + try { + for (String response : elements(r.getBody(), "response")) { + String data = xmlValue(response, "calendar-data"); + if (data == null) { + continue; + } + CalendarEvent event = ICalendarCodec.readEvent(data).setCalendarId(query.getCalendarId()).setSourceId(getId()).setVersion(xmlValue(response, "getetag")); + if (event.getId() == null) { + event.setId(xmlValue(response, "href")); + } + items.add(event); + } + } catch (CalendarException ex) { + out.error(ex); + return; + } + out.complete(new CalendarPage(items, null, xmlValue(r.getBody(), "sync-token"))); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource getEvent(final String calendarId, String eventId) { + final AsyncResource out = new AsyncResource(); + request("GET", resource(calendarId, eventId), null, null, null).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + try { + CalendarEvent event = ICalendarCodec.readEvent(r.getBody()).setCalendarId(calendarId).setSourceId(getId()).setVersion(r.getHeader("ETag")); + out.complete(event); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource saveEvent(final CalendarEvent event, CalendarMutationScope scope) { + final AsyncResource out = new AsyncResource(); + if (event == null || event.getCalendarId() == null) { + return fail(out, "event and calendarId required"); + } + final boolean create = event.getId() == null; + if (create) { + event.setId(String.valueOf(System.currentTimeMillis()) + "@codenameone"); + } + Map h = version(event.getVersion(), create); + request("PUT", resource(event.getCalendarId(), event.getId()), ICalendarCodec.writeEvent(event), "text/calendar; charset=utf-8", h).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + event.setVersion(r.getHeader("ETag")); + out.complete(event); + fireChange(new CalendarChange(getId(), event.getCalendarId(), event.getId(), CalendarChange.EntityType.EVENT, create ? CalendarChange.ChangeType.CREATED : CalendarChange.ChangeType.UPDATED)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource deleteEvent(final String calendarId, final String eventId, CalendarMutationScope scope, String version) { + final AsyncResource out = new AsyncResource(); + request("DELETE", resource(calendarId, eventId), null, null, version(version, false)).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + out.complete(Boolean.TRUE); + fireChange(new CalendarChange(getId(), calendarId, eventId, CalendarChange.EntityType.EVENT, CalendarChange.ChangeType.DELETED)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource> queryTasks(final CalendarQuery query) { + final AsyncResource> out = new AsyncResource>(); + if (query == null || query.getCalendarId() == null) { + return fail(out, "calendarId required"); + } + String body = query.getSyncToken() == null ? calendarQuery("VTODO", query) : syncQuery(query.getSyncToken()); + request("REPORT", query.getCalendarId(), body, "application/xml", headers("Depth", "1")).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + List items = new ArrayList(); + try { + for (String response : elements(r.getBody(), "response")) { + String data = xmlValue(response, "calendar-data"); + if (data == null) { + continue; + } + CalendarTask task = ICalendarCodec.readTask(data).setCalendarId(query.getCalendarId()).setSourceId(getId()).setVersion(xmlValue(response, "getetag")); + if (task.getId() == null) { + task.setId(xmlValue(response, "href")); + } + items.add(task); + } + } catch (CalendarException ex) { + out.error(ex); + return; + } + out.complete(new CalendarPage(items, null, xmlValue(r.getBody(), "sync-token"))); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource getTask(final String calendarId, String taskId) { + final AsyncResource out = new AsyncResource(); + request("GET", resource(calendarId, taskId), null, null, null).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + try { + out.complete(ICalendarCodec.readTask(r.getBody()).setCalendarId(calendarId).setSourceId(getId()).setVersion(r.getHeader("ETag"))); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource saveTask(final CalendarTask task, CalendarMutationScope scope) { + final AsyncResource out = new AsyncResource(); + if (task == null || task.getCalendarId() == null) { + return fail(out, "task and calendarId required"); + } + final boolean create = task.getId() == null; + if (create) { + task.setId(String.valueOf(System.currentTimeMillis()) + "@codenameone"); + } + request("PUT", resource(task.getCalendarId(), task.getId()), ICalendarCodec.writeTask(task), "text/calendar; charset=utf-8", version(task.getVersion(), create)).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + task.setVersion(r.getHeader("ETag")); + out.complete(task); + fireChange(new CalendarChange(getId(), task.getCalendarId(), task.getId(), CalendarChange.EntityType.TASK, create ? CalendarChange.ChangeType.CREATED : CalendarChange.ChangeType.UPDATED)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource deleteTask(final String calendarId, final String taskId, CalendarMutationScope scope, String version) { + final AsyncResource out = new AsyncResource(); + request("DELETE", resource(calendarId, taskId), null, null, version(version, false)).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse r) { + out.complete(Boolean.TRUE); + fireChange(new CalendarChange(getId(), calendarId, taskId, CalendarChange.EntityType.TASK, CalendarChange.ChangeType.DELETED)); + } + }).except(error(out)); + return out; + } + + private AsyncResource request(final String method, final String url, final String body, final String contentType, final Map headers) { + final AsyncResource out = new AsyncResource(); + authorizeAndSend(method, url, body, contentType, headers, null, false, out); + return out; + } + + private void authorizeAndSend(final String method, final String url, final String body, final String contentType, final Map headers, final String challenge, final boolean retried, final AsyncResource out) { + authentication.authorization(method, url, challenge, retried).ready(new SuccessCallback() { + + @Override + public void onSucess(String authorization) { + CalendarHttpRequest request = new CalendarHttpRequest(method, url).setBody(body).header("Accept", "application/xml, text/calendar"); + if (authorization != null) { + request.header("Authorization", authorization); + } + if (contentType != null) { + request.header("Content-Type", contentType); + } + if (headers != null) { + for (Map.Entry h : headers.entrySet()) { + request.header(h.getKey(), h.getValue()); + } + } + transport.execute(request).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarHttpResponse response) { + if (response.getStatusCode() == 401 && !retried) { + authorizeAndSend(method, url, body, contentType, headers, response.getHeader("WWW-Authenticate"), true, out); + return; + } + if (response.getStatusCode() >= 200 && response.getStatusCode() < 300) { + out.complete(response); + } else { + out.error(httpError(response)); + } + } + }).except(error(out)); + } + }).except(error(out)); + } + + private static CalendarException httpError(CalendarHttpResponse r) { + int code = r.getStatusCode(); + CalendarError type = code == 401 ? CalendarError.AUTHENTICATION_REQUIRED : code == 403 ? CalendarError.PERMISSION_DENIED : code == 404 ? CalendarError.NOT_FOUND : code == 409 || code == 412 ? CalendarError.CONFLICT : code == 429 ? CalendarError.RATE_LIMITED : code >= 500 ? CalendarError.NETWORK : CalendarError.INVALID_ARGUMENT; + return new CalendarException(type, "CalDAV server returned HTTP " + code + (r.getBody() == null ? "" : ": " + r.getBody()), code, null); + } + + private String resolve(String href) { + if (href.startsWith("http://") || href.startsWith("https://")) { + return href; + } + int scheme = homeUrl.indexOf("://"); + int slash = homeUrl.indexOf('/', scheme + 3); + String origin = slash < 0 ? homeUrl : homeUrl.substring(0, slash); + return href.startsWith("/") ? origin + href : homeUrl + href; + } + + private static String resource(String calendarId, String id) { + if (id.startsWith("http://") || id.startsWith("https://")) { + return id; + } + String base = calendarId.endsWith("/") ? calendarId : calendarId + "/"; + return base + safe(id) + (id.toLowerCase().endsWith(".ics") ? "" : ".ics"); + } + + private static String safe(String value) { + return value.replace(" ", "%20").replace("/", "%2F"); + } + + private static String calendarQuery(String component, CalendarQuery q) { + StringBuilder filter = new StringBuilder(""); + } else { + filter.append('>'); + filter.append(""); + } + filter.append(""); + return "" + filter + ""; + } + + private static String syncQuery(String token) { + return "" + xml(token) + ("1"); + } + + private static String icalTime(Instant value) { + return CalendarDateUtil.formatBasic(value, ZoneId.of("UTC")) + "Z"; + } + + private static Map headers(String name, String value) { + Map out = new HashMap(); + out.put(name, value); + return out; + } + + private static Map version(String value, boolean create) { + Map out = new HashMap(); + if (create) { + out.put("If-None-Match", "*"); + } else if (value != null) { + out.put("If-Match", value); + } + return out; + } + + private static String xmlValue(String source, String local) { + String element = xmlElement(source, local); + return element == null ? null : unxml(stripTags(element).trim()); + } + + private static String xmlElement(String source, String local) { + if (source == null) { + return null; + } + String lower = source.toLowerCase(); + int open = findElement(lower, local.toLowerCase(), 0); + if (open < 0) { + return null; + } + int gt = source.indexOf('>', open); + if (gt < 0) { + return null; + } + String tag = source.substring(open + 1, gt).trim(); + int space = tag.indexOf(' '); + if (space >= 0) { + tag = tag.substring(0, space); + } + String close = ""; + int end = lower.indexOf(close.toLowerCase(), gt + 1); + return end < 0 ? null : source.substring(gt + 1, end); + } + + private static List elements(String source, String local) { + List out = new ArrayList(); + if (source == null) { + return out; + } + String lower = source.toLowerCase(); + int from = 0; + while (true) { + int open = findElement(lower, local.toLowerCase(), from); + if (open < 0) { + break; + } + int gt = source.indexOf('>', open); + if (gt < 0) { + break; + } + String tag = source.substring(open + 1, gt).trim(); + int space = tag.indexOf(' '); + if (space >= 0) { + tag = tag.substring(0, space); + } + String close = ""; + int end = lower.indexOf(close.toLowerCase(), gt + 1); + if (end < 0) { + break; + } + out.add(source.substring(gt + 1, end)); + from = end + close.length(); + } + return out; + } + + private static int findElement(String source, String local, int from) { + int plain = source.indexOf("<" + local, from); + int prefixed = source.indexOf(":" + local, from); + if (prefixed >= 0) { + prefixed = source.lastIndexOf('<', prefixed); + } + if (plain < 0) { + return prefixed; + } + if (prefixed < 0) { + return plain; + } + return Math.min(plain, prefixed); + } + + private static String stripTags(String value) { + StringBuilder out = new StringBuilder(); + boolean tag = false; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '<') { + tag = true; + } else if (c == '>') { + tag = false; + } else if (!tag) { + out.append(c); + } + } + return out.toString(); + } + + private static String xml(String value) { + return value == null ? "" : value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } + + private static String unxml(String value) { + return value.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&"); + } + + private static SuccessCallback error(final AsyncResource out) { + return new SuccessCallback() { + + @Override + public void onSucess(Throwable error) { + out.error(error); + } + }; + } + + private static AsyncResource fail(AsyncResource out, String message) { + out.error(new CalendarException(CalendarError.INVALID_ARGUMENT, message)); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarAccess.java b/CodenameOne/src/com/codename1/calendar/CalendarAccess.java new file mode 100644 index 00000000000..a8b49db60da --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAccess.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// Permission level requested from a local or online calendar source. +public enum CalendarAccess { + + EVENTS_READ_ONLY, EVENTS_WRITE_ONLY, EVENTS_FULL, TASKS_FULL +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarAlarm.java b/CodenameOne/src/com/codename1/calendar/CalendarAlarm.java new file mode 100644 index 00000000000..e2391f5e29e --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAlarm.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.time.Duration; +import java.time.Instant; + +/// An event/task alarm, either absolute or relative to its start/due time. +public class CalendarAlarm { + + public enum Method { + + DEFAULT, ALERT, EMAIL, AUDIO + } + + private Duration timeBefore; + + private Instant absoluteTime; + + private Method method = Method.DEFAULT; + + public Duration getTimeBefore() { + return timeBefore; + } + + public CalendarAlarm setTimeBefore(Duration v) { + if (v != null && v.compareTo(Duration.ofSeconds(0)) < 0) { + throw new IllegalArgumentException("timeBefore must not be negative"); + } + timeBefore = v; + absoluteTime = null; + return this; + } + + public Instant getAbsoluteTime() { + return absoluteTime; + } + + public CalendarAlarm setAbsoluteTime(Instant v) { + absoluteTime = v; + timeBefore = null; + return this; + } + + public Method getMethod() { + return method; + } + + public CalendarAlarm setMethod(Method v) { + method = v == null ? Method.DEFAULT : v; + return this; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarAttachment.java b/CodenameOne/src/com/codename1/calendar/CalendarAttachment.java new file mode 100644 index 00000000000..adb08c2d461 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAttachment.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// Attachment metadata and optional content for providers that support upload. +public class CalendarAttachment { + + private String id; + + private String name; + + private String mimeType; + + private String uri; + + private long size = -1; + + private byte[] content; + + public String getId() { + return id; + } + + public CalendarAttachment setId(String v) { + id = v; + return this; + } + + public String getName() { + return name; + } + + public CalendarAttachment setName(String v) { + name = v; + return this; + } + + public String getMimeType() { + return mimeType; + } + + public CalendarAttachment setMimeType(String v) { + mimeType = v; + return this; + } + + public String getUri() { + return uri; + } + + public CalendarAttachment setUri(String v) { + uri = v; + return this; + } + + public long getSize() { + return size; + } + + public CalendarAttachment setSize(long v) { + size = v; + return this; + } + + public byte[] getContent() { + return content == null ? null : (byte[]) content.clone(); + } + + public CalendarAttachment setContent(byte[] v) { + content = v == null ? null : (byte[]) v.clone(); + return this; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarAttendee.java b/CodenameOne/src/com/codename1/calendar/CalendarAttendee.java new file mode 100644 index 00000000000..ff0c519cca5 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAttendee.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// A person, group, or resource invited to an event. +public class CalendarAttendee { + + public enum Role { + + REQUIRED, OPTIONAL, RESOURCE + } + + public enum Response { + + NONE, + NEEDS_ACTION, + ACCEPTED, + DECLINED, + TENTATIVE, + DELEGATED + } + + private String name; + + private String email; + + private String uri; + + private Role role = Role.REQUIRED; + + private Response response = Response.NONE; + + private boolean organizer; + + private boolean self; + + public String getName() { + return name; + } + + public CalendarAttendee setName(String v) { + name = v; + return this; + } + + public String getEmail() { + return email; + } + + public CalendarAttendee setEmail(String v) { + email = v; + return this; + } + + public String getUri() { + return uri; + } + + public CalendarAttendee setUri(String v) { + uri = v; + return this; + } + + public Role getRole() { + return role; + } + + public CalendarAttendee setRole(Role v) { + role = v == null ? Role.REQUIRED : v; + return this; + } + + public Response getResponse() { + return response; + } + + public CalendarAttendee setResponse(Response v) { + response = v == null ? Response.NONE : v; + return this; + } + + public boolean isOrganizer() { + return organizer; + } + + public CalendarAttendee setOrganizer(boolean v) { + organizer = v; + return this; + } + + public boolean isSelf() { + return self; + } + + public CalendarAttendee setSelf(boolean v) { + self = v; + return this; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarAuthToken.java b/CodenameOne/src/com/codename1/calendar/CalendarAuthToken.java new file mode 100644 index 00000000000..1e72e568d37 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAuthToken.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.time.Duration; +import java.time.Instant; + +/// App-owned OAuth bearer token returned to an online source. +public final class CalendarAuthToken { + + private final String accessToken; + + private final String scopes; + + private final Instant expiresAt; + + public CalendarAuthToken(String accessToken, Instant expiresAt, String scopes) { + if (accessToken == null) { + throw new IllegalArgumentException("accessToken required"); + } + this.accessToken = accessToken; + this.expiresAt = expiresAt; + this.scopes = scopes; + } + + public String getAccessToken() { + return accessToken; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public String getScopes() { + return scopes; + } + + public boolean isExpiringWithin(Duration duration) { + if (duration == null) { + throw new IllegalArgumentException("duration required"); + } + return expiresAt != null && expiresAt.toEpochMilli() - Instant.now().toEpochMilli() < duration.toMillis(); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarAuthorizationStatus.java b/CodenameOne/src/com/codename1/calendar/CalendarAuthorizationStatus.java new file mode 100644 index 00000000000..d5ff0df7d1c --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAuthorizationStatus.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// Current authorization status for an access level. +public enum CalendarAuthorizationStatus { + + NOT_DETERMINED, RESTRICTED, DENIED, WRITE_ONLY, FULL +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarCache.java b/CodenameOne/src/com/codename1/calendar/CalendarCache.java new file mode 100644 index 00000000000..cc4a7628e2c --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarCache.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.Map; + +/// Persistence seam used by `CalendarSyncEngine`. Credentials must not be stored here. +public interface CalendarCache { + + Map load(String sourceId) throws CalendarException; + + void store(String sourceId, Map state) throws CalendarException; + + void clear(String sourceId) throws CalendarException; +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarCapabilities.java b/CodenameOne/src/com/codename1/calendar/CalendarCapabilities.java new file mode 100644 index 00000000000..1c4179cc7df --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarCapabilities.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/// Immutable set of capabilities advertised by a source or calendar. +public final class CalendarCapabilities { + + private static final CalendarCapabilities NONE = new CalendarCapabilities(new HashSet()); + + private final Set values; + + private CalendarCapabilities(Set values) { + HashSet copy = new HashSet(); + copy.addAll(values); + this.values = Collections.unmodifiableSet(copy); + } + + public static CalendarCapabilities none() { + return NONE; + } + + public static CalendarCapabilities of(CalendarCapability... values) { + HashSet out = new HashSet(); + if (values != null) { + for (CalendarCapability value : values) { + if (value != null) { + out.add(value); + } + } + } + return out.isEmpty() ? NONE : new CalendarCapabilities(out); + } + + public boolean supports(CalendarCapability capability) { + return values.contains(capability); + } + + public Set asSet() { + return values; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarCapability.java b/CodenameOne/src/com/codename1/calendar/CalendarCapability.java new file mode 100644 index 00000000000..acf6012126d --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarCapability.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// A granular operation that a calendar source or collection may support. +public enum CalendarCapability { + + READ_CALENDARS, + MANAGE_CALENDARS, + READ_EVENTS, + WRITE_EVENTS, + DELETE_EVENTS, + READ_TASKS, + WRITE_TASKS, + DELETE_TASKS, + RECURRENCE, + ATTENDEES_READ, + ATTENDEES_WRITE, + RESPOND_TO_INVITATIONS, + ALARMS, + FREE_BUSY, + ATTACHMENTS, + CONFERENCING, + LOCAL_CHANGE_LISTENER, + DELTA_SYNC, + OFFLINE_MUTATIONS +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarChange.java b/CodenameOne/src/com/codename1/calendar/CalendarChange.java new file mode 100644 index 00000000000..42f1e884be0 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarChange.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// Describes a local notification or delta-sync change. +public final class CalendarChange { + + public enum EntityType { + + CALENDAR, EVENT, TASK + } + + public enum ChangeType { + + CREATED, UPDATED, DELETED, RESET + } + + private final String sourceId; + + private final String calendarId; + + private final String itemId; + + private final EntityType entityType; + + private final ChangeType changeType; + + public CalendarChange(String sourceId, String calendarId, String itemId, EntityType entityType, ChangeType changeType) { + this.sourceId = sourceId; + this.calendarId = calendarId; + this.itemId = itemId; + this.entityType = entityType; + this.changeType = changeType; + } + + public String getSourceId() { + return sourceId; + } + + public String getCalendarId() { + return calendarId; + } + + public String getItemId() { + return itemId; + } + + public EntityType getEntityType() { + return entityType; + } + + public ChangeType getChangeType() { + return changeType; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarChangeListener.java b/CodenameOne/src/com/codename1/calendar/CalendarChangeListener.java new file mode 100644 index 00000000000..161bf2d5c3d --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarChangeListener.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// Listener for source changes. Implementations dispatch on the Codename One EDT. +public interface CalendarChangeListener { + + void calendarChanged(CalendarChange change); +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarConference.java b/CodenameOne/src/com/codename1/calendar/CalendarConference.java new file mode 100644 index 00000000000..6bce75f980e --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarConference.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Online meeting metadata associated with an event. +public class CalendarConference { + + private String provider; + + private String id; + + private String joinUrl; + + private boolean createRequested; + + private final List phoneNumbers = new ArrayList(); + + public String getProvider() { + return provider; + } + + public CalendarConference setProvider(String v) { + provider = v; + return this; + } + + public String getId() { + return id; + } + + public CalendarConference setId(String v) { + id = v; + return this; + } + + public String getJoinUrl() { + return joinUrl; + } + + public CalendarConference setJoinUrl(String v) { + joinUrl = v; + return this; + } + + public boolean isCreateRequested() { + return createRequested; + } + + public CalendarConference setCreateRequested(boolean v) { + createRequested = v; + return this; + } + + public CalendarConference addPhoneNumber(String v) { + if (v != null) { + phoneNumbers.add(v); + } + return this; + } + + public List getPhoneNumbers() { + return Collections.unmodifiableList(phoneNumbers); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarConflict.java b/CodenameOne/src/com/codename1/calendar/CalendarConflict.java new file mode 100644 index 00000000000..f99e077434f --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarConflict.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.Map; + +/// A queued local mutation that conflicts with the provider version. +public final class CalendarConflict { + + public enum Resolution { + + KEEP_LOCAL, KEEP_REMOTE, MERGED + } + + private final String mutationId; + + private final Map local; + + private final Map remote; + + CalendarConflict(String mutationId, Map local, Map remote) { + this.mutationId = mutationId; + this.local = local; + this.remote = remote; + } + + public String getMutationId() { + return mutationId; + } + + public Map getLocal() { + return local; + } + + public Map getRemote() { + return remote; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarDateTime.java b/CodenameOne/src/com/codename1/calendar/CalendarDateTime.java new file mode 100644 index 00000000000..ad9b579e481 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarDateTime.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +/// Either a zoned date-time or an all-day date. +public final class CalendarDateTime { + + private final ZonedDateTime dateTime; + + private final LocalDate date; + + private CalendarDateTime(ZonedDateTime dateTime, LocalDate date) { + this.dateTime = dateTime; + this.date = date; + } + + public static CalendarDateTime timed(ZonedDateTime dateTime) { + if (dateTime == null) { + throw new IllegalArgumentException("dateTime required"); + } + return new CalendarDateTime(dateTime, null); + } + + public static CalendarDateTime instant(Instant instant, ZoneId zone) { + if (instant == null || zone == null) { + throw new IllegalArgumentException("instant and zone required"); + } + return timed(ZonedDateTime.ofInstant(instant, zone)); + } + + public static CalendarDateTime allDay(LocalDate date) { + if (date == null) { + throw new IllegalArgumentException("date required"); + } + return new CalendarDateTime(null, date); + } + + public boolean isAllDay() { + return date != null; + } + + public ZonedDateTime getDateTime() { + return dateTime; + } + + public LocalDate getDate() { + return date; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarDateUtil.java b/CodenameOne/src/com/codename1/calendar/CalendarDateUtil.java new file mode 100644 index 00000000000..e5a562deafd --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarDateUtil.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +final class CalendarDateUtil { + + private static final DateTimeFormatter BASIC_DATE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"); + + private static final DateTimeFormatter ISO_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + private static final DateTimeFormatter ISO_DATE_TIME_MILLIS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"); + + private CalendarDateUtil() { + } + + static String[] split(String value, char delimiter) { + int count = 1; + for (int i = 0; i < value.length(); i++) { + if (value.charAt(i) == delimiter) { + count++; + } + } + String[] out = new String[count]; + int start = 0; + int index = 0; + for (int i = 0; i <= value.length(); i++) { + if (i == value.length() || value.charAt(i) == delimiter) { + out[index++] = value.substring(start, i); + start = i + 1; + } + } + return out; + } + + static String formatBasic(Instant value, ZoneId zone) { + return BASIC_DATE_TIME.format(ZonedDateTime.ofInstant(value, zone)); + } + + static String formatIso(Instant value, ZoneId zone, boolean milliseconds) { + return (milliseconds ? ISO_DATE_TIME_MILLIS : ISO_DATE_TIME).format(ZonedDateTime.ofInstant(value, zone)); + } + + static Instant parseDateTime(String value, ZoneId defaultZone) { + String normalized = normalizeDateTime(value); + if (normalized.endsWith("Z") || normalized.endsWith("z")) { + return Instant.parse(normalized.substring(0, normalized.length() - 1) + "Z"); + } + int timeSeparator = normalized.indexOf('T'); + int plus = normalized.lastIndexOf('+'); + int minus = normalized.lastIndexOf('-'); + if (plus > timeSeparator || minus > timeSeparator) { + return OffsetDateTime.parse(normalized).toInstant(); + } + ZoneId zone = defaultZone == null ? ZoneOffset.UTC : defaultZone; + return ZonedDateTime.of(LocalDateTime.parse(normalized), zone).toInstant(); + } + + static Instant allDayInstant(LocalDate date) { + return ZonedDateTime.of(date.atTime(0, 0), ZoneOffset.UTC).toInstant(); + } + + private static String normalizeDateTime(String value) { + if (value == null || value.length() < 15) { + throw new IllegalArgumentException("Invalid date: " + value); + } + String normalized = value; + if (value.charAt(4) != '-') { + normalized = value.substring(0, 4) + "-" + value.substring(4, 6) + "-" + value.substring(6, 8) + "T" + value.substring(9, 11) + ":" + value.substring(11, 13) + ":" + value.substring(13); + } + int timeSeparator = normalized.indexOf('T'); + int plus = normalized.lastIndexOf('+'); + int minus = normalized.lastIndexOf('-'); + int offset = plus > timeSeparator ? plus : minus > timeSeparator ? minus : -1; + if (offset >= 0 && normalized.length() - offset == 5) { + normalized = normalized.substring(0, offset + 3) + ":" + normalized.substring(offset + 3); + } + return normalized; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarError.java b/CodenameOne/src/com/codename1/calendar/CalendarError.java new file mode 100644 index 00000000000..fe7837d466e --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarError.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// Portable failure categories for calendar operations. +public enum CalendarError { + + NOT_AVAILABLE, + NOT_SUPPORTED, + PERMISSION_DENIED, + AUTHENTICATION_REQUIRED, + INVALID_ARGUMENT, + NOT_FOUND, + READ_ONLY, + CONFLICT, + RATE_LIMITED, + NETWORK, + CANCELED, + MALFORMED_RESPONSE, + STORAGE, + UNKNOWN +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarEvent.java b/CodenameOne/src/com/codename1/calendar/CalendarEvent.java new file mode 100644 index 00000000000..0c798f5f8f5 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarEvent.java @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A portable calendar event. Mutable setters make it convenient to create an +/// event, while sources return detached instances so callers may safely edit. +public class CalendarEvent { + + public enum Status { + + CONFIRMED, TENTATIVE, CANCELED + } + + public enum Availability { + + BUSY, FREE, TENTATIVE, OUT_OF_OFFICE, WORKING_ELSEWHERE + } + + public enum Privacy { + + DEFAULT, PUBLIC, PRIVATE, CONFIDENTIAL + } + + private String id; + + private String calendarId; + + private String sourceId; + + private String version; + + private String title; + + private String description; + + private String location; + + private String url; + + private String recurringEventId; + + private CalendarDateTime start; + + private CalendarDateTime end; + + private CalendarRecurrenceRule recurrence; + + private Status status = Status.CONFIRMED; + + private Availability availability = Availability.BUSY; + + private Privacy privacy = Privacy.DEFAULT; + + private CalendarConference conference; + + private final List attendees = new ArrayList(); + + private final List alarms = new ArrayList(); + + private final List attachments = new ArrayList(); + + private final Map providerData = new HashMap(); + + public String getId() { + return id; + } + + public CalendarEvent setId(String v) { + id = v; + return this; + } + + public String getCalendarId() { + return calendarId; + } + + public CalendarEvent setCalendarId(String v) { + calendarId = v; + return this; + } + + public String getSourceId() { + return sourceId; + } + + public CalendarEvent setSourceId(String v) { + sourceId = v; + return this; + } + + public String getVersion() { + return version; + } + + public CalendarEvent setVersion(String v) { + version = v; + return this; + } + + public String getTitle() { + return title; + } + + public CalendarEvent setTitle(String v) { + title = v; + return this; + } + + public String getDescription() { + return description; + } + + public CalendarEvent setDescription(String v) { + description = v; + return this; + } + + public String getLocation() { + return location; + } + + public CalendarEvent setLocation(String v) { + location = v; + return this; + } + + public String getUrl() { + return url; + } + + public CalendarEvent setUrl(String v) { + url = v; + return this; + } + + public String getRecurringEventId() { + return recurringEventId; + } + + public CalendarEvent setRecurringEventId(String v) { + recurringEventId = v; + return this; + } + + public CalendarDateTime getStart() { + return start; + } + + public CalendarEvent setStart(CalendarDateTime v) { + start = v; + return this; + } + + public CalendarDateTime getEnd() { + return end; + } + + public CalendarEvent setEnd(CalendarDateTime v) { + end = v; + return this; + } + + public CalendarRecurrenceRule getRecurrence() { + return recurrence; + } + + public CalendarEvent setRecurrence(CalendarRecurrenceRule v) { + recurrence = v; + return this; + } + + public Status getStatus() { + return status; + } + + public CalendarEvent setStatus(Status v) { + status = v == null ? Status.CONFIRMED : v; + return this; + } + + public Availability getAvailability() { + return availability; + } + + public CalendarEvent setAvailability(Availability v) { + availability = v == null ? Availability.BUSY : v; + return this; + } + + public Privacy getPrivacy() { + return privacy; + } + + public CalendarEvent setPrivacy(Privacy v) { + privacy = v == null ? Privacy.DEFAULT : v; + return this; + } + + public CalendarConference getConference() { + return conference; + } + + public CalendarEvent setConference(CalendarConference v) { + conference = v; + return this; + } + + public CalendarEvent addAttendee(CalendarAttendee v) { + if (v != null) { + attendees.add(v); + } + return this; + } + + public List getAttendees() { + return Collections.unmodifiableList(attendees); + } + + public CalendarEvent addAlarm(CalendarAlarm v) { + if (v != null) { + alarms.add(v); + } + return this; + } + + public List getAlarms() { + return Collections.unmodifiableList(alarms); + } + + public CalendarEvent addAttachment(CalendarAttachment v) { + if (v != null) { + attachments.add(v); + } + return this; + } + + public List getAttachments() { + return Collections.unmodifiableList(attachments); + } + + public CalendarEvent putProviderData(String k, String v) { + if (k != null) { + providerData.put(k, v); + } + return this; + } + + public Map getProviderData() { + return Collections.unmodifiableMap(providerData); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarException.java b/CodenameOne/src/com/codename1/calendar/CalendarException.java new file mode 100644 index 00000000000..8ac1b156c5a --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarException.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// Exception returned by asynchronous calendar operations. +public class CalendarException extends Exception { + + private final CalendarError error; + + private final int responseCode; + + public CalendarException(CalendarError error, String message) { + this(error, message, 0, null); + } + + public CalendarException(CalendarError error, String message, Throwable cause) { + this(error, message, 0, cause); + } + + public CalendarException(CalendarError error, String message, int responseCode, Throwable cause) { + super(message, cause); + this.error = error == null ? CalendarError.UNKNOWN : error; + this.responseCode = responseCode; + } + + public CalendarError getError() { + return error; + } + + public int getResponseCode() { + return responseCode; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarHttpRequest.java b/CodenameOne/src/com/codename1/calendar/CalendarHttpRequest.java new file mode 100644 index 00000000000..511cb54518e --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarHttpRequest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/// Provider HTTP request. Public so applications and tests may inject a transport. +public final class CalendarHttpRequest { + + private final String method; + + private final String url; + + private String body; + + private final Map headers = new LinkedHashMap(); + + public CalendarHttpRequest(String method, String url) { + this.method = method; + this.url = url; + } + + public String getMethod() { + return method; + } + + public String getUrl() { + return url; + } + + public String getBody() { + return body; + } + + public CalendarHttpRequest setBody(String v) { + body = v; + return this; + } + + public CalendarHttpRequest header(String k, String v) { + headers.put(k, v); + return this; + } + + public Map getHeaders() { + return Collections.unmodifiableMap(headers); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarHttpResponse.java b/CodenameOne/src/com/codename1/calendar/CalendarHttpResponse.java new file mode 100644 index 00000000000..586279bafe2 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarHttpResponse.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/// Provider HTTP response body and selected concurrency/authentication headers. +public final class CalendarHttpResponse { + + private final int statusCode; + + private final String body; + + private final Map headers; + + public CalendarHttpResponse(int statusCode, String body, Map headers) { + this.statusCode = statusCode; + this.body = body; + this.headers = Collections.unmodifiableMap(headers == null ? new HashMap() : new HashMap(headers)); + } + + public int getStatusCode() { + return statusCode; + } + + public String getBody() { + return body; + } + + public String getHeader(String name) { + return headers.get(name); + } + + public Map getHeaders() { + return headers; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarHttpTransport.java b/CodenameOne/src/com/codename1/calendar/CalendarHttpTransport.java new file mode 100644 index 00000000000..003ec4489f6 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarHttpTransport.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.util.AsyncResource; + +public interface CalendarHttpTransport { + + AsyncResource execute(CalendarHttpRequest request); +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarInfo.java b/CodenameOne/src/com/codename1/calendar/CalendarInfo.java new file mode 100644 index 00000000000..3279060d8b3 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarInfo.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.time.ZoneId; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/// A calendar or task-list collection exposed by a source. +public class CalendarInfo { + + public enum ContentType { + + EVENTS, TASKS + } + + private String id; + + private String sourceId; + + private String accountId; + + private String name; + + private String owner; + + private ZoneId timeZone; + + private int color; + + private boolean primary; + + private boolean readOnly; + + private ContentType contentType = ContentType.EVENTS; + + private CalendarCapabilities capabilities = CalendarCapabilities.none(); + + private final Map providerData = new HashMap(); + + public String getId() { + return id; + } + + public CalendarInfo setId(String v) { + id = v; + return this; + } + + public String getSourceId() { + return sourceId; + } + + public CalendarInfo setSourceId(String v) { + sourceId = v; + return this; + } + + public String getAccountId() { + return accountId; + } + + public CalendarInfo setAccountId(String v) { + accountId = v; + return this; + } + + public String getName() { + return name; + } + + public CalendarInfo setName(String v) { + name = v; + return this; + } + + public String getOwner() { + return owner; + } + + public CalendarInfo setOwner(String v) { + owner = v; + return this; + } + + public ZoneId getTimeZone() { + return timeZone; + } + + public CalendarInfo setTimeZone(ZoneId v) { + timeZone = v; + return this; + } + + public int getColor() { + return color; + } + + public CalendarInfo setColor(int v) { + color = v; + return this; + } + + public boolean isPrimary() { + return primary; + } + + public CalendarInfo setPrimary(boolean v) { + primary = v; + return this; + } + + public boolean isReadOnly() { + return readOnly; + } + + public CalendarInfo setReadOnly(boolean v) { + readOnly = v; + return this; + } + + public ContentType getContentType() { + return contentType; + } + + public CalendarInfo setContentType(ContentType v) { + contentType = v == null ? ContentType.EVENTS : v; + return this; + } + + public CalendarCapabilities getCapabilities() { + return capabilities; + } + + public CalendarInfo setCapabilities(CalendarCapabilities v) { + capabilities = v == null ? CalendarCapabilities.none() : v; + return this; + } + + public CalendarInfo putProviderData(String k, String v) { + if (k != null) { + providerData.put(k, v); + } + return this; + } + + public Map getProviderData() { + return Collections.unmodifiableMap(providerData); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarManager.java b/CodenameOne/src/com/codename1/calendar/CalendarManager.java new file mode 100644 index 00000000000..85273f13406 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarManager.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Registry and entry point for device and online calendar sources. +public final class CalendarManager { + + private static final CalendarManager INSTANCE = new CalendarManager(); + + private final Map sources = new LinkedHashMap(); + + private CalendarManager() { + } + + public static CalendarManager getInstance() { + return INSTANCE; + } + + public synchronized CalendarManager registerSource(CalendarSource source) { + if (source == null) { + throw new IllegalArgumentException("source required"); + } + sources.put(source.getId(), source); + return this; + } + + public synchronized CalendarSource removeSource(String id) { + return sources.remove(id); + } + + public synchronized CalendarSource getSource(String id) { + return sources.get(id); + } + + public synchronized List getSources() { + return Collections.unmodifiableList(new ArrayList(sources.values())); + } + + public synchronized LocalCalendarSource getLocalSource() { + LocalCalendarSource local = LocalCalendarSource.getInstance(); + sources.put(local.getId(), local); + return local; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarModelCodec.java b/CodenameOne/src/com/codename1/calendar/CalendarModelCodec.java new file mode 100644 index 00000000000..4f8207b5709 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarModelCodec.java @@ -0,0 +1,415 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Lossless map encoding used by the offline cache and provider fixtures. +public final class CalendarModelCodec { + + private CalendarModelCodec() { + } + + public static Map encodeEvent(CalendarEvent e) { + Map m = common(e.getId(), e.getCalendarId(), e.getSourceId(), e.getVersion(), e.getTitle(), e.getDescription(), e.getLocation()); + put(m, "url", e.getUrl()); + put(m, "recurringEventId", e.getRecurringEventId()); + put(m, "start", encodeDateTime(e.getStart())); + put(m, "end", encodeDateTime(e.getEnd())); + put(m, "status", e.getStatus().name()); + put(m, "availability", e.getAvailability().name()); + put(m, "privacy", e.getPrivacy().name()); + put(m, "recurrence", encodeRecurrence(e.getRecurrence())); + List attendees = new ArrayList(); + for (CalendarAttendee a : e.getAttendees()) { + attendees.add(encodeAttendee(a)); + } + m.put("attendees", attendees); + List alarms = new ArrayList(); + for (CalendarAlarm a : e.getAlarms()) { + alarms.add(encodeAlarm(a)); + } + m.put("alarms", alarms); + List attachments = new ArrayList(); + for (CalendarAttachment a : e.getAttachments()) { + attachments.add(encodeAttachment(a)); + } + m.put("attachments", attachments); + put(m, "conference", encodeConference(e.getConference())); + m.put("providerData", new HashMap(e.getProviderData())); + return m; + } + + public static CalendarEvent decodeEvent(Map m) { + CalendarEvent e = new CalendarEvent().setId(s(m, "id")).setCalendarId(s(m, "calendarId")).setSourceId(s(m, "sourceId")).setVersion(s(m, "version")).setTitle(s(m, "title")).setDescription(s(m, "description")).setLocation(s(m, "location")).setUrl(s(m, "url")).setRecurringEventId(s(m, "recurringEventId")).setStart(decodeDateTime(map(m, "start"))).setEnd(decodeDateTime(map(m, "end"))); + e.setStatus(eventStatus(s(m, "status"))); + e.setAvailability(eventAvailability(s(m, "availability"))); + e.setPrivacy(eventPrivacy(s(m, "privacy"))); + e.setRecurrence(decodeRecurrence(map(m, "recurrence"))); + for (Object v : list(m, "attendees")) { + if (v instanceof Map) { + e.addAttendee(decodeAttendee((Map) v)); + } + } + for (Object v : list(m, "alarms")) { + if (v instanceof Map) { + e.addAlarm(decodeAlarm((Map) v)); + } + } + for (Object v : list(m, "attachments")) { + if (v instanceof Map) { + e.addAttachment(decodeAttachment((Map) v)); + } + } + e.setConference(decodeConference(map(m, "conference"))); + copyProvider(m, e, null); + return e; + } + + public static Map encodeTask(CalendarTask t) { + Map m = common(t.getId(), t.getCalendarId(), t.getSourceId(), t.getVersion(), t.getTitle(), t.getDescription(), t.getLocation()); + put(m, "start", encodeDateTime(t.getStart())); + put(m, "due", encodeDateTime(t.getDue())); + put(m, "recurrence", encodeRecurrence(t.getRecurrence())); + m.put("completed", Boolean.valueOf(t.isCompleted())); + put(m, "completionTime", epochMillis(t.getCompletionTime())); + m.put("priority", Integer.valueOf(t.getPriority())); + List alarms = new ArrayList(); + for (CalendarAlarm a : t.getAlarms()) { + alarms.add(encodeAlarm(a)); + } + m.put("alarms", alarms); + List attachments = new ArrayList(); + for (CalendarAttachment a : t.getAttachments()) { + attachments.add(encodeAttachment(a)); + } + m.put("attachments", attachments); + m.put("providerData", new HashMap(t.getProviderData())); + return m; + } + + public static CalendarTask decodeTask(Map m) { + CalendarTask t = new CalendarTask().setId(s(m, "id")).setCalendarId(s(m, "calendarId")).setSourceId(s(m, "sourceId")).setVersion(s(m, "version")).setTitle(s(m, "title")).setDescription(s(m, "description")).setLocation(s(m, "location")).setStart(decodeDateTime(map(m, "start"))).setDue(decodeDateTime(map(m, "due"))).setRecurrence(decodeRecurrence(map(m, "recurrence"))).setCompleted(bool(m, "completed")).setCompletionTime(instantObj(m, "completionTime")).setPriority(integer(m, "priority", 0)); + for (Object v : list(m, "alarms")) { + if (v instanceof Map) { + t.addAlarm(decodeAlarm((Map) v)); + } + } + for (Object v : list(m, "attachments")) { + if (v instanceof Map) { + t.addAttachment(decodeAttachment((Map) v)); + } + } + copyProvider(m, null, t); + return t; + } + + public static Map encodeDateTime(CalendarDateTime d) { + if (d == null) { + return null; + } + Map m = new HashMap(); + m.put("allDay", Boolean.valueOf(d.isAllDay())); + if (d.isAllDay()) { + m.put("year", d.getDate().getYear()); + m.put("month", d.getDate().getMonthValue()); + m.put("day", d.getDate().getDayOfMonth()); + } else { + m.put("timestamp", Long.valueOf(d.getDateTime().toInstant().toEpochMilli())); + m.put("timeZoneId", d.getDateTime().getZone().getId()); + } + return m; + } + + public static CalendarDateTime decodeDateTime(Map m) { + if (m == null) { + return null; + } + if (bool(m, "allDay")) { + return CalendarDateTime.allDay(LocalDate.of(integer(m, "year", 1970), integer(m, "month", 1), integer(m, "day", 1))); + } + String zone = s(m, "timeZoneId") == null ? "UTC" : s(m, "timeZoneId"); + return CalendarDateTime.timed(ZonedDateTime.ofInstant(Instant.ofEpochMilli(lng(m, "timestamp", 0L)), ZoneId.of(zone))); + } + + private static Map common(String id, String cal, String src, String ver, String title, String desc, String loc) { + Map m = new HashMap(); + put(m, "id", id); + put(m, "calendarId", cal); + put(m, "sourceId", src); + put(m, "version", ver); + put(m, "title", title); + put(m, "description", desc); + put(m, "location", loc); + return m; + } + + private static Map encodeRecurrence(CalendarRecurrenceRule r) { + if (r == null) { + return null; + } + Map m = new HashMap(); + put(m, "frequency", r.getFrequency() == null ? null : r.getFrequency().name()); + m.put("interval", r.getInterval()); + put(m, "count", r.getCount()); + put(m, "until", encodeDateTime(r.getUntil())); + m.put("daysOfWeek", new ArrayList(r.getDaysOfWeek())); + m.put("daysOfMonth", new ArrayList(r.getDaysOfMonth())); + m.put("months", new ArrayList(r.getMonths())); + return m; + } + + private static CalendarRecurrenceRule decodeRecurrence(Map m) { + if (m == null) { + return null; + } + CalendarRecurrenceRule r = new CalendarRecurrenceRule().setInterval(integer(m, "interval", 1)).setCount(intObj(m, "count")).setUntil(decodeDateTime(map(m, "until"))); + r.setFrequency(frequency(s(m, "frequency"))); + for (Object v : list(m, "daysOfWeek")) { + r.addDayOfWeek(((Number) v).intValue()); + } + for (Object v : list(m, "daysOfMonth")) { + r.addDayOfMonth(((Number) v).intValue()); + } + for (Object v : list(m, "months")) { + r.addMonth(((Number) v).intValue()); + } + return r; + } + + private static Map encodeAttendee(CalendarAttendee a) { + Map m = new HashMap(); + put(m, "name", a.getName()); + put(m, "email", a.getEmail()); + put(m, "uri", a.getUri()); + m.put("role", a.getRole().name()); + m.put("response", a.getResponse().name()); + m.put("organizer", a.isOrganizer()); + m.put("self", a.isSelf()); + return m; + } + + private static CalendarAttendee decodeAttendee(Map m) { + return new CalendarAttendee().setName(s(m, "name")).setEmail(s(m, "email")).setUri(s(m, "uri")).setOrganizer(bool(m, "organizer")).setSelf(bool(m, "self")).setRole(attendeeRole(s(m, "role"))).setResponse(attendeeResponse(s(m, "response"))); + } + + private static Map encodeAlarm(CalendarAlarm a) { + Map m = new HashMap(); + put(m, "timeBeforeMillis", a.getTimeBefore() == null ? null : Long.valueOf(a.getTimeBefore().toMillis())); + put(m, "absoluteTime", epochMillis(a.getAbsoluteTime())); + m.put("method", a.getMethod().name()); + return m; + } + + private static CalendarAlarm decodeAlarm(Map m) { + CalendarAlarm a = new CalendarAlarm(); + if (m.get("timeBeforeMillis") != null) { + a.setTimeBefore(Duration.ofMillis(lng(m, "timeBeforeMillis", 0L))); + } else { + a.setAbsoluteTime(instantObj(m, "absoluteTime")); + } + a.setMethod(alarmMethod(s(m, "method"))); + return a; + } + + private static Map encodeAttachment(CalendarAttachment a) { + Map m = new HashMap(); + put(m, "id", a.getId()); + put(m, "name", a.getName()); + put(m, "mimeType", a.getMimeType()); + put(m, "uri", a.getUri()); + m.put("size", Long.valueOf(a.getSize())); + put(m, "content", a.getContent()); + return m; + } + + private static CalendarAttachment decodeAttachment(Map m) { + return new CalendarAttachment().setId(s(m, "id")).setName(s(m, "name")).setMimeType(s(m, "mimeType")).setUri(s(m, "uri")).setSize(lng(m, "size", -1)).setContent((byte[]) m.get("content")); + } + + private static Map encodeConference(CalendarConference c) { + if (c == null) { + return null; + } + Map m = new HashMap(); + put(m, "provider", c.getProvider()); + put(m, "id", c.getId()); + put(m, "joinUrl", c.getJoinUrl()); + m.put("createRequested", c.isCreateRequested()); + m.put("phoneNumbers", new ArrayList(c.getPhoneNumbers())); + return m; + } + + private static CalendarConference decodeConference(Map m) { + if (m == null) { + return null; + } + CalendarConference c = new CalendarConference().setProvider(s(m, "provider")).setId(s(m, "id")).setJoinUrl(s(m, "joinUrl")).setCreateRequested(bool(m, "createRequested")); + for (Object v : list(m, "phoneNumbers")) { + c.addPhoneNumber(String.valueOf(v)); + } + return c; + } + + private static void copyProvider(Map m, CalendarEvent e, CalendarTask t) { + Map p = map(m, "providerData"); + if (p != null) { + for (Object value : p.entrySet()) { + Map.Entry entry = (Map.Entry) value; + String key = String.valueOf(entry.getKey()); + String text = entry.getValue() == null ? null : String.valueOf(entry.getValue()); + if (e != null) { + e.putProviderData(key, text); + } else { + t.putProviderData(key, text); + } + } + } + } + + private static CalendarEvent.Status eventStatus(String value) { + for (CalendarEvent.Status candidate : CalendarEvent.Status.values()) { + if (candidate.name().equals(value)) { + return candidate; + } + } + return CalendarEvent.Status.CONFIRMED; + } + + private static CalendarEvent.Availability eventAvailability(String value) { + for (CalendarEvent.Availability candidate : CalendarEvent.Availability.values()) { + if (candidate.name().equals(value)) { + return candidate; + } + } + return CalendarEvent.Availability.BUSY; + } + + private static CalendarEvent.Privacy eventPrivacy(String value) { + for (CalendarEvent.Privacy candidate : CalendarEvent.Privacy.values()) { + if (candidate.name().equals(value)) { + return candidate; + } + } + return CalendarEvent.Privacy.DEFAULT; + } + + private static CalendarRecurrenceRule.Frequency frequency(String value) { + for (CalendarRecurrenceRule.Frequency candidate : CalendarRecurrenceRule.Frequency.values()) { + if (candidate.name().equals(value)) { + return candidate; + } + } + return null; + } + + private static CalendarAttendee.Role attendeeRole(String value) { + for (CalendarAttendee.Role candidate : CalendarAttendee.Role.values()) { + if (candidate.name().equals(value)) { + return candidate; + } + } + return CalendarAttendee.Role.REQUIRED; + } + + private static CalendarAttendee.Response attendeeResponse(String value) { + for (CalendarAttendee.Response candidate : CalendarAttendee.Response.values()) { + if (candidate.name().equals(value)) { + return candidate; + } + } + return CalendarAttendee.Response.NONE; + } + + private static CalendarAlarm.Method alarmMethod(String value) { + for (CalendarAlarm.Method candidate : CalendarAlarm.Method.values()) { + if (candidate.name().equals(value)) { + return candidate; + } + } + return CalendarAlarm.Method.DEFAULT; + } + + private static void put(Map m, String k, Object v) { + if (v != null) { + m.put(k, v); + } + } + + private static String s(Map m, String k) { + Object v = m.get(k); + return v == null ? null : String.valueOf(v); + } + + private static Map map(Map m, String k) { + Object v = m.get(k); + return v instanceof Map ? (Map) v : null; + } + + private static List list(Map m, String k) { + Object v = m.get(k); + return v instanceof List ? (List) v : new ArrayList(); + } + + private static boolean bool(Map m, String k) { + Object v = m.get(k); + return Boolean.TRUE.equals(v) || "true".equals(String.valueOf(v)); + } + + private static int integer(Map m, String k, int d) { + Object v = m.get(k); + return v instanceof Number ? ((Number) v).intValue() : d; + } + + private static long lng(Map m, String k, long d) { + Object v = m.get(k); + return v instanceof Number ? ((Number) v).longValue() : d; + } + + private static Integer intObj(Map m, String k) { + Object v = m.get(k); + return v instanceof Number ? Integer.valueOf(((Number) v).intValue()) : null; + } + + private static Long lngObj(Map m, String k) { + Object v = m.get(k); + return v instanceof Number ? Long.valueOf(((Number) v).longValue()) : null; + } + + private static Long epochMillis(Instant value) { + return value == null ? null : Long.valueOf(value.toEpochMilli()); + } + + private static Instant instantObj(Map m, String k) { + Long value = lngObj(m, k); + return value == null ? null : Instant.ofEpochMilli(value.longValue()); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarMutationScope.java b/CodenameOne/src/com/codename1/calendar/CalendarMutationScope.java new file mode 100644 index 00000000000..fb20f8ed61e --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarMutationScope.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +/// Scope when modifying or deleting a recurring item. +public enum CalendarMutationScope { + + THIS_INSTANCE, THIS_AND_FUTURE, ALL +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarPage.java b/CodenameOne/src/com/codename1/calendar/CalendarPage.java new file mode 100644 index 00000000000..ce38827aad9 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarPage.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// One provider page, including continuation and incremental-sync tokens. +public final class CalendarPage { + + private final List items; + + private final String nextPageToken; + + private final String syncToken; + + public CalendarPage(List items, String nextPageToken, String syncToken) { + this.items = Collections.unmodifiableList(items == null ? new ArrayList() : new ArrayList(items)); + this.nextPageToken = nextPageToken; + this.syncToken = syncToken; + } + + public List getItems() { + return items; + } + + public String getNextPageToken() { + return nextPageToken; + } + + public String getSyncToken() { + return syncToken; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarQuery.java b/CodenameOne/src/com/codename1/calendar/CalendarQuery.java new file mode 100644 index 00000000000..105c0c3e0c0 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarQuery.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.time.Instant; + +/// Query options shared by event and task listing operations. +public class CalendarQuery { + + private String calendarId; + + private String text; + + private String pageToken; + + private String syncToken; + + private Instant startTime; + + private Instant endTime; + + private int pageSize = 100; + + private boolean expandRecurrences = true; + + private boolean includeDeleted; + + public String getCalendarId() { + return calendarId; + } + + public CalendarQuery setCalendarId(String v) { + calendarId = v; + return this; + } + + public String getText() { + return text; + } + + public CalendarQuery setText(String v) { + text = v; + return this; + } + + public String getPageToken() { + return pageToken; + } + + public CalendarQuery setPageToken(String v) { + pageToken = v; + return this; + } + + public String getSyncToken() { + return syncToken; + } + + public CalendarQuery setSyncToken(String v) { + syncToken = v; + return this; + } + + public Instant getStartTime() { + return startTime; + } + + public CalendarQuery setStartTime(Instant v) { + startTime = v; + return this; + } + + public Instant getEndTime() { + return endTime; + } + + public CalendarQuery setEndTime(Instant v) { + endTime = v; + return this; + } + + public int getPageSize() { + return pageSize; + } + + public CalendarQuery setPageSize(int v) { + if (v < 1) { + throw new IllegalArgumentException("pageSize"); + } + pageSize = v; + return this; + } + + public boolean isExpandRecurrences() { + return expandRecurrences; + } + + public CalendarQuery setExpandRecurrences(boolean v) { + expandRecurrences = v; + return this; + } + + public boolean isIncludeDeleted() { + return includeDeleted; + } + + public CalendarQuery setIncludeDeleted(boolean v) { + includeDeleted = v; + return this; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarRecurrenceRule.java b/CodenameOne/src/com/codename1/calendar/CalendarRecurrenceRule.java new file mode 100644 index 00000000000..bb69207cdb5 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarRecurrenceRule.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Portable RFC-5545-style recurrence rule. +public class CalendarRecurrenceRule { + + public enum Frequency { + + DAILY, WEEKLY, MONTHLY, YEARLY + } + + private Frequency frequency; + + private int interval = 1; + + private Integer count; + + private CalendarDateTime until; + + private final List daysOfWeek = new ArrayList(); + + private final List daysOfMonth = new ArrayList(); + + private final List months = new ArrayList(); + + public Frequency getFrequency() { + return frequency; + } + + public CalendarRecurrenceRule setFrequency(Frequency v) { + frequency = v; + return this; + } + + public int getInterval() { + return interval; + } + + public CalendarRecurrenceRule setInterval(int v) { + if (v < 1) { + throw new IllegalArgumentException("interval"); + } + interval = v; + return this; + } + + public Integer getCount() { + return count; + } + + public CalendarRecurrenceRule setCount(Integer v) { + count = v; + return this; + } + + public CalendarDateTime getUntil() { + return until; + } + + public CalendarRecurrenceRule setUntil(CalendarDateTime v) { + until = v; + return this; + } + + public CalendarRecurrenceRule addDayOfWeek(int v) { + if (v < 1 || v > 7) { + throw new IllegalArgumentException("dayOfWeek"); + } + daysOfWeek.add(v); + return this; + } + + public CalendarRecurrenceRule addDayOfMonth(int v) { + if (v == 0 || v < -31 || v > 31) { + throw new IllegalArgumentException("dayOfMonth"); + } + daysOfMonth.add(v); + return this; + } + + public CalendarRecurrenceRule addMonth(int v) { + if (v < 1 || v > 12) { + throw new IllegalArgumentException("month"); + } + months.add(v); + return this; + } + + public List getDaysOfWeek() { + return Collections.unmodifiableList(daysOfWeek); + } + + public List getDaysOfMonth() { + return Collections.unmodifiableList(daysOfMonth); + } + + public List getMonths() { + return Collections.unmodifiableList(months); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarSource.java b/CodenameOne/src/com/codename1/calendar/CalendarSource.java new file mode 100644 index 00000000000..fbd40f6e430 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarSource.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// Base contract shared by local stores and online providers. +public abstract class CalendarSource { + + private final String id; + + private final String displayName; + + private final List listeners = new ArrayList(); + + protected CalendarSource(String id, String displayName) { + if (id == null || id.length() == 0) { + throw new IllegalArgumentException("id required"); + } + this.id = id; + this.displayName = displayName == null ? id : displayName; + } + + public final String getId() { + return id; + } + + public final String getDisplayName() { + return displayName; + } + + public boolean isAvailable() { + return !getCapabilities().asSet().isEmpty(); + } + + public CalendarCapabilities getCapabilities() { + return CalendarCapabilities.none(); + } + + public CalendarAuthorizationStatus getAuthorizationStatus(CalendarAccess access) { + return CalendarAuthorizationStatus.DENIED; + } + + public AsyncResource requestAuthorization(CalendarAccess access) { + return unavailable("Calendar authorization is unavailable"); + } + + public AsyncResource> listCalendars(CalendarInfo.ContentType type, String pageToken) { + return unsupported("Listing calendars"); + } + + public AsyncResource saveCalendar(CalendarInfo calendar) { + return unsupported("Saving calendars"); + } + + public AsyncResource deleteCalendar(String calendarId) { + return unsupported("Deleting calendars"); + } + + public AsyncResource> queryEvents(CalendarQuery query) { + return unsupported("Querying events"); + } + + public AsyncResource getEvent(String calendarId, String eventId) { + return unsupported("Reading events"); + } + + public AsyncResource saveEvent(CalendarEvent event, CalendarMutationScope scope) { + return unsupported("Saving events"); + } + + public AsyncResource deleteEvent(String calendarId, String eventId, CalendarMutationScope scope, String version) { + return unsupported("Deleting events"); + } + + public AsyncResource respondToEvent(String calendarId, String eventId, CalendarAttendee.Response response, String comment) { + return unsupported("Responding to events"); + } + + public AsyncResource> queryFreeBusy(List calendarIds, Instant startTime, Instant endTime) { + return unsupported("Free/busy"); + } + + public AsyncResource> queryTasks(CalendarQuery query) { + return unsupported("Querying tasks"); + } + + public AsyncResource getTask(String calendarId, String taskId) { + return unsupported("Reading tasks"); + } + + public AsyncResource saveTask(CalendarTask task, CalendarMutationScope scope) { + return unsupported("Saving tasks"); + } + + public AsyncResource deleteTask(String calendarId, String taskId, CalendarMutationScope scope, String version) { + return unsupported("Deleting tasks"); + } + + public final synchronized void addChangeListener(CalendarChangeListener listener) { + if (listener != null && !listeners.contains(listener)) { + listeners.add(listener); + } + } + + public final synchronized void removeChangeListener(CalendarChangeListener listener) { + listeners.remove(listener); + } + + protected final void fireChange(final CalendarChange change) { + final List snapshot; + synchronized (this) { + snapshot = new ArrayList(listeners); + } + Runnable notify = new Runnable() { + + @Override + public void run() { + for (CalendarChangeListener listener : snapshot) { + listener.calendarChanged(change); + } + } + }; + if (Display.isInitialized() && !Display.getInstance().isEdt()) { + Display.getInstance().callSerially(notify); + } else { + notify.run(); + } + } + + protected static AsyncResource unsupported(String operation) { + AsyncResource out = new AsyncResource(); + out.error(new CalendarException(CalendarError.NOT_SUPPORTED, operation + " is not supported by this source")); + return out; + } + + protected static AsyncResource unavailable(String message) { + AsyncResource out = new AsyncResource(); + out.error(new CalendarException(CalendarError.NOT_AVAILABLE, message)); + return out; + } + + protected static AsyncResource completed(T value) { + AsyncResource out = new AsyncResource(); + out.complete(value); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarSyncEngine.java b/CodenameOne/src/com/codename1/calendar/CalendarSyncEngine.java new file mode 100644 index 00000000000..05f524a9c31 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarSyncEngine.java @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Opt-in local-first mutation queue. It deliberately does not own credentials +/// or silently schedule work; applications call `sync()` or connect it to a +/// platform background-work callback. +public final class CalendarSyncEngine { + + private static final String MUTATIONS = "mutations"; + + private final CalendarSource source; + + private final CalendarCache cache; + + private Map state; + + private long nextId; + + public CalendarSyncEngine(CalendarSource source, CalendarCache cache) throws CalendarException { + if (source == null || cache == null) { + throw new IllegalArgumentException("source and cache required"); + } + this.source = source; + this.cache = cache; + state = cache.load(source.getId()); + if (!(state.get(MUTATIONS) instanceof List)) { + state.put(MUTATIONS, new ArrayList()); + } + nextId = System.currentTimeMillis(); + } + + public synchronized String queueEventSave(CalendarEvent event, CalendarMutationScope scope) throws CalendarException { + return queue("saveEvent", event.getCalendarId(), event.getId(), event.getVersion(), CalendarModelCodec.encodeEvent(event), scope); + } + + public synchronized String queueEventDelete(String calendarId, String eventId, String version, CalendarMutationScope scope) throws CalendarException { + return queue("deleteEvent", calendarId, eventId, version, null, scope); + } + + public synchronized String queueTaskSave(CalendarTask task, CalendarMutationScope scope) throws CalendarException { + return queue("saveTask", task.getCalendarId(), task.getId(), task.getVersion(), CalendarModelCodec.encodeTask(task), scope); + } + + public synchronized String queueTaskDelete(String calendarId, String taskId, String version, CalendarMutationScope scope) throws CalendarException { + return queue("deleteTask", calendarId, taskId, version, null, scope); + } + + private String queue(String type, String calendarId, String itemId, String version, Map payload, CalendarMutationScope scope) throws CalendarException { + String id = source.getId() + "-" + (++nextId); + Map mutation = new HashMap(); + mutation.put("id", id); + mutation.put("type", type); + mutation.put("calendarId", calendarId); + mutation.put("itemId", itemId); + mutation.put("version", version); + mutation.put("payload", payload); + mutation.put("scope", (scope == null ? CalendarMutationScope.ALL : scope).name()); + mutations().add(mutation); + persist(); + return id; + } + + public synchronized int getPendingCount() { + return mutations().size(); + } + + public AsyncResource sync() { + final AsyncResource out = new AsyncResource(); + final CalendarSyncResult result = new CalendarSyncResult(); + syncNext(out, result); + return out; + } + + private void syncNext(final AsyncResource out, final CalendarSyncResult result) { + final Map mutation; + synchronized (this) { + mutation = firstRunnableMutation(); + if (mutation == null) { + result.setRemaining(mutations().size()); + out.complete(result); + return; + } + } + AsyncResource operation = execute(mutation); + operation.ready(new SuccessCallback() { + + @Override + public void onSucess(Object value) { + synchronized (CalendarSyncEngine.this) { + mutations().remove(mutation); + result.applied(); + try { + persist(); + } catch (CalendarException ex) { + out.error(ex); + return; + } + } + syncNext(out, result); + } + }).except(new SuccessCallback() { + + @Override + public void onSucess(Throwable error) { + if (error instanceof CalendarException && ((CalendarException) error).getError() == CalendarError.CONFLICT) { + loadConflict(mutation, out, result); + return; + } + result.setRemaining(getPendingCount()); + out.error(error); + } + }); + } + + private AsyncResource execute(Map m) { + String type = string(m, "type"); + String cal = string(m, "calendarId"); + String item = string(m, "itemId"); + String version = string(m, "version"); + CalendarMutationScope scope; + try { + scope = CalendarMutationScope.valueOf(string(m, "scope")); + } catch (Exception e) { + scope = CalendarMutationScope.ALL; + } + if ("saveEvent".equals(type)) { + return source.saveEvent(CalendarModelCodec.decodeEvent((Map) m.get("payload")), scope); + } + if ("deleteEvent".equals(type)) { + return source.deleteEvent(cal, item, scope, version); + } + if ("saveTask".equals(type)) { + return source.saveTask(CalendarModelCodec.decodeTask((Map) m.get("payload")), scope); + } + if ("deleteTask".equals(type)) { + return source.deleteTask(cal, item, scope, version); + } + return CalendarSource.unsupported("Unknown offline mutation"); + } + + private void loadConflict(final Map mutation, final AsyncResource out, final CalendarSyncResult result) { + String type = string(mutation, "type"); + AsyncResource remote = type.indexOf("Event") >= 0 ? source.getEvent(string(mutation, "calendarId"), string(mutation, "itemId")) : source.getTask(string(mutation, "calendarId"), string(mutation, "itemId")); + remote.ready(new SuccessCallback() { + + @Override + public void onSucess(Object value) { + Map remoteMap = value instanceof CalendarEvent ? CalendarModelCodec.encodeEvent((CalendarEvent) value) : value instanceof CalendarTask ? CalendarModelCodec.encodeTask((CalendarTask) value) : null; + synchronized (CalendarSyncEngine.this) { + mutation.put("paused", Boolean.TRUE); + mutation.put("remote", remoteMap); + try { + persist(); + } catch (CalendarException ex) { + out.error(ex); + return; + } + } + result.addConflict(new CalendarConflict(string(mutation, "id"), (Map) mutation.get("payload"), remoteMap)); + syncNext(out, result); + } + }).except(new SuccessCallback() { + + @Override + public void onSucess(Throwable error) { + out.error(error); + } + }); + } + + public synchronized void resolveConflict(String mutationId, CalendarConflict.Resolution resolution, Map merged) throws CalendarException { + Map found = find(mutationId); + if (found == null) { + throw new CalendarException(CalendarError.NOT_FOUND, "Conflict not found"); + } + if (resolution == CalendarConflict.Resolution.KEEP_REMOTE) { + mutations().remove(found); + } else { + Map remote = (Map) found.get("remote"); + if (resolution == CalendarConflict.Resolution.MERGED) { + if (merged == null) { + throw new IllegalArgumentException("merged value required"); + } + found.put("payload", merged); + } + if (remote != null) { + found.put("version", remote.get("version")); + } + found.remove("paused"); + found.remove("remote"); + } + persist(); + } + + private Map firstRunnableMutation() { + for (Object value : mutations()) { + Map m = (Map) value; + if (!Boolean.TRUE.equals(m.get("paused"))) { + return m; + } + } + return null; + } + + private Map find(String id) { + for (Object value : mutations()) { + Map m = (Map) value; + if (id.equals(m.get("id"))) { + return m; + } + } + return null; + } + + private List mutations() { + return (List) state.get(MUTATIONS); + } + + private void persist() throws CalendarException { + cache.store(source.getId(), state); + } + + private static String string(Map m, String k) { + Object v = m.get(k); + return v == null ? null : String.valueOf(v); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarSyncResult.java b/CodenameOne/src/com/codename1/calendar/CalendarSyncResult.java new file mode 100644 index 00000000000..a1bac33aaaf --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarSyncResult.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Outcome of a manual/background synchronization pass. +public final class CalendarSyncResult { + + private int applied; + + private int remaining; + + private final List conflicts = new ArrayList(); + + void applied() { + applied++; + } + + void setRemaining(int v) { + remaining = v; + } + + void addConflict(CalendarConflict v) { + conflicts.add(v); + } + + public int getAppliedCount() { + return applied; + } + + public int getRemainingCount() { + return remaining; + } + + public List getConflicts() { + return Collections.unmodifiableList(conflicts); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarTask.java b/CodenameOne/src/com/codename1/calendar/CalendarTask.java new file mode 100644 index 00000000000..4f6d6e3e3fe --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarTask.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A task/reminder in a task collection. +public class CalendarTask { + + private String id; + + private String calendarId; + + private String sourceId; + + private String version; + + private String title; + + private String description; + + private String location; + + private CalendarDateTime start; + + private CalendarDateTime due; + + private CalendarRecurrenceRule recurrence; + + private boolean completed; + + private Instant completionTime; + + private int priority; + + private final List alarms = new ArrayList(); + + private final List attachments = new ArrayList(); + + private final Map providerData = new HashMap(); + + public String getId() { + return id; + } + + public CalendarTask setId(String v) { + id = v; + return this; + } + + public String getCalendarId() { + return calendarId; + } + + public CalendarTask setCalendarId(String v) { + calendarId = v; + return this; + } + + public String getSourceId() { + return sourceId; + } + + public CalendarTask setSourceId(String v) { + sourceId = v; + return this; + } + + public String getVersion() { + return version; + } + + public CalendarTask setVersion(String v) { + version = v; + return this; + } + + public String getTitle() { + return title; + } + + public CalendarTask setTitle(String v) { + title = v; + return this; + } + + public String getDescription() { + return description; + } + + public CalendarTask setDescription(String v) { + description = v; + return this; + } + + public String getLocation() { + return location; + } + + public CalendarTask setLocation(String v) { + location = v; + return this; + } + + public CalendarDateTime getStart() { + return start; + } + + public CalendarTask setStart(CalendarDateTime v) { + start = v; + return this; + } + + public CalendarDateTime getDue() { + return due; + } + + public CalendarTask setDue(CalendarDateTime v) { + due = v; + return this; + } + + public CalendarRecurrenceRule getRecurrence() { + return recurrence; + } + + public CalendarTask setRecurrence(CalendarRecurrenceRule v) { + recurrence = v; + return this; + } + + public boolean isCompleted() { + return completed; + } + + public CalendarTask setCompleted(boolean v) { + completed = v; + return this; + } + + public Instant getCompletionTime() { + return completionTime; + } + + public CalendarTask setCompletionTime(Instant v) { + completionTime = v; + return this; + } + + public int getPriority() { + return priority; + } + + public CalendarTask setPriority(int v) { + priority = v; + return this; + } + + public CalendarTask addAlarm(CalendarAlarm v) { + if (v != null) { + alarms.add(v); + } + return this; + } + + public List getAlarms() { + return Collections.unmodifiableList(alarms); + } + + public CalendarTask addAttachment(CalendarAttachment v) { + if (v != null) { + attachments.add(v); + } + return this; + } + + public List getAttachments() { + return Collections.unmodifiableList(attachments); + } + + public CalendarTask putProviderData(String k, String v) { + if (k != null) { + providerData.put(k, v); + } + return this; + } + + public Map getProviderData() { + return Collections.unmodifiableMap(providerData); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarTokenProvider.java b/CodenameOne/src/com/codename1/calendar/CalendarTokenProvider.java new file mode 100644 index 00000000000..8cd76d0dda9 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarTokenProvider.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.util.AsyncResource; + +/// Supplies app-owned OAuth tokens. `forceRefresh` is true after a 401 response. +public interface CalendarTokenProvider { + + AsyncResource getToken(String[] requiredScopes, boolean forceRefresh); +} diff --git a/CodenameOne/src/com/codename1/calendar/DefaultCalendarHttpTransport.java b/CodenameOne/src/com/codename1/calendar/DefaultCalendarHttpTransport.java new file mode 100644 index 00000000000..696b4b640ae --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/DefaultCalendarHttpTransport.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.NetworkManager; +import com.codename1.io.Util; +import com.codename1.util.AsyncResource; +import com.codename1.util.StringUtil; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +/// Default asynchronous transport backed by Codename One networking. +public class DefaultCalendarHttpTransport implements CalendarHttpTransport { + + @Override + public AsyncResource execute(final CalendarHttpRequest spec) { + final AsyncResource out = new AsyncResource(); + ConnectionRequest request = new ConnectionRequest() { + + private final Map responseHeaders = new HashMap(); + + @Override + protected void readHeaders(Object connection) throws IOException { + capture(connection); + } + + @Override + protected void readErrorCodeHeaders(Object connection) throws IOException { + capture(connection); + } + + private void capture(Object c) throws IOException { + String[] names = { "ETag", "WWW-Authenticate", "Location", "Retry-After", "Sync-Token" }; + for (String name : names) { + String value = getHeader(c, name); + if (value != null) { + responseHeaders.put(name, value); + } + } + } + + @Override + protected void readResponse(InputStream input) throws IOException { + byte[] bytes = Util.readInputStream(input); + String body = StringUtil.newString(bytes); + out.complete(new CalendarHttpResponse(getResponseCode(), body, responseHeaders)); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + byte[] bytes = getResponseData(); + String body = bytes == null ? null : StringUtil.newString(bytes); + out.complete(new CalendarHttpResponse(code, body, responseHeaders)); + } + + @Override + protected void handleException(Exception error) { + out.error(new CalendarException(CalendarError.NETWORK, error.getMessage(), error)); + } + }; + request.setUrl(spec.getUrl()); + request.setPost(!"GET".equals(spec.getMethod()) && !"DELETE".equals(spec.getMethod())); + request.setHttpMethod(spec.getMethod()); + for (Map.Entry h : spec.getHeaders().entrySet()) { + request.addRequestHeader(h.getKey(), h.getValue()); + } + if (spec.getBody() != null) { + request.setRequestBody(spec.getBody()); + } + NetworkManager.getInstance().addToQueue(request); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/FreeBusyInterval.java b/CodenameOne/src/com/codename1/calendar/FreeBusyInterval.java new file mode 100644 index 00000000000..1fd3ad79311 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/FreeBusyInterval.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.time.Instant; + +/// Busy interval returned by a scheduling/free-busy query. +public final class FreeBusyInterval { + + private final Instant startTime; + + private final Instant endTime; + + private final CalendarEvent.Availability availability; + + public FreeBusyInterval(Instant startTime, Instant endTime, CalendarEvent.Availability availability) { + if (startTime == null || endTime == null) { + throw new IllegalArgumentException("startTime and endTime required"); + } + if (endTime.compareTo(startTime) < 0) { + throw new IllegalArgumentException("endTime"); + } + this.startTime = startTime; + this.endTime = endTime; + this.availability = availability == null ? CalendarEvent.Availability.BUSY : availability; + } + + public Instant getStartTime() { + return startTime; + } + + public Instant getEndTime() { + return endTime; + } + + public CalendarEvent.Availability getAvailability() { + return availability; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/GoogleCalendarSource.java b/CodenameOne/src/com/codename1/calendar/GoogleCalendarSource.java new file mode 100644 index 00000000000..3a11cbb2fe9 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/GoogleCalendarSource.java @@ -0,0 +1,617 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.io.Util; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Google Calendar and Google Tasks implementation. The application supplies +/// OAuth tokens and chooses the scopes granted to its own OAuth client. +public class GoogleCalendarSource extends OAuthCalendarSource { + + public static final String SCOPE_CALENDAR = "https://www.googleapis.com/auth/calendar"; + + public static final String SCOPE_TASKS = "https://www.googleapis.com/auth/tasks"; + + private static final String CALENDAR_API = "https://www.googleapis.com/calendar/v3"; + + private static final String TASKS_API = "https://tasks.googleapis.com/tasks/v1"; + + public GoogleCalendarSource(CalendarTokenProvider tokens) { + this(tokens, null); + } + + public GoogleCalendarSource(CalendarTokenProvider tokens, CalendarHttpTransport transport) { + super("google", "Google Calendar", tokens, transport, new String[] { SCOPE_CALENDAR, SCOPE_TASKS }); + } + + @Override + public CalendarCapabilities getCapabilities() { + return CalendarCapabilities.of(CalendarCapability.READ_CALENDARS, CalendarCapability.MANAGE_CALENDARS, CalendarCapability.READ_EVENTS, CalendarCapability.WRITE_EVENTS, CalendarCapability.DELETE_EVENTS, CalendarCapability.READ_TASKS, CalendarCapability.WRITE_TASKS, CalendarCapability.DELETE_TASKS, CalendarCapability.RECURRENCE, CalendarCapability.ATTENDEES_READ, CalendarCapability.ATTENDEES_WRITE, CalendarCapability.RESPOND_TO_INVITATIONS, CalendarCapability.ALARMS, CalendarCapability.FREE_BUSY, CalendarCapability.ATTACHMENTS, CalendarCapability.CONFERENCING, CalendarCapability.DELTA_SYNC, CalendarCapability.OFFLINE_MUTATIONS); + } + + @Override + public AsyncResource> listCalendars(final CalendarInfo.ContentType type, String pageToken) { + final AsyncResource> out = new AsyncResource>(); + String url = type == CalendarInfo.ContentType.TASKS ? TASKS_API + "/users/@me/lists" : CALENDAR_API + "/users/me/calendarList"; + if (pageToken != null) { + url += "?pageToken=" + Util.encodeUrl(pageToken); + } + json("GET", url, null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map root) { + List result = new ArrayList(); + for (Map item : maps(root.get("items"))) { + result.add(calendar(item, type)); + } + out.complete(new CalendarPage(result, string(root, "nextPageToken"), null)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource saveCalendar(final CalendarInfo calendar) { + final AsyncResource out = new AsyncResource(); + boolean task = calendar.getContentType() == CalendarInfo.ContentType.TASKS; + Map body = new HashMap(); + body.put(task ? "title" : "summary", calendar.getName()); + if (!task && calendar.getTimeZone() != null) { + body.put("timeZone", calendar.getTimeZone().getId()); + } + String base = task ? TASKS_API + "/users/@me/lists" : CALENDAR_API + "/calendars"; + String method = calendar.getId() == null ? "POST" : "PUT"; + String url = calendar.getId() == null ? base : base + "/" + e(calendar.getId()); + final CalendarInfo.ContentType type = calendar.getContentType(); + json(method, url, body, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map value) { + out.complete(calendar(value, type)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource deleteCalendar(String calendarId) { + final AsyncResource out = new AsyncResource(); + json("DELETE", CALENDAR_API + "/calendars/" + e(calendarId), null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map ignored) { + out.complete(Boolean.TRUE); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource> queryEvents(CalendarQuery query) { + final AsyncResource> out = new AsyncResource>(); + if (query == null || query.getCalendarId() == null) { + return fail(out, CalendarError.INVALID_ARGUMENT, "calendarId required"); + } + StringBuilder url = new StringBuilder(CALENDAR_API).append("/calendars/").append(e(query.getCalendarId())).append("/events?maxResults=").append(query.getPageSize()); + param(url, "pageToken", query.getPageToken()); + param(url, "syncToken", query.getSyncToken()); + param(url, "q", query.getText()); + if (query.getStartTime() != null) { + param(url, "timeMin", iso(query.getStartTime())); + } + if (query.getEndTime() != null) { + param(url, "timeMax", iso(query.getEndTime())); + } + param(url, "singleEvents", String.valueOf(query.isExpandRecurrences())); + param(url, "showDeleted", String.valueOf(query.isIncludeDeleted())); + final String calendarId = query.getCalendarId(); + json("GET", url.toString(), null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map root) { + List result = new ArrayList(); + try { + for (Map item : maps(root.get("items"))) { + result.add(event(item, calendarId)); + } + } catch (CalendarException ex) { + out.error(ex); + return; + } + out.complete(new CalendarPage(result, string(root, "nextPageToken"), string(root, "nextSyncToken"))); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource getEvent(final String calendarId, String eventId) { + final AsyncResource out = new AsyncResource(); + json("GET", CALENDAR_API + "/calendars/" + e(calendarId) + "/events/" + e(eventId), null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map value) { + try { + out.complete(event(value, calendarId)); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource saveEvent(final CalendarEvent event, CalendarMutationScope scope) { + final AsyncResource out = new AsyncResource(); + if (event == null || event.getCalendarId() == null) { + return fail(out, CalendarError.INVALID_ARGUMENT, "event and calendarId required"); + } + String base = CALENDAR_API + "/calendars/" + e(event.getCalendarId()) + "/events"; + String url = event.getId() == null ? base : base + "/" + e(event.getId()); + if (event.getConference() != null && event.getConference().isCreateRequested()) { + url += "?conferenceDataVersion=1"; + } + Map headers = version(event.getVersion()); + json(event.getId() == null ? "POST" : "PUT", url, eventMap(event), headers).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map value) { + try { + CalendarEvent saved = event(value, event.getCalendarId()); + out.complete(saved); + fireChange(new CalendarChange(getId(), saved.getCalendarId(), saved.getId(), CalendarChange.EntityType.EVENT, event.getId() == null ? CalendarChange.ChangeType.CREATED : CalendarChange.ChangeType.UPDATED)); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource deleteEvent(final String calendarId, final String eventId, CalendarMutationScope scope, String version) { + final AsyncResource out = new AsyncResource(); + json("DELETE", CALENDAR_API + "/calendars/" + e(calendarId) + "/events/" + e(eventId), null, version(version)).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map ignored) { + out.complete(Boolean.TRUE); + fireChange(new CalendarChange(getId(), calendarId, eventId, CalendarChange.EntityType.EVENT, CalendarChange.ChangeType.DELETED)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource respondToEvent(final String calendarId, final String eventId, CalendarAttendee.Response response, String comment) { + final AsyncResource out = new AsyncResource(); + getEvent(calendarId, eventId).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarEvent value) { + out.error(new CalendarException(CalendarError.NOT_SUPPORTED, "Use saveEvent() after updating the self attendee response")); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource> queryFreeBusy(List calendarIds, Instant startTime, Instant endTime) { + final AsyncResource> out = new AsyncResource>(); + Map body = new HashMap(); + body.put("timeMin", iso(startTime)); + body.put("timeMax", iso(endTime)); + List> items = new ArrayList>(); + for (String id : calendarIds) { + Map m = new HashMap(); + m.put("id", id); + items.add(m); + } + body.put("items", items); + json("POST", CALENDAR_API + "/freeBusy", body, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map root) { + List result = new ArrayList(); + Map calendars = map(root.get("calendars")); + for (Object object : calendars.values()) { + Map cal = map(object); + for (Map busy : maps(cal.get("busy"))) { + try { + result.add(new FreeBusyInterval(parseIso(string(busy, "start")), parseIso(string(busy, "end")), CalendarEvent.Availability.BUSY)); + } catch (CalendarException ex) { + out.error(ex); + return; + } + } + } + out.complete(result); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource> queryTasks(CalendarQuery query) { + final AsyncResource> out = new AsyncResource>(); + if (query == null || query.getCalendarId() == null) { + return fail(out, CalendarError.INVALID_ARGUMENT, "calendarId required"); + } + StringBuilder url = new StringBuilder(TASKS_API).append("/lists/").append(e(query.getCalendarId())).append("/tasks?maxResults=").append(query.getPageSize()); + param(url, "pageToken", query.getPageToken()); + param(url, "showCompleted", String.valueOf(query.isIncludeDeleted())); + final String listId = query.getCalendarId(); + json("GET", url.toString(), null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map root) { + List result = new ArrayList(); + try { + for (Map item : maps(root.get("items"))) { + result.add(task(item, listId)); + } + } catch (CalendarException ex) { + out.error(ex); + return; + } + out.complete(new CalendarPage(result, string(root, "nextPageToken"), null)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource getTask(final String listId, String taskId) { + final AsyncResource out = new AsyncResource(); + json("GET", TASKS_API + "/lists/" + e(listId) + "/tasks/" + e(taskId), null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map m) { + try { + out.complete(task(m, listId)); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource saveTask(final CalendarTask task, CalendarMutationScope scope) { + final AsyncResource out = new AsyncResource(); + if (task == null || task.getCalendarId() == null) { + return fail(out, CalendarError.INVALID_ARGUMENT, "task and calendarId required"); + } + String base = TASKS_API + "/lists/" + e(task.getCalendarId()) + "/tasks"; + String url = task.getId() == null ? base : base + "/" + e(task.getId()); + json(task.getId() == null ? "POST" : "PUT", url, taskMap(task), version(task.getVersion())).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map m) { + try { + CalendarTask saved = task(m, task.getCalendarId()); + out.complete(saved); + fireChange(new CalendarChange(getId(), saved.getCalendarId(), saved.getId(), CalendarChange.EntityType.TASK, task.getId() == null ? CalendarChange.ChangeType.CREATED : CalendarChange.ChangeType.UPDATED)); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource deleteTask(final String listId, final String taskId, CalendarMutationScope scope, String version) { + final AsyncResource out = new AsyncResource(); + json("DELETE", TASKS_API + "/lists/" + e(listId) + "/tasks/" + e(taskId), null, version(version)).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map x) { + out.complete(Boolean.TRUE); + fireChange(new CalendarChange(getId(), listId, taskId, CalendarChange.EntityType.TASK, CalendarChange.ChangeType.DELETED)); + } + }).except(error(out)); + return out; + } + + private CalendarInfo calendar(Map m, CalendarInfo.ContentType type) { + String name = string(m, type == CalendarInfo.ContentType.TASKS ? "title" : "summary"); + String zone = string(m, "timeZone"); + return new CalendarInfo().setId(string(m, "id")).setSourceId(getId()).setName(name).setOwner(string(m, "summaryOverride")).setTimeZone(zone == null ? null : ZoneId.of(zone)).setPrimary(bool(m, "primary")).setReadOnly("reader".equals(string(m, "accessRole"))).setContentType(type).setCapabilities(getCapabilities()); + } + + private CalendarEvent event(Map m, String calendarId) throws CalendarException { + CalendarEvent out = new CalendarEvent().setId(string(m, "id")).setCalendarId(calendarId).setSourceId(getId()).setVersion(string(m, "etag")).setTitle(string(m, "summary")).setDescription(string(m, "description")).setLocation(string(m, "location")).setUrl(string(m, "htmlLink")).setRecurringEventId(string(m, "recurringEventId")); + out.setStart(googleDate(map(m.get("start")))).setEnd(googleDate(map(m.get("end")))); + List recurrence = list(m.get("recurrence")); + if (!recurrence.isEmpty()) { + out.setRecurrence(ICalendarCodec.readRecurrenceRule(String.valueOf(recurrence.get(0)))); + } + String status = string(m, "status"); + if ("cancelled".equals(status)) { + out.setStatus(CalendarEvent.Status.CANCELED); + } else if ("tentative".equals(status)) { + out.setStatus(CalendarEvent.Status.TENTATIVE); + } + out.setAvailability("transparent".equals(string(m, "transparency")) ? CalendarEvent.Availability.FREE : CalendarEvent.Availability.BUSY); + for (Map a : maps(m.get("attendees"))) { + CalendarAttendee attendee = new CalendarAttendee().setName(string(a, "displayName")).setEmail(string(a, "email")).setOrganizer(bool(a, "organizer")).setSelf(bool(a, "self")); + String r = string(a, "responseStatus"); + attendee.setResponse(attendeeResponse(r)); + out.addAttendee(attendee); + } + Map reminders = map(m.get("reminders")); + for (Map a : maps(reminders.get("overrides"))) { + Integer minutes = integer(a, "minutes"); + if (minutes == null) { + continue; + } + CalendarAlarm alarm = new CalendarAlarm().setTimeBefore(Duration.ofMinutes(minutes.intValue())); + String method = string(a, "method"); + if ("email".equals(method)) { + alarm.setMethod(CalendarAlarm.Method.EMAIL); + } else if ("sms".equals(method)) { + alarm.setMethod(CalendarAlarm.Method.AUDIO); + } + out.addAlarm(alarm); + } + for (Map a : maps(m.get("attachments"))) { + out.addAttachment(new CalendarAttachment().setUri(string(a, "fileUrl")).setName(string(a, "title")).setMimeType(string(a, "mimeType"))); + } + Map conference = map(m.get("conferenceData")); + for (Map entry : maps(conference.get("entryPoints"))) { + if ("video".equals(string(entry, "entryPointType"))) { + out.setConference(new CalendarConference().setJoinUrl(string(entry, "uri")).setProvider(string(map(conference.get("conferenceSolution")), "name"))); + } + } + return out; + } + + private Map eventMap(CalendarEvent e) { + Map m = new HashMap(); + m.put("summary", e.getTitle()); + if (e.getRecurrence() != null) { + List r = new ArrayList(); + r.add("RRULE:" + ICalendarCodec.writeRecurrenceRule(e.getRecurrence())); + m.put("recurrence", r); + } + m.put("description", e.getDescription()); + m.put("location", e.getLocation()); + m.put("start", googleDate(e.getStart())); + m.put("end", googleDate(e.getEnd())); + m.put("status", e.getStatus().name().toLowerCase()); + m.put("transparency", e.getAvailability() == CalendarEvent.Availability.FREE ? "transparent" : "opaque"); + List> attendees = new ArrayList>(); + for (CalendarAttendee a : e.getAttendees()) { + Map x = new HashMap(); + x.put("email", a.getEmail()); + x.put("displayName", a.getName()); + x.put("optional", Boolean.valueOf(a.getRole() == CalendarAttendee.Role.OPTIONAL)); + x.put("responseStatus", lowerCamel(a.getResponse().name())); + attendees.add(x); + } + m.put("attendees", attendees); + if (!e.getAlarms().isEmpty()) { + Map r = new HashMap(); + r.put("useDefault", Boolean.FALSE); + List> overrides = new ArrayList>(); + for (CalendarAlarm a : e.getAlarms()) { + if (a.getTimeBefore() != null && a.getTimeBefore().toMillis() % 60000L == 0L) { + Map x = new HashMap(); + x.put("minutes", Long.valueOf(a.getTimeBefore().toMillis() / 60000L)); + x.put("method", a.getMethod() == CalendarAlarm.Method.EMAIL ? "email" : a.getMethod() == CalendarAlarm.Method.AUDIO ? "sms" : "popup"); + overrides.add(x); + } + } + r.put("overrides", overrides); + m.put("reminders", r); + } + if (e.getConference() != null && e.getConference().isCreateRequested()) { + Map c = new HashMap(); + Map create = new HashMap(); + create.put("requestId", String.valueOf(System.currentTimeMillis())); + c.put("createRequest", create); + m.put("conferenceData", c); + } + return m; + } + + private CalendarTask task(Map m, String listId) throws CalendarException { + CalendarTask out = new CalendarTask().setId(string(m, "id")).setCalendarId(listId).setSourceId(getId()).setVersion(string(m, "etag")).setTitle(string(m, "title")).setDescription(string(m, "notes")).setCompleted("completed".equals(string(m, "status"))); + String due = string(m, "due"); + String completed = string(m, "completed"); + if (due != null) { + out.setDue(CalendarDateTime.instant(parseIso(due), ZoneId.of("UTC"))); + } + if (completed != null) { + out.setCompletionTime(parseIso(completed)); + } + return out; + } + + private Map taskMap(CalendarTask t) { + Map m = new HashMap(); + m.put("title", t.getTitle()); + m.put("notes", t.getDescription()); + m.put("status", t.isCompleted() ? "completed" : "needsAction"); + if (t.getDue() != null && !t.getDue().isAllDay()) { + m.put("due", iso(t.getDue().getDateTime().toInstant())); + } + if (t.isCompleted() && t.getCompletionTime() != null) { + m.put("completed", iso(t.getCompletionTime())); + } + return m; + } + + private static CalendarDateTime googleDate(Map m) throws CalendarException { + String date = string(m, "date"); + String dateTime = string(m, "dateTime"); + String zone = string(m, "timeZone"); + if (date != null) { + return CalendarDateTime.allDay(LocalDate.parse(date)); + } + if (dateTime != null) { + return CalendarDateTime.instant(parseIso(dateTime), ZoneId.of(zone == null ? "UTC" : zone)); + } + return null; + } + + private static Map googleDate(CalendarDateTime value) { + Map m = new HashMap(); + if (value == null) { + return m; + } + if (value.isAllDay()) { + m.put("date", value.getDate().toString()); + } else { + m.put("dateTime", iso(value.getDateTime().toInstant())); + m.put("timeZone", value.getDateTime().getZone().getId()); + } + return m; + } + + private static String iso(Instant time) { + return CalendarDateUtil.formatIso(time, ZoneId.of("UTC"), true) + "Z"; + } + + private static Instant parseIso(String value) throws CalendarException { + try { + return CalendarDateUtil.parseDateTime(value, ZoneId.of("UTC")); + } catch (IllegalArgumentException ex) { + throw new CalendarException(CalendarError.MALFORMED_RESPONSE, "Invalid provider date: " + value, ex); + } + } + + private static void param(StringBuilder b, String name, String value) { + if (value != null) { + b.append('&').append(name).append('=').append(Util.encodeUrl(value)); + } + } + + private static String e(String v) { + return Util.encodeUrl(v == null ? "" : v); + } + + private static Map version(String v) { + if (v == null) { + return null; + } + Map m = new HashMap(); + m.put("If-Match", v); + return m; + } + + private static String string(Map m, String k) { + Object v = m == null ? null : m.get(k); + return v == null ? null : String.valueOf(v); + } + + private static boolean bool(Map m, String k) { + Object v = m == null ? null : m.get(k); + return Boolean.TRUE.equals(v) || "true".equals(String.valueOf(v)); + } + + private static Integer integer(Map m, String k) { + Object v = m.get(k); + return v instanceof Number ? Integer.valueOf(((Number) v).intValue()) : v == null ? null : Integer.valueOf(String.valueOf(v)); + } + + @SuppressWarnings("unchecked") + private static Map map(Object v) { + return v instanceof Map ? (Map) v : new HashMap(); + } + + @SuppressWarnings("unchecked") + private static List> maps(Object v) { + List> out = new ArrayList>(); + if (v instanceof List) { + for (Object x : (List) v) { + if (x instanceof Map) { + out.add((Map) x); + } + } + } + return out; + } + + @SuppressWarnings("unchecked") + private static List list(Object v) { + return v instanceof List ? (List) v : new ArrayList(); + } + + private static String camelEnum(String v) { + StringBuilder b = new StringBuilder(); + for (int i = 0; i < v.length(); i++) { + char c = v.charAt(i); + if (Character.isUpperCase(c)) { + b.append('_').append(c); + } else { + b.append(Character.toUpperCase(c)); + } + } + return b.toString(); + } + + private static CalendarAttendee.Response attendeeResponse(String value) { + String name = value == null ? null : camelEnum(value); + for (CalendarAttendee.Response candidate : CalendarAttendee.Response.values()) { + if (candidate.name().equals(name)) { + return candidate; + } + } + return CalendarAttendee.Response.NONE; + } + + private static String lowerCamel(String v) { + String s = v.toLowerCase(); + int p = s.indexOf('_'); + return p < 0 ? s : s.substring(0, p) + Character.toUpperCase(s.charAt(p + 1)) + s.substring(p + 2); + } + + private static SuccessCallback error(final AsyncResource out) { + return new SuccessCallback() { + + @Override + public void onSucess(Throwable error) { + out.error(error); + } + }; + } + + private static AsyncResource fail(AsyncResource out, CalendarError type, String message) { + out.error(new CalendarException(type, message)); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/ICalendarCodec.java b/CodenameOne/src/com/codename1/calendar/ICalendarCodec.java new file mode 100644 index 00000000000..860bc17c0ad --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/ICalendarCodec.java @@ -0,0 +1,679 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.util.StringUtil; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Reads and writes the interoperable portion of RFC 5545 used by events, +/// tasks, CalDAV, and file import/export. Unknown X-properties are preserved +/// in provider data so a read/write cycle doesn't silently discard extensions. +public final class ICalendarCodec { + + private static final String CRLF = "\r\n"; + + private ICalendarCodec() { + } + + public static String writeEvent(CalendarEvent event) { + StringBuilder out = beginCalendar(); + append(out, "BEGIN:VEVENT"); + writeCommon(out, event.getId(), event.getTitle(), event.getDescription(), event.getLocation(), event.getStart(), event.getEnd(), event.getRecurrence(), event.getProviderData()); + if (event.getUrl() != null) { + append(out, "URL:" + escape(event.getUrl())); + } + append(out, "STATUS:" + event.getStatus().name()); + append(out, "TRANSP:" + (event.getAvailability() == CalendarEvent.Availability.FREE ? "TRANSPARENT" : "OPAQUE")); + append(out, "CLASS:" + event.getPrivacy().name()); + for (CalendarAttendee attendee : event.getAttendees()) { + writeAttendee(out, attendee); + } + for (CalendarAttachment attachment : event.getAttachments()) { + writeAttachment(out, attachment); + } + if (event.getConference() != null && event.getConference().getJoinUrl() != null) { + append(out, "CONFERENCE;VALUE=URI:" + escape(event.getConference().getJoinUrl())); + } + for (CalendarAlarm alarm : event.getAlarms()) { + writeAlarm(out, alarm); + } + append(out, "END:VEVENT"); + return endCalendar(out); + } + + public static String writeTask(CalendarTask task) { + StringBuilder out = beginCalendar(); + append(out, "BEGIN:VTODO"); + writeCommon(out, task.getId(), task.getTitle(), task.getDescription(), task.getLocation(), task.getStart(), task.getDue(), task.getRecurrence(), task.getProviderData()); + append(out, "STATUS:" + (task.isCompleted() ? "COMPLETED" : "NEEDS-ACTION")); + if (task.getCompletionTime() != null) { + append(out, "COMPLETED:" + utc(task.getCompletionTime())); + } + if (task.getPriority() > 0) { + append(out, "PRIORITY:" + task.getPriority()); + } + for (CalendarAttachment attachment : task.getAttachments()) { + writeAttachment(out, attachment); + } + for (CalendarAlarm alarm : task.getAlarms()) { + writeAlarm(out, alarm); + } + append(out, "END:VTODO"); + return endCalendar(out); + } + + public static CalendarEvent readEvent(String text) throws CalendarException { + Component component = component(text, "VEVENT"); + CalendarEvent out = new CalendarEvent(); + readCommon(component, out, null); + out.setUrl(value(component, "URL")); + out.setStatus(enumValue(CalendarEvent.Status.class, value(component, "STATUS"), CalendarEvent.Status.CONFIRMED)); + if ("TRANSPARENT".equalsIgnoreCase(value(component, "TRANSP"))) { + out.setAvailability(CalendarEvent.Availability.FREE); + } + out.setPrivacy(enumValue(CalendarEvent.Privacy.class, value(component, "CLASS"), CalendarEvent.Privacy.DEFAULT)); + for (Property property : component.all("ATTENDEE")) { + out.addAttendee(readAttendee(property)); + } + for (Property property : component.all("ATTACH")) { + out.addAttachment(readAttachment(property)); + } + Property conference = component.first("CONFERENCE"); + if (conference != null) { + out.setConference(new CalendarConference().setJoinUrl(unescape(conference.value))); + } + for (Component alarm : component.children) { + if ("VALARM".equals(alarm.name)) { + out.addAlarm(readAlarm(alarm)); + } + } + for (Property property : component.properties) { + if (property.name.startsWith("X-")) { + out.putProviderData(property.name, property.value); + } + } + return out; + } + + public static CalendarTask readTask(String text) throws CalendarException { + Component component = component(text, "VTODO"); + CalendarTask out = new CalendarTask(); + readCommon(component, null, out); + out.setCompleted("COMPLETED".equalsIgnoreCase(value(component, "STATUS"))); + String completed = value(component, "COMPLETED"); + if (completed != null) { + out.setCompletionTime(parseDateTime(completed, null).getDateTime().toInstant()); + } + String priority = value(component, "PRIORITY"); + if (priority != null) { + try { + out.setPriority(Integer.parseInt(priority)); + } catch (NumberFormatException ignored) { + } + } + for (Property property : component.all("ATTACH")) { + out.addAttachment(readAttachment(property)); + } + for (Component alarm : component.children) { + if ("VALARM".equals(alarm.name)) { + out.addAlarm(readAlarm(alarm)); + } + } + for (Property property : component.properties) { + if (property.name.startsWith("X-")) { + out.putProviderData(property.name, property.value); + } + } + return out; + } + + /// Serializes a recurrence rule without the {@code RRULE:} prefix. + public static String writeRecurrenceRule(CalendarRecurrenceRule rule) { + if (rule == null || rule.getFrequency() == null) { + throw new IllegalArgumentException("frequency required"); + } + return recurrence(rule); + } + + /// Parses a recurrence rule with or without an {@code RRULE:} prefix. + public static CalendarRecurrenceRule readRecurrenceRule(String value) throws CalendarException { + if (value != null && value.toUpperCase().startsWith("RRULE:")) { + value = value.substring(6); + } + return parseRecurrence(value); + } + + private static void readCommon(Component component, CalendarEvent event, CalendarTask task) throws CalendarException { + String id = value(component, "UID"); + String title = value(component, "SUMMARY"); + String description = value(component, "DESCRIPTION"); + String location = value(component, "LOCATION"); + Property start = component.first("DTSTART"); + Property end = component.first(event == null ? "DUE" : "DTEND"); + CalendarDateTime startValue = start == null ? null : parseDateTime(start.value, start.params.get("TZID")); + CalendarDateTime endValue = end == null ? null : parseDateTime(end.value, end.params.get("TZID")); + CalendarRecurrenceRule recurrence = parseRecurrence(value(component, "RRULE")); + if (event != null) { + event.setId(id).setTitle(title).setDescription(description).setLocation(location).setStart(startValue).setEnd(endValue).setRecurrence(recurrence); + } else { + task.setId(id).setTitle(title).setDescription(description).setLocation(location).setStart(startValue).setDue(endValue).setRecurrence(recurrence); + } + } + + private static void writeCommon(StringBuilder out, String id, String title, String description, String location, CalendarDateTime start, CalendarDateTime end, CalendarRecurrenceRule recurrence, Map extensions) { + append(out, "UID:" + escape(id == null ? String.valueOf(System.currentTimeMillis()) + "@codenameone" : id)); + append(out, "DTSTAMP:" + utc(Instant.now())); + if (title != null) { + append(out, "SUMMARY:" + escape(title)); + } + if (description != null) { + append(out, "DESCRIPTION:" + escape(description)); + } + if (location != null) { + append(out, "LOCATION:" + escape(location)); + } + if (start != null) { + writeDateTime(out, "DTSTART", start); + } + if (end != null) { + writeDateTime(out, endName(out), end); + } + if (recurrence != null) { + append(out, "RRULE:" + recurrence(recurrence)); + } + for (Map.Entry entry : extensions.entrySet()) { + if (entry.getKey().toUpperCase().startsWith("X-")) { + append(out, entry.getKey().toUpperCase() + ":" + escape(entry.getValue())); + } + } + } + + private static String endName(StringBuilder out) { + return out.toString().indexOf("BEGIN:VTODO") >= 0 ? "DUE" : "DTEND"; + } + + private static void writeDateTime(StringBuilder out, String name, CalendarDateTime value) { + if (value.isAllDay()) { + LocalDate date = value.getDate(); + append(out, name + ";VALUE=DATE:" + digits(date)); + } else { + ZonedDateTime dateTime = value.getDateTime(); + String zone = dateTime.getZone().getId(); + if (ZoneOffset.UTC.equals(dateTime.getZone())) { + append(out, name + ":" + utc(dateTime.toInstant())); + } else { + append(out, name + ";TZID=" + zone + ":" + local(dateTime.toInstant(), dateTime.getZone())); + } + } + } + + private static String recurrence(CalendarRecurrenceRule rule) { + StringBuilder out = new StringBuilder("FREQ=").append(rule.getFrequency().name()); + if (rule.getInterval() != 1) { + out.append(";INTERVAL=").append(rule.getInterval()); + } + if (rule.getCount() != null) { + out.append(";COUNT=").append(rule.getCount()); + } + if (rule.getUntil() != null) { + out.append(";UNTIL=").append(formatRecurrenceUntil(rule.getUntil())); + } + appendNumbers(out, "BYDAY", rule.getDaysOfWeek(), true); + appendNumbers(out, "BYMONTHDAY", rule.getDaysOfMonth(), false); + appendNumbers(out, "BYMONTH", rule.getMonths(), false); + return out.toString(); + } + + private static CalendarRecurrenceRule parseRecurrence(String value) throws CalendarException { + if (value == null) { + return null; + } + CalendarRecurrenceRule out = new CalendarRecurrenceRule(); + String[] parts = CalendarDateUtil.split(value, ';'); + for (String part : parts) { + int equals = part.indexOf('='); + if (equals < 1) { + continue; + } + String key = part.substring(0, equals).toUpperCase(); + String data = part.substring(equals + 1); + try { + if ("FREQ".equals(key)) { + out.setFrequency(CalendarRecurrenceRule.Frequency.valueOf(data.toUpperCase())); + } else if ("INTERVAL".equals(key)) { + out.setInterval(Integer.parseInt(data)); + } else if ("COUNT".equals(key)) { + out.setCount(Integer.valueOf(data)); + } else if ("UNTIL".equals(key)) { + out.setUntil(parseDateTime(data, null)); + } else if ("BYDAY".equals(key)) { + for (String item : CalendarDateUtil.split(data, ',')) { + out.addDayOfWeek(day(item)); + } + } else if ("BYMONTHDAY".equals(key)) { + for (String item : CalendarDateUtil.split(data, ',')) { + out.addDayOfMonth(Integer.parseInt(item)); + } + } else if ("BYMONTH".equals(key)) { + for (String item : CalendarDateUtil.split(data, ',')) { + out.addMonth(Integer.parseInt(item)); + } + } + } catch (IllegalArgumentException ex) { + throw new CalendarException(CalendarError.MALFORMED_RESPONSE, "Invalid recurrence rule: " + value, ex); + } + } + if (out.getFrequency() == null) { + throw new CalendarException(CalendarError.MALFORMED_RESPONSE, "RRULE has no frequency"); + } + return out; + } + + private static void writeAttendee(StringBuilder out, CalendarAttendee attendee) { + StringBuilder line = new StringBuilder("ATTENDEE"); + if (attendee.getName() != null) { + line.append(";CN=").append(quote(attendee.getName())); + } + line.append(";ROLE=").append(attendee.getRole() == CalendarAttendee.Role.OPTIONAL ? "OPT-PARTICIPANT" : attendee.getRole() == CalendarAttendee.Role.RESOURCE ? "NON-PARTICIPANT" : "REQ-PARTICIPANT"); + line.append(";PARTSTAT=").append(attendee.getResponse().name().replace('_', '-')); + line.append(':'); + String uri = attendee.getUri() != null ? attendee.getUri() : attendee.getEmail(); + if (uri != null && uri.indexOf(':') < 0) { + uri = "mailto:" + uri; + } + line.append(uri == null ? "" : uri); + append(out, line.toString()); + } + + private static CalendarAttendee readAttendee(Property property) { + String uri = unescape(property.value); + String email = uri; + if (email != null && email.toLowerCase().startsWith("mailto:")) { + email = email.substring(7); + } + CalendarAttendee out = new CalendarAttendee().setUri(uri).setEmail(email).setName(property.params.get("CN")); + String role = property.params.get("ROLE"); + if ("OPT-PARTICIPANT".equals(role)) { + out.setRole(CalendarAttendee.Role.OPTIONAL); + } else if ("NON-PARTICIPANT".equals(role)) { + out.setRole(CalendarAttendee.Role.RESOURCE); + } + String response = property.params.get("PARTSTAT"); + if (response != null) { + try { + out.setResponse(CalendarAttendee.Response.valueOf(response.replace('-', '_'))); + } catch (IllegalArgumentException ignored) { + } + } + return out; + } + + private static void writeAlarm(StringBuilder out, CalendarAlarm alarm) { + append(out, "BEGIN:VALARM"); + append(out, "ACTION:" + (alarm.getMethod() == CalendarAlarm.Method.EMAIL ? "EMAIL" : alarm.getMethod() == CalendarAlarm.Method.AUDIO ? "AUDIO" : "DISPLAY")); + if (alarm.getTimeBefore() != null) { + long seconds = alarm.getTimeBefore().getSeconds(); + if (alarm.getTimeBefore().getNano() != 0 || seconds < 0) { + throw new IllegalArgumentException("timeBefore must be a non-negative whole-second duration"); + } + append(out, "TRIGGER:-" + alarm.getTimeBefore()); + } else if (alarm.getAbsoluteTime() != null) { + append(out, "TRIGGER;VALUE=DATE-TIME:" + utc(alarm.getAbsoluteTime())); + } + append(out, "END:VALARM"); + } + + private static CalendarAlarm readAlarm(Component component) throws CalendarException { + String action = value(component, "ACTION"); + String trigger = value(component, "TRIGGER"); + CalendarAlarm out = new CalendarAlarm(); + if ("EMAIL".equals(action)) { + out.setMethod(CalendarAlarm.Method.EMAIL); + } else if ("AUDIO".equals(action)) { + out.setMethod(CalendarAlarm.Method.AUDIO); + } + if (trigger != null && trigger.startsWith("-P")) { + try { + out.setTimeBefore(Duration.parse(trigger.substring(1))); + } catch (DateTimeException ignored) { + } + } else if (trigger != null) { + out.setAbsoluteTime(parseDateTime(trigger, null).getDateTime().toInstant()); + } + return out; + } + + private static void writeAttachment(StringBuilder out, CalendarAttachment attachment) { + if (attachment.getUri() == null) { + return; + } + StringBuilder line = new StringBuilder("ATTACH"); + if (attachment.getMimeType() != null) { + line.append(";FMTTYPE=").append(attachment.getMimeType()); + } + line.append(':').append(escape(attachment.getUri())); + append(out, line.toString()); + } + + private static CalendarAttachment readAttachment(Property property) { + return new CalendarAttachment().setUri(unescape(property.value)).setMimeType(property.params.get("FMTTYPE")); + } + + private static Component component(String input, String wanted) throws CalendarException { + if (input == null) { + throw new CalendarException(CalendarError.MALFORMED_RESPONSE, "Calendar data is null"); + } + List lines = unfold(input); + Component root = new Component("ROOT"); + Component current = root; + List stack = new ArrayList(); + for (String line : lines) { + if (line.startsWith("BEGIN:")) { + Component child = new Component(line.substring(6).toUpperCase()); + current.children.add(child); + stack.add(current); + current = child; + } else if (line.startsWith("END:")) { + if (!stack.isEmpty()) { + current = stack.remove(stack.size() - 1); + } + } else { + int colon = colon(line); + if (colon < 0) { + continue; + } + String left = line.substring(0, colon); + String data = line.substring(colon + 1); + String[] fields = CalendarDateUtil.split(left, ';'); + Property property = new Property(fields[0].toUpperCase(), data); + for (int i = 1; i < fields.length; i++) { + int equals = fields[i].indexOf('='); + if (equals > 0) { + property.params.put(fields[i].substring(0, equals).toUpperCase(), unquote(fields[i].substring(equals + 1))); + } + } + current.properties.add(property); + } + } + Component found = find(root, wanted); + if (found == null) { + throw new CalendarException(CalendarError.MALFORMED_RESPONSE, "No " + wanted + " component found"); + } + return found; + } + + private static Component find(Component component, String name) { + if (name.equals(component.name)) { + return component; + } + for (Component child : component.children) { + Component result = find(child, name); + if (result != null) { + return result; + } + } + return null; + } + + private static CalendarDateTime parseDateTime(String value, String zone) throws CalendarException { + try { + if (value.length() == 8) { + return CalendarDateTime.allDay(LocalDate.of(Integer.parseInt(value.substring(0, 4)), Integer.parseInt(value.substring(4, 6)), Integer.parseInt(value.substring(6, 8)))); + } + boolean zulu = value.endsWith("Z"); + String timeZone = zulu || zone == null ? "UTC" : zone; + ZoneId zoneId = ZoneId.of(timeZone); + return CalendarDateTime.instant(CalendarDateUtil.parseDateTime(value, zoneId), zoneId); + } catch (IllegalArgumentException ex) { + throw new CalendarException(CalendarError.MALFORMED_RESPONSE, "Invalid calendar date: " + value, ex); + } + } + + private static List unfold(String input) { + String normalized = input.replace("\r\n", "\n").replace('\r', '\n'); + String[] raw = CalendarDateUtil.split(normalized, '\n'); + List out = new ArrayList(); + for (String line : raw) { + if ((line.startsWith(" ") || line.startsWith("\t")) && !out.isEmpty()) { + int last = out.size() - 1; + out.set(last, out.get(last) + line.substring(1)); + } else if (line.length() > 0) { + out.add(line); + } + } + return out; + } + + private static void append(StringBuilder out, String line) { + byte[] bytes = StringUtil.getBytes(line); + if (bytes.length <= 75) { + out.append(line).append(CRLF); + return; + } + int start = 0; + int chars = line.length(); + while (start < chars) { + int end = Math.min(chars, start + 72); + while (end > start && utf8Length(line.substring(start, end)) > 74) { + end--; + } + if (start > 0) { + out.append(' '); + } + out.append(line.substring(start, end)).append(CRLF); + start = end; + } + } + + private static int utf8Length(String value) { + try { + return value.getBytes("UTF-8").length; + } catch (java.io.UnsupportedEncodingException ex) { + return value.length(); + } + } + + private static String escape(String value) { + if (value == null) { + return null; + } + return value.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n"); + } + + private static String unescape(String value) { + if (value == null) { + return null; + } + StringBuilder out = new StringBuilder(); + boolean slash = false; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (slash) { + out.append(c == 'n' || c == 'N' ? '\n' : c); + slash = false; + } else if (c == '\\') { + slash = true; + } else { + out.append(c); + } + } + if (slash) { + out.append('\\'); + } + return out.toString(); + } + + private static String quote(String value) { + return '"' + value.replace("\"", "\\\"") + '"'; + } + + private static String unquote(String value) { + return value.length() > 1 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"' ? value.substring(1, value.length() - 1) : value; + } + + private static int colon(String value) { + boolean quoted = false; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + quoted = !quoted; + } else if (c == ':' && !quoted) { + return i; + } + } + return -1; + } + + private static String value(Component component, String name) { + Property property = component.first(name); + return property == null ? null : unescape(property.value); + } + + private static String digits(LocalDate date) { + return pad(date.getYear(), 4) + pad(date.getMonthValue(), 2) + pad(date.getDayOfMonth(), 2); + } + + private static String pad(int value, int count) { + String s = String.valueOf(value); + while (s.length() < count) { + s = "0" + s; + } + return s; + } + + private static String utc(Instant value) { + return format(value, ZoneOffset.UTC) + "Z"; + } + + private static String local(Instant value, ZoneId zone) { + return format(value, zone); + } + + private static String format(Instant value, ZoneId zone) { + return CalendarDateUtil.formatBasic(value, zone); + } + + private static String formatRecurrenceUntil(CalendarDateTime value) { + return value.isAllDay() ? digits(value.getDate()) : utc(value.getDateTime().toInstant()); + } + + private static int day(String value) { + String v = value.length() > 2 ? value.substring(value.length() - 2) : value; + String[] days = { "MO", "TU", "WE", "TH", "FR", "SA", "SU" }; + for (int i = 0; i < days.length; i++) { + if (days[i].equals(v)) { + return i + 1; + } + } + throw new IllegalArgumentException(value); + } + + private static void appendNumbers(StringBuilder out, String name, List values, boolean weekdays) { + if (values.isEmpty()) { + return; + } + out.append(';').append(name).append('='); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + out.append(','); + } + int v = values.get(i).intValue(); + out.append(weekdays ? new String[] { "MO", "TU", "WE", "TH", "FR", "SA", "SU" }[v - 1] : String.valueOf(v)); + } + } + + private static StringBuilder beginCalendar() { + return new StringBuilder("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Codename One//Calendar API//EN\r\nCALSCALE:GREGORIAN\r\n"); + } + + private static String endCalendar(StringBuilder out) { + return out.append("END:VCALENDAR\r\n").toString(); + } + + private static > E enumValue(Class type, String value, E fallback) { + if (value == null) { + return fallback; + } + try { + return Enum.valueOf(type, value.toUpperCase().replace('-', '_')); + } catch (IllegalArgumentException ex) { + return fallback; + } + } + + private static final class Component { + + final String name; + + final List properties = new ArrayList(); + + final List children = new ArrayList(); + + Component(String name) { + this.name = name; + } + + Property first(String propertyName) { + for (Property p : properties) { + if (propertyName.equals(p.name)) { + return p; + } + } + return null; + } + + List all(String propertyName) { + List out = new ArrayList(); + for (Property p : properties) { + if (propertyName.equals(p.name)) { + out.add(p); + } + } + return out; + } + } + + private static final class Property { + + final String name; + + final String value; + + final Map params = new HashMap(); + + Property(String name, String value) { + this.name = name; + this.value = value; + } + } +} diff --git a/CodenameOne/src/com/codename1/calendar/LocalCalendarSource.java b/CodenameOne/src/com/codename1/calendar/LocalCalendarSource.java new file mode 100644 index 00000000000..cb191904260 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/LocalCalendarSource.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.ui.Display; + +/// Device calendar source. Referencing this class is the builder signal for +/// native calendar permissions; online-only apps should use provider sources. +public class LocalCalendarSource extends CalendarSource { + + private static final LocalCalendarSource FALLBACK = new LocalCalendarSource(); + + protected LocalCalendarSource() { + super("local", "Device calendar"); + } + + public static LocalCalendarSource getInstance() { + LocalCalendarSource out = Display.getInstance().getLocalCalendarSource(); + return out == null ? FALLBACK : out; + } +} diff --git a/CodenameOne/src/com/codename1/calendar/MemoryCalendarCache.java b/CodenameOne/src/com/codename1/calendar/MemoryCalendarCache.java new file mode 100644 index 00000000000..a1336e80ba9 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/MemoryCalendarCache.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import java.util.HashMap; +import java.util.Map; + +/// Process-local cache, useful for tests or sessions that must leave no data on disk. +public class MemoryCalendarCache implements CalendarCache { + + private final Map> states = new HashMap>(); + + @Override + public synchronized Map load(String sourceId) { + Map state = states.get(sourceId); + return state == null ? new HashMap() : new HashMap(state); + } + + @Override + public synchronized void store(String sourceId, Map state) { + states.put(sourceId, new HashMap(state)); + } + + @Override + public synchronized void clear(String sourceId) { + states.remove(sourceId); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/MicrosoftCalendarSource.java b/CodenameOne/src/com/codename1/calendar/MicrosoftCalendarSource.java new file mode 100644 index 00000000000..dfff7c12107 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/MicrosoftCalendarSource.java @@ -0,0 +1,688 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.io.Util; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Microsoft Graph calendar and Microsoft To Do source. No client secret or +/// refresh token is stored by this class. +public class MicrosoftCalendarSource extends OAuthCalendarSource { + + public static final String SCOPE_CALENDARS = "Calendars.ReadWrite"; + + public static final String SCOPE_TASKS = "Tasks.ReadWrite"; + + private static final String GRAPH = "https://graph.microsoft.com/v1.0"; + + public MicrosoftCalendarSource(CalendarTokenProvider tokens) { + this(tokens, null); + } + + public MicrosoftCalendarSource(CalendarTokenProvider tokens, CalendarHttpTransport transport) { + super("microsoft", "Microsoft 365", tokens, transport, new String[] { SCOPE_CALENDARS, SCOPE_TASKS, "offline_access" }); + } + + @Override + public CalendarCapabilities getCapabilities() { + return CalendarCapabilities.of(CalendarCapability.READ_CALENDARS, CalendarCapability.MANAGE_CALENDARS, CalendarCapability.READ_EVENTS, CalendarCapability.WRITE_EVENTS, CalendarCapability.DELETE_EVENTS, CalendarCapability.READ_TASKS, CalendarCapability.WRITE_TASKS, CalendarCapability.DELETE_TASKS, CalendarCapability.RECURRENCE, CalendarCapability.ATTENDEES_READ, CalendarCapability.ATTENDEES_WRITE, CalendarCapability.RESPOND_TO_INVITATIONS, CalendarCapability.ALARMS, CalendarCapability.FREE_BUSY, CalendarCapability.ATTACHMENTS, CalendarCapability.CONFERENCING, CalendarCapability.DELTA_SYNC, CalendarCapability.OFFLINE_MUTATIONS); + } + + @Override + public AsyncResource> listCalendars(final CalendarInfo.ContentType type, String pageToken) { + final AsyncResource> out = new AsyncResource>(); + String url = pageToken != null ? pageToken : (type == CalendarInfo.ContentType.TASKS ? GRAPH + "/me/todo/lists" : GRAPH + "/me/calendars"); + json("GET", url, null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map root) { + List items = new ArrayList(); + for (Map m : maps(root.get("value"))) { + items.add(calendar(m, type)); + } + out.complete(new CalendarPage(items, string(root, "@odata.nextLink"), null)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource saveCalendar(final CalendarInfo calendar) { + final AsyncResource out = new AsyncResource(); + boolean task = calendar.getContentType() == CalendarInfo.ContentType.TASKS; + Map body = new HashMap(); + body.put(task ? "displayName" : "name", calendar.getName()); + String base = task ? GRAPH + "/me/todo/lists" : GRAPH + "/me/calendars"; + String url = calendar.getId() == null ? base : base + "/" + e(calendar.getId()); + final CalendarInfo.ContentType type = calendar.getContentType(); + json(calendar.getId() == null ? "POST" : "PATCH", url, body, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map m) { + out.complete(calendar(m, type)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource deleteCalendar(String calendarId) { + final AsyncResource out = new AsyncResource(); + json("DELETE", GRAPH + "/me/calendars/" + e(calendarId), null, null).ready(done(out)).except(error(out)); + return out; + } + + @Override + public AsyncResource> queryEvents(CalendarQuery query) { + final AsyncResource> out = new AsyncResource>(); + if (query == null || query.getCalendarId() == null) { + return fail(out, "calendarId required"); + } + String url; + if (query.getPageToken() != null) { + url = query.getPageToken(); + } else if (query.getSyncToken() != null) { + url = query.getSyncToken(); + } else { + StringBuilder b = new StringBuilder(GRAPH).append("/me/calendars/").append(e(query.getCalendarId())).append("/events/delta?$top=").append(query.getPageSize()); + if (query.getStartTime() != null) { + b.append("&startDateTime=").append(e(iso(query.getStartTime()))); + } + if (query.getEndTime() != null) { + b.append("&endDateTime=").append(e(iso(query.getEndTime()))); + } + url = b.toString(); + } + final String calendarId = query.getCalendarId(); + json("GET", url, null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map root) { + List items = new ArrayList(); + try { + for (Map m : maps(root.get("value"))) { + items.add(event(m, calendarId)); + } + } catch (CalendarException ex) { + out.error(ex); + return; + } + out.complete(new CalendarPage(items, string(root, "@odata.nextLink"), string(root, "@odata.deltaLink"))); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource getEvent(final String calendarId, String eventId) { + final AsyncResource out = new AsyncResource(); + json("GET", GRAPH + "/me/calendars/" + e(calendarId) + "/events/" + e(eventId), null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map m) { + try { + out.complete(event(m, calendarId)); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource saveEvent(final CalendarEvent event, CalendarMutationScope scope) { + final AsyncResource out = new AsyncResource(); + if (event == null || event.getCalendarId() == null) { + return fail(out, "event and calendarId required"); + } + String base = GRAPH + "/me/calendars/" + e(event.getCalendarId()) + "/events"; + String url = event.getId() == null ? base : base + "/" + e(event.getId()); + json(event.getId() == null ? "POST" : "PATCH", url, eventMap(event), version(event.getVersion())).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map m) { + try { + CalendarEvent saved = event(m, event.getCalendarId()); + out.complete(saved); + fireChange(new CalendarChange(getId(), saved.getCalendarId(), saved.getId(), CalendarChange.EntityType.EVENT, event.getId() == null ? CalendarChange.ChangeType.CREATED : CalendarChange.ChangeType.UPDATED)); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource deleteEvent(final String calendarId, final String eventId, CalendarMutationScope scope, String version) { + final AsyncResource out = new AsyncResource(); + json("DELETE", GRAPH + "/me/calendars/" + e(calendarId) + "/events/" + e(eventId), null, version(version)).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map x) { + out.complete(Boolean.TRUE); + fireChange(new CalendarChange(getId(), calendarId, eventId, CalendarChange.EntityType.EVENT, CalendarChange.ChangeType.DELETED)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource respondToEvent(final String calendarId, final String eventId, CalendarAttendee.Response response, String comment) { + final AsyncResource out = new AsyncResource(); + String action = response == CalendarAttendee.Response.ACCEPTED ? "accept" : response == CalendarAttendee.Response.DECLINED ? "decline" : "tentativelyAccept"; + Map body = new HashMap(); + body.put("comment", comment); + body.put("sendResponse", Boolean.TRUE); + json("POST", GRAPH + "/me/events/" + e(eventId) + "/" + action, body, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map x) { + getEvent(calendarId, eventId).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarEvent event) { + out.complete(event); + } + }).except(error(out)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource> queryFreeBusy(List ids, Instant start, Instant end) { + final AsyncResource> out = new AsyncResource>(); + Map body = new HashMap(); + body.put("schedules", ids); + body.put("startTime", dateMap(CalendarDateTime.instant(start, ZoneId.of("UTC")))); + body.put("endTime", dateMap(CalendarDateTime.instant(end, ZoneId.of("UTC")))); + body.put("availabilityViewInterval", Integer.valueOf(30)); + json("POST", GRAPH + "/me/calendar/getSchedule", body, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map root) { + List items = new ArrayList(); + try { + for (Map s : maps(root.get("value"))) { + for (Map i : maps(s.get("scheduleItems"))) { + CalendarEvent.Availability a = "free".equals(string(i, "status")) ? CalendarEvent.Availability.FREE : CalendarEvent.Availability.BUSY; + items.add(new FreeBusyInterval(graphDate(map(i.get("start"))).getDateTime().toInstant(), graphDate(map(i.get("end"))).getDateTime().toInstant(), a)); + } + } + } catch (CalendarException ex) { + out.error(ex); + return; + } + out.complete(items); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource> queryTasks(CalendarQuery query) { + final AsyncResource> out = new AsyncResource>(); + if (query == null || query.getCalendarId() == null) { + return fail(out, "calendarId required"); + } + String url = query.getPageToken() != null ? query.getPageToken() : GRAPH + "/me/todo/lists/" + e(query.getCalendarId()) + "/tasks?$top=" + query.getPageSize(); + final String list = query.getCalendarId(); + json("GET", url, null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map root) { + List items = new ArrayList(); + try { + for (Map m : maps(root.get("value"))) { + items.add(task(m, list)); + } + } catch (CalendarException ex) { + out.error(ex); + return; + } + out.complete(new CalendarPage(items, string(root, "@odata.nextLink"), null)); + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource getTask(final String list, String id) { + final AsyncResource out = new AsyncResource(); + json("GET", GRAPH + "/me/todo/lists/" + e(list) + "/tasks/" + e(id), null, null).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map m) { + try { + out.complete(task(m, list)); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource saveTask(final CalendarTask task, CalendarMutationScope scope) { + final AsyncResource out = new AsyncResource(); + if (task == null || task.getCalendarId() == null) { + return fail(out, "task and calendarId required"); + } + String base = GRAPH + "/me/todo/lists/" + e(task.getCalendarId()) + "/tasks"; + String url = task.getId() == null ? base : base + "/" + e(task.getId()); + json(task.getId() == null ? "POST" : "PATCH", url, taskMap(task), version(task.getVersion())).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map m) { + try { + CalendarTask saved = task(m, task.getCalendarId()); + out.complete(saved); + fireChange(new CalendarChange(getId(), saved.getCalendarId(), saved.getId(), CalendarChange.EntityType.TASK, task.getId() == null ? CalendarChange.ChangeType.CREATED : CalendarChange.ChangeType.UPDATED)); + } catch (CalendarException ex) { + out.error(ex); + } + } + }).except(error(out)); + return out; + } + + @Override + public AsyncResource deleteTask(final String list, final String id, CalendarMutationScope scope, String version) { + final AsyncResource out = new AsyncResource(); + json("DELETE", GRAPH + "/me/todo/lists/" + e(list) + "/tasks/" + e(id), null, version(version)).ready(new SuccessCallback>() { + + @Override + public void onSucess(Map x) { + out.complete(Boolean.TRUE); + fireChange(new CalendarChange(getId(), list, id, CalendarChange.EntityType.TASK, CalendarChange.ChangeType.DELETED)); + } + }).except(error(out)); + return out; + } + + private CalendarInfo calendar(Map m, CalendarInfo.ContentType type) { + return new CalendarInfo().setId(string(m, "id")).setSourceId(getId()).setName(string(m, type == CalendarInfo.ContentType.TASKS ? "displayName" : "name")).setOwner(string(map(m.get("owner")), "address")).setReadOnly(!bool(m, "canEdit")).setPrimary(bool(m, "isDefaultCalendar")).setContentType(type).setCapabilities(getCapabilities()); + } + + private CalendarEvent event(Map m, String calendarId) throws CalendarException { + CalendarEvent out = new CalendarEvent().setId(string(m, "id")).setCalendarId(calendarId).setSourceId(getId()).setVersion(string(m, "@odata.etag")).setTitle(string(m, "subject")).setDescription(string(map(m.get("body")), "content")).setLocation(string(map(m.get("location")), "displayName")).setUrl(string(m, "webLink")).setStart(graphDate(map(m.get("start")))).setEnd(graphDate(map(m.get("end")))).setRecurringEventId(string(m, "seriesMasterId")); + out.setRecurrence(readGraphRecurrence(map(m.get("recurrence")))); + if (bool(m, "isCancelled")) { + out.setStatus(CalendarEvent.Status.CANCELED); + } + if (bool(m, "isAllDay")) { + out.setStart(toAllDay(out.getStart())).setEnd(toAllDay(out.getEnd())); + } + String show = string(m, "showAs"); + if ("free".equals(show)) { + out.setAvailability(CalendarEvent.Availability.FREE); + } else if ("tentative".equals(show)) { + out.setAvailability(CalendarEvent.Availability.TENTATIVE); + } else if ("oof".equals(show)) { + out.setAvailability(CalendarEvent.Availability.OUT_OF_OFFICE); + } + for (Map a : maps(m.get("attendees"))) { + Map email = map(a.get("emailAddress")); + CalendarAttendee attendee = new CalendarAttendee().setName(string(email, "name")).setEmail(string(email, "address")); + if ("optional".equals(string(a, "type"))) { + attendee.setRole(CalendarAttendee.Role.OPTIONAL); + } else if ("resource".equals(string(a, "type"))) { + attendee.setRole(CalendarAttendee.Role.RESOURCE); + } + Map status = map(a.get("status")); + String response = string(status, "response"); + attendee.setResponse(attendeeResponse(response)); + out.addAttendee(attendee); + } + if (bool(m, "isReminderOn")) { + Integer minutes = integer(m, "reminderMinutesBeforeStart"); + if (minutes != null) { + out.addAlarm(new CalendarAlarm().setTimeBefore(Duration.ofMinutes(minutes.intValue()))); + } + } + Map meeting = map(m.get("onlineMeeting")); + if (!meeting.isEmpty()) { + out.setConference(new CalendarConference().setProvider(string(m, "onlineMeetingProvider")).setJoinUrl(string(meeting, "joinUrl"))); + } + return out; + } + + private Map eventMap(CalendarEvent e) { + Map m = new HashMap(); + m.put("subject", e.getTitle()); + if (e.getRecurrence() != null) { + m.put("recurrence", writeGraphRecurrence(e)); + } + Map body = new HashMap(); + body.put("contentType", "text"); + body.put("content", e.getDescription()); + m.put("body", body); + Map location = new HashMap(); + location.put("displayName", e.getLocation()); + m.put("location", location); + m.put("start", dateMap(e.getStart())); + m.put("end", dateMap(e.getEnd())); + m.put("isAllDay", Boolean.valueOf(e.getStart() != null && e.getStart().isAllDay())); + m.put("showAs", e.getAvailability() == CalendarEvent.Availability.FREE ? "free" : e.getAvailability() == CalendarEvent.Availability.TENTATIVE ? "tentative" : e.getAvailability() == CalendarEvent.Availability.OUT_OF_OFFICE ? "oof" : "busy"); + List> attendees = new ArrayList>(); + for (CalendarAttendee a : e.getAttendees()) { + Map x = new HashMap(); + Map address = new HashMap(); + address.put("address", a.getEmail()); + address.put("name", a.getName()); + x.put("emailAddress", address); + x.put("type", a.getRole() == CalendarAttendee.Role.OPTIONAL ? "optional" : a.getRole() == CalendarAttendee.Role.RESOURCE ? "resource" : "required"); + attendees.add(x); + } + m.put("attendees", attendees); + if (!e.getAlarms().isEmpty() && e.getAlarms().get(0).getTimeBefore() != null && e.getAlarms().get(0).getTimeBefore().toMillis() % 60000L == 0L) { + m.put("isReminderOn", Boolean.TRUE); + m.put("reminderMinutesBeforeStart", Long.valueOf(e.getAlarms().get(0).getTimeBefore().toMillis() / 60000L)); + } + if (e.getConference() != null && e.getConference().isCreateRequested()) { + m.put("isOnlineMeeting", Boolean.TRUE); + m.put("onlineMeetingProvider", e.getConference().getProvider() == null ? "teamsForBusiness" : e.getConference().getProvider()); + } + return m; + } + + private CalendarTask task(Map m, String list) throws CalendarException { + CalendarTask out = new CalendarTask().setId(string(m, "id")).setCalendarId(list).setSourceId(getId()).setVersion(string(m, "@odata.etag")).setTitle(string(m, "title")).setDescription(string(map(m.get("body")), "content")).setCompleted("completed".equals(string(m, "status"))).setPriority("high".equals(string(m, "importance")) ? 1 : "low".equals(string(m, "importance")) ? 9 : 5); + if (m.get("startDateTime") instanceof Map) { + out.setStart(graphDate(map(m.get("startDateTime")))); + } + if (m.get("dueDateTime") instanceof Map) { + out.setDue(graphDate(map(m.get("dueDateTime")))); + } + if (m.get("completedDateTime") instanceof Map) { + out.setCompletionTime(graphDate(map(m.get("completedDateTime"))).getDateTime().toInstant()); + } + if (bool(m, "isReminderOn") && m.get("reminderDateTime") instanceof Map) { + out.addAlarm(new CalendarAlarm().setAbsoluteTime(graphDate(map(m.get("reminderDateTime"))).getDateTime().toInstant())); + } + return out; + } + + private Map taskMap(CalendarTask t) { + Map m = new HashMap(); + m.put("title", t.getTitle()); + Map body = new HashMap(); + body.put("contentType", "text"); + body.put("content", t.getDescription()); + m.put("body", body); + m.put("status", t.isCompleted() ? "completed" : "notStarted"); + m.put("importance", t.getPriority() > 0 && t.getPriority() < 5 ? "high" : t.getPriority() > 5 ? "low" : "normal"); + if (t.getStart() != null) { + m.put("startDateTime", dateMap(t.getStart())); + } + if (t.getDue() != null) { + m.put("dueDateTime", dateMap(t.getDue())); + } + if (!t.getAlarms().isEmpty() && t.getAlarms().get(0).getAbsoluteTime() != null) { + m.put("isReminderOn", Boolean.TRUE); + m.put("reminderDateTime", dateMap(CalendarDateTime.instant(t.getAlarms().get(0).getAbsoluteTime(), ZoneId.of("UTC")))); + } + return m; + } + + private static CalendarDateTime graphDate(Map m) throws CalendarException { + String date = string(m, "dateTime"); + String zone = string(m, "timeZone"); + if (date == null) { + return null; + } + String timeZone = zone == null ? "UTC" : zone; + try { + ZoneId zoneId = ZoneId.of(timeZone); + return CalendarDateTime.instant(CalendarDateUtil.parseDateTime(date, zoneId), zoneId); + } catch (IllegalArgumentException ex) { + throw new CalendarException(CalendarError.MALFORMED_RESPONSE, "Invalid Graph date: " + date, ex); + } + } + + private static Map dateMap(CalendarDateTime d) { + Map m = new HashMap(); + if (d == null) { + return m; + } + Instant time = d.isAllDay() ? CalendarDateUtil.allDayInstant(d.getDate()) : d.getDateTime().toInstant(); + ZoneId zone = d.isAllDay() ? ZoneId.of("UTC") : d.getDateTime().getZone(); + m.put("dateTime", CalendarDateUtil.formatIso(time, zone, false)); + m.put("timeZone", zone.getId()); + return m; + } + + private static CalendarDateTime toAllDay(CalendarDateTime d) { + if (d == null) { + return null; + } + return CalendarDateTime.allDay(d.getDateTime().toLocalDateTime().toLocalDate()); + } + + private static String iso(Instant time) { + return CalendarDateUtil.formatIso(time, ZoneId.of("UTC"), false) + "Z"; + } + + private static String e(String value) { + return Util.encodeUrl(value == null ? "" : value); + } + + private static Map version(String v) { + if (v == null) { + return null; + } + Map m = new HashMap(); + m.put("If-Match", v); + return m; + } + + private static String string(Map m, String k) { + Object v = m == null ? null : m.get(k); + return v == null ? null : String.valueOf(v); + } + + private static boolean bool(Map m, String k) { + Object v = m == null ? null : m.get(k); + return Boolean.TRUE.equals(v) || "true".equals(String.valueOf(v)); + } + + private static Integer integer(Map m, String k) { + Object v = m.get(k); + return v instanceof Number ? Integer.valueOf(((Number) v).intValue()) : v == null ? null : Integer.valueOf(String.valueOf(v)); + } + + @SuppressWarnings("unchecked") + private static Map map(Object v) { + return v instanceof Map ? (Map) v : new HashMap(); + } + + @SuppressWarnings("unchecked") + private static List> maps(Object v) { + List> out = new ArrayList>(); + if (v instanceof List) { + for (Object x : (List) v) { + if (x instanceof Map) { + out.add((Map) x); + } + } + } + return out; + } + + private static String camelEnum(String v) { + StringBuilder b = new StringBuilder(); + for (int i = 0; i < v.length(); i++) { + char c = v.charAt(i); + if (Character.isUpperCase(c)) { + b.append('_').append(c); + } else { + b.append(Character.toUpperCase(c)); + } + } + String out = b.toString(); + return "NOT_STARTED".equals(out) ? "NONE" : "TENTATIVELY_ACCEPTED".equals(out) ? "TENTATIVE" : out; + } + + private static CalendarAttendee.Response attendeeResponse(String value) { + String name = value == null ? null : camelEnum(value); + for (CalendarAttendee.Response candidate : CalendarAttendee.Response.values()) { + if (candidate.name().equals(name)) { + return candidate; + } + } + return CalendarAttendee.Response.NONE; + } + + private static SuccessCallback error(final AsyncResource out) { + return new SuccessCallback() { + + @Override + public void onSucess(Throwable error) { + out.error(error); + } + }; + } + + private static SuccessCallback> done(final AsyncResource out) { + return new SuccessCallback>() { + + @Override + public void onSucess(Map x) { + out.complete(Boolean.TRUE); + } + }; + } + + private static AsyncResource fail(AsyncResource out, String message) { + out.error(new CalendarException(CalendarError.INVALID_ARGUMENT, message)); + return out; + } + + private static CalendarRecurrenceRule readGraphRecurrence(Map recurrence) { + if (recurrence.isEmpty()) { + return null; + } + Map pattern = map(recurrence.get("pattern")); + Map range = map(recurrence.get("range")); + String type = string(pattern, "type"); + CalendarRecurrenceRule.Frequency frequency = "daily".equals(type) ? CalendarRecurrenceRule.Frequency.DAILY : "weekly".equals(type) ? CalendarRecurrenceRule.Frequency.WEEKLY : type != null && type.toLowerCase().indexOf("yearly") >= 0 ? CalendarRecurrenceRule.Frequency.YEARLY : CalendarRecurrenceRule.Frequency.MONTHLY; + Integer interval = integer(pattern, "interval"); + CalendarRecurrenceRule out = new CalendarRecurrenceRule().setFrequency(frequency).setInterval(Math.max(1, interval == null ? 1 : interval.intValue())); + for (Object day : list(pattern.get("daysOfWeek"))) { + out.addDayOfWeek(graphDay(String.valueOf(day))); + } + Integer day = integer(pattern, "dayOfMonth"); + Integer month = integer(pattern, "month"); + if (day != null) { + out.addDayOfMonth(day.intValue()); + } + if (month != null) { + out.addMonth(month.intValue()); + } + Integer count = integer(range, "numberOfOccurrences"); + if (count != null) { + out.setCount(count); + } + String end = string(range, "endDate"); + if (end != null) { + out.setUntil(CalendarDateTime.allDay(parseDate(end))); + } + return out; + } + + private static Map writeGraphRecurrence(CalendarEvent event) { + CalendarRecurrenceRule rule = event.getRecurrence(); + Map out = new HashMap(); + Map pattern = new HashMap(); + Map range = new HashMap(); + String type = rule.getFrequency() == CalendarRecurrenceRule.Frequency.DAILY ? "daily" : rule.getFrequency() == CalendarRecurrenceRule.Frequency.WEEKLY ? "weekly" : rule.getFrequency() == CalendarRecurrenceRule.Frequency.YEARLY ? "absoluteYearly" : "absoluteMonthly"; + pattern.put("type", type); + pattern.put("interval", Integer.valueOf(rule.getInterval())); + List days = new ArrayList(); + for (Integer day : rule.getDaysOfWeek()) { + days.add(graphDay(day.intValue())); + } + if (!days.isEmpty()) { + pattern.put("daysOfWeek", days); + } + if (!rule.getDaysOfMonth().isEmpty()) { + pattern.put("dayOfMonth", rule.getDaysOfMonth().get(0)); + } + if (!rule.getMonths().isEmpty()) { + pattern.put("month", rule.getMonths().get(0)); + } + LocalDate start = event.getStart() != null && event.getStart().isAllDay() ? event.getStart().getDate() : dateFor(event.getStart()); + range.put("startDate", start == null ? "1970-01-01" : start.toString()); + if (rule.getCount() != null) { + range.put("type", "numbered"); + range.put("numberOfOccurrences", rule.getCount()); + } else if (rule.getUntil() != null) { + range.put("type", "endDate"); + range.put("endDate", rule.getUntil().isAllDay() ? rule.getUntil().getDate().toString() : dateFor(rule.getUntil()).toString()); + } else { + range.put("type", "noEnd"); + } + out.put("pattern", pattern); + out.put("range", range); + return out; + } + + private static LocalDate dateFor(CalendarDateTime value) { + return value == null ? null : value.getDateTime().toLocalDateTime().toLocalDate(); + } + + private static LocalDate parseDate(String value) { + return LocalDate.parse(value); + } + + private static int graphDay(String value) { + String[] days = { "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" }; + for (int i = 0; i < days.length; i++) { + if (days[i].equalsIgnoreCase(value)) { + return i + 1; + } + } + return 1; + } + + private static String graphDay(int value) { + return new String[] { "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" }[value - 1]; + } + + @SuppressWarnings("unchecked") + private static List list(Object value) { + return value instanceof List ? (List) value : new ArrayList(); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/OAuthCalendarSource.java b/CodenameOne/src/com/codename1/calendar/OAuthCalendarSource.java new file mode 100644 index 00000000000..590acdf2e1d --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/OAuthCalendarSource.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.io.JSONParser; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +/// Shared authenticated HTTP behavior for online calendar sources. Tokens and +/// credentials remain app-owned; the source only asks for a token when needed. +public abstract class OAuthCalendarSource extends CalendarSource { + + private final CalendarTokenProvider tokenProvider; + + private final CalendarHttpTransport transport; + + private final String[] scopes; + + private CalendarAuthorizationStatus authorizationStatus = CalendarAuthorizationStatus.NOT_DETERMINED; + + protected OAuthCalendarSource(String id, String displayName, CalendarTokenProvider tokenProvider, CalendarHttpTransport transport, String[] scopes) { + super(id, displayName); + if (tokenProvider == null) { + throw new IllegalArgumentException("tokenProvider required"); + } + this.tokenProvider = tokenProvider; + this.transport = transport == null ? new DefaultCalendarHttpTransport() : transport; + this.scopes = scopes == null ? new String[0] : (String[]) scopes.clone(); + } + + @Override + public synchronized CalendarAuthorizationStatus getAuthorizationStatus(CalendarAccess access) { + return authorizationStatus; + } + + private synchronized CalendarAuthorizationStatus setAuthorizationStatus(CalendarAuthorizationStatus status) { + authorizationStatus = status; + return status; + } + + @Override + public AsyncResource requestAuthorization(CalendarAccess access) { + final AsyncResource out = new AsyncResource(); + tokenProvider.getToken(scopes, false).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarAuthToken token) { + out.complete(setAuthorizationStatus(CalendarAuthorizationStatus.FULL)); + } + }).except(new SuccessCallback() { + + @Override + public void onSucess(Throwable error) { + setAuthorizationStatus(CalendarAuthorizationStatus.DENIED); + out.error(authentication(error)); + } + }); + return out; + } + + protected final AsyncResource> json(String method, String url, Map body, Map headers) { + final AsyncResource> out = new AsyncResource>(); + send(method, url, body == null ? null : JSONParser.toJson(body), "application/json", headers, false, new ResponseCallback() { + + @Override + public void complete(CalendarHttpResponse response) { + if (response.getBody() == null || response.getBody().length() == 0) { + out.complete(Collections.emptyMap()); + return; + } + try { + out.complete(JSONParser.parseJSON(response.getBody())); + } catch (IOException ex) { + out.error(new CalendarException(CalendarError.MALFORMED_RESPONSE, "Invalid provider JSON", response.getStatusCode(), ex)); + } + } + + @Override + public void error(Throwable error) { + out.error(error); + } + }); + return out; + } + + protected final AsyncResource raw(String method, String url, String body, String contentType, Map headers) { + final AsyncResource out = new AsyncResource(); + send(method, url, body, contentType, headers, false, new ResponseCallback() { + + @Override + public void complete(CalendarHttpResponse response) { + out.complete(response); + } + + @Override + public void error(Throwable error) { + out.error(error); + } + }); + return out; + } + + private void send(final String method, final String url, final String body, final String contentType, final Map headers, final boolean refreshed, final ResponseCallback callback) { + tokenProvider.getToken(scopes, refreshed).ready(new SuccessCallback() { + + @Override + public void onSucess(CalendarAuthToken token) { + CalendarHttpRequest request = new CalendarHttpRequest(method, url).setBody(body).header("Authorization", "Bearer " + token.getAccessToken()).header("Accept", "application/json"); + if (body != null && contentType != null) { + request.header("Content-Type", contentType); + } + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + request.header(entry.getKey(), entry.getValue()); + } + } + transport.execute(request).ready(new SuccessCallback() { + + public void onSucess(CalendarHttpResponse response) { + if (response.getStatusCode() == 401 && !refreshed) { + send(method, url, body, contentType, headers, true, callback); + } else if (response.getStatusCode() >= 200 && response.getStatusCode() < 300) { + callback.complete(response); + } else { + callback.error(httpError(response)); + } + } + }).except(new SuccessCallback() { + + public void onSucess(Throwable error) { + callback.error(error); + } + }); + } + }).except(new SuccessCallback() { + + @Override + public void onSucess(Throwable error) { + callback.error(authentication(error)); + } + }); + } + + private CalendarException httpError(CalendarHttpResponse response) { + int code = response.getStatusCode(); + CalendarError type = code == 401 ? CalendarError.AUTHENTICATION_REQUIRED : code == 403 ? CalendarError.PERMISSION_DENIED : code == 404 ? CalendarError.NOT_FOUND : code == 409 || code == 412 ? CalendarError.CONFLICT : code == 429 ? CalendarError.RATE_LIMITED : code >= 500 ? CalendarError.NETWORK : CalendarError.INVALID_ARGUMENT; + String message = "Calendar provider returned HTTP " + code; + if (response.getBody() != null && response.getBody().length() > 0) { + message += ": " + response.getBody(); + } + return new CalendarException(type, message, code, null); + } + + private CalendarException authentication(Throwable error) { + if (error instanceof CalendarException) { + return (CalendarException) error; + } + return new CalendarException(CalendarError.AUTHENTICATION_REQUIRED, error == null ? "Calendar authentication failed" : error.getMessage(), error); + } + + private interface ResponseCallback { + + void complete(CalendarHttpResponse response); + + void error(Throwable error); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/OidcCalendarTokenProvider.java b/CodenameOne/src/com/codename1/calendar/OidcCalendarTokenProvider.java new file mode 100644 index 00000000000..e7bc033c894 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/OidcCalendarTokenProvider.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; +import java.time.Instant; + +/// In-memory adapter around an application-configured `OidcClient`. Updated +/// tokens are reported to the app; this class never persists them itself. +public final class OidcCalendarTokenProvider implements CalendarTokenProvider { + + public interface TokenListener { + + void tokensUpdated(OidcTokens tokens); + } + + private final OidcClient client; + + private OidcTokens tokens; + + private TokenListener listener; + + public OidcCalendarTokenProvider(OidcClient client, OidcTokens initialTokens) { + if (client == null) { + throw new IllegalArgumentException("client required"); + } + this.client = client; + this.tokens = initialTokens; + } + + public OidcCalendarTokenProvider setTokenListener(TokenListener listener) { + this.listener = listener; + return this; + } + + public synchronized void setTokens(OidcTokens value) { + tokens = value; + } + + @Override + public AsyncResource getToken(String[] scopes, boolean forceRefresh) { + final AsyncResource out = new AsyncResource(); + final OidcTokens current; + synchronized (this) { + current = tokens; + } + if (current == null) { + out.error(new CalendarException(CalendarError.AUTHENTICATION_REQUIRED, "No OAuth tokens supplied")); + return out; + } + if (!forceRefresh && !current.isExpiringWithin(90)) { + out.complete(convert(current)); + return out; + } + if (current.getRefreshToken() == null) { + out.error(new CalendarException(CalendarError.AUTHENTICATION_REQUIRED, "OAuth token expired and has no refresh token")); + return out; + } + client.refresh(current.getRefreshToken()).ready(new SuccessCallback() { + + @Override + public void onSucess(OidcTokens fresh) { + synchronized (OidcCalendarTokenProvider.this) { + tokens = fresh; + } + if (listener != null) { + listener.tokensUpdated(fresh); + } + out.complete(convert(fresh)); + } + }).except(new SuccessCallback() { + + @Override + public void onSucess(Throwable error) { + out.error(error); + } + }); + return out; + } + + private static CalendarAuthToken convert(OidcTokens t) { + return new CalendarAuthToken(t.getAccessToken(), t.getExpiresAt() == null ? null : Instant.ofEpochMilli(t.getExpiresAt().getTime()), t.getScope()); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/StorageCalendarCache.java b/CodenameOne/src/com/codename1/calendar/StorageCalendarCache.java new file mode 100644 index 00000000000..8810c58d26c --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/StorageCalendarCache.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.calendar; + +import com.codename1.io.Storage; +import java.util.HashMap; +import java.util.Map; + +/// Opt-in persistent cache backed by application `Storage`. +public class StorageCalendarCache implements CalendarCache { + + private final String prefix; + + public StorageCalendarCache(String namespace) { + if (namespace == null || namespace.length() == 0) { + throw new IllegalArgumentException("namespace required"); + } + prefix = "cn1-calendar-" + namespace + "-"; + } + + @Override + public Map load(String sourceId) throws CalendarException { + Object value = Storage.getInstance().readObject(prefix + sourceId); + if (value == null) { + return new HashMap(); + } + if (!(value instanceof Map)) { + throw new CalendarException(CalendarError.STORAGE, "Invalid calendar cache data"); + } + return new HashMap((Map) value); + } + + @Override + public void store(String sourceId, Map state) throws CalendarException { + if (!Storage.getInstance().writeObject(prefix + sourceId, state)) { + throw new CalendarException(CalendarError.STORAGE, "Unable to write calendar cache"); + } + } + + @Override + public void clear(String sourceId) { + Storage.getInstance().deleteStorageFile(prefix + sourceId); + } +} diff --git a/CodenameOne/src/com/codename1/calendar/package-info.java b/CodenameOne/src/com/codename1/calendar/package-info.java new file mode 100644 index 00000000000..d0ad49b3677 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/package-info.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// Cross-platform access to local device calendars and explicitly registered +/// online calendar sources. +/// +/// Use `CalendarManager` to discover the available sources. The built-in +/// `LocalCalendarSource` reports the capabilities and permission states of the +/// current platform before an application requests access or reads and writes +/// events. Applications can register additional `CalendarSource` +/// implementations for online providers. +/// +/// Calendar changes use explicit refresh and query operations. A source may +/// additionally advertise change observation through its capabilities, but +/// applications must not assume that every platform can deliver live change +/// notifications. +package com.codename1.calendar; diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index dfe6ca8bc0c..33920093011 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -7336,6 +7336,12 @@ public com.codename1.nfc.Nfc getNfc() { return null; } + /// Returns the port-specific local calendar source, or null when the port + /// has no device calendar integration. + public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { + return null; + } + /// Allows buggy implementations (Android) to release image objects /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index fa99e67291c..c03733efbd6 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -4807,6 +4807,12 @@ public com.codename1.nfc.Nfc getNfc() { return impl.getNfc(); } + /// Returns the active port's local device-calendar source. Applications + /// should normally use `LocalCalendarSource#getInstance()`. + public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { + return impl.getLocalCalendarSource(); + } + /// This method tries to invoke the device native camera to capture images. /// The method returns immediately and the response will be sent asynchronously /// to the given ActionListener Object diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidCalendarSource.java b/Ports/Android/src/com/codename1/impl/android/AndroidCalendarSource.java new file mode 100644 index 00000000000..170081b39b2 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidCalendarSource.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.Manifest; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.ContentObserver; +import android.database.Cursor; +import android.net.Uri; +import android.os.Handler; +import android.provider.CalendarContract.Attendees; +import android.provider.CalendarContract.Calendars; +import android.provider.CalendarContract.Events; +import android.provider.CalendarContract.Reminders; +import android.support.v4.content.ContextCompat; +import com.codename1.calendar.*; +import com.codename1.util.AsyncResource; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.TimeZone; + +/** Android Calendar Provider integration. */ +final class AndroidCalendarSource extends LocalCalendarSource { + private final Context context; + private final CalendarCapabilities capabilities=CalendarCapabilities.of(CalendarCapability.READ_CALENDARS, + CalendarCapability.READ_EVENTS,CalendarCapability.WRITE_EVENTS,CalendarCapability.DELETE_EVENTS, + CalendarCapability.RECURRENCE,CalendarCapability.ATTENDEES_READ,CalendarCapability.ATTENDEES_WRITE, + CalendarCapability.ALARMS,CalendarCapability.FREE_BUSY,CalendarCapability.LOCAL_CHANGE_LISTENER, + CalendarCapability.OFFLINE_MUTATIONS); + + AndroidCalendarSource(Context context){ + this.context=context.getApplicationContext(); + try{this.context.getContentResolver().registerContentObserver(Events.CONTENT_URI,true,new ContentObserver(new Handler()){ + public void onChange(boolean selfChange,Uri uri){fireChange(new CalendarChange(getId(),null,null,CalendarChange.EntityType.EVENT,CalendarChange.ChangeType.RESET));} + });}catch(Throwable ignored){} + } + public CalendarCapabilities getCapabilities(){return capabilities;} + public CalendarAuthorizationStatus getAuthorizationStatus(CalendarAccess access){ + if(access==CalendarAccess.TASKS_FULL)return CalendarAuthorizationStatus.DENIED; + boolean read=granted(Manifest.permission.READ_CALENDAR),write=granted(Manifest.permission.WRITE_CALENDAR); + if(access==CalendarAccess.EVENTS_WRITE_ONLY)return write?CalendarAuthorizationStatus.WRITE_ONLY:CalendarAuthorizationStatus.DENIED; + if(access==CalendarAccess.EVENTS_READ_ONLY)return read?CalendarAuthorizationStatus.FULL:CalendarAuthorizationStatus.DENIED; + return read&&write?CalendarAuthorizationStatus.FULL:CalendarAuthorizationStatus.DENIED; + } + public AsyncResourcerequestAuthorization(CalendarAccess access){ + if(access==CalendarAccess.TASKS_FULL)return failed(CalendarError.NOT_SUPPORTED,"Android Calendar Provider does not expose tasks"); + boolean read=access==CalendarAccess.EVENTS_WRITE_ONLY||AndroidImplementation.checkForPermission(Manifest.permission.READ_CALENDAR,"Calendar access is required to read your calendars"); + boolean write=access==CalendarAccess.EVENTS_READ_ONLY||AndroidImplementation.checkForPermission(Manifest.permission.WRITE_CALENDAR,"Calendar access is required to schedule events"); + if(!read||!write)return value(CalendarAuthorizationStatus.DENIED); + return value(access==CalendarAccess.EVENTS_WRITE_ONLY?CalendarAuthorizationStatus.WRITE_ONLY:CalendarAuthorizationStatus.FULL); + } + public AsyncResource>listCalendars(CalendarInfo.ContentType type,String token){ + if(type==CalendarInfo.ContentType.TASKS)return value(new CalendarPage(new ArrayList(),null,null)); + if(!granted(Manifest.permission.READ_CALENDAR))return failed(CalendarError.PERMISSION_DENIED,"Calendar read permission is required"); + Listitems=new ArrayList();Cursor c=null; + try{c=resolver().query(Calendars.CONTENT_URI,new String[]{Calendars._ID,Calendars.CALENDAR_DISPLAY_NAME,Calendars.ACCOUNT_NAME,Calendars.CALENDAR_COLOR,Calendars.CALENDAR_TIME_ZONE,Calendars.IS_PRIMARY,Calendars.CALENDAR_ACCESS_LEVEL},null,null,null); + while(c!=null&&c.moveToNext())items.add(new CalendarInfo().setId(String.valueOf(c.getLong(0))).setSourceId(getId()).setName(c.getString(1)).setAccountId(c.getString(2)).setOwner(c.getString(2)).setColor(c.getInt(3)).setTimeZone(c.getString(4)==null?null:ZoneId.of(c.getString(4))).setPrimary(c.getInt(5)!=0).setReadOnly(c.getInt(6)(items,null,String.valueOf(System.currentTimeMillis()))); + }catch(Throwable ex){return failure(ex);}finally{if(c!=null)c.close();} + } + public AsyncResource>queryEvents(CalendarQuery query){ + if(!granted(Manifest.permission.READ_CALENDAR))return failed(CalendarError.PERMISSION_DENIED,"Calendar read permission is required"); + Listitems=new ArrayList();StringBuilder where=new StringBuilder("1=1");Listargs=new ArrayList(); + if(query!=null&&query.getCalendarId()!=null){where.append(" AND ").append(Events.CALENDAR_ID).append("=?");args.add(query.getCalendarId());} + if(query!=null&&query.getStartTime()!=null){where.append(" AND ").append(Events.DTEND).append(">=?");args.add(String.valueOf(query.getStartTime().toEpochMilli()));} + if(query!=null&&query.getEndTime()!=null){where.append(" AND ").append(Events.DTSTART).append("<=?");args.add(String.valueOf(query.getEndTime().toEpochMilli()));} + Cursor c=null;try{String[]columns={Events._ID,Events.CALENDAR_ID,Events.TITLE,Events.DESCRIPTION,Events.EVENT_LOCATION,Events.DTSTART,Events.DTEND,Events.EVENT_TIMEZONE,Events.ALL_DAY,Events.RRULE,Events.STATUS,Events.AVAILABILITY,Events.DIRTY}; + c=resolver().query(Events.CONTENT_URI,columns,where.toString(),args.toArray(new String[args.size()]),Events.DTSTART+" ASC"); + while(c!=null&&c.moveToNext()){CalendarEvent event=new CalendarEvent().setId(String.valueOf(c.getLong(0))).setCalendarId(String.valueOf(c.getLong(1))).setSourceId(getId()).setTitle(c.getString(2)).setDescription(c.getString(3)).setLocation(c.getString(4)).setVersion(String.valueOf(c.getLong(12))); + if(c.getInt(8)!=0){event.setStart(allDay(c.getLong(5))).setEnd(allDay(c.getLong(6)));}else{String zone=c.getString(7);if(zone==null)zone=TimeZone.getDefault().getID();event.setStart(CalendarDateTime.instant(Instant.ofEpochMilli(c.getLong(5)),ZoneId.of(zone))).setEnd(CalendarDateTime.instant(Instant.ofEpochMilli(c.getLong(6)),ZoneId.of(zone)));} + if(c.getString(9)!=null)event.setRecurrence(ICalendarCodec.readRecurrenceRule(c.getString(9)));if(c.getInt(10)==Events.STATUS_CANCELED)event.setStatus(CalendarEvent.Status.CANCELED);else if(c.getInt(10)==Events.STATUS_TENTATIVE)event.setStatus(CalendarEvent.Status.TENTATIVE);event.setAvailability(c.getInt(11)==Events.AVAILABILITY_FREE?CalendarEvent.Availability.FREE:c.getInt(11)==Events.AVAILABILITY_TENTATIVE?CalendarEvent.Availability.TENTATIVE:CalendarEvent.Availability.BUSY);readDetails(event);items.add(event);} + return value(new CalendarPage(items,null,String.valueOf(System.currentTimeMillis()))); + }catch(Throwable ex){return failure(ex);}finally{if(c!=null)c.close();} + } + public AsyncResourcegetEvent(String calendarId,String eventId){ + CalendarPagepage=queryEvents(new CalendarQuery().setCalendarId(calendarId)).get();for(CalendarEvent event:page.getItems())if(eventId.equals(event.getId()))return value(event);return failed(CalendarError.NOT_FOUND,"Event not found"); + } + public AsyncResourcesaveEvent(CalendarEvent event,CalendarMutationScope scope){ + if(!granted(Manifest.permission.WRITE_CALENDAR))return failed(CalendarError.PERMISSION_DENIED,"Calendar write permission is required");if(event==null||event.getCalendarId()==null)return failed(CalendarError.INVALID_ARGUMENT,"event and calendarId required"); + ContentValues v=new ContentValues();v.put(Events.CALENDAR_ID,Long.valueOf(event.getCalendarId()));v.put(Events.TITLE,event.getTitle());v.put(Events.DESCRIPTION,event.getDescription());v.put(Events.EVENT_LOCATION,event.getLocation());if(event.getStart()!=null){v.put(Events.ALL_DAY,Integer.valueOf(event.getStart().isAllDay()?1:0));v.put(Events.DTSTART,Long.valueOf(millis(event.getStart())));v.put(Events.EVENT_TIMEZONE,event.getStart().isAllDay()?"UTC":event.getStart().getDateTime().getZone().getId());}if(event.getEnd()!=null)v.put(Events.DTEND,Long.valueOf(millis(event.getEnd())));if(event.getRecurrence()!=null)v.put(Events.RRULE,ICalendarCodec.writeRecurrenceRule(event.getRecurrence())); + boolean create=event.getId()==null;try{if(create){Uri uri=resolver().insert(Events.CONTENT_URI,v);event.setId(String.valueOf(ContentUris.parseId(uri)));}else resolver().update(ContentUris.withAppendedId(Events.CONTENT_URI,Long.parseLong(event.getId())),v,null,null);replaceDetails(event);event.setSourceId(getId()).setVersion(String.valueOf(System.currentTimeMillis()));fireChange(new CalendarChange(getId(),event.getCalendarId(),event.getId(),CalendarChange.EntityType.EVENT,create?CalendarChange.ChangeType.CREATED:CalendarChange.ChangeType.UPDATED));return value(event);}catch(Throwable ex){return failure(ex);} + } + public AsyncResourcedeleteEvent(String calendarId,String eventId,CalendarMutationScope scope,String version){ + if(!granted(Manifest.permission.WRITE_CALENDAR))return failed(CalendarError.PERMISSION_DENIED,"Calendar write permission is required");try{boolean deleted=resolver().delete(ContentUris.withAppendedId(Events.CONTENT_URI,Long.parseLong(eventId)),null,null)>0;if(deleted)fireChange(new CalendarChange(getId(),calendarId,eventId,CalendarChange.EntityType.EVENT,CalendarChange.ChangeType.DELETED));return value(Boolean.valueOf(deleted));}catch(Throwable ex){return failure(ex);} + } + public AsyncResource>queryFreeBusy(Listids,Instant start,Instant end){Listout=new ArrayList();for(CalendarEvent event:queryEvents(new CalendarQuery().setStartTime(start).setEndTime(end)).get().getItems())if(event.getAvailability()!=CalendarEvent.Availability.FREE&&!event.getStart().isAllDay())out.add(new FreeBusyInterval(event.getStart().getDateTime().toInstant(),event.getEnd().getDateTime().toInstant(),event.getAvailability()));return value(out);} + + private void readDetails(CalendarEvent event){Cursor c=null;try{c=resolver().query(Attendees.CONTENT_URI,new String[]{Attendees.ATTENDEE_NAME,Attendees.ATTENDEE_EMAIL,Attendees.ATTENDEE_TYPE,Attendees.ATTENDEE_STATUS},Attendees.EVENT_ID+"=?",new String[]{event.getId()},null);while(c!=null&&c.moveToNext()){CalendarAttendee a=new CalendarAttendee().setName(c.getString(0)).setEmail(c.getString(1));if(c.getInt(2)==Attendees.TYPE_OPTIONAL)a.setRole(CalendarAttendee.Role.OPTIONAL);else if(c.getInt(2)==Attendees.TYPE_RESOURCE)a.setRole(CalendarAttendee.Role.RESOURCE);int s=c.getInt(3);a.setResponse(s==Attendees.ATTENDEE_STATUS_ACCEPTED?CalendarAttendee.Response.ACCEPTED:s==Attendees.ATTENDEE_STATUS_DECLINED?CalendarAttendee.Response.DECLINED:s==Attendees.ATTENDEE_STATUS_TENTATIVE?CalendarAttendee.Response.TENTATIVE:CalendarAttendee.Response.NEEDS_ACTION);event.addAttendee(a);}}finally{if(c!=null)c.close();}try{c=resolver().query(Reminders.CONTENT_URI,new String[]{Reminders.MINUTES,Reminders.METHOD},Reminders.EVENT_ID+"=?",new String[]{event.getId()},null);while(c!=null&&c.moveToNext())event.addAlarm(new CalendarAlarm().setTimeBefore(Duration.ofMinutes(c.getInt(0))).setMethod(c.getInt(1)==Reminders.METHOD_EMAIL?CalendarAlarm.Method.EMAIL:CalendarAlarm.Method.ALERT));}finally{if(c!=null)c.close();}} + private void replaceDetails(CalendarEvent event){resolver().delete(Reminders.CONTENT_URI,Reminders.EVENT_ID+"=?",new String[]{event.getId()});for(CalendarAlarm alarm:event.getAlarms())if(alarm.getTimeBefore()!=null){ContentValues v=new ContentValues();v.put(Reminders.EVENT_ID,Long.valueOf(event.getId()));v.put(Reminders.MINUTES,Long.valueOf(alarm.getTimeBefore().getSeconds()/60L));v.put(Reminders.METHOD,Integer.valueOf(alarm.getMethod()==CalendarAlarm.Method.EMAIL?Reminders.METHOD_EMAIL:Reminders.METHOD_ALERT));resolver().insert(Reminders.CONTENT_URI,v);}resolver().delete(Attendees.CONTENT_URI,Attendees.EVENT_ID+"=?",new String[]{event.getId()});for(CalendarAttendee a:event.getAttendees()){ContentValues v=new ContentValues();v.put(Attendees.EVENT_ID,Long.valueOf(event.getId()));v.put(Attendees.ATTENDEE_NAME,a.getName());v.put(Attendees.ATTENDEE_EMAIL,a.getEmail());v.put(Attendees.ATTENDEE_TYPE,Integer.valueOf(a.getRole()==CalendarAttendee.Role.OPTIONAL?Attendees.TYPE_OPTIONAL:a.getRole()==CalendarAttendee.Role.RESOURCE?Attendees.TYPE_RESOURCE:Attendees.TYPE_REQUIRED));resolver().insert(Attendees.CONTENT_URI,v);}} + private ContentResolver resolver(){return context.getContentResolver();}private boolean granted(String permission){return ContextCompat.checkSelfPermission(context,permission)==PackageManager.PERMISSION_GRANTED;}private static CalendarDateTime allDay(long time){LocalDate date=ZonedDateTime.ofInstant(Instant.ofEpochMilli(time),ZoneOffset.UTC).toLocalDateTime().toLocalDate();return CalendarDateTime.allDay(date);}private static long millis(CalendarDateTime value){if(!value.isAllDay())return value.getDateTime().toInstant().toEpochMilli();return ZonedDateTime.of(value.getDate().atTime(0,0),ZoneOffset.UTC).toInstant().toEpochMilli();} + private static AsyncResourcevalue(T value){AsyncResourceout=new AsyncResource();out.complete(value);return out;}private static AsyncResourcefailed(CalendarError type,String message){AsyncResourceout=new AsyncResource();out.error(new CalendarException(type,message));return out;}private static AsyncResourcefailure(Throwable error){AsyncResourceout=new AsyncResource();out.error(error instanceof CalendarException?error:new CalendarException(CalendarError.UNKNOWN,error.getMessage(),error));return out;} +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 789069ad603..ba91169e46f 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -230,7 +230,8 @@ import org.xml.sax.SAXException; //import android.webkit.JavascriptInterface; -public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { +public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { + private AndroidCalendarSource calendarSource; public static final Thread.UncaughtExceptionHandler exceptionHandler = new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread t, Throwable e) { @@ -8313,12 +8314,20 @@ public boolean isContactsPermissionGranted() { @Override - public String[] getAllContacts(boolean withNumbers) { + public String[] getAllContacts(boolean withNumbers) { if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ return new String[]{}; } return AndroidContactsManager.getInstance().getContacts(getContext(), withNumbers); - } + } + + @Override + public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { + if (calendarSource == null) { + calendarSource = new AndroidCalendarSource(getContext()); + } + return calendarSource; + } @Override public Contact getContactById(String id) { diff --git a/Ports/CLDC11/src/java/time/Duration.java b/Ports/CLDC11/src/java/time/Duration.java index 6532a92cffa..f34c4117263 100644 --- a/Ports/CLDC11/src/java/time/Duration.java +++ b/Ports/CLDC11/src/java/time/Duration.java @@ -35,6 +35,108 @@ public static Duration ofMillis(long millis) { return ofSeconds(DateTimeSupport.floorDiv(millis, 1000L), DateTimeSupport.floorMod(millis, 1000L) * 1000000L); } + public static Duration parse(CharSequence text) { + if (text == null) { + throw new NullPointerException(); + } + String value = text.toString().toUpperCase(); + int length = value.length(); + int position = 0; + int overallSign = 1; + if (position < length && (value.charAt(position) == '+' || value.charAt(position) == '-')) { + if (value.charAt(position++) == '-') { + overallSign = -1; + } + } + if (position >= length || value.charAt(position++) != 'P') { + throw invalidDuration(value); + } + boolean time = false; + boolean found = false; + boolean foundTime = false; + boolean foundDays = false; + int lastTimeUnit = 0; + long seconds = 0L; + int nanos = 0; + while (position < length) { + if (value.charAt(position) == 'T') { + if (time) { + throw invalidDuration(value); + } + time = true; + position++; + continue; + } + int numberStart = position; + if (value.charAt(position) == '+' || value.charAt(position) == '-') { + position++; + } + int digitStart = position; + while (position < length && Character.isDigit(value.charAt(position))) { + position++; + } + boolean hasWholeDigits = position > digitStart; + int fractionStart = -1; + if (position < length && (value.charAt(position) == '.' || value.charAt(position) == ',')) { + fractionStart = ++position; + while (position < length && Character.isDigit(value.charAt(position))) { + position++; + } + } + if (!hasWholeDigits || position >= length) { + throw invalidDuration(value); + } + char unit = value.charAt(position++); + String wholeText = value.substring(numberStart, fractionStart < 0 ? position - 1 : fractionStart - 1); + long amount; + try { + amount = Long.parseLong(wholeText); + } catch (NumberFormatException ex) { + throw invalidDuration(value); + } + if (unit == 'D' && !time && !foundDays && fractionStart < 0) { + seconds += amount * 86400L; + foundDays = true; + } else if (unit == 'H' && time && lastTimeUnit < 1 && fractionStart < 0) { + seconds += amount * 3600L; + lastTimeUnit = 1; + foundTime = true; + } else if (unit == 'M' && time && lastTimeUnit < 2 && fractionStart < 0) { + seconds += amount * 60L; + lastTimeUnit = 2; + foundTime = true; + } else if (unit == 'S' && time && lastTimeUnit < 3) { + seconds += amount; + lastTimeUnit = 3; + foundTime = true; + if (fractionStart >= 0) { + String fraction = value.substring(fractionStart, position - 1); + if (fraction.length() == 0 || fraction.length() > 9) { + throw invalidDuration(value); + } + while (fraction.length() < 9) { + fraction += "0"; + } + nanos = Integer.parseInt(fraction); + if (amount < 0 || wholeText.startsWith("-")) { + nanos = -nanos; + } + } + } else { + throw invalidDuration(value); + } + found = true; + } + if (!found || (time && !foundTime)) { + throw invalidDuration(value); + } + return ofSeconds(overallSign * seconds, overallSign * (long) nanos); + } + + private static DateTimeException invalidDuration(String value) { + return new DateTimeException("Invalid duration: " + value); + } + public long getSeconds() { return seconds; } diff --git a/Ports/CLDC11/src/java/time/format/DateTimeFormatter.java b/Ports/CLDC11/src/java/time/format/DateTimeFormatter.java index 38180f4f635..96d85e0b5d4 100644 --- a/Ports/CLDC11/src/java/time/format/DateTimeFormatter.java +++ b/Ports/CLDC11/src/java/time/format/DateTimeFormatter.java @@ -141,7 +141,8 @@ LocalDateTime parseLocalDateTime(String text) { throw new DateTimeParseException("Formatter does not produce LocalDateTime", text, 0); } int t = text.indexOf('T'); - return LocalDateTime.of(parseLocalDate(text.substring(0, t)), parseLocalTime(text.substring(t + 1))); + return LocalDateTime.of((LocalDate) ISO_LOCAL_DATE.parse(text.substring(0, t)), + (LocalTime) ISO_LOCAL_TIME.parse(text.substring(t + 1))); } OffsetDateTime parseOffsetDateTime(String text) { @@ -156,7 +157,7 @@ OffsetDateTime parseOffsetDateTime(String text) { if (text.endsWith("Z")) { idx = text.length() - 1; } - LocalDateTime ldt = parseLocalDateTime(text.substring(0, idx)); + LocalDateTime ldt = (LocalDateTime) ISO_LOCAL_DATE_TIME.parse(text.substring(0, idx)); ZoneOffset offset = text.endsWith("Z") ? ZoneOffset.UTC : ZoneOffset.of(text.substring(idx)); return OffsetDateTime.of(ldt, offset); } @@ -174,7 +175,7 @@ ZonedDateTime parseZonedDateTime(String text) { if (text.indexOf('Z') > 0 && (offsetIdx < 0 || text.indexOf('Z') < zoneStart)) { offsetIdx = text.indexOf('Z'); } - LocalDateTime ldt = parseLocalDateTime(text.substring(0, offsetIdx)); + LocalDateTime ldt = (LocalDateTime) ISO_LOCAL_DATE_TIME.parse(text.substring(0, offsetIdx)); ZoneOffset offset = text.charAt(offsetIdx) == 'Z' ? ZoneOffset.UTC : ZoneOffset.of(text.substring(offsetIdx, zoneStart)); ZoneId zone = ZoneId.of(text.substring(zoneStart + 1, text.length() - 1)); return ZonedDateTime.ofInstant(ldt.toInstant(offset), zone); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSECalendarSource.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSECalendarSource.java new file mode 100644 index 00000000000..38ed376b369 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSECalendarSource.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.calendar.CalendarAccess; +import com.codename1.calendar.CalendarAttendee; +import com.codename1.calendar.CalendarAuthorizationStatus; +import com.codename1.calendar.CalendarCapabilities; +import com.codename1.calendar.CalendarCapability; +import com.codename1.calendar.CalendarChange; +import com.codename1.calendar.CalendarEvent; +import com.codename1.calendar.CalendarInfo; +import com.codename1.calendar.CalendarMutationScope; +import com.codename1.calendar.CalendarPage; +import com.codename1.calendar.CalendarQuery; +import com.codename1.calendar.CalendarTask; +import com.codename1.calendar.FreeBusyInterval; +import com.codename1.calendar.LocalCalendarSource; +import com.codename1.util.AsyncResource; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Deterministic in-memory calendar used by the simulator and desktop run. */ +final class JavaSECalendarSource extends LocalCalendarSource { + private final Map calendars = new LinkedHashMap(); + private final Map events = new LinkedHashMap(); + private final Map tasks = new LinkedHashMap(); + private int nextId = 1; + private final CalendarCapabilities capabilities = CalendarCapabilities.of( + CalendarCapability.READ_CALENDARS, CalendarCapability.MANAGE_CALENDARS, + CalendarCapability.READ_EVENTS, CalendarCapability.WRITE_EVENTS, CalendarCapability.DELETE_EVENTS, + CalendarCapability.READ_TASKS, CalendarCapability.WRITE_TASKS, CalendarCapability.DELETE_TASKS, + CalendarCapability.RECURRENCE, CalendarCapability.ATTENDEES_READ, + CalendarCapability.ATTENDEES_WRITE, CalendarCapability.RESPOND_TO_INVITATIONS, + CalendarCapability.ALARMS, CalendarCapability.FREE_BUSY, CalendarCapability.ATTACHMENTS, + CalendarCapability.CONFERENCING, CalendarCapability.LOCAL_CHANGE_LISTENER, + CalendarCapability.OFFLINE_MUTATIONS); + + JavaSECalendarSource() { + CalendarInfo eventCalendar = new CalendarInfo().setId("sim-events").setSourceId(getId()) + .setName("Simulator Calendar").setPrimary(true).setCapabilities(capabilities); + CalendarInfo taskCalendar = new CalendarInfo().setId("sim-tasks").setSourceId(getId()) + .setName("Simulator Tasks").setContentType(CalendarInfo.ContentType.TASKS).setCapabilities(capabilities); + calendars.put(eventCalendar.getId(), eventCalendar); + calendars.put(taskCalendar.getId(), taskCalendar); + } + public boolean isAvailable() { return true; } + public CalendarCapabilities getCapabilities() { return capabilities; } + public CalendarAuthorizationStatus getAuthorizationStatus(CalendarAccess access) { return CalendarAuthorizationStatus.FULL; } + public AsyncResource requestAuthorization(CalendarAccess access) { return completed(CalendarAuthorizationStatus.FULL); } + public synchronized AsyncResource> listCalendars(CalendarInfo.ContentType type, String token) { + List out = new ArrayList(); + for (CalendarInfo c : calendars.values()) if (type == null || type == c.getContentType()) out.add(c); + return completed(new CalendarPage(out, null, String.valueOf(nextId))); + } + public synchronized AsyncResource saveCalendar(CalendarInfo calendar) { + if (calendar.getId() == null) calendar.setId("sim-cal-" + nextId++); + calendar.setSourceId(getId()).setCapabilities(capabilities); + calendars.put(calendar.getId(), calendar); + fireChange(new CalendarChange(getId(), calendar.getId(), null, CalendarChange.EntityType.CALENDAR, CalendarChange.ChangeType.UPDATED)); + return completed(calendar); + } + public synchronized AsyncResource deleteCalendar(String id) { + boolean removed = calendars.remove(id) != null; + if (removed) fireChange(new CalendarChange(getId(), id, null, CalendarChange.EntityType.CALENDAR, CalendarChange.ChangeType.DELETED)); + return completed(Boolean.valueOf(removed)); + } + public synchronized AsyncResource> queryEvents(CalendarQuery query) { + List out = new ArrayList(); + for (CalendarEvent e : events.values()) { + if (query != null && query.getCalendarId() != null && !query.getCalendarId().equals(e.getCalendarId())) continue; + if (query != null && query.getStartTime() != null && e.getEnd() != null && !e.getEnd().isAllDay() + && e.getEnd().getDateTime().toInstant().compareTo(query.getStartTime()) < 0) continue; + if (query != null && query.getEndTime() != null && e.getStart() != null && !e.getStart().isAllDay() + && e.getStart().getDateTime().toInstant().compareTo(query.getEndTime()) > 0) continue; + out.add(e); + } + return completed(new CalendarPage(out, null, String.valueOf(nextId))); + } + public synchronized AsyncResource getEvent(String calendarId, String id) { return completed(events.get(id)); } + public synchronized AsyncResource saveEvent(CalendarEvent event, CalendarMutationScope scope) { + boolean created = event.getId() == null; + if (created) event.setId("sim-event-" + nextId++); + if (event.getCalendarId() == null) event.setCalendarId("sim-events"); + event.setSourceId(getId()).setVersion(String.valueOf(nextId)); + events.put(event.getId(), event); + fireChange(new CalendarChange(getId(), event.getCalendarId(), event.getId(), CalendarChange.EntityType.EVENT, + created ? CalendarChange.ChangeType.CREATED : CalendarChange.ChangeType.UPDATED)); + return completed(event); + } + public synchronized AsyncResource deleteEvent(String calendarId, String id, CalendarMutationScope scope, String version) { + boolean removed = events.remove(id) != null; + if (removed) fireChange(new CalendarChange(getId(), calendarId, id, CalendarChange.EntityType.EVENT, CalendarChange.ChangeType.DELETED)); + return completed(Boolean.valueOf(removed)); + } + public synchronized AsyncResource respondToEvent(String calendarId, String id, CalendarAttendee.Response response, String comment) { + CalendarEvent event = events.get(id); + if (event != null) for (CalendarAttendee a : event.getAttendees()) if (a.isSelf()) a.setResponse(response); + return completed(event); + } + public synchronized AsyncResource> queryFreeBusy(List ids, Instant start, Instant end) { + List out = new ArrayList(); + for (CalendarEvent event : events.values()) if (event.getAvailability() != CalendarEvent.Availability.FREE + && event.getStart() != null && event.getEnd() != null && !event.getStart().isAllDay() && !event.getEnd().isAllDay() + && event.getEnd().getDateTime().toInstant().compareTo(start) >= 0 + && event.getStart().getDateTime().toInstant().compareTo(end) <= 0) + out.add(new FreeBusyInterval(event.getStart().getDateTime().toInstant(), + event.getEnd().getDateTime().toInstant(), event.getAvailability())); + return completed(out); + } + public synchronized AsyncResource> queryTasks(CalendarQuery query) { + List out = new ArrayList(); + for (CalendarTask task : tasks.values()) if (query == null || query.getCalendarId() == null || query.getCalendarId().equals(task.getCalendarId())) out.add(task); + return completed(new CalendarPage(out, null, String.valueOf(nextId))); + } + public synchronized AsyncResource getTask(String calendarId, String id) { return completed(tasks.get(id)); } + public synchronized AsyncResource saveTask(CalendarTask task, CalendarMutationScope scope) { + boolean created = task.getId() == null; + if (created) task.setId("sim-task-" + nextId++); + if (task.getCalendarId() == null) task.setCalendarId("sim-tasks"); + task.setSourceId(getId()).setVersion(String.valueOf(nextId)); + tasks.put(task.getId(), task); + fireChange(new CalendarChange(getId(), task.getCalendarId(), task.getId(), CalendarChange.EntityType.TASK, + created ? CalendarChange.ChangeType.CREATED : CalendarChange.ChangeType.UPDATED)); + return completed(task); + } + public synchronized AsyncResource deleteTask(String calendarId, String id, CalendarMutationScope scope, String version) { + boolean removed = tasks.remove(id) != null; + if (removed) fireChange(new CalendarChange(getId(), calendarId, id, CalendarChange.EntityType.TASK, CalendarChange.ChangeType.DELETED)); + return completed(Boolean.valueOf(removed)); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index d8097e80dbb..1bbfe8ae89a 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -14805,6 +14805,7 @@ public String[] getPlatformOverrides() { private JavaSEBiometrics biometrics; private JavaSESecureStorage secureStorage; private JavaSENfc nfc; + private JavaSECalendarSource calendarSource; private boolean biometricsBuildHintsInstalled; private boolean nfcBuildHintsInstalled; @@ -14835,6 +14836,17 @@ public com.codename1.nfc.Nfc getNfc() { return nfc; } + @Override + public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { + if (!isSimulator()) { + return null; + } + if (calendarSource == null) { + calendarSource = new JavaSECalendarSource(); + } + return calendarSource; + } + /** * The first time the app reaches the NFC API in the simulator, write * placeholders for ios.NFCReaderUsageDescription if the developer has diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 51d88da0611..bbd5987433e 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -78,6 +78,12 @@ #endif #import #import +#if !TARGET_OS_WATCH && !TARGET_OS_TV +//#define CN1_USE_CALENDAR +#ifdef CN1_USE_CALENDAR +#import +#endif +#endif #import #include #include @@ -93,7 +99,7 @@ // Catalyst); on tvOS MessageUI ships only a link stub with no composer headers // and AddressBookUI is absent. The native methods that use them are guarded to // no-ops on those slices. -#if !TARGET_OS_WATCH && !TARGET_OS_TV +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV #import #endif #if !TARGET_OS_MACCATALYST && !TARGET_OS_WATCH && !TARGET_OS_TV @@ -568,6 +574,180 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isPainted__(CN1_THREAD_STATE_MULTI //XMLVM_END_WRAPPER } +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV +static EKEventStore* cn1CalendarStore() { + static EKEventStore *store; + static dispatch_once_t once; + dispatch_once(&once, ^{ store = [[EKEventStore alloc] init]; }); + return store; +} + +static NSString* cn1CalendarJson(id value) { + if (value == nil) return @"{}"; + NSData *data = [NSJSONSerialization dataWithJSONObject:value options:0 error:nil]; + return data == nil ? @"{}" : [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +} + +static NSDictionary* cn1CalendarDictionary(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT json) { + NSString *text = toNSString(CN1_THREAD_STATE_PASS_ARG json); + if (text == nil) return @{}; + NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *out = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [out isKindOfClass:[NSDictionary class]] ? out : @{}; +} + +static NSDate* cn1CalendarDate(NSDictionary *json, NSString *key) { + NSNumber *milliseconds = json[key]; + if ([milliseconds isKindOfClass:[NSNumber class]]) return [NSDate dateWithTimeIntervalSince1970:milliseconds.doubleValue / 1000.0]; + NSString *dateText = json[[key stringByAppendingString:@"Date"]]; + if (![dateText isKindOfClass:[NSString class]]) return nil; + NSDateFormatter *format = [[NSDateFormatter alloc] init]; + format.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]; + format.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0]; + format.dateFormat = @"yyyy-MM-dd"; + return [format dateFromString:dateText]; +} + +static NSDictionary* cn1CalendarDateFields(NSDate *date, NSString *key, NSTimeZone *zone, BOOL allDay) { + if (date == nil) return @{}; + if (allDay) { + NSDateFormatter *format = [[NSDateFormatter alloc] init]; + format.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]; + format.timeZone = zone ?: [NSTimeZone timeZoneForSecondsFromGMT:0]; + format.dateFormat = @"yyyy-MM-dd"; + return @{[key stringByAppendingString:@"Date"]: [format stringFromDate:date], [key stringByAppendingString:@"AllDay"]: @YES}; + } + return @{key: @([date timeIntervalSince1970] * 1000.0), [key stringByAppendingString:@"Zone"]: zone.name ?: @"UTC"}; +} + +static NSDictionary* cn1EventDictionary(EKEvent *event) { + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + if (event.eventIdentifier) out[@"id"] = event.eventIdentifier; + if (event.calendar.calendarIdentifier) out[@"calendarId"] = event.calendar.calendarIdentifier; + if (event.title) out[@"title"] = event.title; + if (event.notes) out[@"notes"] = event.notes; + if (event.location) out[@"location"] = event.location; + out[@"allDay"] = @(event.allDay); + [out addEntriesFromDictionary:cn1CalendarDateFields(event.startDate, @"start", event.timeZone, event.allDay)]; + [out addEntriesFromDictionary:cn1CalendarDateFields(event.endDate, @"end", event.timeZone, event.allDay)]; + out[@"available"] = @(event.availability == EKEventAvailabilityFree); + out[@"version"] = [NSString stringWithFormat:@"%.0f", event.lastModifiedDate.timeIntervalSince1970 * 1000.0]; + NSMutableArray *alarms = [NSMutableArray array]; + for (EKAlarm *alarm in event.alarms ?: @[]) { + if (alarm.absoluteDate) [alarms addObject:@{@"absolute": @([alarm.absoluteDate timeIntervalSince1970] * 1000.0)}]; + else [alarms addObject:@{@"minutes": @((NSInteger)(-alarm.relativeOffset / 60.0))}]; + } + out[@"alarms"] = alarms; + return out; +} + +static NSDictionary* cn1ReminderDictionary(EKReminder *reminder) { + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + if (reminder.calendarItemIdentifier) out[@"id"] = reminder.calendarItemIdentifier; + if (reminder.calendar.calendarIdentifier) out[@"calendarId"] = reminder.calendar.calendarIdentifier; + if (reminder.title) out[@"title"] = reminder.title; + if (reminder.notes) out[@"notes"] = reminder.notes; + out[@"completed"] = @(reminder.completed); + out[@"version"] = [NSString stringWithFormat:@"%.0f", reminder.lastModifiedDate.timeIntervalSince1970 * 1000.0]; + if (reminder.dueDateComponents) { + NSDate *due = [reminder.dueDateComponents.calendar dateFromComponents:reminder.dueDateComponents]; + BOOL allDay = reminder.dueDateComponents.hour == NSDateComponentUndefined; + [out addEntriesFromDictionary:cn1CalendarDateFields(due, @"due", reminder.dueDateComponents.timeZone, allDay)]; + out[@"dueAllDay"] = @(allDay); + } + return out; +} +#endif + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_calendarSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + return JAVA_TRUE; +#else + return JAVA_FALSE; +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_calendarAuthorizationStatus___int_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT entityType) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + return (JAVA_INT)[EKEventStore authorizationStatusForEntityType:entityType == 1 ? EKEntityTypeReminder : EKEntityTypeEvent]; +#else + return 2; +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_calendarRequestAccess___int_boolean_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT entityType, JAVA_BOOLEAN writeOnly) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + __block BOOL granted = NO; dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); EKEventStore *store = cn1CalendarStore(); + void (^completion)(BOOL,NSError*) = ^(BOOL value, NSError *error){ granted = value; dispatch_semaphore_signal(semaphore); }; + if (@available(iOS 17.0, macCatalyst 17.0, *)) { + if (entityType == 1) [store requestFullAccessToRemindersWithCompletion:completion]; + else if (writeOnly) [store requestWriteOnlyAccessToEventsWithCompletion:completion]; + else [store requestFullAccessToEventsWithCompletion:completion]; + } else [store requestAccessToEntityType:entityType == 1 ? EKEntityTypeReminder : EKEntityTypeEvent completion:completion]; + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); return granted; +#else + return 0; +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_calendarList___int_R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT entityType) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + NSMutableArray *items=[NSMutableArray array]; for(EKCalendar *calendar in [cn1CalendarStore() calendarsForEntityType:entityType==1?EKEntityTypeReminder:EKEntityTypeEvent]) [items addObject:@{@"id":calendar.calendarIdentifier?:@"",@"title":calendar.title?:@"",@"allowsModify":@(calendar.allowsContentModifications),@"color":@0}]; + return fromNSString(CN1_THREAD_STATE_PASS_ARG cn1CalendarJson(@{@"items":items})); +#else + return fromNSString(CN1_THREAD_STATE_PASS_ARG @"{\"items\":[]}"); +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_calendarEvents___java_lang_String_long_long_R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT calendarId, JAVA_LONG startTime, JAVA_LONG endTime) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + EKEventStore *store=cn1CalendarStore();NSArray *calendars=nil;NSString *identifier=toNSString(CN1_THREAD_STATE_PASS_ARG calendarId);if(identifier){EKCalendar *calendar=[store calendarWithIdentifier:identifier];if(calendar)calendars=@[calendar];} + NSPredicate *predicate=[store predicateForEventsWithStartDate:[NSDate dateWithTimeIntervalSince1970:startTime/1000.0] endDate:[NSDate dateWithTimeIntervalSince1970:endTime/1000.0] calendars:calendars];NSMutableArray *items=[NSMutableArray array];for(EKEvent *event in [store eventsMatchingPredicate:predicate])[items addObject:cn1EventDictionary(event)];return fromNSString(CN1_THREAD_STATE_PASS_ARG cn1CalendarJson(@{@"items":items})); +#else + return fromNSString(CN1_THREAD_STATE_PASS_ARG @"{\"items\":[]}"); +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_calendarSaveEvent___java_lang_String_int_R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT eventJson, JAVA_INT mutationScope) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + NSDictionary *json=cn1CalendarDictionary(CN1_THREAD_STATE_PASS_ARG eventJson);EKEventStore *store=cn1CalendarStore();NSString *identifier=json[@"id"];EKEvent *event=identifier?[store eventWithIdentifier:identifier]:nil;if(!event)event=[EKEvent eventWithEventStore:store];NSString *calendarId=json[@"calendarId"];event.calendar=calendarId?[store calendarWithIdentifier:calendarId]:[store defaultCalendarForNewEvents];event.title=json[@"title"]?:@"";event.notes=json[@"notes"];event.location=json[@"location"];event.startDate=cn1CalendarDate(json,@"start");event.endDate=cn1CalendarDate(json,@"end");event.allDay=[json[@"allDay"] boolValue];NSMutableArray *alarms=[NSMutableArray array];for(NSDictionary *alarm in json[@"alarms"]?:@[]){if(alarm[@"absolute"])[alarms addObject:[EKAlarm alarmWithAbsoluteDate:[NSDate dateWithTimeIntervalSince1970:[alarm[@"absolute"] doubleValue]/1000.0]]];else if(alarm[@"minutes"])[alarms addObject:[EKAlarm alarmWithRelativeOffset:-[alarm[@"minutes"] doubleValue]*60.0]];}event.alarms=alarms;NSError *error=nil;BOOL ok=[store saveEvent:event span:mutationScope==0?EKSpanThisEvent:EKSpanFutureEvents commit:YES error:&error];return fromNSString(CN1_THREAD_STATE_PASS_ARG cn1CalendarJson(ok?cn1EventDictionary(event):@{@"error":error.localizedDescription?:@"Unable to save event"})); +#else + return fromNSString(CN1_THREAD_STATE_PASS_ARG @"{\"error\":\"Unsupported\"}"); +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_calendarDeleteEvent___java_lang_String_int_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT eventId, JAVA_INT mutationScope) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + EKEventStore *store=cn1CalendarStore();EKEvent *event=[store eventWithIdentifier:toNSString(CN1_THREAD_STATE_PASS_ARG eventId)];return event&&[store removeEvent:event span:mutationScope==0?EKSpanThisEvent:EKSpanFutureEvents commit:YES error:nil]; +#else + return 0; +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_calendarTasks___java_lang_String_R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT calendarId) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + EKEventStore *store=cn1CalendarStore();NSArray *calendars=nil;NSString *identifier=toNSString(CN1_THREAD_STATE_PASS_ARG calendarId);if(identifier){EKCalendar *calendar=[store calendarWithIdentifier:identifier];if(calendar)calendars=@[calendar];}__block NSArray *reminders=@[];dispatch_semaphore_t semaphore=dispatch_semaphore_create(0);[store fetchRemindersMatchingPredicate:[store predicateForRemindersInCalendars:calendars] completion:^(NSArray *value){reminders=value?:@[];dispatch_semaphore_signal(semaphore);}];dispatch_semaphore_wait(semaphore,DISPATCH_TIME_FOREVER);NSMutableArray *items=[NSMutableArray array];for(EKReminder *reminder in reminders)[items addObject:cn1ReminderDictionary(reminder)];return fromNSString(CN1_THREAD_STATE_PASS_ARG cn1CalendarJson(@{@"items":items})); +#else + return fromNSString(CN1_THREAD_STATE_PASS_ARG @"{\"items\":[]}"); +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_calendarSaveTask___java_lang_String_R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT taskJson) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + NSDictionary *json=cn1CalendarDictionary(CN1_THREAD_STATE_PASS_ARG taskJson);EKEventStore *store=cn1CalendarStore();NSString *identifier=json[@"id"];EKReminder *reminder=identifier?(EKReminder*)[store calendarItemWithIdentifier:identifier]:nil;if(!reminder)reminder=[EKReminder reminderWithEventStore:store];NSString *calendarId=json[@"calendarId"];reminder.calendar=calendarId?[store calendarWithIdentifier:calendarId]:[store defaultCalendarForNewReminders];reminder.title=json[@"title"]?:@"";reminder.notes=json[@"notes"];reminder.completed=[json[@"completed"] boolValue];NSDate *due=cn1CalendarDate(json,@"due");if(due){NSCalendar *calendar=[NSCalendar currentCalendar];NSUInteger units=NSCalendarUnitYear|NSCalendarUnitMonth|NSCalendarUnitDay;if(![json[@"dueAllDay"] boolValue])units|=NSCalendarUnitHour|NSCalendarUnitMinute|NSCalendarUnitSecond;reminder.dueDateComponents=[calendar components:units fromDate:due];}NSError *error=nil;BOOL ok=[store saveReminder:reminder commit:YES error:&error];return fromNSString(CN1_THREAD_STATE_PASS_ARG cn1CalendarJson(ok?cn1ReminderDictionary(reminder):@{@"error":error.localizedDescription?:@"Unable to save reminder"})); +#else + return fromNSString(CN1_THREAD_STATE_PASS_ARG @"{\"error\":\"Unsupported\"}"); +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_calendarDeleteTask___java_lang_String_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT taskId) { +#if defined(CN1_USE_CALENDAR) && !TARGET_OS_WATCH && !TARGET_OS_TV + EKEventStore *store=cn1CalendarStore();EKReminder *reminder=(EKReminder*)[store calendarItemWithIdentifier:toNSString(CN1_THREAD_STATE_PASS_ARG taskId)];return reminder&&[store removeReminder:reminder commit:YES error:nil]; +#else + return 0; +#endif +} + JAVA_INT com_codename1_impl_ios_IOSNative_getDisplayWidth__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { //XMLVM_BEGIN_WRAPPER[com_codename1_impl_ios_IOSNative_getDisplayWidth__] diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSCalendarSource.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSCalendarSource.java new file mode 100644 index 00000000000..cb600e95857 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSCalendarSource.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.ios; + +import com.codename1.calendar.*; +import com.codename1.io.JSONParser; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** EventKit-backed local calendar source for iOS and Mac Catalyst. */ +final class IOSCalendarSource extends LocalCalendarSource { + private static final int EVENTS=0,REMINDERS=1; + private final IOSNative nativeInstance; + private final CalendarCapabilities capabilities; + IOSCalendarSource(IOSNative nativeInstance){ + this.nativeInstance=nativeInstance; + capabilities=nativeInstance.calendarSupported()?CalendarCapabilities.of(CalendarCapability.READ_CALENDARS, + CalendarCapability.READ_EVENTS,CalendarCapability.WRITE_EVENTS,CalendarCapability.DELETE_EVENTS, + CalendarCapability.READ_TASKS,CalendarCapability.WRITE_TASKS,CalendarCapability.DELETE_TASKS, + CalendarCapability.ALARMS,CalendarCapability.FREE_BUSY,CalendarCapability.OFFLINE_MUTATIONS):CalendarCapabilities.none(); + } + public CalendarCapabilities getCapabilities(){return capabilities;} + public CalendarAuthorizationStatus getAuthorizationStatus(CalendarAccess access){return status(nativeInstance.calendarAuthorizationStatus(access==CalendarAccess.TASKS_FULL?REMINDERS:EVENTS));} + public AsyncResourcerequestAuthorization(final CalendarAccess access){ + final AsyncResourceout=new AsyncResource(); + Display.getInstance().scheduleBackgroundTask(new Runnable(){public void run(){ + boolean granted=nativeInstance.calendarRequestAccess(access==CalendarAccess.TASKS_FULL?REMINDERS:EVENTS,access==CalendarAccess.EVENTS_WRITE_ONLY); + out.complete(granted?(access==CalendarAccess.EVENTS_WRITE_ONLY?CalendarAuthorizationStatus.WRITE_ONLY:CalendarAuthorizationStatus.FULL):CalendarAuthorizationStatus.DENIED); + }}); + return out; + } + public AsyncResource>listCalendars(CalendarInfo.ContentType type,String token){try{int entity=type==CalendarInfo.ContentType.TASKS?REMINDERS:EVENTS;Listitems=new ArrayList();for(Mapm:items(nativeInstance.calendarList(entity)))items.add(new CalendarInfo().setId(s(m,"id")).setSourceId(getId()).setName(s(m,"title")).setColor(i(m,"color")).setReadOnly(!b(m,"allowsModify")).setContentType(type).setCapabilities(capabilities));return value(new CalendarPage(items,null,String.valueOf(System.currentTimeMillis())));}catch(Exception ex){return failure(ex);}} + public AsyncResource>queryEvents(CalendarQuery query){try{String calendar=query==null?null:query.getCalendarId();long start=query==null||query.getStartTime()==null?System.currentTimeMillis()-31536000000L:query.getStartTime().toEpochMilli(),end=query==null||query.getEndTime()==null?System.currentTimeMillis()+31536000000L:query.getEndTime().toEpochMilli();Listitems=new ArrayList();for(Mapm:items(nativeInstance.calendarEvents(calendar,start,end)))items.add(event(m));return value(new CalendarPage(items,null,String.valueOf(System.currentTimeMillis())));}catch(Exception ex){return failure(ex);}} + public AsyncResourcegetEvent(String calendarId,String id){CalendarPagepage=queryEvents(new CalendarQuery().setCalendarId(calendarId)).get();for(CalendarEvent e:page.getItems())if(id.equals(e.getId()))return value(e);return failed(CalendarError.NOT_FOUND,"Event not found");} + public AsyncResourcesaveEvent(CalendarEvent event,CalendarMutationScope scope){try{Mapm=new HashMap();m.put("id",event.getId());m.put("calendarId",event.getCalendarId());m.put("title",event.getTitle());m.put("notes",event.getDescription());m.put("location",event.getLocation());putDate(m,"start",event.getStart());putDate(m,"end",event.getEnd());m.put("allDay",Boolean.valueOf(event.getStart()!=null&&event.getStart().isAllDay()));List>alarms=new ArrayList>();for(CalendarAlarm alarm:event.getAlarms()){Mapa=new HashMap();a.put("minutes",alarm.getTimeBefore()==null?null:Long.valueOf(alarm.getTimeBefore().getSeconds()/60L));a.put("absolute",alarm.getAbsoluteTime()==null?null:Long.valueOf(alarm.getAbsoluteTime().toEpochMilli()));alarms.add(a);}m.put("alarms",alarms);boolean create=event.getId()==null;Mapsaved=parseResult(nativeInstance.calendarSaveEvent(JSONParser.toJson(m),scope==null?0:scope.ordinal()));CalendarEvent result=event(saved);fireChange(new CalendarChange(getId(),result.getCalendarId(),result.getId(),CalendarChange.EntityType.EVENT,create?CalendarChange.ChangeType.CREATED:CalendarChange.ChangeType.UPDATED));return value(result);}catch(IOException ex){return failure(ex);}catch(CalendarException ex){return failure(ex);}} + public AsyncResourcedeleteEvent(String calendarId,String eventId,CalendarMutationScope scope,String version){boolean deleted=nativeInstance.calendarDeleteEvent(eventId,scope==null?0:scope.ordinal());if(deleted)fireChange(new CalendarChange(getId(),calendarId,eventId,CalendarChange.EntityType.EVENT,CalendarChange.ChangeType.DELETED));return value(Boolean.valueOf(deleted));} + public AsyncResource>queryFreeBusy(Listids,Instant start,Instant end){Listout=new ArrayList();for(CalendarEvent event:queryEvents(new CalendarQuery().setStartTime(start).setEndTime(end)).get().getItems())if(event.getAvailability()!=CalendarEvent.Availability.FREE&&!event.getStart().isAllDay())out.add(new FreeBusyInterval(event.getStart().getDateTime().toInstant(),event.getEnd().getDateTime().toInstant(),event.getAvailability()));return value(out);} + public AsyncResource>queryTasks(CalendarQuery query){try{Listout=new ArrayList();for(Mapm:items(nativeInstance.calendarTasks(query==null?null:query.getCalendarId())))out.add(task(m));return value(new CalendarPage(out,null,String.valueOf(System.currentTimeMillis())));}catch(Exception ex){return failure(ex);}} + public AsyncResourcegetTask(String calendarId,String id){for(CalendarTask task:queryTasks(new CalendarQuery().setCalendarId(calendarId)).get().getItems())if(id.equals(task.getId()))return value(task);return failed(CalendarError.NOT_FOUND,"Task not found");} + public AsyncResourcesaveTask(CalendarTask task,CalendarMutationScope scope){try{Mapm=new HashMap();m.put("id",task.getId());m.put("calendarId",task.getCalendarId());m.put("title",task.getTitle());m.put("notes",task.getDescription());m.put("completed",Boolean.valueOf(task.isCompleted()));putDate(m,"due",task.getDue());boolean create=task.getId()==null;CalendarTask saved=task(parseResult(nativeInstance.calendarSaveTask(JSONParser.toJson(m))));fireChange(new CalendarChange(getId(),saved.getCalendarId(),saved.getId(),CalendarChange.EntityType.TASK,create?CalendarChange.ChangeType.CREATED:CalendarChange.ChangeType.UPDATED));return value(saved);}catch(IOException ex){return failure(ex);}catch(CalendarException ex){return failure(ex);}} + public AsyncResourcedeleteTask(String calendarId,String id,CalendarMutationScope scope,String version){boolean deleted=nativeInstance.calendarDeleteTask(id);if(deleted)fireChange(new CalendarChange(getId(),calendarId,id,CalendarChange.EntityType.TASK,CalendarChange.ChangeType.DELETED));return value(Boolean.valueOf(deleted));} + private CalendarEvent event(Mapm){CalendarEvent out=new CalendarEvent().setId(s(m,"id")).setCalendarId(s(m,"calendarId")).setSourceId(getId()).setVersion(s(m,"version")).setTitle(s(m,"title")).setDescription(s(m,"notes")).setLocation(s(m,"location")).setAvailability(b(m,"available")?CalendarEvent.Availability.FREE:CalendarEvent.Availability.BUSY);out.setStart(date(m,"start",b(m,"allDay"))).setEnd(date(m,"end",b(m,"allDay")));for(Mapa:maps(m.get("alarms"))){if(a.get("minutes")!=null)out.addAlarm(new CalendarAlarm().setTimeBefore(Duration.ofMinutes(i(a,"minutes"))));else if(a.get("absolute")!=null)out.addAlarm(new CalendarAlarm().setAbsoluteTime(Instant.ofEpochMilli(l(a,"absolute"))));}return out;} + private CalendarTask task(Mapm){return new CalendarTask().setId(s(m,"id")).setCalendarId(s(m,"calendarId")).setSourceId(getId()).setVersion(s(m,"version")).setTitle(s(m,"title")).setDescription(s(m,"notes")).setCompleted(b(m,"completed")).setDue(date(m,"due",b(m,"dueAllDay")));} + private static void putDate(Mapm,String key,CalendarDateTime d){if(d==null)return;if(d.isAllDay()){m.put(key+"Date",d.getDate().toString());m.put(key+"AllDay",Boolean.TRUE);}else{m.put(key,Long.valueOf(d.getDateTime().toInstant().toEpochMilli()));m.put(key+"Zone",d.getDateTime().getZone().getId());}} + private static CalendarDateTime date(Mapm,String key,boolean allDay){String value=s(m,key+"Date");if(value!=null)return CalendarDateTime.allDay(LocalDate.parse(value));String zone=s(m,key+"Zone");return m.get(key)==null?null:CalendarDateTime.instant(Instant.ofEpochMilli(l(m,key)),ZoneId.of(zone==null?"UTC":zone));} + private static CalendarAuthorizationStatus status(int value){return value==3?CalendarAuthorizationStatus.FULL:value==4?CalendarAuthorizationStatus.WRITE_ONLY:value==1?CalendarAuthorizationStatus.RESTRICTED:value==2?CalendarAuthorizationStatus.DENIED:CalendarAuthorizationStatus.NOT_DETERMINED;} + private static Mapparse(String json)throws IOException{return JSONParser.parseJSON(json);}private static MapparseResult(String json)throws IOException,CalendarException{Mapout=parse(json);if(out.get("error")!=null)throw new CalendarException(CalendarError.UNKNOWN,String.valueOf(out.get("error")));return out;}@SuppressWarnings("unchecked")private static List>items(String json)throws IOException{return maps(parse(json).get("items"));}@SuppressWarnings("unchecked")private static List>maps(Object v){List>out=new ArrayList>();if(v instanceof List)for(Object x:(List)v)if(x instanceof Map)out.add((Map)x);return out;}private static String s(Mapm,String k){Object v=m.get(k);return v==null?null:String.valueOf(v);}private static long l(Mapm,String k){Object v=m.get(k);return v instanceof Number?((Number)v).longValue():Long.parseLong(String.valueOf(v));}private static int i(Mapm,String k){Object v=m.get(k);return v instanceof Number?((Number)v).intValue():Integer.parseInt(String.valueOf(v));}private static boolean b(Mapm,String k){Object v=m.get(k);return Boolean.TRUE.equals(v)||"true".equals(String.valueOf(v));} + private static AsyncResourcevalue(T value){AsyncResourceout=new AsyncResource();out.complete(value);return out;}private static AsyncResourcefailed(CalendarError type,String message){AsyncResourceout=new AsyncResource();out.error(new CalendarException(type,message));return out;}private static AsyncResourcefailure(Throwable error){AsyncResourceout=new AsyncResource();out.error(error instanceof CalendarException?error:new CalendarException(CalendarError.UNKNOWN,error.getMessage(),error));return out;} +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 49df4e1edf4..fb745673edd 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -131,6 +131,7 @@ * @author Shai Almog */ public class IOSImplementation extends CodenameOneImplementation { + private IOSCalendarSource calendarSource; // Flag to indicate if the current openGallery process is selecting multiple files private boolean disableUIWebView=true; private static boolean gallerySelectMultiple; @@ -4592,6 +4593,14 @@ public com.codename1.nfc.Nfc getNfc() { return nfc; } + @Override + public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { + if (calendarSource == null) { + calendarSource = new IOSCalendarSource(nativeInstance); + } + return calendarSource; + } + public LocationManager getLocationManager() { if (!nativeInstance.checkLocationUsage()) { throw new RuntimeException("Please add the ios.NSLocationUsageDescription or ios.NSLocationAlwaysUsageDescription build hint"); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 6c17c893f30..9810df72e5a 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -54,6 +54,16 @@ public final class IOSNative { /// source of truth on the Java side since the build flag only /// affects native compilation. native boolean isMetalRendering(); + native boolean calendarSupported(); + native int calendarAuthorizationStatus(int entityType); + native boolean calendarRequestAccess(int entityType, boolean writeOnly); + native String calendarList(int entityType); + native String calendarEvents(String calendarId, long startTime, long endTime); + native String calendarSaveEvent(String eventJson, int mutationScope); + native boolean calendarDeleteEvent(String eventId, int mutationScope); + native String calendarTasks(String calendarId); + native String calendarSaveTask(String taskJson); + native boolean calendarDeleteTask(String taskId); static native void deinitializeVM(); native boolean isPainted(); native int getDisplayWidth(); diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/calendar/CalendarIntegrationSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/calendar/CalendarIntegrationSnippets.java new file mode 100644 index 00000000000..5b28265012a --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/calendar/CalendarIntegrationSnippets.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.calendar; + +import com.codename1.calendar.CalDavAuthentication; +import com.codename1.calendar.CalDavCalendarSource; +import com.codename1.calendar.CalendarAccess; +import com.codename1.calendar.CalendarAlarm; +import com.codename1.calendar.CalendarAttendee; +import com.codename1.calendar.CalendarAuthorizationStatus; +import com.codename1.calendar.CalendarCapabilities; +import com.codename1.calendar.CalendarCapability; +import com.codename1.calendar.CalendarConflict; +import com.codename1.calendar.CalendarDateTime; +import com.codename1.calendar.CalendarEvent; +import com.codename1.calendar.CalendarException; +import com.codename1.calendar.CalendarManager; +import com.codename1.calendar.CalendarMutationScope; +import com.codename1.calendar.CalendarQuery; +import com.codename1.calendar.CalendarRecurrenceRule; +import com.codename1.calendar.CalendarSource; +import com.codename1.calendar.CalendarSyncEngine; +import com.codename1.calendar.GoogleCalendarSource; +import com.codename1.calendar.ICalendarCodec; +import com.codename1.calendar.LocalCalendarSource; +import com.codename1.calendar.OidcCalendarTokenProvider; +import com.codename1.calendar.StorageCalendarCache; +import com.codename1.io.Log; +import com.codename1.io.oidc.OidcClient; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +public class CalendarIntegrationSnippets { + public void discoverCapabilities() { + // tag::calendar-capabilities[] + LocalCalendarSource local = LocalCalendarSource.getInstance(); + CalendarCapabilities capabilities = local.getCapabilities(); + + if (capabilities.supports(CalendarCapability.READ_EVENTS)) { + local.requestAuthorization(CalendarAccess.EVENTS_READ_ONLY).ready(status -> { + if (status == CalendarAuthorizationStatus.FULL) { + local.queryEvents(new CalendarQuery() + .setCalendarId("primary") + .setStartTime(Instant.now())) + .ready(page -> page.getItems().forEach(System.out::println)); + } + }); + } + // end::calendar-capabilities[] + } + + public void createEvent(LocalCalendarSource local, String calendarId, Instant start, Instant end) { + // tag::calendar-create-event[] + CalendarEvent event = new CalendarEvent() + .setCalendarId(calendarId) + .setTitle("Architecture review") + .setStart(CalendarDateTime.instant(start, ZoneId.of("Europe/Paris"))) + .setEnd(CalendarDateTime.instant(end, ZoneId.of("Europe/Paris"))) + .setRecurrence(new CalendarRecurrenceRule() + .setFrequency(CalendarRecurrenceRule.Frequency.WEEKLY) + .addDayOfWeek(2)) + .addAttendee(new CalendarAttendee() + .setName("Ari") + .setEmail("ari@example.com")) + .addAlarm(new CalendarAlarm().setTimeBefore(Duration.ofMinutes(15))); + + local.saveEvent(event, CalendarMutationScope.ALL).ready(saved -> { + Log.p("Created " + saved.getId()); + }); + // end::calendar-create-event[] + } + + public void configureGoogle(String clientId, String redirectUri) { + // tag::calendar-google-oidc[] + OidcClient.discover("https://accounts.google.com").ready(oidc -> { + oidc.setClientId(clientId) + .setRedirectUri(redirectUri) + .setScopes(GoogleCalendarSource.SCOPE_CALENDAR, + GoogleCalendarSource.SCOPE_TASKS) + .authorize().ready(tokens -> { + CalendarSource google = new GoogleCalendarSource( + new OidcCalendarTokenProvider(oidc, tokens)); + CalendarManager.getInstance().registerSource(google); + }); + }); + // end::calendar-google-oidc[] + } + + public CalendarSource configureCalDav(String calendarHomeUrl, String username, String password) { + // tag::calendar-caldav[] + CalendarSource caldav = new CalDavCalendarSource( + "work", "Work CalDAV", calendarHomeUrl, + CalDavAuthentication.digest(username, password)); + // end::calendar-caldav[] + return caldav; + } + + public CalendarEvent importExport(CalendarEvent event) throws CalendarException { + // tag::calendar-import-export[] + String ics = ICalendarCodec.writeEvent(event); + CalendarEvent imported = ICalendarCodec.readEvent(ics); + // end::calendar-import-export[] + return imported; + } + + public CalendarSyncEngine sync(CalendarSource google, CalendarEvent event) throws CalendarException { + // tag::calendar-sync[] + CalendarSyncEngine sync = new CalendarSyncEngine( + google, new StorageCalendarCache("google-account-1")); + sync.queueEventSave(event, CalendarMutationScope.ALL); + sync.sync().ready(result -> { + for (CalendarConflict conflict : result.getConflicts()) { + // Present both versions, then call resolveConflict(...). + } + }); + // end::calendar-sync[] + return sync; + } +} diff --git a/docs/developer-guide/Calendar-Integration.asciidoc b/docs/developer-guide/Calendar-Integration.asciidoc new file mode 100644 index 00000000000..5842867036b --- /dev/null +++ b/docs/developer-guide/Calendar-Integration.asciidoc @@ -0,0 +1,155 @@ +[[calendar-integration]] +== Calendar integration + +The `com.codename1.calendar` package provides scheduling rather than just an +"add event" shortcut. It represents calendars, events, task/reminders, +recurrence, attendees, alarms, free/busy intervals, attachments, conference +links, paging, incremental synchronization, and explicit edit conflicts. + +The API is capability based. Don't branch on the platform name. Ask the +source what it can do: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/calendar/CalendarIntegrationSnippets.java[tag=calendar-capabilities,indent=0] +---- + +`LocalCalendarSource` is implemented with Android's Calendar Provider and +Apple EventKit on iOS and Mac Catalyst. The simulator supplies an isolated, +in-memory source so tests never modify the developer's real calendar. A port +that has no local calendar service returns an empty capability set. Online +sources and `.ics` import/export remain available on every port, including +JavaScript and Linux. + +[cols="1,2,2",options="header"] +|=== +|Target |Built-in local source |Portable alternatives + +|Android +|Calendar Provider events +|Google, Microsoft, CalDAV, and `.ics` + +|iOS and Mac Catalyst +|EventKit events and reminders +|Google, Microsoft, CalDAV, and `.ics` + +|Codename One simulator +|Isolated in-memory source +|Google, Microsoft, CalDAV, and `.ics` + +|Win32, native macOS desktop, Linux, and JavaScript +|No built-in local source; the capability set is empty +|Google, Microsoft, CalDAV, and `.ics` +|=== + +This API is a clean replacement for the old `CN1Calendar` integration. New +code shouldn't mix objects or identifiers from the two APIs. + +=== Creating an event + +All-day values use `CalendarDateTime.allDay(LocalDate)`. Timed values use +`ZonedDateTime` (or the `Instant` and `ZoneId` convenience factory). Alarms use +`Duration`, and query ranges and absolute timestamps use `Instant`. The API +uses `java.time` instead of defining parallel date, time-zone, or duration +types. Keeping all-day and timed values separate avoids shifting an +all-day event when it crosses a time-zone boundary. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/calendar/CalendarIntegrationSnippets.java[tag=calendar-create-event,indent=0] +---- + +The `version` returned by a source is an optimistic-concurrency token. Pass +the returned object to `saveEvent()` or its version to a delete method. +A stale token produces `CalendarError.CONFLICT` instead of overwriting a +newer server edit. + +=== Online sources + +Online sources work on all ports and use the application's OAuth client. They +never embed a client secret and never persist credentials implicitly. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/calendar/CalendarIntegrationSnippets.java[tag=calendar-google-oidc,indent=0] +---- + +`GoogleCalendarSource` covers Google Calendar and Google Tasks. +`MicrosoftCalendarSource` covers Microsoft Graph calendars and Microsoft To +Do. Both expose provider paging and delta tokens through `CalendarPage` and +retry once with a refreshed token after HTTP 401. + +For CalDAV, pass the account's calendar-home URL and an authentication +strategy: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/calendar/CalendarIntegrationSnippets.java[tag=calendar-caldav,indent=0] +---- + +`CalDavAuthentication.basic()`, `bearer()`, and `digest()` are supported. +Prefer Bearer or Digest over Basic unless Basic is protected by HTTPS. Calendar +discovery filters collections by their advertised `VEVENT` and `VTODO` +component support. Check the result of each asynchronous operation as servers +can restrict individual collections further. + +=== Import and export + +`ICalendarCodec` reads and writes RFC 5545 `VEVENT`, `VTODO`, `VALARM`, +attendees, recurrence rules, URI attachments, conference URIs, all-day values, +and time zones. Unknown `X-` properties are retained in provider data. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/calendar/CalendarIntegrationSnippets.java[tag=calendar-import-export,indent=0] +---- + +=== Change notifications and offline synchronization + +The cross-platform guarantee is intentionally pull based: + +* Local sources can emit `CalendarChange` through `addChangeListener()` when + the operating system reports a local store change. +* Online providers return a sync/delta token. Store it and pass it in the next + `CalendarQuery`. The API doesn't register provider webhooks. + +Listeners run on the Codename One EDT. Treat a `RESET` change as an instruction +to query again; it doesn't promise one callback per underlying OS edit. + +Offline mutation storage is opt-in. Supply a `CalendarCache` to +`CalendarSyncEngine`, queue edits, and call `sync()` yourself or from the +platform's background-work callback. `StorageCalendarCache` stores calendar +data and pending mutations, never credentials. Conflicts remain paused until +the application chooses `KEEP_LOCAL`, `KEEP_REMOTE`, or `MERGED`. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/calendar/CalendarIntegrationSnippets.java[tag=calendar-sync,indent=0] +---- + +=== Permissions and build hints + +Referencing `LocalCalendarSource` is the builder signal for local calendar +integration. Android builds add `READ_CALENDAR` and `WRITE_CALENDAR`; iOS and +Mac Catalyst builds add EventKit and calendar/reminder privacy strings. You +can replace the default user-facing strings with: + +---- +ios.NSCalendarsFullAccessUsageDescription=Explain why events are needed +ios.NSCalendarsWriteOnlyAccessUsageDescription=Explain why creating events is needed +ios.NSRemindersFullAccessUsageDescription=Explain why reminders are needed +---- + +Windows calendar access is a restricted package capability that requires an +explicit opt-in. The built-in Win32 source currently reports unavailable, but +an application-supplied native integration can request the MSIX capability +after it has been approved. Opt in with: + +---- +windows.msix=true +windows.calendar.restrictedCapability=true +---- + +Always check `getCapabilities()` and `getAuthorizationStatus()` at runtime. +Manifest declarations make a request possible; they don't mean the user or +provider granted it. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index a850b22b269..f4cc768a8a7 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -93,6 +93,8 @@ include::Push-Notifications.asciidoc[] include::Notifications-And-Background-Execution.asciidoc[] +include::Calendar-Integration.asciidoc[] + include::External-Surfaces.asciidoc[] include::Maps.asciidoc[] diff --git a/docs/website/data/port_status.json b/docs/website/data/port_status.json index 3d107af2136..65efde54555 100644 --- a/docs/website/data/port_status.json +++ b/docs/website/data/port_status.json @@ -377,6 +377,13 @@ "description": "Checks camera discovery and capture where hardware and runtime permissions are available.", "tests": ["CameraApiTest"] }, + { + "id": "calendar-integration", + "category": "Platform-dependent APIs", + "name": "Calendar integration", + "description": "Checks portable calendar data and local-source capability discovery without requesting access to user data.", + "tests": ["CalendarApiTest"] + }, { "id": "mutable-images", "category": "Media and graphics", diff --git a/docs/website/data/port_status_reports/android.json b/docs/website/data/port_status_reports/android.json index 8a2db9351b2..8da69261ae6 100644 --- a/docs/website/data/port_status_reports/android.json +++ b/docs/website/data/port_status_reports/android.json @@ -675,6 +675,10 @@ "feature": "video-round-trip", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/ios-gl.json b/docs/website/data/port_status_reports/ios-gl.json index e2fba82f448..a17b7de0cbf 100644 --- a/docs/website/data/port_status_reports/ios-gl.json +++ b/docs/website/data/port_status_reports/ios-gl.json @@ -678,6 +678,10 @@ ], "status": "skip" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/ios-metal.json b/docs/website/data/port_status_reports/ios-metal.json index c67cf9314d6..dd8910cd5e8 100644 --- a/docs/website/data/port_status_reports/ios-metal.json +++ b/docs/website/data/port_status_reports/ios-metal.json @@ -678,6 +678,10 @@ ], "status": "skip" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/javascript.json b/docs/website/data/port_status_reports/javascript.json index 23489c93ce4..c48cde4c8c1 100644 --- a/docs/website/data/port_status_reports/javascript.json +++ b/docs/website/data/port_status_reports/javascript.json @@ -675,6 +675,10 @@ "feature": "video-round-trip", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/linux-arm64.json b/docs/website/data/port_status_reports/linux-arm64.json index c8dc23366ce..b7e1e9e0acf 100644 --- a/docs/website/data/port_status_reports/linux-arm64.json +++ b/docs/website/data/port_status_reports/linux-arm64.json @@ -672,6 +672,10 @@ "feature": "video-round-trip", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/linux-x64.json b/docs/website/data/port_status_reports/linux-x64.json index 4be252d9e1a..782e551b720 100644 --- a/docs/website/data/port_status_reports/linux-x64.json +++ b/docs/website/data/port_status_reports/linux-x64.json @@ -672,6 +672,10 @@ "feature": "video-round-trip", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/mac-native.json b/docs/website/data/port_status_reports/mac-native.json index 8f83bbdce7d..0ff436a2839 100644 --- a/docs/website/data/port_status_reports/mac-native.json +++ b/docs/website/data/port_status_reports/mac-native.json @@ -678,6 +678,10 @@ ], "status": "skip" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/tvos.json b/docs/website/data/port_status_reports/tvos.json index 618855a2513..5a608484997 100644 --- a/docs/website/data/port_status_reports/tvos.json +++ b/docs/website/data/port_status_reports/tvos.json @@ -678,6 +678,10 @@ ], "status": "skip" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/watchos.json b/docs/website/data/port_status_reports/watchos.json index 3df3c538cd3..d0a5fc21f7a 100644 --- a/docs/website/data/port_status_reports/watchos.json +++ b/docs/website/data/port_status_reports/watchos.json @@ -678,6 +678,10 @@ ], "status": "skip" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/windows-arm64.json b/docs/website/data/port_status_reports/windows-arm64.json index 25ec321e901..636a57e5cbb 100644 --- a/docs/website/data/port_status_reports/windows-arm64.json +++ b/docs/website/data/port_status_reports/windows-arm64.json @@ -672,6 +672,10 @@ "feature": "video-round-trip", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/docs/website/data/port_status_reports/windows-x64.json b/docs/website/data/port_status_reports/windows-x64.json index 3613318299f..b0025155457 100644 --- a/docs/website/data/port_status_reports/windows-x64.json +++ b/docs/website/data/port_status_reports/windows-x64.json @@ -672,6 +672,10 @@ "feature": "video-round-trip", "status": "pass" }, + "CalendarApiTest": { + "feature": "calendar-integration", + "status": "not-run" + }, "ClipboardRoundTripTest": { "feature": "clipboard", "status": "not-run" diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index dde0cd08278..8067bdd0996 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -307,6 +307,8 @@ public File getGradleProjectDirectory() { private boolean pushPermission; private boolean foregroundServicePermission; private boolean contactsReadPermission; + private boolean calendarReadPermission; + private boolean calendarWritePermission; private boolean contactsWritePermission; private boolean addRemoteControlService; /** @@ -1450,6 +1452,13 @@ public void usesClass(String cls) { @Override public void usesClassMethod(String cls, String method) { + if (cls.indexOf("com/codename1/calendar/LocalCalendarSource") == 0) { + // getInstance() is deliberately the builder signal. Local + // calendar access is runtime capability-gated, but Android + // still requires both dangerous permissions in the manifest. + calendarReadPermission = true; + calendarWritePermission = true; + } if (cls.indexOf("com/codename1/ui/Display") == 0 && (method.indexOf("vibrate") > -1 || method.indexOf("notifyStatusBar") > -1)) { vibratePermission = true; } @@ -3035,6 +3044,14 @@ public void usesClassMethod(String cls, String method) { permissions += permissionAdd(request, "WRITE_CONTACTS", " \n"); } + if (calendarReadPermission) { + permissions += permissionAdd(request, "READ_CALENDAR", + " \n"); + } + if (calendarWritePermission) { + permissions += permissionAdd(request, "WRITE_CALENDAR", + " \n"); + } if (accessWifiStatePermissions) { permissions += permissionAdd(request, "ACCESS_WIFI_STATE", diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 58cee299c10..a8cb0b73144 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -145,6 +145,7 @@ public class IPhoneBuilder extends Executor { private boolean usesWifiInfo; private boolean usesWifiHotspotConfig; private boolean usesBonjour; + private boolean usesCalendarApi; private String firstBonjourType; // so we need to store the main class name for later here. // Map will be used for Xcode 8 privacy usage descriptions. Don't need it yet @@ -797,6 +798,9 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException public void usesClass(String cls) { if (cls == null) return; aiAcc.consume(cls); + if (cls.indexOf("com/codename1/calendar/LocalCalendarSource") == 0) { + usesCalendarApi = true; + } if (!usesLocalNotifications && cls.indexOf("com/codename1/notifications/LocalNotification") == 0) { usesLocalNotifications = true; } @@ -926,6 +930,30 @@ public void usesClassMethod(String cls, String method) { } stopwatch.split("Scan Classes"); + if (usesCalendarApi) { + String calendarDescription = request.getArg("ios.NSCalendarsFullAccessUsageDescription", + "This app uses your calendars to read and schedule events."); + String calendarWriteDescription = request.getArg("ios.NSCalendarsWriteOnlyAccessUsageDescription", + "This app uses your calendar to schedule events."); + String remindersDescription = request.getArg("ios.NSRemindersFullAccessUsageDescription", + "This app uses your reminders to read and schedule tasks."); + privacyUsageDescriptions.put("NSCalendarsFullAccessUsageDescription", calendarDescription); + privacyUsageDescriptions.put("NSCalendarsWriteOnlyAccessUsageDescription", calendarWriteDescription); + privacyUsageDescriptions.put("NSRemindersFullAccessUsageDescription", remindersDescription); + // Retain the pre-iOS-17 keys when an app supports older releases. + privacyUsageDescriptions.put("NSCalendarsUsageDescription", + request.getArg("ios.NSCalendarsUsageDescription", calendarDescription)); + privacyUsageDescriptions.put("NSRemindersUsageDescription", + request.getArg("ios.NSRemindersUsageDescription", remindersDescription)); + request.putArgument("ios.NSCalendarsFullAccessUsageDescription", calendarDescription); + request.putArgument("ios.NSCalendarsWriteOnlyAccessUsageDescription", calendarWriteDescription); + request.putArgument("ios.NSRemindersFullAccessUsageDescription", remindersDescription); + request.putArgument("ios.NSCalendarsUsageDescription", + request.getArg("ios.NSCalendarsUsageDescription", calendarDescription)); + request.putArgument("ios.NSRemindersUsageDescription", + request.getArg("ios.NSRemindersUsageDescription", remindersDescription)); + } + // External surfaces: parse the build-time kinds manifest (surfaces.json in the project // resources, delivered alongside .ios.appext archives in resDir) and resolve the app // group. Widget kinds must be known at build time -- the Swift WidgetBundle is static. @@ -2200,6 +2228,20 @@ public void usesClassMethod(String cls, String method) { addLibs = addLibs + ";LocalAuthentication.framework"; } } + if (usesCalendarApi) { + try { + replaceInFile(new File(buildinRes, "IOSNative.m"), + "//#define CN1_USE_CALENDAR", "#define CN1_USE_CALENDAR"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_USE_CALENDAR", ex); + } + if (addLibs == null || addLibs.length() == 0) { + addLibs = "EventKit.framework"; + } else if (!addLibs.toLowerCase().contains("eventkit")) { + addLibs = addLibs + ";EventKit.framework"; + } + } // DeviceCheck.framework backs App Attest (com.codename1.security. // DeviceIntegrity.requestIntegrityToken on iOS). Only linked when the diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java index 172b574222d..3a6eb378bbf 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java @@ -353,6 +353,12 @@ private void writeEntitlementsFile(BuildRequest request, File appSrcDir, if (micOptIn) { sb.append(" com.apple.security.device.microphone\n \n"); } + boolean needsCalendar = request.getArg("ios.NSCalendarsUsageDescription", null) != null + || request.getArg("ios.NSCalendarsFullAccessUsageDescription", null) != null; + if (parseEntitlementBool(request, + "macNative.entitlements.personalInformation.calendars", needsCalendar)) { + sb.append(" com.apple.security.personal-information.calendars\n \n"); + } } if (extra != null && extra.trim().length() > 0) { sb.append(extra); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java index b1c1ba5df34..f7811ed64be 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java @@ -847,8 +847,13 @@ private String buildAppxManifest(BuildRequest request, String arch, String widge sb.append(" \n") .append(" \n") .append(" \n") - .append(" \n") - .append(" \n") + .append(" \n"); + // Windows calendar access is a restricted store capability. It must + // remain an explicit opt-in even when LocalCalendarSource is present. + if ("true".equalsIgnoreCase(request.getArg("windows.calendar.restrictedCapability", "false"))) { + sb.append(" \n"); + } + sb.append(" \n") .append("\n"); return sb.toString(); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java index 38eae401dc2..9204ec5696d 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java @@ -137,6 +137,20 @@ void developerCanForceCameraEntitlementOff(@TempDir Path tmp) throws IOException "Explicit opt-out should win over the auto-detection"); } + @Test + void calendarPrivacyHintAddsSandboxCalendarEntitlement(@TempDir Path tmp) throws IOException { + BuildRequest req = new BuildRequest(); + req.setMainClass("MyApp"); + req.putArgument("macNative.enabled", "true"); + req.putArgument("macNative.distribution", "appStore"); + req.putArgument("macNative.teamId", "ABCDEFG123"); + req.putArgument("ios.NSCalendarsFullAccessUsageDescription", "Schedule events"); + + String body = writeEntitlements(req, tmp, "MyApp"); + + assertTrue(body.contains("com.apple.security.personal-information.calendars")); + } + // ------------------------------------------------------------------ // Helper // ------------------------------------------------------------------ diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WindowsNativeBuilderArchTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WindowsNativeBuilderArchTest.java index 9a8a634502c..58f520cfc68 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WindowsNativeBuilderArchTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WindowsNativeBuilderArchTest.java @@ -24,7 +24,11 @@ import org.junit.jupiter.api.Test; +import java.lang.reflect.Method; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Platform-independent unit tests for the architecture resolution in @@ -62,4 +66,24 @@ void vcvarsUsesCrossFormWhenHostDiffersFromTarget() { assertEquals("arm64_x64", WindowsNativeBuilder.vcvarsArchArg("arm64", "x64")); assertEquals("x64_arm64", WindowsNativeBuilder.vcvarsArchArg("x64", "arm64")); } + + @Test + void restrictedCalendarCapabilityRequiresExplicitOptIn() throws Exception { + WindowsNativeBuilder builder = new WindowsNativeBuilder(); + Method method = WindowsNativeBuilder.class.getDeclaredMethod("buildAppxManifest", + BuildRequest.class, String.class, String.class); + method.setAccessible(true); + BuildRequest request = new BuildRequest(); + request.setPackageName("com.example.calendar"); + request.setDisplayName("Calendar Test"); + request.setVendor("Example"); + request.setVersion("1.0"); + + String withoutOptIn = (String)method.invoke(builder, request, "x64", null); + assertFalse(withoutOptIn.contains("Name=\"appointments\"")); + + request.putArgument("windows.calendar.restrictedCapability", "true"); + String withOptIn = (String)method.invoke(builder, request, "x64", null); + assertTrue(withOptIn.contains("")); + } } diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index f613d6b86db..59a27e2691d 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -159,6 +159,21 @@ + + + + + + + + + +