Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TIMOB-24248] Android: Ti.Calendar Recurring Events are not clearly exposed #9412

Merged
merged 18 commits into from Feb 26, 2018

Conversation

ypbnv
Copy link
Contributor

@ypbnv ypbnv commented Sep 7, 2017

JIRA: https://jira.appcelerator.org/browse/TIMOB-24248

Description:
Implements Ti.Calendar.RecurrenceRule for Android. Due to the way Android handles recurrence rules it is not as complex as iOS and does not support all of the conditions, but provides parity for the most common cases.

Requirements:

  • Device with logged in Google account with valid calendars.
  • Permissions in tiapp.xml
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<uses-permission android:name="android.permission.READ_CALENDAR"/>

Test case:

var win = Ti.UI.createWindow({backgroundColor:'gray'});
var pickerCalendar = Ti.UI.createPicker({
    top: 50,
});
var pickerEvents = Ti.UI.createPicker({
    top: 100
});
var buttonGetRRule = Ti.UI.createButton({
    top: 150,
    title: 'Log RRule'
});
var buttonAddRRule = Ti.UI.createButton({
    top: 200,
    title: 'Add RRule'
});
var buttonGetEvent = Ti.UI.createButton({
    top: 250,
    title: 'Log Event'
});
var events = [];
var selectedEventIndex;
buttonGetRRule.addEventListener('click', function () {
    events[selectedEventIndex].recurrenceRules.forEach(function(rrule){
    	Ti.API.info('----------RRULE----------');
    	Ti.API.info(JSON.stringify(rrule));
    })
});

buttonAddRRule.addEventListener('click', function () {
     var newRule = events[selectedEventIndex].createRecurrenceRule({
                        frequency: Ti.Calendar.RECURRENCEFREQUENCY_MONTHLY,
                        interval: 1,
                        daysOfTheWeek: [{daysOfWeek:1,week:2},{daysOfWeek:2}],
                        end: {occurrenceCount:10}});
     events[selectedEventIndex].recurrenceRules = [newRule];
     events[selectedEventIndex].save();
});

buttonGetEvent.addEventListener('click', function () {
    	Ti.API.info('----------EVENT----------');
    	Ti.API.info(JSON.stringify(events[selectedEventIndex]));
});

var listEvents = function (calendarSource) {
    pickerEvents = Ti.UI.createPicker({
        top: 100
    });
    var i=0;
    var eventsRows = [];
    events = [];
    Ti.API.info(calendarSource);
    calendarSource.getEventsInYear(2018).forEach(function(event){
        //create row for each calendar
        events[i] = event;
        eventsRows[i++]=Ti.UI.createPickerRow({title:event.getTitle()});
    });
    if (eventsRows.length > 0) {
        pickerEvents.add(eventsRows);
        pickerEvents.addEventListener('change', function(e){
            selectedEventIndex = e.rowIndex;
        })
        win.add(pickerEvents);
        selectedEventIndex = 0;
    }
}

var getCalendars = function() {
    var calendarRows = [];
    Ti.Calendar.getAllCalendars().forEach(function(calendar){
        //create row for each calendar
        calendarRows.push(Ti.UI.createPickerRow({title:calendar.getName(), calendar: calendar}));
    });
    if (calendarRows.length >0 ) {
        pickerCalendar.add(calendarRows);
            pickerCalendar.addEventListener('change', function(e){
                win.remove(pickerEvents);
                listEvents(e.row.calendar);
            })
            win.add(pickerCalendar);
            listEvents(pickerCalendar.getSelectedRow(0).calendar);
               win.add(buttonGetRRule);
               win.add(buttonAddRRule);
               win.add(buttonGetEvent);
    }
}

win.addEventListener('open', function(){
    if (Ti.Calendar.hasCalendarPermissions()) {
        getCalendars();
    } else {
        Ti.Calendar.requestCalendarPermissions(function(){
            getCalendars();
        })
    }
});

win.open();

@ypbnv ypbnv added this to the 7.0.0 milestone Sep 7, 2017
@@ -42,6 +42,13 @@
@Kroll.constant public static final int STATE_FIRED = AlertProxy.STATE_FIRED;
@Kroll.constant public static final int STATE_SCHEDULED = AlertProxy.STATE_SCHEDULED;

//region recurrence frequency
@Kroll.constant public static final int RECURRENCEFREQUENCY_DAILY = 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'd prefer RECURRENCE_FREQUENCY_DAILY to RECURRENCEFREQUENCY_DAILY (same goes for the rest)

Copy link
Contributor Author

@ypbnv ypbnv Nov 28, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the way we currently have them for iOS:
https://docs.appcelerator.com/platform/latest/#!/api/Titanium.Calendar-property-RECURRENCEFREQUENCY_DAILY

