From 2a0ed924a03739a6a7d922fca8452cfb097a1a69 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:12:51 +0300 Subject: [PATCH 1/7] Add deep calendar integration --- .../calendar/CalDavAuthentication.java | 86 ++++ .../calendar/CalDavCalendarSource.java | 83 ++++ .../codename1/calendar/CalendarAccess.java | 28 ++ .../com/codename1/calendar/CalendarAlarm.java | 37 ++ .../calendar/CalendarAttachment.java | 42 ++ .../codename1/calendar/CalendarAttendee.java | 47 +++ .../codename1/calendar/CalendarAuthToken.java | 37 ++ .../calendar/CalendarAuthorizationStatus.java | 28 ++ .../com/codename1/calendar/CalendarCache.java | 32 ++ .../calendar/CalendarCapabilities.java | 50 +++ .../calendar/CalendarCapability.java | 33 ++ .../codename1/calendar/CalendarChange.java | 41 ++ .../calendar/CalendarChangeListener.java | 26 ++ .../calendar/CalendarConference.java | 44 +++ .../codename1/calendar/CalendarConflict.java | 38 ++ .../com/codename1/calendar/CalendarDate.java | 50 +++ .../codename1/calendar/CalendarDateTime.java | 45 +++ .../com/codename1/calendar/CalendarError.java | 30 ++ .../com/codename1/calendar/CalendarEvent.java | 88 +++++ .../codename1/calendar/CalendarException.java | 39 ++ .../calendar/CalendarHttpRequest.java | 41 ++ .../calendar/CalendarHttpResponse.java | 34 ++ .../calendar/CalendarHttpTransport.java | 26 ++ .../com/codename1/calendar/CalendarInfo.java | 62 +++ .../codename1/calendar/CalendarManager.java | 51 +++ .../calendar/CalendarModelCodec.java | 108 +++++ .../calendar/CalendarMutationScope.java | 26 ++ .../com/codename1/calendar/CalendarPage.java | 40 ++ .../com/codename1/calendar/CalendarQuery.java | 49 +++ .../calendar/CalendarRecurrenceRule.java | 53 +++ .../codename1/calendar/CalendarSource.java | 83 ++++ .../calendar/CalendarSyncEngine.java | 135 +++++++ .../calendar/CalendarSyncResult.java | 39 ++ .../com/codename1/calendar/CalendarTask.java | 74 ++++ .../calendar/CalendarTokenProvider.java | 30 ++ .../DefaultCalendarHttpTransport.java | 62 +++ .../codename1/calendar/FreeBusyInterval.java | 37 ++ .../calendar/GoogleCalendarSource.java | 199 ++++++++++ .../codename1/calendar/ICalendarCodec.java | 374 ++++++++++++++++++ .../calendar/LocalCalendarSource.java | 36 ++ .../calendar/MemoryCalendarCache.java | 37 ++ .../calendar/MicrosoftCalendarSource.java | 111 ++++++ .../calendar/OAuthCalendarSource.java | 145 +++++++ .../calendar/OidcCalendarTokenProvider.java | 57 +++ .../calendar/StorageCalendarCache.java | 46 +++ .../impl/CodenameOneImplementation.java | 6 + CodenameOne/src/com/codename1/ui/Display.java | 6 + .../impl/android/AndroidCalendarSource.java | 116 ++++++ .../impl/android/AndroidImplementation.java | 15 +- .../impl/javase/JavaSECalendarSource.java | 150 +++++++ .../com/codename1/impl/javase/JavaSEPort.java | 12 + Ports/iOSPort/nativeSources/IOSNative.m | 182 ++++++++- .../codename1/impl/ios/IOSCalendarSource.java | 74 ++++ .../codename1/impl/ios/IOSImplementation.java | 9 + .../src/com/codename1/impl/ios/IOSNative.java | 10 + .../Calendar-Integration.asciidoc | 199 ++++++++++ docs/developer-guide/developer-guide.asciidoc | 2 + .../builders/AndroidGradleBuilder.java | 17 + .../com/codename1/builders/IPhoneBuilder.java | 37 ++ .../codename1/builders/MacNativeBuilder.java | 6 + .../builders/WindowsNativeBuilder.java | 9 +- .../MacNativeBuilderEntitlementsTest.java | 14 + .../WindowsNativeBuilderArchTest.java | 24 ++ maven/core-unittests/spotbugs-exclude.xml | 15 + .../calendar/CalendarIntegrationTest.java | 172 ++++++++ .../tests/CalendarApiTest.java | 36 ++ .../hellocodenameone/HelloCodenameOne.kt | 2 + 67 files changed, 3966 insertions(+), 6 deletions(-) create mode 100644 CodenameOne/src/com/codename1/calendar/CalDavAuthentication.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalDavCalendarSource.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarAccess.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarAlarm.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarAttachment.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarAttendee.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarAuthToken.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarAuthorizationStatus.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarCache.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarCapabilities.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarCapability.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarChange.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarChangeListener.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarConference.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarConflict.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarDate.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarDateTime.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarError.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarEvent.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarException.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarHttpRequest.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarHttpResponse.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarHttpTransport.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarInfo.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarManager.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarModelCodec.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarMutationScope.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarPage.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarQuery.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarRecurrenceRule.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarSource.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarSyncEngine.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarSyncResult.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarTask.java create mode 100644 CodenameOne/src/com/codename1/calendar/CalendarTokenProvider.java create mode 100644 CodenameOne/src/com/codename1/calendar/DefaultCalendarHttpTransport.java create mode 100644 CodenameOne/src/com/codename1/calendar/FreeBusyInterval.java create mode 100644 CodenameOne/src/com/codename1/calendar/GoogleCalendarSource.java create mode 100644 CodenameOne/src/com/codename1/calendar/ICalendarCodec.java create mode 100644 CodenameOne/src/com/codename1/calendar/LocalCalendarSource.java create mode 100644 CodenameOne/src/com/codename1/calendar/MemoryCalendarCache.java create mode 100644 CodenameOne/src/com/codename1/calendar/MicrosoftCalendarSource.java create mode 100644 CodenameOne/src/com/codename1/calendar/OAuthCalendarSource.java create mode 100644 CodenameOne/src/com/codename1/calendar/OidcCalendarTokenProvider.java create mode 100644 CodenameOne/src/com/codename1/calendar/StorageCalendarCache.java create mode 100644 Ports/Android/src/com/codename1/impl/android/AndroidCalendarSource.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/JavaSECalendarSource.java create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSCalendarSource.java create mode 100644 docs/developer-guide/Calendar-Integration.asciidoc create mode 100644 maven/core-unittests/src/test/java/com/codename1/calendar/CalendarIntegrationTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CalendarApiTest.java diff --git a/CodenameOne/src/com/codename1/calendar/CalDavAuthentication.java b/CodenameOne/src/com/codename1/calendar/CalDavAuthentication.java new file mode 100644 index 00000000000..882676988c0 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalDavAuthentication.java @@ -0,0 +1,86 @@ +/* + * 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() { + 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() { + public AsyncResource authorization(String method, String uri, String challenge, boolean forceRefresh) { + final AsyncResource out=new AsyncResource(); + tokens.getToken(scopes,forceRefresh).ready(new SuccessCallback(){public void onSucess(CalendarAuthToken token){out.complete("Bearer "+token.getAccessToken());}}).except(new SuccessCallback(){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,password; private int nonceCount; + Digest(String username,String password){this.username=username;this.password=password;} + public synchronized AsyncResourceauthorization(String method,String uri,String challenge,boolean forceRefresh){ + if(challenge==null||!challenge.toLowerCase().startsWith("digest "))return complete(null); + Mapp=parse(challenge.substring(7));String realm=p.get("realm"),nonce=p.get("nonce"),opaque=p.get("opaque"),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),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 Mapparse(String value){Mapout=new HashMap();int i=0;while(iAsyncResourcecomplete(T value){AsyncResourceout=new AsyncResource();out.complete(value);return out;} + private static AsyncResourcefailed(Throwable error){AsyncResourceout=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..55a69458ce7 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalDavCalendarSource.java @@ -0,0 +1,83 @@ +/* + * 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; + +/** 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; + } + + 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);} + public CalendarAuthorizationStatus getAuthorizationStatus(CalendarAccess access){return CalendarAuthorizationStatus.NOT_DETERMINED;} + public AsyncResourcerequestAuthorization(CalendarAccess access){final AsyncResourceout=new AsyncResource();request("PROPFIND",homeUrl,"","application/xml",headers("Depth","0")).ready(new SuccessCallback(){public void onSucess(CalendarHttpResponse r){out.complete(CalendarAuthorizationStatus.FULL);}}).except(error(out));return out;} + + 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(){public void onSucess(CalendarHttpResponse r){Listitems=new ArrayList();for(String response:elements(r.getBody(),"response")){String href=xmlValue(response,"href"),components=xmlElement(response,"supported-calendar-component-set");if(href==null||xmlElement(response,"calendar")==null)continue;boolean events=components==null||components.toUpperCase().indexOf("VEVENT")>=0, 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;} + + 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(){public void onSucess(CalendarHttpResponse r){Listitems=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;} + public AsyncResourcegetEvent(final String calendarId,String eventId){final AsyncResourceout=new AsyncResource();request("GET",resource(calendarId,eventId),null,null,null).ready(new SuccessCallback(){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;} + public AsyncResourcesaveEvent(final CalendarEvent event,CalendarMutationScope scope){final AsyncResourceout=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");Maph=version(event.getVersion(),create);request("PUT",resource(event.getCalendarId(),event.getId()),ICalendarCodec.writeEvent(event),"text/calendar; charset=utf-8",h).ready(new SuccessCallback(){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;} + public AsyncResourcedeleteEvent(final String calendarId,final String eventId,CalendarMutationScope scope,String version){final AsyncResourceout=new AsyncResource();request("DELETE",resource(calendarId,eventId),null,null,version(version,false)).ready(new SuccessCallback(){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;} + + 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(){public void onSucess(CalendarHttpResponse r){Listitems=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;} + public AsyncResourcegetTask(final String calendarId,String taskId){final AsyncResourceout=new AsyncResource();request("GET",resource(calendarId,taskId),null,null,null).ready(new SuccessCallback(){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;} + public AsyncResourcesaveTask(final CalendarTask task,CalendarMutationScope scope){final AsyncResourceout=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(){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;} + public AsyncResourcedeleteTask(final String calendarId,final String taskId,CalendarMutationScope scope,String version){final AsyncResourceout=new AsyncResource();request("DELETE",resource(calendarId,taskId),null,null,version(version,false)).ready(new SuccessCallback(){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 AsyncResourcerequest(final String method,final String url,final String body,final String contentType,final Mapheaders){final AsyncResourceout=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 Mapheaders,final String challenge,final boolean retried,final AsyncResourceout){authentication.authorization(method,url,challenge,retried).ready(new SuccessCallback(){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.Entryh:headers.entrySet())request.header(h.getKey(),h.getValue());transport.execute(request).ready(new SuccessCallback(){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("://"),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(long value){java.text.SimpleDateFormat f=new java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");f.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));return f.format(new java.util.Date(value));} + private static Mapheaders(String name,String value){Mapout=new HashMap();out.put(name,value);return out;} + private static Mapversion(String value,boolean create){Mapout=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 Listelements(String source,String local){Listout=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),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){return value.replaceAll("<[^>]+>","");} + 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 SuccessCallbackerror(final AsyncResourceout){return new SuccessCallback(){public void onSucess(Throwable error){out.error(error);}};} + private static AsyncResourcefail(AsyncResourceout,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..5ec4d92e162 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAccess.java @@ -0,0 +1,28 @@ +/* + * 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..bd9736eed74 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAlarm.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. + */ +package com.codename1.calendar; + +/** An event/task alarm, either absolute or relative to its start/due time. */ +public class CalendarAlarm { + public enum Method { DEFAULT, ALERT, EMAIL, AUDIO } + private Integer minutesBefore; + private Long absoluteTime; + private Method method = Method.DEFAULT; + public Integer getMinutesBefore() { return minutesBefore; } + public CalendarAlarm setMinutesBefore(Integer v) { minutesBefore = v; absoluteTime = null; return this; } + public Long getAbsoluteTime() { return absoluteTime; } + public CalendarAlarm setAbsoluteTime(Long v) { absoluteTime = v; minutesBefore = 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..13209537680 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAttachment.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; + +/** Attachment metadata and optional content for providers that support upload. */ +public class CalendarAttachment { + private String id, name, mimeType, 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..5e0b4d73ea0 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAttendee.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 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, email, uri; + private Role role = Role.REQUIRED; + private Response response = Response.NONE; + private boolean organizer, 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..40597fbd82c --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAuthToken.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. + */ +package com.codename1.calendar; + +/** App-owned OAuth bearer token returned to an online source. */ +public final class CalendarAuthToken { + private final String accessToken, scopes; + private final Long expiresAt; + public CalendarAuthToken(String accessToken, Long 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 Long getExpiresAt() { return expiresAt; } + public String getScopes() { return scopes; } + public boolean isExpiringWithin(int seconds) { return expiresAt != null && expiresAt.longValue() - System.currentTimeMillis() < seconds * 1000L; } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarAuthorizationStatus.java b/CodenameOne/src/com/codename1/calendar/CalendarAuthorizationStatus.java new file mode 100644 index 00000000000..31fbd9f56dd --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarAuthorizationStatus.java @@ -0,0 +1,28 @@ +/* + * 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..339d54e4736 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarCache.java @@ -0,0 +1,32 @@ +/* + * 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..3d7a2c4db0e --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarCapabilities.java @@ -0,0 +1,50 @@ +/* + * 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.EnumSet; +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(EnumSet.noneOf(CalendarCapability.class)); + private final Set values; + + private CalendarCapabilities(Set values) { + this.values = Collections.unmodifiableSet(EnumSet.copyOf(values)); + } + + public static CalendarCapabilities none() { return NONE; } + + public static CalendarCapabilities of(CalendarCapability... values) { + EnumSet out = EnumSet.noneOf(CalendarCapability.class); + 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..f9a2be4e6c3 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarCapability.java @@ -0,0 +1,33 @@ +/* + * 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..93165e1f38a --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarChange.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; + +/** 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, calendarId, 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..03f9ce65799 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarChangeListener.java @@ -0,0 +1,26 @@ +/* + * 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..a0e71ebe74e --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarConference.java @@ -0,0 +1,44 @@ +/* + * 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, id, 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..8d9f50e185e --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarConflict.java @@ -0,0 +1,38 @@ +/* + * 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, 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/CalendarDate.java b/CodenameOne/src/com/codename1/calendar/CalendarDate.java new file mode 100644 index 00000000000..4f8ce88cbde --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarDate.java @@ -0,0 +1,50 @@ +/* + * 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 date without a time zone, used for all-day events and date-only tasks. */ +public final class CalendarDate { + private final int year, month, day; + public CalendarDate(int year, int month, int day) { + if (month < 1 || month > 12 || day < 1 || day > 31) throw new IllegalArgumentException("Invalid date"); + this.year = year; this.month = month; this.day = day; + } + public int getYear() { return year; } + public int getMonth() { return month; } + public int getDay() { return day; } + public String toString() { + return pad(year, 4) + "-" + pad(month, 2) + "-" + pad(day, 2); + } + private static String pad(int value, int size) { + String s = String.valueOf(value); + StringBuilder out = new StringBuilder(size); + for (int i = s.length(); i < size; i++) out.append('0'); + return out.append(s).toString(); + } + public boolean equals(Object o) { + if (!(o instanceof CalendarDate)) return false; + CalendarDate d = (CalendarDate)o; + return year == d.year && month == d.month && day == d.day; + } + public int hashCode() { return ((year * 31) + month) * 31 + day; } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarDateTime.java b/CodenameOne/src/com/codename1/calendar/CalendarDateTime.java new file mode 100644 index 00000000000..c57a45fa397 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarDateTime.java @@ -0,0 +1,45 @@ +/* + * 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; + +/** Either an instant with an Olson time-zone ID or an all-day date. */ +public final class CalendarDateTime { + private final long timestamp; + private final String timeZoneId; + private final CalendarDate date; + private CalendarDateTime(long timestamp, String timeZoneId, CalendarDate date) { + this.timestamp = timestamp; this.timeZoneId = timeZoneId; this.date = date; + } + public static CalendarDateTime instant(long timestamp, String timeZoneId) { + if (timeZoneId == null || timeZoneId.length() == 0) throw new IllegalArgumentException("timeZoneId required"); + return new CalendarDateTime(timestamp, timeZoneId, null); + } + public static CalendarDateTime allDay(CalendarDate date) { + if (date == null) throw new IllegalArgumentException("date required"); + return new CalendarDateTime(0L, null, date); + } + public boolean isAllDay() { return date != null; } + public long getTimestamp() { return timestamp; } + public String getTimeZoneId() { return timeZoneId; } + public CalendarDate getDate() { return date; } +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarError.java b/CodenameOne/src/com/codename1/calendar/CalendarError.java new file mode 100644 index 00000000000..9be8520df5c --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarError.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; + +/** 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..1431266b84f --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarEvent.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.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, calendarId, sourceId, version, title, description, location, url, recurringEventId; + private CalendarDateTime start, 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..449dfa184a4 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarException.java @@ -0,0 +1,39 @@ +/* + * 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..e3227960627 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarHttpRequest.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 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, 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..8202b8e573a --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarHttpResponse.java @@ -0,0 +1,34 @@ +/* + * 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,Mapheaders){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 MapgetHeaders(){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..8db22f2628d --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarHttpTransport.java @@ -0,0 +1,26 @@ +/* + * 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..77ec8a86560 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarInfo.java @@ -0,0 +1,62 @@ +/* + * 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; + +/** A calendar or task-list collection exposed by a source. */ +public class CalendarInfo { + public enum ContentType { EVENTS, TASKS } + private String id, sourceId, accountId, name, owner, timeZoneId; + private int color; + private boolean primary, 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 String getTimeZoneId() { return timeZoneId; } + public CalendarInfo setTimeZoneId(String v) { timeZoneId = 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..1f043ce8c18 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarManager.java @@ -0,0 +1,51 @@ +/* + * 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..b211fc880ad --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarModelCodec.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.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",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(lngObj(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().getMonth()); m.put("day",d.getDate().getDay()); } + else { m.put("timestamp",Long.valueOf(d.getTimestamp())); m.put("timeZoneId",d.getTimeZoneId()); } return m; + } + public static CalendarDateTime decodeDateTime(Map m) { + if (m == null) return null; if (bool(m,"allDay")) return CalendarDateTime.allDay(new CalendarDate(integer(m,"year",1970),integer(m,"month",1),integer(m,"day",1))); + return CalendarDateTime.instant(lng(m,"timestamp",0L), s(m,"timeZoneId") == null ? "UTC" : s(m,"timeZoneId")); + } + 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;Mapm=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){Mapm=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){Mapm=new HashMap();put(m,"minutesBefore",a.getMinutesBefore());put(m,"absoluteTime",a.getAbsoluteTime());m.put("method",a.getMethod().name());return m;} + private static CalendarAlarm decodeAlarm(Map m){CalendarAlarm a=new CalendarAlarm();if(m.get("minutesBefore")!=null)a.setMinutesBefore(intObj(m,"minutesBefore"));else a.setAbsoluteTime(lngObj(m,"absoluteTime"));a.setMethod(alarmMethod(s(m,"method")));return a;} + private static Map encodeAttachment(CalendarAttachment a){Mapm=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;Mapm=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()),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(Mapm,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;} +} diff --git a/CodenameOne/src/com/codename1/calendar/CalendarMutationScope.java b/CodenameOne/src/com/codename1/calendar/CalendarMutationScope.java new file mode 100644 index 00000000000..7f4a8f3aa52 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarMutationScope.java @@ -0,0 +1,26 @@ +/* + * 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..8271317ab7d --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarPage.java @@ -0,0 +1,40 @@ +/* + * 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, 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..428eb43af0b --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarQuery.java @@ -0,0 +1,49 @@ +/* + * 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; + +/** Query options shared by event and task listing operations. */ +public class CalendarQuery { + private String calendarId, text, pageToken, syncToken; + private Long startTime, endTime; + private int pageSize = 100; + private boolean expandRecurrences = true, 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 Long getStartTime() { return startTime; } + public CalendarQuery setStartTime(Long v) { startTime = v; return this; } + public Long getEndTime() { return endTime; } + public CalendarQuery setEndTime(Long 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..5310650c600 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarRecurrenceRule.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; + +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..e292bd65173 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarSource.java @@ -0,0 +1,83 @@ +/* + * 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.util.ArrayList; +import java.util.List; + +/** Base contract shared by local stores and online providers. */ +public abstract class CalendarSource { + private final String id, 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, long startTime, long 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() { 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..0f4ac1540aa --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarSyncEngine.java @@ -0,0 +1,135 @@ +/* + * 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() { 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() { 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"), cal = string(m,"calendarId"), item = string(m,"itemId"), 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() { 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() { 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..2bfca4f5450 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarSyncResult.java @@ -0,0 +1,39 @@ +/* + * 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, 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..b3266523145 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarTask.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.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, calendarId, sourceId, version, title, description, location; + private CalendarDateTime start, due; + private CalendarRecurrenceRule recurrence; + private boolean completed; + private Long 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 Long getCompletionTime() { return completionTime; } + public CalendarTask setCompletionTime(Long 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..4bdd97ca113 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/CalendarTokenProvider.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; + +/** 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..9701ad8247b --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/DefaultCalendarHttpTransport.java @@ -0,0 +1,62 @@ +/* + * 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 { + public AsyncResource execute(final CalendarHttpRequest spec) { + final AsyncResource out = new AsyncResource(); + ConnectionRequest request = new ConnectionRequest() { + private final Map responseHeaders = new HashMap(); + protected void readHeaders(Object connection) throws IOException { capture(connection); } + 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);} + } + protected void readResponse(InputStream input) throws IOException { + byte[] bytes=Util.readInputStream(input);String body=StringUtil.newString(bytes); + out.complete(new CalendarHttpResponse(getResponseCode(),body,responseHeaders)); + } + 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)); + } + 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..27d8c3a0d88 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/FreeBusyInterval.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. + */ +package com.codename1.calendar; + +/** Busy interval returned by a scheduling/free-busy query. */ +public final class FreeBusyInterval { + private final long startTime, endTime; + private final CalendarEvent.Availability availability; + public FreeBusyInterval(long startTime, long endTime, CalendarEvent.Availability availability) { + if (endTime < startTime) throw new IllegalArgumentException("endTime"); + this.startTime = startTime; this.endTime = endTime; + this.availability = availability == null ? CalendarEvent.Availability.BUSY : availability; + } + public long getStartTime() { return startTime; } + public long 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..55019803b5d --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/GoogleCalendarSource.java @@ -0,0 +1,199 @@ +/* + * 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.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +/** 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}); + } + + 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); + } + + 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>() { + 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; + } + + 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.getTimeZoneId() != null) body.put("timeZone", calendar.getTimeZoneId()); + 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>() { + public void onSucess(Map value) { out.complete(calendar(value, type)); } + }).except(error(out)); + return out; + } + + public AsyncResource deleteCalendar(String calendarId) { + final AsyncResource out = new AsyncResource(); + json("DELETE", CALENDAR_API + "/calendars/" + e(calendarId), null, null).ready(new SuccessCallback>() { + public void onSucess(Map ignored) { out.complete(Boolean.TRUE); } + }).except(error(out)); + return out; + } + + 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().longValue())); + if (query.getEndTime() != null) param(url, "timeMax", iso(query.getEndTime().longValue())); + 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>() { + 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; + } + + 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>() { public void onSucess(Map value) { try { out.complete(event(value, calendarId)); } catch (CalendarException ex) { out.error(ex); } } }).except(error(out)); + return out; + } + + 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>() { + 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; + } + + 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>() { + 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; + } + + 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() { + 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; + } + + public AsyncResource> queryFreeBusy(List calendarIds, long startTime, long 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){Mapm=new HashMap();m.put("id",id);items.add(m);}body.put("items",items); + json("POST",CALENDAR_API+"/freeBusy",body,null).ready(new SuccessCallback>() { + public void onSucess(Map root){Listresult=new ArrayList();Map calendars=map(root.get("calendars"));for(Object object:calendars.values()){Mapcal=map(object);for(Mapbusy: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; + } + + 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>(){public void onSucess(Maproot){Listresult=new ArrayList();try{for(Mapitem: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; + } + + public AsyncResource getTask(final String listId,String taskId){final AsyncResourceout=new AsyncResource();json("GET",TASKS_API+"/lists/"+e(listId)+"/tasks/"+e(taskId),null,null).ready(new SuccessCallback>(){public void onSucess(Mapm){try{out.complete(task(m,listId));}catch(CalendarException ex){out.error(ex);}}}).except(error(out));return out;} + public AsyncResource saveTask(final CalendarTask task,CalendarMutationScope scope){final AsyncResourceout=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>(){public void onSucess(Mapm){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;} + public AsyncResource deleteTask(final String listId,final String taskId,CalendarMutationScope scope,String version){final AsyncResourceout=new AsyncResource();json("DELETE",TASKS_API+"/lists/"+e(listId)+"/tasks/"+e(taskId),null,version(version)).ready(new SuccessCallback>(){public void onSucess(Mapx){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");return new CalendarInfo().setId(string(m,"id")).setSourceId(getId()).setName(name).setOwner(string(m,"summaryOverride")).setTimeZoneId(string(m,"timeZone")).setPrimary(bool(m,"primary")).setReadOnly("reader".equals(string(m,"accessRole"))).setContentType(type).setCapabilities(getCapabilities());} + private CalendarEvent event(Mapm,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(Mapa: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(Mapa:maps(reminders.get("overrides"))){CalendarAlarm alarm=new CalendarAlarm().setMinutesBefore(integer(a,"minutes"));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(Mapa:maps(m.get("attachments")))out.addAttachment(new CalendarAttachment().setUri(string(a,"fileUrl")).setName(string(a,"title")).setMimeType(string(a,"mimeType")));Mapconference=map(m.get("conferenceData"));for(Mapentry: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 MapeventMap(CalendarEvent e){Mapm=new HashMap();m.put("summary",e.getTitle());if(e.getRecurrence()!=null){Listr=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()){Mapx=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()){Mapr=new HashMap();r.put("useDefault",Boolean.FALSE);List>overrides=new ArrayList>();for(CalendarAlarm a:e.getAlarms())if(a.getMinutesBefore()!=null){Mapx=new HashMap();x.put("minutes",a.getMinutesBefore());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()){Mapc=new HashMap();Mapcreate=new HashMap();create.put("requestId",String.valueOf(System.currentTimeMillis()));c.put("createRequest",create);m.put("conferenceData",c);}return m;} + private CalendarTask task(Mapm,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"),completed=string(m,"completed");if(due!=null)out.setDue(CalendarDateTime.instant(parseIso(due),"UTC"));if(completed!=null)out.setCompletionTime(Long.valueOf(parseIso(completed)));return out;} + private MaptaskMap(CalendarTask t){Mapm=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().getTimestamp()));if(t.isCompleted()&&t.getCompletionTime()!=null)m.put("completed",iso(t.getCompletionTime().longValue()));return m;} + private static CalendarDateTime googleDate(Mapm)throws CalendarException{String date=string(m,"date"),dateTime=string(m,"dateTime"),zone=string(m,"timeZone");if(date!=null){String[]p=date.split("-");return CalendarDateTime.allDay(new CalendarDate(Integer.parseInt(p[0]),Integer.parseInt(p[1]),Integer.parseInt(p[2])));}if(dateTime!=null)return CalendarDateTime.instant(parseIso(dateTime),zone==null?"UTC":zone);return null;} + private static MapgoogleDate(CalendarDateTime value){Mapm=new HashMap();if(value==null)return m;if(value.isAllDay())m.put("date",value.getDate().toString());else{m.put("dateTime",iso(value.getTimestamp()));m.put("timeZone",value.getTimeZoneId());}return m;} + private static String iso(long time){SimpleDateFormat f=new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",Locale.US);f.setTimeZone(TimeZone.getTimeZone("UTC"));return f.format(new Date(time));} + private static long parseIso(String value)throws CalendarException{String normalized=value;if(value.length()>19&&value.charAt(19)=='.'){int zone=Math.max(value.indexOf('Z',19),Math.max(value.indexOf('+',19),value.indexOf('-',19)));if(zone>19)normalized=value.substring(0,19)+value.substring(zone);}String[]patterns={"yyyy-MM-dd'T'HH:mm:ssX","yyyy-MM-dd'T'HH:mm:ssZ","yyyy-MM-dd'T'HH:mm:ss'Z'"};for(String pattern:patterns)try{return new SimpleDateFormat(pattern,Locale.US).parse(normalized.replaceAll("([+-][0-9][0-9]):([0-9][0-9])$","$1$2")).getTime();}catch(ParseException ignored){}throw new CalendarException(CalendarError.MALFORMED_RESPONSE,"Invalid provider date: "+value);} + 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 Mapversion(String v){if(v==null)return null;Mapm=new HashMap();m.put("If-Match",v);return m;} + private static String string(Mapm,String k){Object v=m==null?null:m.get(k);return v==null?null:String.valueOf(v);} + private static boolean bool(Mapm,String k){Object v=m==null?null:m.get(k);return Boolean.TRUE.equals(v)||"true".equals(String.valueOf(v));} + private static Integer integer(Mapm,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 Mapmap(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 Listlist(Object v){return v instanceof List?(List)v:new ArrayList();} + private static String camelEnum(String v){StringBuilder b=new StringBuilder();for(int i=0;iSuccessCallbackerror(final AsyncResourceout){return new SuccessCallback(){public void onSucess(Throwable error){out.error(error);}};} + private static AsyncResourcefail(AsyncResourceout,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..2196c81a63b --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/ICalendarCodec.java @@ -0,0 +1,374 @@ +/* + * 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.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; +import com.codename1.util.StringUtil; + +/** 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().longValue())); + 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(Long.valueOf(parseDateTime(completed, null).getTimestamp())); + 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"), title = value(component, "SUMMARY"); + String description = value(component, "DESCRIPTION"), 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(System.currentTimeMillis())); + 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.indexOf("BEGIN:VTODO") >= 0 ? "DUE" : "DTEND"; + } + + private static void writeDateTime(StringBuilder out, String name, CalendarDateTime value) { + if (value.isAllDay()) { + CalendarDate date = value.getDate(); + append(out, name + ";VALUE=DATE:" + digits(date)); + } else if ("UTC".equals(value.getTimeZoneId()) || "GMT".equals(value.getTimeZoneId())) { + append(out, name + ":" + utc(value.getTimestamp())); + } else { + append(out, name + ";TZID=" + value.getTimeZoneId() + ":" + local(value.getTimestamp(), value.getTimeZoneId())); + } + } + + 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 = value.split(";"); + for (String part : parts) { + int equals = part.indexOf('='); + if (equals < 1) continue; + String key = part.substring(0, equals).toUpperCase(), 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 : data.split(",")) out.addDayOfWeek(day(item)); + else if ("BYMONTHDAY".equals(key)) for (String item : data.split(",")) out.addDayOfMonth(Integer.parseInt(item)); + else if ("BYMONTH".equals(key)) for (String item : data.split(",")) 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), 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.getMinutesBefore() != null) append(out, "TRIGGER:-PT" + alarm.getMinutesBefore() + "M"); + else if (alarm.getAbsoluteTime() != null) append(out, "TRIGGER;VALUE=DATE-TIME:" + utc(alarm.getAbsoluteTime().longValue())); + append(out, "END:VALARM"); + } + + private static CalendarAlarm readAlarm(Component component) throws CalendarException { + String action = value(component, "ACTION"), 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("-PT") && trigger.endsWith("M")) { + try { out.setMinutesBefore(Integer.valueOf(trigger.substring(3, trigger.length() - 1))); } catch (NumberFormatException ignored) {} + } else if (trigger != null) out.setAbsoluteTime(Long.valueOf(parseDateTime(trigger, null).getTimestamp())); + 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"), 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), data = line.substring(colon + 1); + String[] fields = left.split(";"); + 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(new CalendarDate(Integer.parseInt(value.substring(0, 4)), Integer.parseInt(value.substring(4, 6)), Integer.parseInt(value.substring(6, 8)))); + boolean zulu = value.endsWith("Z"); + String pattern = value.indexOf('-') >= 0 ? "yyyy-MM-dd'T'HH:mm:ss" : "yyyyMMdd'T'HHmmss"; + String data = zulu ? value.substring(0, value.length() - 1) : value; + SimpleDateFormat format = new SimpleDateFormat(pattern, Locale.US); + String timeZone = zulu || zone == null ? "UTC" : zone; + format.setTimeZone(TimeZone.getTimeZone(timeZone)); + return CalendarDateTime.instant(format.parse(data).getTime(), timeZone); + } catch (ParseException 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 = normalized.split("\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, 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;i2?value.substring(value.length()-2):value;String[] days={"MO","TU","WE","TH","FR","SA","SU"};for(int i=0;i values, boolean weekdays) { if(values.isEmpty())return;out.append(';').append(name).append('=');for(int i=0;i0)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){Listout=new ArrayList();for(Property p:properties)if(propertyName.equals(p.name))out.add(p);return out;} + } + private static final class Property { + final String name,value; final Mapparams=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..7e32cfc0f21 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/LocalCalendarSource.java @@ -0,0 +1,36 @@ +/* + * 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..8ae7b3d96de --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/MemoryCalendarCache.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. + */ +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>(); + public synchronized Map load(String sourceId) { + Map state = states.get(sourceId); + return state == null ? new HashMap() : new HashMap(state); + } + public synchronized void store(String sourceId, Map state) { states.put(sourceId, new HashMap(state)); } + 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..78f4fdc24d6 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/MicrosoftCalendarSource.java @@ -0,0 +1,111 @@ +/* + * 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.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +/** 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"}); + } + + 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); + } + + 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>(){public void onSucess(Maproot){Listitems=new ArrayList();for(Mapm:maps(root.get("value")))items.add(calendar(m,type));out.complete(new CalendarPage(items,string(root,"@odata.nextLink"),null));}}).except(error(out));return out; + } + + public AsyncResource saveCalendar(final CalendarInfo calendar){final AsyncResourceout=new AsyncResource();boolean task=calendar.getContentType()==CalendarInfo.ContentType.TASKS;Mapbody=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>(){public void onSucess(Mapm){out.complete(calendar(m,type));}}).except(error(out));return out;} + public AsyncResource deleteCalendar(String calendarId){final AsyncResourceout=new AsyncResource();json("DELETE",GRAPH+"/me/calendars/"+e(calendarId),null,null).ready(done(out)).except(error(out));return out;} + + 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().longValue())));if(query.getEndTime()!=null)b.append("&endDateTime=").append(e(iso(query.getEndTime().longValue())));url=b.toString();}final String calendarId=query.getCalendarId();json("GET",url,null,null).ready(new SuccessCallback>(){public void onSucess(Maproot){Listitems=new ArrayList();try{for(Mapm: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;} + public AsyncResourcegetEvent(final String calendarId,String eventId){final AsyncResourceout=new AsyncResource();json("GET",GRAPH+"/me/calendars/"+e(calendarId)+"/events/"+e(eventId),null,null).ready(new SuccessCallback>(){public void onSucess(Mapm){try{out.complete(event(m,calendarId));}catch(CalendarException ex){out.error(ex);}}}).except(error(out));return out;} + public AsyncResourcesaveEvent(final CalendarEvent event,CalendarMutationScope scope){final AsyncResourceout=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>(){public void onSucess(Mapm){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;} + public AsyncResourcedeleteEvent(final String calendarId,final String eventId,CalendarMutationScope scope,String version){final AsyncResourceout=new AsyncResource();json("DELETE",GRAPH+"/me/calendars/"+e(calendarId)+"/events/"+e(eventId),null,version(version)).ready(new SuccessCallback>(){public void onSucess(Mapx){out.complete(Boolean.TRUE);fireChange(new CalendarChange(getId(),calendarId,eventId,CalendarChange.EntityType.EVENT,CalendarChange.ChangeType.DELETED));}}).except(error(out));return out;} + public AsyncResourcerespondToEvent(final String calendarId,final String eventId,CalendarAttendee.Response response,String comment){final AsyncResourceout=new AsyncResource();String action=response==CalendarAttendee.Response.ACCEPTED?"accept":response==CalendarAttendee.Response.DECLINED?"decline":"tentativelyAccept";Mapbody=new HashMap();body.put("comment",comment);body.put("sendResponse",Boolean.TRUE);json("POST",GRAPH+"/me/events/"+e(eventId)+"/"+action,body,null).ready(new SuccessCallback>(){public void onSucess(Mapx){getEvent(calendarId,eventId).ready(new SuccessCallback(){public void onSucess(CalendarEvent event){out.complete(event);}}).except(error(out));}}).except(error(out));return out;} + + public AsyncResource>queryFreeBusy(Listids,long start,long end){final AsyncResource>out=new AsyncResource>();Mapbody=new HashMap();body.put("schedules",ids);body.put("startTime",dateMap(CalendarDateTime.instant(start,"UTC")));body.put("endTime",dateMap(CalendarDateTime.instant(end,"UTC")));body.put("availabilityViewInterval",Integer.valueOf(30));json("POST",GRAPH+"/me/calendar/getSchedule",body,null).ready(new SuccessCallback>(){public void onSucess(Maproot){Listitems=new ArrayList();try{for(Maps:maps(root.get("value")))for(Mapi: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"))).getTimestamp(),graphDate(map(i.get("end"))).getTimestamp(),a));}}catch(CalendarException ex){out.error(ex);return;}out.complete(items);}}).except(error(out));return out;} + + 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>(){public void onSucess(Maproot){Listitems=new ArrayList();try{for(Mapm: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;} + public AsyncResourcegetTask(final String list,String id){final AsyncResourceout=new AsyncResource();json("GET",GRAPH+"/me/todo/lists/"+e(list)+"/tasks/"+e(id),null,null).ready(new SuccessCallback>(){public void onSucess(Mapm){try{out.complete(task(m,list));}catch(CalendarException ex){out.error(ex);}}}).except(error(out));return out;} + public AsyncResourcesaveTask(final CalendarTask task,CalendarMutationScope scope){final AsyncResourceout=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>(){public void onSucess(Mapm){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;} + public AsyncResourcedeleteTask(final String list,final String id,CalendarMutationScope scope,String version){final AsyncResourceout=new AsyncResource();json("DELETE",GRAPH+"/me/todo/lists/"+e(list)+"/tasks/"+e(id),null,version(version)).ready(new SuccessCallback>(){public void onSucess(Mapx){out.complete(Boolean.TRUE);fireChange(new CalendarChange(getId(),list,id,CalendarChange.EntityType.TASK,CalendarChange.ChangeType.DELETED));}}).except(error(out));return out;} + + private CalendarInfo calendar(Mapm,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(Mapm,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(Mapa:maps(m.get("attendees"))){Mapemail=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);Mapstatus=map(a.get("status"));String response=string(status,"response");attendee.setResponse(attendeeResponse(response));out.addAttendee(attendee);}if(bool(m,"isReminderOn"))out.addAlarm(new CalendarAlarm().setMinutesBefore(integer(m,"reminderMinutesBeforeStart")));Mapmeeting=map(m.get("onlineMeeting"));if(!meeting.isEmpty())out.setConference(new CalendarConference().setProvider(string(m,"onlineMeetingProvider")).setJoinUrl(string(meeting,"joinUrl")));return out;} + private MapeventMap(CalendarEvent e){Mapm=new HashMap();m.put("subject",e.getTitle());if(e.getRecurrence()!=null)m.put("recurrence",writeGraphRecurrence(e));Mapbody=new HashMap();body.put("contentType","text");body.put("content",e.getDescription());m.put("body",body);Maplocation=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()){Mapx=new HashMap();Mapaddress=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).getMinutesBefore()!=null){m.put("isReminderOn",Boolean.TRUE);m.put("reminderMinutesBeforeStart",e.getAlarms().get(0).getMinutesBefore());}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(Mapm,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(Long.valueOf(graphDate(map(m.get("completedDateTime"))).getTimestamp()));if(bool(m,"isReminderOn")&&m.get("reminderDateTime") instanceof Map)out.addAlarm(new CalendarAlarm().setAbsoluteTime(Long.valueOf(graphDate(map(m.get("reminderDateTime"))).getTimestamp())));return out;} + private MaptaskMap(CalendarTask t){Mapm=new HashMap();m.put("title",t.getTitle());Mapbody=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().longValue(),"UTC")));}return m;} + private static CalendarDateTime graphDate(Mapm)throws CalendarException{String date=string(m,"dateTime"),zone=string(m,"timeZone");if(date==null)return null;try{SimpleDateFormat f=new SimpleDateFormat(date.indexOf('.')>=0?"yyyy-MM-dd'T'HH:mm:ss.SSSSSSS":"yyyy-MM-dd'T'HH:mm:ss",Locale.US);f.setTimeZone(TimeZone.getTimeZone(zone==null?"UTC":zone));return CalendarDateTime.instant(f.parse(date).getTime(),zone==null?"UTC":zone);}catch(ParseException ex){throw new CalendarException(CalendarError.MALFORMED_RESPONSE,"Invalid Graph date: "+date,ex);}} + private static MapdateMap(CalendarDateTime d){Mapm=new HashMap();if(d==null)return m;long time=d.isAllDay()?allDayMillis(d.getDate()):d.getTimestamp();String zone=d.isAllDay()?"UTC":d.getTimeZoneId();SimpleDateFormat f=new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss",Locale.US);f.setTimeZone(TimeZone.getTimeZone(zone));m.put("dateTime",f.format(new Date(time)));m.put("timeZone",zone);return m;} + private static CalendarDateTime toAllDay(CalendarDateTime d){if(d==null)return null;java.util.Calendar c=java.util.Calendar.getInstance(TimeZone.getTimeZone(d.getTimeZoneId()));c.setTimeInMillis(d.getTimestamp());return CalendarDateTime.allDay(new CalendarDate(c.get(java.util.Calendar.YEAR),c.get(java.util.Calendar.MONTH)+1,c.get(java.util.Calendar.DAY_OF_MONTH)));} + private static long allDayMillis(CalendarDate d){java.util.Calendar c=java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC"));c.clear();c.set(d.getYear(),d.getMonth()-1,d.getDay());return c.getTimeInMillis();} + private static String iso(long time){SimpleDateFormat f=new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'",Locale.US);f.setTimeZone(TimeZone.getTimeZone("UTC"));return f.format(new Date(time));} + private static String e(String value){return Util.encodeUrl(value==null?"":value);} + private static Mapversion(String v){if(v==null)return null;Mapm=new HashMap();m.put("If-Match",v);return m;} + private static String string(Mapm,String k){Object v=m==null?null:m.get(k);return v==null?null:String.valueOf(v);} + private static boolean bool(Mapm,String k){Object v=m==null?null:m.get(k);return Boolean.TRUE.equals(v)||"true".equals(String.valueOf(v));} + private static Integer integer(Mapm,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 Mapmap(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;iSuccessCallbackerror(final AsyncResourceout){return new SuccessCallback(){public void onSucess(Throwable error){out.error(error);}};} + private static SuccessCallback>done(final AsyncResourceout){return new SuccessCallback>(){public void onSucess(Mapx){out.complete(Boolean.TRUE);}};} + private static AsyncResourcefail(AsyncResourceout,String message){out.error(new CalendarException(CalendarError.INVALID_ARGUMENT,message));return out;} + private static CalendarRecurrenceRule readGraphRecurrence(Map recurrence){if(recurrence.isEmpty())return null;Mappattern=map(recurrence.get("pattern")),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"),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 MapwriteGraphRecurrence(CalendarEvent event){CalendarRecurrenceRule rule=event.getRecurrence();Mapout=new HashMap(),pattern=new HashMap(),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()));Listdays=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));CalendarDate 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 CalendarDate dateFor(CalendarDateTime value){if(value==null)return null;java.util.Calendar c=java.util.Calendar.getInstance(TimeZone.getTimeZone(value.getTimeZoneId()));c.setTimeInMillis(value.getTimestamp());return new CalendarDate(c.get(java.util.Calendar.YEAR),c.get(java.util.Calendar.MONTH)+1,c.get(java.util.Calendar.DAY_OF_MONTH));} + private static CalendarDate parseDate(String value){String[]p=value.split("-");return new CalendarDate(Integer.parseInt(p[0]),Integer.parseInt(p[1]),Integer.parseInt(p[2]));} + private static int graphDay(String value){String[]days={"monday","tuesday","wednesday","thursday","friday","saturday","sunday"};for(int i=0;ilist(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..58e8ed2da15 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/OAuthCalendarSource.java @@ -0,0 +1,145 @@ +/* + * 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 volatile 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(); + } + + public CalendarAuthorizationStatus getAuthorizationStatus(CalendarAccess access) { + return authorizationStatus; + } + + public AsyncResource requestAuthorization(CalendarAccess access) { + final AsyncResource out = new AsyncResource(); + tokenProvider.getToken(scopes, false).ready(new SuccessCallback() { + public void onSucess(CalendarAuthToken token) { + authorizationStatus = CalendarAuthorizationStatus.FULL; + out.complete(authorizationStatus); + } + }).except(new SuccessCallback() { + public void onSucess(Throwable error) { + authorizationStatus = 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() { + 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)); } + } + 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() { + public void complete(CalendarHttpResponse response) { out.complete(response); } + 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() { + 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() { + 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..2f9c87a01ba --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/OidcCalendarTokenProvider.java @@ -0,0 +1,57 @@ +/* + * 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; + +/** 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; } + 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() { public void onSucess(OidcTokens fresh) { + synchronized (OidcCalendarTokenProvider.this) { tokens = fresh; } + if (listener != null) listener.tokensUpdated(fresh); out.complete(convert(fresh)); + }}).except(new SuccessCallback() { 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 : Long.valueOf(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..c77acfc37d0 --- /dev/null +++ b/CodenameOne/src/com/codename1/calendar/StorageCalendarCache.java @@ -0,0 +1,46 @@ +/* + * 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 + "-"; + } + 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); + } + 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"); + } + public void clear(String sourceId) { Storage.getInstance().deleteStorageFile(prefix + sourceId); } +} 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..34dfaa3e646 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidCalendarSource.java @@ -0,0 +1,116 @@ +/* + * 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.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)).setTimeZoneId(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()));} + if(query!=null&&query.getEndTime()!=null){where.append(" AND ").append(Events.DTSTART).append("<=?");args.add(String.valueOf(query.getEndTime()));} + 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(c.getLong(5),zone)).setEnd(CalendarDateTime.instant(c.getLong(6),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().getTimeZoneId());}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,long start,long end){Listout=new ArrayList();for(CalendarEvent event:queryEvents(new CalendarQuery().setStartTime(Long.valueOf(start)).setEndTime(Long.valueOf(end))).get().getItems())if(event.getAvailability()!=CalendarEvent.Availability.FREE&&!event.getStart().isAllDay())out.add(new FreeBusyInterval(event.getStart().getTimestamp(),event.getEnd().getTimestamp(),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().setMinutesBefore(Integer.valueOf(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.getMinutesBefore()!=null){ContentValues v=new ContentValues();v.put(Reminders.EVENT_ID,Long.valueOf(event.getId()));v.put(Reminders.MINUTES,alarm.getMinutesBefore());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){java.util.Calendar c=java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC"));c.setTimeInMillis(time);return CalendarDateTime.allDay(new CalendarDate(c.get(java.util.Calendar.YEAR),c.get(java.util.Calendar.MONTH)+1,c.get(java.util.Calendar.DAY_OF_MONTH)));}private static long millis(CalendarDateTime value){if(!value.isAllDay())return value.getTimestamp();java.util.Calendar c=java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC"));c.clear();c.set(value.getDate().getYear(),value.getDate().getMonth()-1,value.getDate().getDay());return c.getTimeInMillis();} + 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/JavaSE/src/com/codename1/impl/javase/JavaSECalendarSource.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSECalendarSource.java new file mode 100644 index 00000000000..ba56490b36e --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSECalendarSource.java @@ -0,0 +1,150 @@ +/* + * 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.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().getTimestamp() < query.getStartTime().longValue()) continue; + if (query != null && query.getEndTime() != null && e.getStart() != null && !e.getStart().isAllDay() && e.getStart().getTimestamp() > query.getEndTime().longValue()) 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, long start, long 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().getTimestamp() >= start && event.getStart().getTimestamp() <= end) + out.add(new FreeBusyInterval(event.getStart().getTimestamp(), event.getEnd().getTimestamp(), 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..b0489d03c7c 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 !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..36e60716e4d --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSCalendarSource.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.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.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().longValue(),end=query==null||query.getEndTime()==null?System.currentTimeMillis()+31536000000L:query.getEndTime().longValue();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.getMinutesBefore());a.put("absolute",alarm.getAbsoluteTime());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(Exception 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,long start,long end){Listout=new ArrayList();for(CalendarEvent event:queryEvents(new CalendarQuery().setStartTime(Long.valueOf(start)).setEndTime(Long.valueOf(end))).get().getItems())if(event.getAvailability()!=CalendarEvent.Availability.FREE&&!event.getStart().isAllDay())out.add(new FreeBusyInterval(event.getStart().getTimestamp(),event.getEnd().getTimestamp(),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(Exception 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().setMinutesBefore(Integer.valueOf(i(a,"minutes"))));else if(a.get("absolute")!=null)out.addAlarm(new CalendarAlarm().setAbsoluteTime(Long.valueOf(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.getTimestamp()));m.put(key+"Zone",d.getTimeZoneId());}} + private static CalendarDateTime date(Mapm,String key,boolean allDay){String value=s(m,key+"Date");if(value!=null){String[]p=value.split("-");return CalendarDateTime.allDay(new CalendarDate(Integer.parseInt(p[0]),Integer.parseInt(p[1]),Integer.parseInt(p[2])));}return m.get(key)==null?null:CalendarDateTime.instant(l(m,key),s(m,key+"Zone")==null?"UTC":s(m,key+"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/developer-guide/Calendar-Integration.asciidoc b/docs/developer-guide/Calendar-Integration.asciidoc new file mode 100644 index 00000000000..d4aedd845cb --- /dev/null +++ b/docs/developer-guide/Calendar-Integration.asciidoc @@ -0,0 +1,199 @@ +[[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. Do not branch on the platform name. Ask the +source what it can do: + +[source,java] +---- +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(System.currentTimeMillis())) + .ready(page -> page.getItems().forEach(System.out::println)); + } + }); +} +---- + +`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()`. Timed values carry both an +instant and an Olson time-zone identifier. Keeping those concepts separate +avoids shifting an all-day event when it crosses a time-zone boundary. + +[source,java] +---- +CalendarEvent event = new CalendarEvent() + .setCalendarId(calendarId) + .setTitle("Architecture review") + .setStart(CalendarDateTime.instant(startMillis, "Europe/Paris")) + .setEnd(CalendarDateTime.instant(endMillis, "Europe/Paris")) + .setRecurrence(new CalendarRecurrenceRule() + .setFrequency(CalendarRecurrenceRule.Frequency.WEEKLY) + .addDayOfWeek(2)) + .addAttendee(new CalendarAttendee() + .setName("Ari") + .setEmail("ari@example.com")) + .addAlarm(new CalendarAlarm().setMinutesBefore(15)); + +local.saveEvent(event, CalendarMutationScope.ALL).ready(saved -> { + Log.p("Created " + saved.getId()); +}); +---- + +The `version` returned by a source is an optimistic-concurrency token. Pass +the returned object back 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] +---- +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); + }); +}); +---- + +`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] +---- +CalendarSource caldav = new CalDavCalendarSource( + "work", "Work CalDAV", calendarHomeUrl, + CalDavAuthentication.digest(username, password)); +---- + +`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] +---- +String ics = ICalendarCodec.writeEvent(event); +CalendarEvent imported = ICalendarCodec.readEvent(ics); +---- + +=== 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] +---- +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(...). + } +}); +---- + +=== 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 and is never +silently enabled. 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/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..a96ebff62fe 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,15 @@ public void usesClassMethod(String cls, String method) { addLibs = addLibs + ";LocalAuthentication.framework"; } } + if (usesCalendarApi) { + replaceInFile(new File(buildinRes, "IOSNative.m"), + "//#define CN1_USE_CALENDAR", "#define CN1_USE_CALENDAR"); + 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 @@ + + + + + + + + + +