We can change them in there too - now would be the time because that would be a breaking change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'd want to "fix" them to match our naming conventions. We'd have to retain the existing definitions and mark them as deprecated for this release, then in 8.0 or later we can remove the ones missing the underscores. So not really a breaking change, but an important deprecation to get in before GA.

@@ -117,7 +120,7 @@ public static String getExtendedPropertiesUri()
event.hasAlarm = !eventCursor.getString(7).equals("0");
event.status = eventCursor.getInt(8);
event.visibility = eventCursor.getInt(9);

event.setRecurrenceRules(eventCursor.getString(10), eventCursor.getInt(11));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this break when pulling up existing events with no recurrence?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently it could. I did not get any exception, but it is documented as "implementation-defined"
https://developer.android.com/reference/android/database/Cursor.html#getString(int)
I will add an exception handling.

{
return recurrenceRule;
RecurrenceRuleProxy[] result;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe something a little more compact?

RecurrenceRuleProxy[] result = new RecurrenceRuleProxy[]{};
if (rrule != null) {
  result = new RecurrenceRuleProxy[] { new RecurrenceRuleProxy(rrule, calendarID, begin) };
} 
setProperty(TiC.PROPERTY_RECURRENCE_RULES, result);

// Currently only saving added recurrenceRules.
String ruleToSave = ((RecurrenceRuleProxy) ((Object[]) getProperty(TiC.PROPERTY_RECURRENCE_RULES))[0]).generateRRULEString();
ContentValues contentValues = new ContentValues();
contentValues.put(Events.RRULE, ruleToSave);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a huge fan of the truncated constant name here: Events.RRULE versus something like Events.RECURRENCE_RULE

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

//region Methods for converting native to Kroll values
private void calculateDaysOfTheMonth(){
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private void calculateDaysOfTheMonth() {

private void calculateDaysOfTheYear() {
String days = matchExpression(".*(BYYEARDAY=[0-9]*).*", 10);
if (days != null && frequencyMap.get(frequency).equals(CalendarModule.RECURRENCEFREQUENCY_YEARLY)) {
daysOfTheYear = new int[1];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

daysOfTheYear = new int[] { Integer.valueOf(days) };

//region values that can be set from a Creation Dictionary
private int frequency = -1;
private int interval = -1;
private int[] daysOfTheMonth;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious why the choice to use int[] for these fields that all seem to be either empty or have one single value. Personally I'd have used Integer type, with null representing a "no value".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use something like Optional: https://developer.android.com/reference/java/util/Optional.html or not until Api level 24 is our minimum?
cc @garymathews @jquick-axway

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our JavaScript API defines it as an array (link below). So, using an int[] is fine.
http://docs.appcelerator.com/platform/latest/#!/api/Titanium.Calendar.RecurrenceRule-property-daysOfTheMonth

@ypbnv, we need to double check what iOS returns if this property is not set upon creation. That is, if it returns null or an empty array. We don't document this and these details matter. Once known, we can then initialize the int[] member variables appropriately (either null or empty).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jquick-axway I agree. I used this as a reference:
https://developer.apple.com/documentation/eventkit/ekrecurrencerule/1507410-daysofthemonth?language=objc
Couldn't find the result there. Maybe try it on a device?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ypbnv, I just tested it out and confirmed that the following properties never return null on iOS. They return an empty array/dicationary if not assigned upon creation. Personally, I agree with this behavior, because I find most script developers (unlike C/C++ devs) tend to not bother doing null checks.

  • daysOfTheMonth
  • daysOfTheWeek
  • daysOfTheYear
  • monthsOfTheYear
  • weeksOfTheYear

So, we'll need to match the above behavior on Android.

Also, when you change the docs, would you mind updating the above properties with this info too please? Perhaps add the following sentence to the end of each property description like this...
Will be an empty array if no recurrence of this type has been configured.

Note that the daysOfTheWeek property is a dictionary and not an array. So, update the docs appropriately.

Any case, I hope this helps!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are currently working this way on Android. I will add the description in the documentation.


private String matchExpression(String regEx, int length) {
Pattern pattern = Pattern.compile(regEx);
Matcher matcher = pattern.matcher(rRule);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might want to use this.rRule for consistency here.

public String generateRRULEString() {
StringBuilder finalRRule = new StringBuilder();
// Handle frequency.
if (frequency > -1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use this.frequency to be consistent about field access?

I assume -1 is some magical constant to denote "not specified"? Again I'd personally use Optional<Integer> or at least Integer with a null value to mark whether we have a value or not. But even if you don't do that, you may want to at least extract a constant for -1 to state it's "UNSPECIFIED" or something.

StringBuilder finalRRule = new StringBuilder();
// Handle frequency.
if (frequency > -1) {
String frequencyPart = "FREQ=" + frequencyMap.keySet().toArray()[frequency - 1];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How in the world is this consistent? The frequency map's key set is presumably not sorted/ordered.

I'm starting to think frequency should be an enum.

} // end of switch
}
// Handle interval.
if (interval > -1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, use of a magic -1 constant is not particularly obvious.

}
}

private void calculateMonthsOfTheYear() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

combine with calculateWeeksOfTheYear since they both really apply to yearly frequencies (and maybe rename to something like calculateYearlyFrequency)


private void calculateDaysOfTheWeek() {
// Create a dictionary in the context of a month.
if (frequency == CalendarModule.RECURRENCEFREQUENCY_MONTHLY) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these two if clauses are exclusive. I think it may make more sense to calculate the necessary fields grouped by the frequency value, rather than to calculate grouped by specific fields.

Basically, initialize days of the week, days of the year, months of the year, etc with the default empty array value (or whatever empty value you decide to use based on Optional/Integer usage); and then override to the real value if the frequency is of a given type in a method that just handles that frequency type.

Get rid of "magic" numbers and possible inconsistency between frequency words and numbers.
Make fields access more distinguished.
Guard for exceptions when getting recurrence rules for events.
Fix some formatting.
@ypbnv
Copy link
Contributor Author

ypbnv commented Nov 29, 2017

@sgtcoolguy Updated the PR. I haven't marked the frequency constants yet. If we are OK to do that on iOS as well I will mark them once I update the documentation. @hansemannn, what are your thoughts about that?

@jquick-axway Speaking of documentation - I did not find what are the values for freqency (should that even be accessible) and interval when they are not specified. For the latter we have '1' written in the docs, but it would be great if we can double check them before setting them on Android side and update documentation.

@jquick-axway
Copy link
Contributor

jquick-axway commented Nov 29, 2017

@ypbnv,

The recurrence frequency constants are defined under our "Ti.Calendar" doc here...

iOS defaults to RECURRENCEFREQUENCY_DAILY if the frequency field is not set when calling the createRecurrenceRule() function. This can be seen in our code here.

There are no constants for interval. It must be an integer value that is >= 1. iOS will throw an exception stating "Interval must be greater than 0" if you call createRecurrenceRule() with an interval less than 1 or if the interval field is missing. I've confirmed this in the iOS simulator.

private String rRule;

//region values that can be set from a Creation Dictionary
private Integer frequency = null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, frequency should be an enum, not an int or Integer. This way we can easily constrain the value to known constants and provide easy mappings to non-Titanium identifiers.

For example:

public enum TiRecurrenceFrequencyType
{
	DAILY(CalendarModule.RECURRENCEFREQUENCY_DAILY, "DAILY"),
	WEEKLY(CalendarModule.RECURRENCEFREQUENCY_WEEKLY, "WEEKLY"),
	MONTHLY(CalendarModule.RECURRENCEFREQUENCY_MONTHLY, "MONTHLY"),
	YEARLY(CalendarModule.RECURRENCEFREQUENCY_YEARLY, "YEARLY");

	private final int tiIntId;
	private final String rfcStringId;

	private TiRecurrenceFrequencyType(int tiIntId, String rfcStringId)
	{
		this.tiIntId = tiIntd;
		this.rfcStringId = rfcStringId;
	}

	public int toTiIntId()
	{
		return this.tiIntId;
	}

	public String toRfcStringId()
	{
		return this.rfcStringId;
	}

	public static TiRecurrenceFrequencyType fromTiIntId(int value)
	{
		for (TiRecurrenceFrequencyType nextObject : TiRecurrenceFrequencyType.values()) {
			if ((nextObject != null) && (nextObject.tiIntId == value)) {
				return nextObject;
			}
		}
		return null;
	}

	public static TiRecurrenceFrequencyType fromRfcStringId(String value)
	{
		for (TiRecurrenceFrequencyType nextObject : TiRecurrenceFrequencyType.values()) {
			if ((nextObject != null) && (nextObject.rfcStringId == value)) {
				return nextObject;
			}
		}
		return null;
	}
}

With the above, you no longer need your frequencyMap static variable since the RFC frequency string ID mapping is already embedded into the enum. You can also easily convert/validate the JavaScript frequency field in your RecurrenceRuleProxy via the enum's fromTiIntId() like this...

if (creationDictionary.containsKey(TiC.PROPERTY_FREQUENCY)) {
	int integerId = TiConvert.toInt(creationDictionary.get(TiC.PROPERTY_FREQUENCY));
	this.frequency = TiRecurrenceFrequencyType.fromTiIntId(integerId);
	if (this.frequency == null) {
		// Given frequency ID from JavaScript is invalid. Log an error.
	}
}
if (this.frequency == null) {
	// Default to daily if not assigned or given an invalid frequency.
	this.frequency = TiRecurrenceFrequencyType.DAILY;
}

You don't have to do this now, but Gary and I want to slowly transition our code to use the above pattern since it's a much more maintainable and less error prone solution. I've already started doing so with Titanium's orientation type handling here: TiDeviceOrientation.java.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jquick-axway Updated.

@ypbnv
Copy link
Contributor Author

ypbnv commented Feb 22, 2018

Unit test will be added once we establish the best way to have a properly set calendar on the emulators. All of the additions in this PR rely on having an accessible calendar with events.

if (creationDictionary.containsKey(TiC.PROPERTY_FREQUENCY)) {
this.frequency =
TiRecurrenceFrequencyType.fromTiIntId(TiConvert.toInt(creationDictionary.get(TiC.PROPERTY_FREQUENCY)));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iOS defaults to DAILY if the frequency was not set. We should do the same. Something like this...

if (creationDictionary.containsKey(TiC.PROPERTY_FREQUENCY)) {
	this.frequency = TiRecurrenceFrequencyType.fromTiIntId(TiConvert.toInt(creationDictionary.get(TiC.PROPERTY_FREQUENCY)));
}
if (this.frequency == null) {
	this.frequency = TiRecurrenceFrequencyType.DAILY;
}

@Kroll
.getProperty
@Kroll.method
public String getCalendarID()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, adding multiple annotations to the same member triggers this clang formatting issue. We currently work-around it by adding the following comments around it like the below. (It's a bummer, I know. Sorry.)

// clang-format off
@Kroll.getProperty
@Kroll.method
public String getCalendarID()
// clang-format on
{
	return this.calendarID;
}

@@ -86,7 +86,8 @@ methods:
type: Number
constants: Titanium.Calendar.SPAN_*
default: <Titanium.Calendar.SPAN_THISEVENT>
platforms: [iphone, ipad]
platforms: [android, iphone, ipad]
since: {android: "7.0.0", iphone: "3.1.0", ipad: "3.1.0"}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change Android version to "7.1.0"

type: Array<Titanium.Calendar.RecurrenceRule>
osver: {ios: {min: "5.0"}}
platforms: [iphone, ipad]
platforms: [android, iphone, ipad]
since: {android: "7.0.0", iphone: "3.1.0", ipad: "3.1.0"}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change Android version to "7.1.0"

@@ -249,9 +250,13 @@ properties:

- name: recurrenceRules
summary: The recurrence rules for the calendar item. (Available in iOS 5.1 and above.)
description: |
On Android only the first element of the recurrenceRules is take into account
due to the way it handles conditions for recurrence rules.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a comma after "On Android," and the word "take" should be "taken".

On Android, only the first element of the recurrenceRules is taken into account
due to the way it handles conditions for recurrence rules.

int index = 0;
for (String record : value) {
try {
result[index++] = new KrollDict(new JSONObject(record));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From looking at TiConvert.toStringArray(), an element in the array can be null. So, this will wrongly remove null entries and reduce the size of the array. Perhaps the below would be better?

for (String record : value) {
	KrollDict dictionary = null;
	if (record != null) {
		try {
			dictionary = new KrollDict(new JSONObject(record));
		} catch (Exception e) {
			e.printStackTrace();
			Log.w(TAG, "Unable to parse dictionary.");
		}
	}
	result[index++] = dictionary;
}

@build
Copy link
Contributor

build commented Feb 26, 2018

Messages
📖

💾 Here's the generated SDK zipfile.

Generated by 🚫 dangerJS

Fix docs.
Add clang-format off for proxy methods.
Clear tresting code.
Fix MONTHLY rules.
Fix toStringArray() mothod.
@ypbnv
Copy link
Contributor Author

ypbnv commented Feb 26, 2018

@jquick-axway Updated the PR. Also found few other things that should be updated.
@lokeshchdhry I updated the test case as well.

@lokeshchdhry
Copy link
Contributor

FR Passed.

Recurrence events works as expected.

Studio Ver: 5.0.0.201712081732
SDK Ver: 7.2.0 local build
OS Ver: 10.13.2
Xcode Ver: Xcode 9.2
Appc NPM: 4.2.12
Appc CLI: 7.0.2
Daemon Ver: 1.0.1
Ti CLI Ver: 5.0.14
Alloy Ver: 1.11.0
Node Ver: 8.9.1
NPM Ver: 5.5.1
Java Ver: 1.8.0_101
Devices: ⇨ samsung SM-G955U1 --- Android 7.0
⇨ google Nexus 5 --- Android 6.0.1

@lokeshchdhry lokeshchdhry merged commit 228d6ce into tidev:master Feb 26, 2018
Copy link
Contributor

@jquick-axway jquick-axway left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CR: Pass

@sgtcoolguy sgtcoolguy modified the milestones: 7.2.0, 7.3.0 May 16, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants