Repository: Shusshu/Android-RecurrencePicker Branch: master Commit: 4f0ae74482e4 Files: 135 Total size: 794.7 KB Directory structure: gitextract_fk9y8ksi/ ├── .gitignore ├── LICENSE.txt ├── README.md ├── demo/ │ ├── pom.xml │ ├── project.properties │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── be/ │ │ └── billington/ │ │ └── calendar/ │ │ └── recurrencepicker/ │ │ └── demo/ │ │ └── activity/ │ │ └── DemoActivity.java │ └── res/ │ ├── layout/ │ │ └── demo.xml │ └── values/ │ ├── colors.xml │ └── strings.xml ├── library/ │ ├── pom.xml │ ├── project.properties │ ├── release.properties │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── be/ │ │ └── billington/ │ │ └── calendar/ │ │ └── recurrencepicker/ │ │ ├── EventRecurrence.java │ │ ├── EventRecurrenceFormatter.java │ │ ├── LinearLayoutWithMaxWidth.java │ │ ├── RecurrencePickerDialog.java │ │ ├── Utils.java │ │ └── WeekButton.java │ └── res/ │ ├── color/ │ │ ├── done_text_color.xml │ │ ├── recurrence_bubble_text_color.xml │ │ └── recurrence_spinner_text_color.xml │ ├── drawable/ │ │ ├── recurrence_bubble_fill.xml │ │ └── switch_thumb.xml │ ├── layout/ │ │ ├── recurrencepicker.xml │ │ ├── recurrencepicker_end_text.xml │ │ └── recurrencepicker_freq_item.xml │ ├── values/ │ │ ├── arrays.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-af/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-am/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-ar/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-be/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-bg/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-ca/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-cs/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-da/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-de/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-el/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-en-rGB/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-es/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-es-rUS/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-et/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-fa/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-fi/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-fr/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-hi/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-hr/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-hu/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-in/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-it/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-iw/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-ja/ │ │ ├── arrays.xml │ │ ├── colors.xml │ │ └── strings.xml │ ├── values-ko/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-land/ │ │ └── dimens.xml │ ├── values-lt/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-lv/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-mcc262/ │ │ └── strings.xml │ ├── values-ms/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-nb/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-nl/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-pl/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-pt/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-pt-rPT/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-ro/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-ru/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-sk/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-sl/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-sr/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-sv/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-sw/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-sw600dp-land/ │ │ └── dimens.xml │ ├── values-th/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-tl/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-tr/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-uk/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-vi/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-zh-rCN/ │ │ ├── arrays.xml │ │ └── strings.xml │ ├── values-zh-rTW/ │ │ ├── arrays.xml │ │ └── strings.xml │ └── values-zu/ │ ├── arrays.xml │ └── strings.xml └── pom.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # built application files *.apk *.ap_ # files for the dex VM *.dex # Java class files *.class # generated files bin/ gen/ out/ gen-external-apklibs/ # Local configuration file (sdk path, etc) local.properties # Eclipse project files .classpath .project .settings # Proguard folder generated by Eclipse proguard/ # Intellij project files *.iml *.ipr *.iws .idea/ # Mac OS X files *.DS_Store # Maven target #Python .pydevproject #Crashlytics com_crashlytics_export_strings.xml #gradle .gradle build/ *.*~ ================================================ FILE: LICENSE.txt ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ Android Recurrence Picker ========================= Google Calendar Recurrence picker Screenshot ========== ![Example Image][1] Usage ===== Maven / Gradle -------------- ![Maven Central](https://maven-badges.herokuapp.com/maven-central/be.billington.calendar.recurrencepicker/library/badge.png?style=flat) Maven: be.billington.calendar.recurrencepicker library 1.1.1 aar Gradle: compile 'be.billington.calendar.recurrencepicker:library:1.1.1' Credits ======= This library uses Google Calendar Date & Time pickers from [Laurent Flavien & Edison Wang's library][2] The original source code of the recurrence picker can be found [here][3] License ======= [The Apache Software License, Version 2.0][4] [1]: https://github.com/Shusshu/Android-RecurrencePicker/blob/master/screenshots/recurrence-picker.png [2]: https://github.com/flavienlaurent/datetimepicker [3]: https://github.com/android/platform_packages_apps_calendar/tree/master/src/com/android/calendar [4]: http://www.apache.org/licenses/LICENSE-2.0.txt ================================================ FILE: demo/pom.xml ================================================ 4.0.0 be.billington.calendar.recurrencepicker parent 1.1.2-SNAPSHOT Android Date Time Picker - Demo demo apk com.google.android android 4.1.1.4 provided be.billington.calendar.recurrencepicker library ${project.parent.version} aar joda-time joda-time 2.3 com.simpligility.maven.plugins android-maven-plugin true true ${sdk.platform} true true org.apache.maven.plugins maven-compiler-plugin ${java.version} ${java.version} org.apache.maven.plugins maven-source-plugin attach-sources package jar org.apache.maven.plugins maven-scm-plugin developerConnection ================================================ FILE: demo/project.properties ================================================ # This file is automatically generated by Android Tools. # Do not modify this file -- YOUR CHANGES WILL BE ERASED! # # This file must be checked in Version Control Systems. # # To customize properties used by the Ant build system edit # "ant.properties", and override values to adapt the script to your # project structure. # # To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): #proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt # Project target. target=android-19 ================================================ FILE: demo/src/main/AndroidManifest.xml ================================================ ================================================ FILE: demo/src/main/java/be/billington/calendar/recurrencepicker/demo/activity/DemoActivity.java ================================================ package be.billington.calendar.recurrencepicker.demo.activity; import android.os.Bundle; import android.support.v4.app.FragmentActivity; import android.text.format.Time; import android.view.View; import android.widget.TextView; import be.billington.calendar.recurrencepicker.EventRecurrence; import be.billington.calendar.recurrencepicker.EventRecurrenceFormatter; import be.billington.calendar.recurrencepicker.RecurrencePickerDialog; import be.billington.calendar.recurrencepicker.demo.R; import java.util.Date; public class DemoActivity extends FragmentActivity { private TextView recurrence; private String recurrenceRule; public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.demo); recurrence = (TextView) findViewById(R.id.recurrence); recurrence.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { RecurrencePickerDialog recurrencePickerDialog = new RecurrencePickerDialog(); if (recurrenceRule != null && recurrenceRule.length() > 0) { Bundle bundle = new Bundle(); bundle.putString(RecurrencePickerDialog.BUNDLE_RRULE, recurrenceRule); recurrencePickerDialog.setArguments(bundle); } recurrencePickerDialog.setOnRecurrenceSetListener(new RecurrencePickerDialog.OnRecurrenceSetListener() { @Override public void onRecurrenceSet(String rrule) { recurrenceRule = rrule; if (recurrenceRule != null && recurrenceRule.length() > 0) { EventRecurrence recurrenceEvent = new EventRecurrence(); recurrenceEvent.setStartDate(new Time("" + new Date().getTime())); recurrenceEvent.parse(rrule); String srt = EventRecurrenceFormatter.getRepeatString(DemoActivity.this, getResources(), recurrenceEvent, true); recurrence.setText(srt); } else { recurrence.setText("No recurrence"); } } }); recurrencePickerDialog.show(getSupportFragmentManager(), "recurrencePicker"); } }); } } ================================================ FILE: demo/src/main/res/layout/demo.xml ================================================ ================================================ FILE: demo/src/main/res/values/colors.xml ================================================ #FFFFFF #000000 #5bba1e #B2000000 #99000000 #7F000000 #72000000 #66000000 #4C000000 #33000000 #19000000 #99ff0000 #00000000 #99ffffff #7FFFFFFF #8000aeef #CC292e2c #665bba1e #1969bd45 ================================================ FILE: demo/src/main/res/values/strings.xml ================================================ Android Recurrence Picker - Demo ================================================ FILE: library/pom.xml ================================================ 4.0.0 be.billington.calendar.recurrencepicker parent 1.1.2-SNAPSHOT Android Recurrence Picker - Library library aar android android ${android.version} provided com.nineoldandroids library 2.4.0 com.android.support support-v4 21.0.3 aar com.github.flavienlaurent.datetimepicker library 0.0.2 aar com.android.support support-v4 com.simpligility.maven.plugins android-maven-plugin true true ${sdk.platform} org.apache.maven.plugins maven-source-plugin attach-sources verify jar-no-fork ================================================ FILE: library/project.properties ================================================ # This file is automatically generated by Android Tools. # Do not modify this file -- YOUR CHANGES WILL BE ERASED! # # This file must be checked in Version Control Systems. # # To customize properties used by the Ant build system edit # "ant.properties", and override values to adapt the script to your # project structure. # # To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): #proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt # Project target. target=android-19 android.library=true ================================================ FILE: library/release.properties ================================================ #release configuration #Mon Jan 20 16:21:01 CET 2014 preparationGoals=clean verify pushChanges=true scm.commentPrefix=[maven-release-plugin] remoteTagging=true exec.additionalArguments=-Psonatype-oss-release completedPhase=scm-check-modifications scm.url=scm\:git\:git@github.com\:Shusshu/Android-RecurrencePicker.git/library ================================================ FILE: library/src/main/AndroidManifest.xml ================================================ ================================================ FILE: library/src/main/java/be/billington/calendar/recurrencepicker/EventRecurrence.java ================================================ package be.billington.calendar.recurrencepicker; /* * Copyright (C) 2006 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import android.text.TextUtils; import android.text.format.Time; import android.util.Log; import android.util.TimeFormatException; import java.util.Calendar; import java.util.HashMap; /** * Event recurrence utility functions. */ public class EventRecurrence { private static String TAG = "EventRecur"; public static final int SECONDLY = 1; public static final int MINUTELY = 2; public static final int HOURLY = 3; public static final int DAILY = 4; public static final int WEEKLY = 5; public static final int MONTHLY = 6; public static final int YEARLY = 7; public static final int SU = 0x00010000; public static final int MO = 0x00020000; public static final int TU = 0x00040000; public static final int WE = 0x00080000; public static final int TH = 0x00100000; public static final int FR = 0x00200000; public static final int SA = 0x00400000; public Time startDate; // set by setStartDate(), not parse() public int freq; // SECONDLY, MINUTELY, etc. public String until; public int count; public int interval; public int wkst; // SU, MO, TU, etc. /* lists with zero entries may be null references */ public int[] bysecond; public int bysecondCount; public int[] byminute; public int byminuteCount; public int[] byhour; public int byhourCount; public int[] byday; public int[] bydayNum; public int bydayCount; public int[] bymonthday; public int bymonthdayCount; public int[] byyearday; public int byyeardayCount; public int[] byweekno; public int byweeknoCount; public int[] bymonth; public int bymonthCount; public int[] bysetpos; public int bysetposCount; /** * maps a part string to a parser object */ private static HashMap sParsePartMap; static { sParsePartMap = new HashMap(); sParsePartMap.put("FREQ", new ParseFreq()); sParsePartMap.put("UNTIL", new ParseUntil()); sParsePartMap.put("COUNT", new ParseCount()); sParsePartMap.put("INTERVAL", new ParseInterval()); sParsePartMap.put("BYSECOND", new ParseBySecond()); sParsePartMap.put("BYMINUTE", new ParseByMinute()); sParsePartMap.put("BYHOUR", new ParseByHour()); sParsePartMap.put("BYDAY", new ParseByDay()); sParsePartMap.put("BYMONTHDAY", new ParseByMonthDay()); sParsePartMap.put("BYYEARDAY", new ParseByYearDay()); sParsePartMap.put("BYWEEKNO", new ParseByWeekNo()); sParsePartMap.put("BYMONTH", new ParseByMonth()); sParsePartMap.put("BYSETPOS", new ParseBySetPos()); sParsePartMap.put("WKST", new ParseWkst()); } /* values for bit vector that keeps track of what we have already seen */ private static final int PARSED_FREQ = 1 << 0; private static final int PARSED_UNTIL = 1 << 1; private static final int PARSED_COUNT = 1 << 2; private static final int PARSED_INTERVAL = 1 << 3; private static final int PARSED_BYSECOND = 1 << 4; private static final int PARSED_BYMINUTE = 1 << 5; private static final int PARSED_BYHOUR = 1 << 6; private static final int PARSED_BYDAY = 1 << 7; private static final int PARSED_BYMONTHDAY = 1 << 8; private static final int PARSED_BYYEARDAY = 1 << 9; private static final int PARSED_BYWEEKNO = 1 << 10; private static final int PARSED_BYMONTH = 1 << 11; private static final int PARSED_BYSETPOS = 1 << 12; private static final int PARSED_WKST = 1 << 13; /** * maps a FREQ value to an integer constant */ private static final HashMap sParseFreqMap = new HashMap(); static { sParseFreqMap.put("SECONDLY", SECONDLY); sParseFreqMap.put("MINUTELY", MINUTELY); sParseFreqMap.put("HOURLY", HOURLY); sParseFreqMap.put("DAILY", DAILY); sParseFreqMap.put("WEEKLY", WEEKLY); sParseFreqMap.put("MONTHLY", MONTHLY); sParseFreqMap.put("YEARLY", YEARLY); } /** * maps a two-character weekday string to an integer constant */ private static final HashMap sParseWeekdayMap = new HashMap(); static { sParseWeekdayMap.put("SU", SU); sParseWeekdayMap.put("MO", MO); sParseWeekdayMap.put("TU", TU); sParseWeekdayMap.put("WE", WE); sParseWeekdayMap.put("TH", TH); sParseWeekdayMap.put("FR", FR); sParseWeekdayMap.put("SA", SA); } /** * If set, allow lower-case recurrence rule strings. Minor performance impact. */ private static final boolean ALLOW_LOWER_CASE = true; /** * If set, validate the value of UNTIL parts. Minor performance impact. */ private static final boolean VALIDATE_UNTIL = false; /** * If set, require that only one of {UNTIL,COUNT} is present. Breaks compat w/ old parser. */ private static final boolean ONLY_ONE_UNTIL_COUNT = false; /** * Thrown when a recurrence string provided can not be parsed according * to RFC2445. */ public static class InvalidFormatException extends RuntimeException { InvalidFormatException(String s) { super(s); } } public void setStartDate(Time date) { startDate = date; } /** * Converts one of the Calendar.SUNDAY constants to the SU, MO, etc. * constants. btw, I think we should switch to those here too, to * get rid of this function, if possible. */ public static int calendarDay2Day(int day) { switch (day) { case Calendar.SUNDAY: return SU; case Calendar.MONDAY: return MO; case Calendar.TUESDAY: return TU; case Calendar.WEDNESDAY: return WE; case Calendar.THURSDAY: return TH; case Calendar.FRIDAY: return FR; case Calendar.SATURDAY: return SA; default: throw new RuntimeException("bad day of week: " + day); } } public static int timeDay2Day(int day) { switch (day) { case Time.SUNDAY: return SU; case Time.MONDAY: return MO; case Time.TUESDAY: return TU; case Time.WEDNESDAY: return WE; case Time.THURSDAY: return TH; case Time.FRIDAY: return FR; case Time.SATURDAY: return SA; default: throw new RuntimeException("bad day of week: " + day); } } public static int day2TimeDay(int day) { switch (day) { case SU: return Time.SUNDAY; case MO: return Time.MONDAY; case TU: return Time.TUESDAY; case WE: return Time.WEDNESDAY; case TH: return Time.THURSDAY; case FR: return Time.FRIDAY; case SA: return Time.SATURDAY; default: throw new RuntimeException("bad day of week: " + day); } } /** * Converts one of the SU, MO, etc. constants to the Calendar.SUNDAY * constants. btw, I think we should switch to those here too, to * get rid of this function, if possible. */ public static int day2CalendarDay(int day) { switch (day) { case SU: return Calendar.SUNDAY; case MO: return Calendar.MONDAY; case TU: return Calendar.TUESDAY; case WE: return Calendar.WEDNESDAY; case TH: return Calendar.THURSDAY; case FR: return Calendar.FRIDAY; case SA: return Calendar.SATURDAY; default: throw new RuntimeException("bad day of week: " + day); } } /** * Converts one of the internal day constants (SU, MO, etc.) to the * two-letter string representing that constant. * * @param day one the internal constants SU, MO, etc. * @return the two-letter string for the day ("SU", "MO", etc.) * @throws IllegalArgumentException Thrown if the day argument is not one of * the defined day constants. */ private static String day2String(int day) { switch (day) { case SU: return "SU"; case MO: return "MO"; case TU: return "TU"; case WE: return "WE"; case TH: return "TH"; case FR: return "FR"; case SA: return "SA"; default: throw new IllegalArgumentException("bad day argument: " + day); } } private static void appendNumbers(StringBuilder s, String label, int count, int[] values) { if (count > 0) { s.append(label); count--; for (int i = 0; i < count; i++) { s.append(values[i]); s.append(","); } s.append(values[count]); } } private void appendByDay(StringBuilder s, int i) { int n = this.bydayNum[i]; if (n != 0) { s.append(n); } String str = day2String(this.byday[i]); s.append(str); } @Override public String toString() { StringBuilder s = new StringBuilder(); s.append("FREQ="); switch (this.freq) { case SECONDLY: s.append("SECONDLY"); break; case MINUTELY: s.append("MINUTELY"); break; case HOURLY: s.append("HOURLY"); break; case DAILY: s.append("DAILY"); break; case WEEKLY: s.append("WEEKLY"); break; case MONTHLY: s.append("MONTHLY"); break; case YEARLY: s.append("YEARLY"); break; } if (!TextUtils.isEmpty(this.until)) { s.append(";UNTIL="); s.append(until); } if (this.count != 0) { s.append(";COUNT="); s.append(this.count); } if (this.interval != 0) { s.append(";INTERVAL="); s.append(this.interval); } if (this.wkst != 0) { s.append(";WKST="); s.append(day2String(this.wkst)); } appendNumbers(s, ";BYSECOND=", this.bysecondCount, this.bysecond); appendNumbers(s, ";BYMINUTE=", this.byminuteCount, this.byminute); appendNumbers(s, ";BYSECOND=", this.byhourCount, this.byhour); // day int count = this.bydayCount; if (count > 0) { s.append(";BYDAY="); count--; for (int i = 0; i < count; i++) { appendByDay(s, i); s.append(","); } appendByDay(s, count); } appendNumbers(s, ";BYMONTHDAY=", this.bymonthdayCount, this.bymonthday); appendNumbers(s, ";BYYEARDAY=", this.byyeardayCount, this.byyearday); appendNumbers(s, ";BYWEEKNO=", this.byweeknoCount, this.byweekno); appendNumbers(s, ";BYMONTH=", this.bymonthCount, this.bymonth); appendNumbers(s, ";BYSETPOS=", this.bysetposCount, this.bysetpos); return s.toString(); } public boolean repeatsOnEveryWeekDay() { if (this.freq != WEEKLY) { return false; } int count = this.bydayCount; if (count != 5) { return false; } for (int i = 0; i < count; i++) { int day = byday[i]; if (day == SU || day == SA) { return false; } } return true; } /** * Determines whether this rule specifies a simple monthly rule by weekday, such as * "FREQ=MONTHLY;BYDAY=3TU" (the 3rd Tuesday of every month). *

* Negative days, e.g. "FREQ=MONTHLY;BYDAY=-1TU" (the last Tuesday of every month), * will cause "false" to be returned. *

* Rules that fire every week, such as "FREQ=MONTHLY;BYDAY=TU" (every Tuesday of every * month) will cause "false" to be returned. (Note these are usually expressed as * WEEKLY rules, and hence are uncommon.) * * @return true if this rule is of the appropriate form */ public boolean repeatsMonthlyOnDayCount() { if (this.freq != MONTHLY) { return false; } if (bydayCount != 1 || bymonthdayCount != 0) { return false; } if (bydayNum[0] <= 0) { return false; } return true; } /** * Determines whether two integer arrays contain identical elements. *

* The native implementation over-allocated the arrays (and may have stuff left over from * a previous run), so we can't just check the arrays -- the separately-maintained count * field also matters. We assume that a null array will have a count of zero, and that the * array can hold as many elements as the associated count indicates. *

* TODO: replace this with Arrays.equals() when the old parser goes away. */ private static boolean arraysEqual(int[] array1, int count1, int[] array2, int count2) { if (count1 != count2) { return false; } for (int i = 0; i < count1; i++) { if (array1[i] != array2[i]) return false; } return true; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (!(obj instanceof EventRecurrence)) { return false; } EventRecurrence er = (EventRecurrence) obj; return (startDate == null ? er.startDate == null : Time.compare(startDate, er.startDate) == 0) && freq == er.freq && (until == null ? er.until == null : until.equals(er.until)) && count == er.count && interval == er.interval && wkst == er.wkst && arraysEqual(bysecond, bysecondCount, er.bysecond, er.bysecondCount) && arraysEqual(byminute, byminuteCount, er.byminute, er.byminuteCount) && arraysEqual(byhour, byhourCount, er.byhour, er.byhourCount) && arraysEqual(byday, bydayCount, er.byday, er.bydayCount) && arraysEqual(bydayNum, bydayCount, er.bydayNum, er.bydayCount) && arraysEqual(bymonthday, bymonthdayCount, er.bymonthday, er.bymonthdayCount) && arraysEqual(byyearday, byyeardayCount, er.byyearday, er.byyeardayCount) && arraysEqual(byweekno, byweeknoCount, er.byweekno, er.byweeknoCount) && arraysEqual(bymonth, bymonthCount, er.bymonth, er.bymonthCount) && arraysEqual(bysetpos, bysetposCount, er.bysetpos, er.bysetposCount); } @Override public int hashCode() { // We overrode equals, so we must override hashCode(). Nobody seems to need this though. throw new UnsupportedOperationException(); } /** * Resets parser-modified fields to their initial state. Does not alter startDate. *

* The original parser always set all of the "count" fields, "wkst", and "until", * essentially allowing the same object to be used multiple times by calling parse(). * It's unclear whether this behavior was intentional. For now, be paranoid and * preserve the existing behavior by resetting the fields. *

* We don't need to touch the integer arrays; they will either be ignored or * overwritten. The "startDate" field is not set by the parser, so we ignore it here. */ private void resetFields() { until = null; freq = count = interval = bysecondCount = byminuteCount = byhourCount = bydayCount = bymonthdayCount = byyeardayCount = byweeknoCount = bymonthCount = bysetposCount = 0; } /** * Parses an rfc2445 recurrence rule string into its component pieces. Attempting to parse * malformed input will result in an EventRecurrence.InvalidFormatException. * * @param recur The recurrence rule to parse (in un-folded form). */ public void parse(String recur) { /* * From RFC 2445 section 4.3.10: * * recur = "FREQ"=freq *( * ; either UNTIL or COUNT may appear in a 'recur', * ; but UNTIL and COUNT MUST NOT occur in the same 'recur' * * ( ";" "UNTIL" "=" enddate ) / * ( ";" "COUNT" "=" 1*DIGIT ) / * * ; the rest of these keywords are optional, * ; but MUST NOT occur more than once * * ( ";" "INTERVAL" "=" 1*DIGIT ) / * ( ";" "BYSECOND" "=" byseclist ) / * ( ";" "BYMINUTE" "=" byminlist ) / * ( ";" "BYHOUR" "=" byhrlist ) / * ( ";" "BYDAY" "=" bywdaylist ) / * ( ";" "BYMONTHDAY" "=" bymodaylist ) / * ( ";" "BYYEARDAY" "=" byyrdaylist ) / * ( ";" "BYWEEKNO" "=" bywknolist ) / * ( ";" "BYMONTH" "=" bymolist ) / * ( ";" "BYSETPOS" "=" bysplist ) / * ( ";" "WKST" "=" weekday ) / * ( ";" x-name "=" text ) * ) * * The rule parts are not ordered in any particular sequence. * * Examples: * FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU * FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8 * * Strategy: * (1) Split the string at ';' boundaries to get an array of rule "parts". * (2) For each part, find substrings for left/right sides of '=' (name/value). * (3) Call a -specific parsing function to parse the into an * output field. * * By keeping track of which names we've seen in a bit vector, we can verify the * constraints indicated above (FREQ appears first, none of them appear more than once -- * though x-[name] would require special treatment), and we have either UNTIL or COUNT * but not both. * * In general, RFC 2445 property names (e.g. "FREQ") and enumerations ("TU") must * be handled in a case-insensitive fashion, but case may be significant for other * properties. We don't have any case-sensitive values in RRULE, except possibly * for the custom "X-" properties, but we ignore those anyway. Thus, we can trivially * convert the entire string to upper case and then use simple comparisons. * * Differences from previous version: * - allows lower-case property and enumeration values [optional] * - enforces that FREQ appears first * - enforces that only one of UNTIL and COUNT may be specified * - allows (but ignores) X-* parts * - improved validation on various values (e.g. UNTIL timestamps) * - error messages are more specific * * TODO: enforce additional constraints listed in RFC 5545, notably the "N/A" entries * in section 3.3.10. For example, if FREQ=WEEKLY, we should reject a rule that * includes a BYMONTHDAY part. */ /* TODO: replace with "if (freq != 0) throw" if nothing requires this */ resetFields(); int parseFlags = 0; String[] parts; if (ALLOW_LOWER_CASE) { parts = recur.toUpperCase().split(";"); } else { parts = recur.split(";"); } for (String part : parts) { // allow empty part (e.g., double semicolon ";;") if (TextUtils.isEmpty(part)) { continue; } int equalIndex = part.indexOf('='); if (equalIndex <= 0) { /* no '=' or no LHS */ throw new InvalidFormatException("Missing LHS in " + part); } String lhs = part.substring(0, equalIndex); String rhs = part.substring(equalIndex + 1); if (rhs.length() == 0) { throw new InvalidFormatException("Missing RHS in " + part); } /* * In lieu of a "switch" statement that allows string arguments, we use a * map from strings to parsing functions. */ PartParser parser = sParsePartMap.get(lhs); if (parser == null) { if (lhs.startsWith("X-")) { //Log.d(TAG, "Ignoring custom part " + lhs); continue; } throw new InvalidFormatException("Couldn't find parser for " + lhs); } else { int flag = parser.parsePart(rhs, this); if ((parseFlags & flag) != 0) { throw new InvalidFormatException("Part " + lhs + " was specified twice"); } parseFlags |= flag; } } // If not specified, week starts on Monday. if ((parseFlags & PARSED_WKST) == 0) { wkst = MO; } // FREQ is mandatory. if ((parseFlags & PARSED_FREQ) == 0) { throw new InvalidFormatException("Must specify a FREQ value"); } // Can't have both UNTIL and COUNT. if ((parseFlags & (PARSED_UNTIL | PARSED_COUNT)) == (PARSED_UNTIL | PARSED_COUNT)) { if (ONLY_ONE_UNTIL_COUNT) { throw new InvalidFormatException("Must not specify both UNTIL and COUNT: " + recur); } else { Log.w(TAG, "Warning: rrule has both UNTIL and COUNT: " + recur); } } } /** * Base class for the RRULE part parsers. */ abstract static class PartParser { /** * Parses a single part. * * @param value The right-hand-side of the part. * @param er The EventRecurrence into which the result is stored. * @return A bit value indicating which part was parsed. */ public abstract int parsePart(String value, EventRecurrence er); /** * Parses an integer, with range-checking. * * @param str The string to parse. * @param minVal Minimum allowed value. * @param maxVal Maximum allowed value. * @param allowZero Is 0 allowed? * @return The parsed value. */ public static int parseIntRange(String str, int minVal, int maxVal, boolean allowZero) { try { if (str.charAt(0) == '+') { // Integer.parseInt does not allow a leading '+', so skip it manually. str = str.substring(1); } int val = Integer.parseInt(str); if (val < minVal || val > maxVal || (val == 0 && !allowZero)) { throw new InvalidFormatException("Integer value out of range: " + str); } return val; } catch (NumberFormatException nfe) { throw new InvalidFormatException("Invalid integer value: " + str); } } /** * Parses a comma-separated list of integers, with range-checking. * * @param listStr The string to parse. * @param minVal Minimum allowed value. * @param maxVal Maximum allowed value. * @param allowZero Is 0 allowed? * @return A new array with values, sized to hold the exact number of elements. */ public static int[] parseNumberList(String listStr, int minVal, int maxVal, boolean allowZero) { int[] values; if (listStr.indexOf(",") < 0) { // Common case: only one entry, skip split() overhead. values = new int[1]; values[0] = parseIntRange(listStr, minVal, maxVal, allowZero); } else { String[] valueStrs = listStr.split(","); int len = valueStrs.length; values = new int[len]; for (int i = 0; i < len; i++) { values[i] = parseIntRange(valueStrs[i], minVal, maxVal, allowZero); } } return values; } } /** * parses FREQ={SECONDLY,MINUTELY,...} */ private static class ParseFreq extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { Integer freq = sParseFreqMap.get(value); if (freq == null) { throw new InvalidFormatException("Invalid FREQ value: " + value); } er.freq = freq; return PARSED_FREQ; } } /** * parses UNTIL=enddate, e.g. "19970829T021400" */ private static class ParseUntil extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { if (VALIDATE_UNTIL) { try { // Parse the time to validate it. The result isn't retained. Time until = new Time(); until.parse(value); } catch (TimeFormatException tfe) { throw new InvalidFormatException("Invalid UNTIL value: " + value); } } er.until = value; return PARSED_UNTIL; } } /** * parses COUNT=[non-negative-integer] */ private static class ParseCount extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { er.count = parseIntRange(value, Integer.MIN_VALUE, Integer.MAX_VALUE, true); if (er.count < 0) { Log.d(TAG, "Invalid Count. Forcing COUNT to 1 from " + value); er.count = 1; // invalid count. assume one time recurrence. } return PARSED_COUNT; } } /** * parses INTERVAL=[non-negative-integer] */ private static class ParseInterval extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { er.interval = parseIntRange(value, Integer.MIN_VALUE, Integer.MAX_VALUE, true); if (er.interval < 1) { Log.d(TAG, "Invalid Interval. Forcing INTERVAL to 1 from " + value); er.interval = 1; } return PARSED_INTERVAL; } } /** * parses BYSECOND=byseclist */ private static class ParseBySecond extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { int[] bysecond = parseNumberList(value, 0, 59, true); er.bysecond = bysecond; er.bysecondCount = bysecond.length; return PARSED_BYSECOND; } } /** * parses BYMINUTE=byminlist */ private static class ParseByMinute extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { int[] byminute = parseNumberList(value, 0, 59, true); er.byminute = byminute; er.byminuteCount = byminute.length; return PARSED_BYMINUTE; } } /** * parses BYHOUR=byhrlist */ private static class ParseByHour extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { int[] byhour = parseNumberList(value, 0, 23, true); er.byhour = byhour; er.byhourCount = byhour.length; return PARSED_BYHOUR; } } /** * parses BYDAY=bywdaylist, e.g. "1SU,-1SU" */ private static class ParseByDay extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { int[] byday; int[] bydayNum; int bydayCount; if (value.indexOf(",") < 0) { /* only one entry, skip split() overhead */ bydayCount = 1; byday = new int[1]; bydayNum = new int[1]; parseWday(value, byday, bydayNum, 0); } else { String[] wdays = value.split(","); int len = wdays.length; bydayCount = len; byday = new int[len]; bydayNum = new int[len]; for (int i = 0; i < len; i++) { parseWday(wdays[i], byday, bydayNum, i); } } er.byday = byday; er.bydayNum = bydayNum; er.bydayCount = bydayCount; return PARSED_BYDAY; } /** * parses [int]weekday, putting the pieces into parallel array entries */ private static void parseWday(String str, int[] byday, int[] bydayNum, int index) { int wdayStrStart = str.length() - 2; String wdayStr; if (wdayStrStart > 0) { /* number is included; parse it out and advance to weekday */ String numPart = str.substring(0, wdayStrStart); int num = parseIntRange(numPart, -53, 53, false); bydayNum[index] = num; wdayStr = str.substring(wdayStrStart); } else { /* just the weekday string */ wdayStr = str; } Integer wday = sParseWeekdayMap.get(wdayStr); if (wday == null) { throw new InvalidFormatException("Invalid BYDAY value: " + str); } byday[index] = wday; } } /** * parses BYMONTHDAY=bymodaylist */ private static class ParseByMonthDay extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { int[] bymonthday = parseNumberList(value, -31, 31, false); er.bymonthday = bymonthday; er.bymonthdayCount = bymonthday.length; return PARSED_BYMONTHDAY; } } /** * parses BYYEARDAY=byyrdaylist */ private static class ParseByYearDay extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { int[] byyearday = parseNumberList(value, -366, 366, false); er.byyearday = byyearday; er.byyeardayCount = byyearday.length; return PARSED_BYYEARDAY; } } /** * parses BYWEEKNO=bywknolist */ private static class ParseByWeekNo extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { int[] byweekno = parseNumberList(value, -53, 53, false); er.byweekno = byweekno; er.byweeknoCount = byweekno.length; return PARSED_BYWEEKNO; } } /** * parses BYMONTH=bymolist */ private static class ParseByMonth extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { int[] bymonth = parseNumberList(value, 1, 12, false); er.bymonth = bymonth; er.bymonthCount = bymonth.length; return PARSED_BYMONTH; } } /** * parses BYSETPOS=bysplist */ private static class ParseBySetPos extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { int[] bysetpos = parseNumberList(value, Integer.MIN_VALUE, Integer.MAX_VALUE, true); er.bysetpos = bysetpos; er.bysetposCount = bysetpos.length; return PARSED_BYSETPOS; } } /** * parses WKST={SU,MO,...} */ private static class ParseWkst extends PartParser { @Override public int parsePart(String value, EventRecurrence er) { Integer wkst = sParseWeekdayMap.get(value); if (wkst == null) { throw new InvalidFormatException("Invalid WKST value: " + value); } er.wkst = wkst; return PARSED_WKST; } } } ================================================ FILE: library/src/main/java/be/billington/calendar/recurrencepicker/EventRecurrenceFormatter.java ================================================ package be.billington.calendar.recurrencepicker; /* * Copyright (C) 2006 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import android.content.Context; import android.content.res.Resources; import android.text.format.DateUtils; import android.text.format.Time; import android.util.TimeFormatException; import java.util.Calendar; public class EventRecurrenceFormatter { private static int[] mMonthRepeatByDayOfWeekIds; private static String[][] mMonthRepeatByDayOfWeekStrs; public static String getRepeatString(Context context, Resources r, EventRecurrence recurrence, boolean includeEndString) { String endString = ""; if (includeEndString) { StringBuilder sb = new StringBuilder(); if (recurrence.until != null) { try { Time t = new Time(); t.parse(recurrence.until); final String dateStr = DateUtils.formatDateTime(context, t.toMillis(false), DateUtils.FORMAT_NUMERIC_DATE); sb.append(r.getString(R.string.endByDate, dateStr)); } catch (TimeFormatException e) { } } if (recurrence.count > 0) { sb.append(r.getQuantityString(R.plurals.endByCount, recurrence.count, recurrence.count)); } endString = sb.toString(); } // TODO Implement "Until" portion of string, as well as custom settings int interval = recurrence.interval <= 1 ? 1 : recurrence.interval; switch (recurrence.freq) { case EventRecurrence.DAILY: return r.getQuantityString(R.plurals.daily, interval, interval) + endString; case EventRecurrence.WEEKLY: { if (recurrence.repeatsOnEveryWeekDay()) { return r.getString(R.string.every_weekday) + endString; } else { String string; int dayOfWeekLength = DateUtils.LENGTH_MEDIUM; if (recurrence.bydayCount == 1) { dayOfWeekLength = DateUtils.LENGTH_LONG; } StringBuilder days = new StringBuilder(); // Do one less iteration in the loop so the last element is added out of the // loop. This is done so the comma is not placed after the last item. if (recurrence.bydayCount > 0) { int count = recurrence.bydayCount - 1; for (int i = 0 ; i < count ; i++) { days.append(dayToString(recurrence.byday[i], dayOfWeekLength)); days.append(", "); } days.append(dayToString(recurrence.byday[count], dayOfWeekLength)); string = days.toString(); } else { // There is no "BYDAY" specifier, so use the day of the // first event. For this to work, the setStartDate() // method must have been used by the caller to set the // date of the first event in the recurrence. if (recurrence.startDate == null) { return null; } int day = EventRecurrence.timeDay2Day(recurrence.startDate.weekDay); string = dayToString(day, DateUtils.LENGTH_LONG); } return r.getQuantityString(R.plurals.weekly, interval, interval, string) + endString; } } case EventRecurrence.MONTHLY: { if (recurrence.bydayCount == 1) { int weekday = recurrence.startDate.weekDay; // Cache this stuff so we won't have to redo work again later. cacheMonthRepeatStrings(r, weekday); int dayNumber = (recurrence.startDate.monthDay - 1) / 7; StringBuilder sb = new StringBuilder(); sb.append(r.getString(R.string.monthly)); sb.append(" ("); sb.append(mMonthRepeatByDayOfWeekStrs[weekday][dayNumber]); sb.append(")"); sb.append(endString); return sb.toString(); } return r.getString(R.string.monthly) + endString; } case EventRecurrence.YEARLY: return r.getString(R.string.yearly_plain) + endString; } return null; } private static void cacheMonthRepeatStrings(Resources r, int weekday) { if (mMonthRepeatByDayOfWeekIds == null) { mMonthRepeatByDayOfWeekIds = new int[7]; mMonthRepeatByDayOfWeekIds[0] = R.array.repeat_by_nth_sun; mMonthRepeatByDayOfWeekIds[1] = R.array.repeat_by_nth_mon; mMonthRepeatByDayOfWeekIds[2] = R.array.repeat_by_nth_tues; mMonthRepeatByDayOfWeekIds[3] = R.array.repeat_by_nth_wed; mMonthRepeatByDayOfWeekIds[4] = R.array.repeat_by_nth_thurs; mMonthRepeatByDayOfWeekIds[5] = R.array.repeat_by_nth_fri; mMonthRepeatByDayOfWeekIds[6] = R.array.repeat_by_nth_sat; } if (mMonthRepeatByDayOfWeekStrs == null) { mMonthRepeatByDayOfWeekStrs = new String[7][]; } if (mMonthRepeatByDayOfWeekStrs[weekday] == null) { mMonthRepeatByDayOfWeekStrs[weekday] = r.getStringArray(mMonthRepeatByDayOfWeekIds[weekday]); } } /** * Converts day of week to a String. * @param day a EventRecurrence constant * @return day of week as a string */ private static String dayToString(int day, int dayOfWeekLength) { return DateUtils.getDayOfWeekString(dayToUtilDay(day), dayOfWeekLength); } /** * Converts EventRecurrence's day of week to DateUtil's day of week. * @param day of week as an EventRecurrence value * @return day of week as a DateUtil value. */ private static int dayToUtilDay(int day) { switch (day) { case EventRecurrence.SU: return Calendar.SUNDAY; case EventRecurrence.MO: return Calendar.MONDAY; case EventRecurrence.TU: return Calendar.TUESDAY; case EventRecurrence.WE: return Calendar.WEDNESDAY; case EventRecurrence.TH: return Calendar.THURSDAY; case EventRecurrence.FR: return Calendar.FRIDAY; case EventRecurrence.SA: return Calendar.SATURDAY; default: throw new IllegalArgumentException("bad day argument: " + day); } } } ================================================ FILE: library/src/main/java/be/billington/calendar/recurrencepicker/LinearLayoutWithMaxWidth.java ================================================ /* * Copyright (C) 2013 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package be.billington.calendar.recurrencepicker; import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.widget.LinearLayout; public class LinearLayoutWithMaxWidth extends LinearLayout { public LinearLayoutWithMaxWidth(Context context) { super(context); } public LinearLayoutWithMaxWidth(Context context, AttributeSet attrs) { super(context, attrs); } public LinearLayoutWithMaxWidth(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { WeekButton.setSuggestedWidth((View.MeasureSpec.getSize(widthMeasureSpec)) / 7); super.onMeasure(widthMeasureSpec, heightMeasureSpec); } } ================================================ FILE: library/src/main/java/be/billington/calendar/recurrencepicker/RecurrencePickerDialog.java ================================================ /* * Copyright (C) 2013 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package be.billington.calendar.recurrencepicker; import android.app.Activity; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import android.support.v4.app.DialogFragment; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; import android.text.format.DateUtils; import android.text.format.Time; import android.util.Log; import android.util.TimeFormatException; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.Window; import android.widget.AdapterView; import android.widget.AdapterView.OnItemSelectedListener; import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.CompoundButton; import android.widget.CompoundButton.OnCheckedChangeListener; import android.widget.EditText; import android.widget.LinearLayout; import android.widget.RadioButton; import android.widget.RadioGroup; import android.widget.Spinner; import android.widget.Switch; import android.widget.TableLayout; import android.widget.TextView; import android.widget.Toast; import android.widget.ToggleButton; import com.fourmob.datetimepicker.date.DatePickerDialog; import java.text.DateFormatSymbols; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; public class RecurrencePickerDialog extends DialogFragment implements OnItemSelectedListener, OnCheckedChangeListener, OnClickListener, android.widget.RadioGroup.OnCheckedChangeListener, DatePickerDialog.OnDateSetListener { private static final String TAG = "RecurrencePickerDialog"; // in dp's private static final int MIN_SCREEN_WIDTH_FOR_SINGLE_ROW_WEEK = 450; // Update android:maxLength in EditText as needed private static final int INTERVAL_MAX = 99; private static final int INTERVAL_DEFAULT = 1; // Update android:maxLength in EditText as needed private static final int COUNT_MAX = 730; private static final int COUNT_DEFAULT = 5; // Special cases in monthlyByNthDayOfWeek private static final int FIFTH_WEEK_IN_A_MONTH = 5; private static final int LAST_NTH_DAY_OF_WEEK = -1; private DatePickerDialog mDatePickerDialog; private class RecurrenceModel implements Parcelable { // Should match EventRecurrence.DAILY, etc static final int FREQ_DAILY = 0; static final int FREQ_WEEKLY = 1; static final int FREQ_MONTHLY = 2; static final int FREQ_YEARLY = 3; static final int END_NEVER = 0; static final int END_BY_DATE = 1; static final int END_BY_COUNT = 2; static final int MONTHLY_BY_DATE = 0; static final int MONTHLY_BY_NTH_DAY_OF_WEEK = 1; static final int STATE_NO_RECURRENCE = 0; static final int STATE_RECURRENCE = 1; int recurrenceState; /** * FREQ: Repeat pattern * * @see FREQ_DAILY * @see FREQ_WEEKLY * @see FREQ_MONTHLY * @see FREQ_YEARLY */ int freq = FREQ_WEEKLY; /** * INTERVAL: Every n days/weeks/months/years. n >= 1 */ int interval = INTERVAL_DEFAULT; /** * UNTIL and COUNT: How does the the event end? * * @see END_NEVER * @see END_BY_DATE * @see END_BY_COUNT * @see untilDate * @see untilCount */ int end; /** * UNTIL: Date of the last recurrence. Used when until == END_BY_DATE */ Time endDate; /** * COUNT: Times to repeat. Use when until == END_BY_COUNT */ int endCount = COUNT_DEFAULT; /** * BYDAY: Days of the week to be repeated. Sun = 0, Mon = 1, etc */ boolean[] weeklyByDayOfWeek = new boolean[7]; /** * BYDAY AND BYMONTHDAY: How to repeat monthly events? Same date of the * month or Same nth day of week. * * @see MONTHLY_BY_DATE * @see MONTHLY_BY_NTH_DAY_OF_WEEK */ int monthlyRepeat; /** * Day of the month to repeat. Used when monthlyRepeat == * MONTHLY_BY_DATE */ int monthlyByMonthDay; /** * Day of the week to repeat. Used when monthlyRepeat == * MONTHLY_BY_NTH_DAY_OF_WEEK */ int monthlyByDayOfWeek; /** * Nth day of the week to repeat. Used when monthlyRepeat == * MONTHLY_BY_NTH_DAY_OF_WEEK 0=undefined, -1=Last, 1=1st, 2=2nd, ..., 5=5th *

* We support 5th, just to handle backwards capabilities with old bug, but it * gets converted to -1 once edited. */ int monthlyByNthDayOfWeek; /* * (generated method) */ @Override public String toString() { return "Model [freq=" + freq + ", interval=" + interval + ", end=" + end + ", endDate=" + endDate + ", endCount=" + endCount + ", weeklyByDayOfWeek=" + Arrays.toString(weeklyByDayOfWeek) + ", monthlyRepeat=" + monthlyRepeat + ", monthlyByMonthDay=" + monthlyByMonthDay + ", monthlyByDayOfWeek=" + monthlyByDayOfWeek + ", monthlyByNthDayOfWeek=" + monthlyByNthDayOfWeek + "]"; } @Override public int describeContents() { return 0; } public RecurrenceModel() { } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeInt(freq); dest.writeInt(interval); dest.writeInt(end); dest.writeInt(endDate.year); dest.writeInt(endDate.month); dest.writeInt(endDate.monthDay); dest.writeInt(endCount); dest.writeBooleanArray(weeklyByDayOfWeek); dest.writeInt(monthlyRepeat); dest.writeInt(monthlyByMonthDay); dest.writeInt(monthlyByDayOfWeek); dest.writeInt(monthlyByNthDayOfWeek); dest.writeInt(recurrenceState); } } class minMaxTextWatcher implements TextWatcher { private int mMin; private int mMax; private int mDefault; public minMaxTextWatcher(int min, int defaultInt, int max) { mMin = min; mMax = max; mDefault = defaultInt; } @Override public void afterTextChanged(Editable s) { boolean updated = false; int value; try { value = Integer.parseInt(s.toString()); } catch (NumberFormatException e) { value = mDefault; } if (value < mMin) { value = mMin; updated = true; } else if (value > mMax) { updated = true; value = mMax; } // Update UI if (updated) { s.clear(); s.append(Integer.toString(value)); } updateDoneButtonState(); onChange(value); } /** * Override to be called after each key stroke */ void onChange(int value) { } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { } } private Resources mResources; private EventRecurrence mRecurrence = new EventRecurrence(); private Time mTime = new Time(); // TODO timezone? private RecurrenceModel mModel = new RecurrenceModel(); private Toast mToast; private final int[] TIME_DAY_TO_CALENDAR_DAY = new int[]{ Calendar.SUNDAY, Calendar.MONDAY, Calendar.TUESDAY, Calendar.WEDNESDAY, Calendar.THURSDAY, Calendar.FRIDAY, Calendar.SATURDAY, }; // Call mStringBuilder.setLength(0) before formatting any string or else the // formatted text will accumulate. // private final StringBuilder mStringBuilder = new StringBuilder(); // private Formatter mFormatter = new Formatter(mStringBuilder); private View mView; private Spinner mFreqSpinner; private static final int[] mFreqModelToEventRecurrence = { EventRecurrence.DAILY, EventRecurrence.WEEKLY, EventRecurrence.MONTHLY, EventRecurrence.YEARLY }; public static final String BUNDLE_START_TIME_MILLIS = "bundle_event_start_time"; public static final String BUNDLE_TIME_ZONE = "bundle_event_time_zone"; public static final String BUNDLE_RRULE = "bundle_event_rrule"; private static final String BUNDLE_MODEL = "bundle_model"; private static final String BUNDLE_END_COUNT_HAS_FOCUS = "bundle_end_count_has_focus"; private static final String FRAG_TAG_DATE_PICKER = "tag_date_picker_frag"; private Switch mRepeatSwitch; private EditText mInterval; private TextView mIntervalPreText; private TextView mIntervalPostText; private int mIntervalResId = -1; private Spinner mEndSpinner; private TextView mEndDateTextView; private EditText mEndCount; private TextView mPostEndCount; private boolean mHidePostEndCount; private ArrayList mEndSpinnerArray = new ArrayList(3); private EndSpinnerAdapter mEndSpinnerAdapter; private String mEndNeverStr; private String mEndDateLabel; private String mEndCountLabel; /** * Hold toggle buttons in the order per user's first day of week preference */ private LinearLayout mWeekGroup; private LinearLayout mWeekGroup2; // Sun = 0 private ToggleButton[] mWeekByDayButtons = new ToggleButton[7]; /** * A double array of Strings to hold the 7x5 list of possible strings of the form: * "on every [Nth] [DAY_OF_WEEK]", e.g. "on every second Monday", * where [Nth] can be [first, second, third, fourth, last] */ private String[][] mMonthRepeatByDayOfWeekStrs; private LinearLayout mMonthGroup; private RadioGroup mMonthRepeatByRadioGroup; private RadioButton mRepeatMonthlyByNthDayOfWeek; private RadioButton mRepeatMonthlyByNthDayOfMonth; private String mMonthRepeatByDayOfWeekStr; private Button mDone; private OnRecurrenceSetListener mRecurrenceSetListener; public RecurrencePickerDialog() { } static public boolean isSupportedMonthlyByNthDayOfWeek(int num) { // We only support monthlyByNthDayOfWeek when it is greater then 0 but less then 5. // Or if -1 when it is the last monthly day of the week. return (num > 0 && num <= FIFTH_WEEK_IN_A_MONTH) || num == LAST_NTH_DAY_OF_WEEK; } static public boolean canHandleRecurrenceRule(EventRecurrence er) { switch (er.freq) { case EventRecurrence.DAILY: case EventRecurrence.MONTHLY: case EventRecurrence.YEARLY: case EventRecurrence.WEEKLY: break; default: return false; } if (er.count > 0 && !TextUtils.isEmpty(er.until)) { return false; } // Weekly: For "repeat by day of week", the day of week to repeat is in // er.byday[] /* * Monthly: For "repeat by nth day of week" the day of week to repeat is * in er.byday[] and the "nth" is stored in er.bydayNum[]. Currently we * can handle only one and only in monthly */ int numOfByDayNum = 0; for (int i = 0; i < er.bydayCount; i++) { if (isSupportedMonthlyByNthDayOfWeek(er.bydayNum[i])) { ++numOfByDayNum; } } if (numOfByDayNum > 1) { return false; } if (numOfByDayNum > 0 && er.freq != EventRecurrence.MONTHLY) { return false; } // The UI only handle repeat by one day of month i.e. not 9th and 10th // of every month if (er.bymonthdayCount > 1) { return false; } if (er.freq == EventRecurrence.MONTHLY) { if (er.bydayCount > 1) { return false; } if (er.bydayCount > 0 && er.bymonthdayCount > 0) { return false; } } return true; } // TODO don't lose data when getting data that our UI can't handle static private void copyEventRecurrenceToModel(final EventRecurrence er, RecurrenceModel model) { // Freq: switch (er.freq) { case EventRecurrence.DAILY: model.freq = RecurrenceModel.FREQ_DAILY; break; case EventRecurrence.MONTHLY: model.freq = RecurrenceModel.FREQ_MONTHLY; break; case EventRecurrence.YEARLY: model.freq = RecurrenceModel.FREQ_YEARLY; break; case EventRecurrence.WEEKLY: model.freq = RecurrenceModel.FREQ_WEEKLY; break; default: throw new IllegalStateException("freq=" + er.freq); } // Interval: if (er.interval > 0) { model.interval = er.interval; } // End: // End by count: model.endCount = er.count; if (model.endCount > 0) { model.end = RecurrenceModel.END_BY_COUNT; } // End by date: if (!TextUtils.isEmpty(er.until)) { if (model.endDate == null) { model.endDate = new Time(); } try { model.endDate.parse(er.until); } catch (TimeFormatException e) { model.endDate = null; } // LIMITATION: The UI can only handle END_BY_DATE or END_BY_COUNT if (model.end == RecurrenceModel.END_BY_COUNT && model.endDate != null) { throw new IllegalStateException("freq=" + er.freq); } model.end = RecurrenceModel.END_BY_DATE; } // Weekly: repeat by day of week or Monthly: repeat by nth day of week // in the month Arrays.fill(model.weeklyByDayOfWeek, false); if (er.bydayCount > 0) { int count = 0; for (int i = 0; i < er.bydayCount; i++) { int dayOfWeek = EventRecurrence.day2TimeDay(er.byday[i]); model.weeklyByDayOfWeek[dayOfWeek] = true; if (model.freq == RecurrenceModel.FREQ_MONTHLY && isSupportedMonthlyByNthDayOfWeek(er.bydayNum[i])) { // LIMITATION: Can handle only (one) weekDayNum in nth or last and only // when // monthly model.monthlyByDayOfWeek = dayOfWeek; model.monthlyByNthDayOfWeek = er.bydayNum[i]; model.monthlyRepeat = RecurrenceModel.MONTHLY_BY_NTH_DAY_OF_WEEK; count++; } } if (model.freq == RecurrenceModel.FREQ_MONTHLY) { if (er.bydayCount != 1) { // Can't handle 1st Monday and 2nd Wed throw new IllegalStateException("Can handle only 1 byDayOfWeek in monthly"); } if (count != 1) { throw new IllegalStateException( "Didn't specify which nth day of week to repeat for a monthly"); } } } // Monthly by day of month if (model.freq == RecurrenceModel.FREQ_MONTHLY) { if (er.bymonthdayCount == 1) { if (model.monthlyRepeat == RecurrenceModel.MONTHLY_BY_NTH_DAY_OF_WEEK) { throw new IllegalStateException( "Can handle only by monthday or by nth day of week, not both"); } model.monthlyByMonthDay = er.bymonthday[0]; model.monthlyRepeat = RecurrenceModel.MONTHLY_BY_DATE; } else if (er.bymonthCount > 1) { // LIMITATION: Can handle only one month day throw new IllegalStateException("Can handle only one bymonthday"); } } } static private void copyModelToEventRecurrence(final RecurrenceModel model, EventRecurrence er) { if (model.recurrenceState == RecurrenceModel.STATE_NO_RECURRENCE) { throw new IllegalStateException("There's no recurrence"); } // Freq er.freq = mFreqModelToEventRecurrence[model.freq]; // Interval if (model.interval <= 1) { er.interval = 0; } else { er.interval = model.interval; } // End switch (model.end) { case RecurrenceModel.END_BY_DATE: if (model.endDate != null) { model.endDate.switchTimezone(Time.TIMEZONE_UTC); model.endDate.normalize(false); er.until = model.endDate.format2445(); er.count = 0; } else { throw new IllegalStateException("end = END_BY_DATE but endDate is null"); } break; case RecurrenceModel.END_BY_COUNT: er.count = model.endCount; er.until = null; if (er.count <= 0) { throw new IllegalStateException("count is " + er.count); } break; default: er.count = 0; er.until = null; break; } // Weekly && monthly repeat patterns er.bydayCount = 0; er.bymonthdayCount = 0; switch (model.freq) { case RecurrenceModel.FREQ_MONTHLY: if (model.monthlyRepeat == RecurrenceModel.MONTHLY_BY_DATE) { if (model.monthlyByMonthDay > 0) { if (er.bymonthday == null || er.bymonthdayCount < 1) { er.bymonthday = new int[1]; } er.bymonthday[0] = model.monthlyByMonthDay; er.bymonthdayCount = 1; } } else if (model.monthlyRepeat == RecurrenceModel.MONTHLY_BY_NTH_DAY_OF_WEEK) { if (!isSupportedMonthlyByNthDayOfWeek(model.monthlyByNthDayOfWeek)) { throw new IllegalStateException("month repeat by nth week but n is " + model.monthlyByNthDayOfWeek); } int count = 1; if (er.bydayCount < count || er.byday == null || er.bydayNum == null) { er.byday = new int[count]; er.bydayNum = new int[count]; } er.bydayCount = count; er.byday[0] = EventRecurrence.timeDay2Day(model.monthlyByDayOfWeek); er.bydayNum[0] = model.monthlyByNthDayOfWeek; } break; case RecurrenceModel.FREQ_WEEKLY: int count = 0; for (int i = 0; i < 7; i++) { if (model.weeklyByDayOfWeek[i]) { count++; } } if (er.bydayCount < count || er.byday == null || er.bydayNum == null) { er.byday = new int[count]; er.bydayNum = new int[count]; } er.bydayCount = count; for (int i = 6; i >= 0; i--) { if (model.weeklyByDayOfWeek[i]) { er.bydayNum[--count] = 0; er.byday[count] = EventRecurrence.timeDay2Day(i); } } break; } if (!canHandleRecurrenceRule(er)) { throw new IllegalStateException("UI generated recurrence that it can't handle. ER:" + er.toString() + " Model: " + model.toString()); } } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { mRecurrence.wkst = EventRecurrence.timeDay2Day(Utils.getFirstDayOfWeek(getActivity())); getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE); boolean endCountHasFocus = false; if (savedInstanceState != null) { RecurrenceModel m = (RecurrenceModel) savedInstanceState.get(BUNDLE_MODEL); if (m != null) { mModel = m; } endCountHasFocus = savedInstanceState.getBoolean(BUNDLE_END_COUNT_HAS_FOCUS); } else { Bundle b = getArguments(); if (b != null) { mTime.set(b.getLong(BUNDLE_START_TIME_MILLIS)); String tz = b.getString(BUNDLE_TIME_ZONE); if (!TextUtils.isEmpty(tz)) { mTime.timezone = tz; } mTime.normalize(false); // Time days of week: Sun=0, Mon=1, etc mModel.weeklyByDayOfWeek[mTime.weekDay] = true; String rrule = b.getString(BUNDLE_RRULE); if (!TextUtils.isEmpty(rrule)) { mModel.recurrenceState = RecurrenceModel.STATE_RECURRENCE; mRecurrence.parse(rrule); copyEventRecurrenceToModel(mRecurrence, mModel); // Leave today's day of week as checked by default in weekly view. if (mRecurrence.bydayCount == 0) { mModel.weeklyByDayOfWeek[mTime.weekDay] = true; } } } else { mTime.setToNow(); } } mResources = getResources(); mView = inflater.inflate(R.layout.recurrencepicker, container, true); final Activity activity = getActivity(); final Configuration config = activity.getResources().getConfiguration(); mRepeatSwitch = (Switch) mView.findViewById(R.id.repeat_switch); mRepeatSwitch.setChecked(mModel.recurrenceState == RecurrenceModel.STATE_RECURRENCE); mRepeatSwitch.setOnCheckedChangeListener(new OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { mModel.recurrenceState = isChecked ? RecurrenceModel.STATE_RECURRENCE : RecurrenceModel.STATE_NO_RECURRENCE; togglePickerOptions(); } }); mFreqSpinner = (Spinner) mView.findViewById(R.id.freqSpinner); mFreqSpinner.setOnItemSelectedListener(this); ArrayAdapter freqAdapter = ArrayAdapter.createFromResource(getActivity(), R.array.recurrence_freq, R.layout.recurrencepicker_freq_item); freqAdapter.setDropDownViewResource(R.layout.recurrencepicker_freq_item); mFreqSpinner.setAdapter(freqAdapter); mInterval = (EditText) mView.findViewById(R.id.interval); mInterval.addTextChangedListener(new minMaxTextWatcher(1, INTERVAL_DEFAULT, INTERVAL_MAX) { @Override void onChange(int v) { if (mIntervalResId != -1 && mInterval.getText().toString().length() > 0) { mModel.interval = v; updateIntervalText(); mInterval.requestLayout(); } } }); mIntervalPreText = (TextView) mView.findViewById(R.id.intervalPreText); mIntervalPostText = (TextView) mView.findViewById(R.id.intervalPostText); mEndNeverStr = mResources.getString(R.string.recurrence_end_continously); mEndDateLabel = mResources.getString(R.string.recurrence_end_date_label); mEndCountLabel = mResources.getString(R.string.recurrence_end_count_label); mEndSpinnerArray.add(mEndNeverStr); mEndSpinnerArray.add(mEndDateLabel); mEndSpinnerArray.add(mEndCountLabel); mEndSpinner = (Spinner) mView.findViewById(R.id.endSpinner); mEndSpinner.setOnItemSelectedListener(this); mEndSpinnerAdapter = new EndSpinnerAdapter(getActivity(), mEndSpinnerArray, R.layout.recurrencepicker_freq_item, R.layout.recurrencepicker_end_text); mEndSpinnerAdapter.setDropDownViewResource(R.layout.recurrencepicker_freq_item); mEndSpinner.setAdapter(mEndSpinnerAdapter); mEndCount = (EditText) mView.findViewById(R.id.endCount); mEndCount.addTextChangedListener(new minMaxTextWatcher(1, COUNT_DEFAULT, COUNT_MAX) { @Override void onChange(int v) { if (mModel.endCount != v) { mModel.endCount = v; updateEndCountText(); mEndCount.requestLayout(); } } }); mPostEndCount = (TextView) mView.findViewById(R.id.postEndCount); mEndDateTextView = (TextView) mView.findViewById(R.id.endDate); mEndDateTextView.setOnClickListener(this); if (mModel.endDate == null) { mModel.endDate = new Time(mTime); switch (mModel.freq) { case RecurrenceModel.FREQ_DAILY: case RecurrenceModel.FREQ_WEEKLY: mModel.endDate.month += 1; break; case RecurrenceModel.FREQ_MONTHLY: mModel.endDate.month += 3; break; case RecurrenceModel.FREQ_YEARLY: mModel.endDate.year += 3; break; } mModel.endDate.normalize(false); } mWeekGroup = (LinearLayout) mView.findViewById(R.id.weekGroup); mWeekGroup2 = (LinearLayout) mView.findViewById(R.id.weekGroup2); // In Calendar.java day of week order e.g Sun = 1 ... Sat = 7 String[] dayOfWeekString = new DateFormatSymbols().getWeekdays(); mMonthRepeatByDayOfWeekStrs = new String[7][]; // from Time.SUNDAY as 0 through Time.SATURDAY as 6 mMonthRepeatByDayOfWeekStrs[0] = mResources.getStringArray(R.array.repeat_by_nth_sun); mMonthRepeatByDayOfWeekStrs[1] = mResources.getStringArray(R.array.repeat_by_nth_mon); mMonthRepeatByDayOfWeekStrs[2] = mResources.getStringArray(R.array.repeat_by_nth_tues); mMonthRepeatByDayOfWeekStrs[3] = mResources.getStringArray(R.array.repeat_by_nth_wed); mMonthRepeatByDayOfWeekStrs[4] = mResources.getStringArray(R.array.repeat_by_nth_thurs); mMonthRepeatByDayOfWeekStrs[5] = mResources.getStringArray(R.array.repeat_by_nth_fri); mMonthRepeatByDayOfWeekStrs[6] = mResources.getStringArray(R.array.repeat_by_nth_sat); // In Time.java day of week order e.g. Sun = 0 int idx = Utils.getFirstDayOfWeek(getActivity()); // In Calendar.java day of week order e.g Sun = 1 ... Sat = 7 dayOfWeekString = new DateFormatSymbols().getShortWeekdays(); int numOfButtonsInRow1; int numOfButtonsInRow2; if (mResources.getConfiguration().screenWidthDp > MIN_SCREEN_WIDTH_FOR_SINGLE_ROW_WEEK) { numOfButtonsInRow1 = 7; numOfButtonsInRow2 = 0; mWeekGroup2.setVisibility(View.GONE); mWeekGroup2.getChildAt(3).setVisibility(View.GONE); } else { numOfButtonsInRow1 = 4; numOfButtonsInRow2 = 3; mWeekGroup2.setVisibility(View.VISIBLE); // Set rightmost button on the second row invisible so it takes up // space and everything centers properly mWeekGroup2.getChildAt(3).setVisibility(View.INVISIBLE); } /* First row */ for (int i = 0; i < 7; i++) { if (i >= numOfButtonsInRow1) { mWeekGroup.getChildAt(i).setVisibility(View.GONE); continue; } mWeekByDayButtons[idx] = (ToggleButton) mWeekGroup.getChildAt(i); mWeekByDayButtons[idx].setTextOff(dayOfWeekString[TIME_DAY_TO_CALENDAR_DAY[idx]]); mWeekByDayButtons[idx].setTextOn(dayOfWeekString[TIME_DAY_TO_CALENDAR_DAY[idx]]); mWeekByDayButtons[idx].setOnCheckedChangeListener(this); if (++idx >= 7) { idx = 0; } } /* 2nd Row */ for (int i = 0; i < 3; i++) { if (i >= numOfButtonsInRow2) { mWeekGroup2.getChildAt(i).setVisibility(View.GONE); continue; } mWeekByDayButtons[idx] = (ToggleButton) mWeekGroup2.getChildAt(i); mWeekByDayButtons[idx].setTextOff(dayOfWeekString[TIME_DAY_TO_CALENDAR_DAY[idx]]); mWeekByDayButtons[idx].setTextOn(dayOfWeekString[TIME_DAY_TO_CALENDAR_DAY[idx]]); mWeekByDayButtons[idx].setOnCheckedChangeListener(this); if (++idx >= 7) { idx = 0; } } mMonthGroup = (LinearLayout) mView.findViewById(R.id.monthGroup); mMonthRepeatByRadioGroup = (RadioGroup) mView.findViewById(R.id.monthGroup); mMonthRepeatByRadioGroup.setOnCheckedChangeListener(this); mRepeatMonthlyByNthDayOfWeek = (RadioButton) mView .findViewById(R.id.repeatMonthlyByNthDayOfTheWeek); mRepeatMonthlyByNthDayOfMonth = (RadioButton) mView .findViewById(R.id.repeatMonthlyByNthDayOfMonth); mDone = (Button) mView.findViewById(R.id.done); mDone.setOnClickListener(this); togglePickerOptions(); updateDialog(); if (endCountHasFocus) { mEndCount.requestFocus(); } return mView; } private void togglePickerOptions() { if (mModel.recurrenceState == RecurrenceModel.STATE_NO_RECURRENCE) { mFreqSpinner.setEnabled(false); mEndSpinner.setEnabled(false); mIntervalPreText.setEnabled(false); mInterval.setEnabled(false); mIntervalPostText.setEnabled(false); mMonthRepeatByRadioGroup.setEnabled(false); mEndCount.setEnabled(false); mPostEndCount.setEnabled(false); mEndDateTextView.setEnabled(false); mRepeatMonthlyByNthDayOfWeek.setEnabled(false); mRepeatMonthlyByNthDayOfMonth.setEnabled(false); for (Button button : mWeekByDayButtons) { button.setEnabled(false); } } else { mView.findViewById(R.id.options).setEnabled(true); mFreqSpinner.setEnabled(true); mEndSpinner.setEnabled(true); mIntervalPreText.setEnabled(true); mInterval.setEnabled(true); mIntervalPostText.setEnabled(true); mMonthRepeatByRadioGroup.setEnabled(true); mEndCount.setEnabled(true); mPostEndCount.setEnabled(true); mEndDateTextView.setEnabled(true); mRepeatMonthlyByNthDayOfWeek.setEnabled(true); mRepeatMonthlyByNthDayOfMonth.setEnabled(true); for (Button button : mWeekByDayButtons) { button.setEnabled(true); } } updateDoneButtonState(); } private void updateDoneButtonState() { if (mModel.recurrenceState == RecurrenceModel.STATE_NO_RECURRENCE) { mDone.setEnabled(true); return; } if (mInterval.getText().toString().length() == 0) { mDone.setEnabled(false); return; } if (mEndCount.getVisibility() == View.VISIBLE && mEndCount.getText().toString().length() == 0) { mDone.setEnabled(false); return; } if (mModel.freq == RecurrenceModel.FREQ_WEEKLY) { for (CompoundButton b : mWeekByDayButtons) { if (b.isChecked()) { mDone.setEnabled(true); return; } } mDone.setEnabled(false); return; } mDone.setEnabled(true); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putParcelable(BUNDLE_MODEL, mModel); if (mEndCount.hasFocus()) { outState.putBoolean(BUNDLE_END_COUNT_HAS_FOCUS, true); } } public void updateDialog() { // Interval // Checking before setting because this causes infinite recursion // in afterTextWatcher final String intervalStr = Integer.toString(mModel.interval); if (!intervalStr.equals(mInterval.getText().toString())) { mInterval.setText(intervalStr); } mFreqSpinner.setSelection(mModel.freq); mWeekGroup.setVisibility(mModel.freq == RecurrenceModel.FREQ_WEEKLY ? View.VISIBLE : View.GONE); mWeekGroup2.setVisibility(mModel.freq == RecurrenceModel.FREQ_WEEKLY ? View.VISIBLE : View.GONE); mMonthGroup.setVisibility(mModel.freq == RecurrenceModel.FREQ_MONTHLY ? View.VISIBLE : View.GONE); switch (mModel.freq) { case RecurrenceModel.FREQ_DAILY: mIntervalResId = R.plurals.recurrence_interval_daily; break; case RecurrenceModel.FREQ_WEEKLY: mIntervalResId = R.plurals.recurrence_interval_weekly; for (int i = 0; i < 7; i++) { mWeekByDayButtons[i].setChecked(mModel.weeklyByDayOfWeek[i]); } break; case RecurrenceModel.FREQ_MONTHLY: mIntervalResId = R.plurals.recurrence_interval_monthly; if (mModel.monthlyRepeat == RecurrenceModel.MONTHLY_BY_DATE) { mMonthRepeatByRadioGroup.check(R.id.repeatMonthlyByNthDayOfMonth); } else if (mModel.monthlyRepeat == RecurrenceModel.MONTHLY_BY_NTH_DAY_OF_WEEK) { mMonthRepeatByRadioGroup.check(R.id.repeatMonthlyByNthDayOfTheWeek); } if (mMonthRepeatByDayOfWeekStr == null) { if (mModel.monthlyByNthDayOfWeek == 0) { mModel.monthlyByNthDayOfWeek = (mTime.monthDay + 6) / 7; // Since not all months have 5 weeks, we convert 5th NthDayOfWeek to // -1 for last monthly day of the week if (mModel.monthlyByNthDayOfWeek >= FIFTH_WEEK_IN_A_MONTH) { mModel.monthlyByNthDayOfWeek = LAST_NTH_DAY_OF_WEEK; } mModel.monthlyByDayOfWeek = mTime.weekDay; } String[] monthlyByNthDayOfWeekStrs = mMonthRepeatByDayOfWeekStrs[mModel.monthlyByDayOfWeek]; // TODO(psliwowski): Find a better way handle -1 indexes int msgIndex = mModel.monthlyByNthDayOfWeek < 0 ? FIFTH_WEEK_IN_A_MONTH : mModel.monthlyByNthDayOfWeek; mMonthRepeatByDayOfWeekStr = monthlyByNthDayOfWeekStrs[msgIndex - 1]; mRepeatMonthlyByNthDayOfWeek.setText(mMonthRepeatByDayOfWeekStr); } break; case RecurrenceModel.FREQ_YEARLY: mIntervalResId = R.plurals.recurrence_interval_yearly; break; } updateIntervalText(); updateDoneButtonState(); mEndSpinner.setSelection(mModel.end); if (mModel.end == RecurrenceModel.END_BY_DATE) { final String dateStr = DateUtils.formatDateTime(getActivity(), mModel.endDate.toMillis(false), DateUtils.FORMAT_NUMERIC_DATE); mEndDateTextView.setText(dateStr); } else { if (mModel.end == RecurrenceModel.END_BY_COUNT) { // Checking before setting because this causes infinite // recursion // in afterTextWatcher final String countStr = Integer.toString(mModel.endCount); if (!countStr.equals(mEndCount.getText().toString())) { mEndCount.setText(countStr); } } } } /** * @param endDateString */ private void setEndSpinnerEndDateStr(final String endDateString) { mEndSpinnerArray.set(1, endDateString); mEndSpinnerAdapter.notifyDataSetChanged(); } private void doToast() { Log.e(TAG, "Model = " + mModel.toString()); String rrule; if (mModel.recurrenceState == RecurrenceModel.STATE_NO_RECURRENCE) { rrule = "Not repeating"; } else { copyModelToEventRecurrence(mModel, mRecurrence); rrule = mRecurrence.toString(); } if (mToast != null) { mToast.cancel(); } mToast = Toast.makeText(getActivity(), rrule, Toast.LENGTH_LONG); mToast.show(); } // TODO Test and update for Right-to-Left private void updateIntervalText() { if (mIntervalResId == -1) { return; } final String INTERVAL_COUNT_MARKER = "%d"; String intervalString = mResources.getQuantityString(mIntervalResId, mModel.interval); int markerStart = intervalString.indexOf(INTERVAL_COUNT_MARKER); if (markerStart != -1) { int postTextStart = markerStart + INTERVAL_COUNT_MARKER.length(); mIntervalPostText.setText(intervalString.substring(postTextStart, intervalString.length()).trim()); mIntervalPreText.setText(intervalString.substring(0, markerStart).trim()); } } /** * Update the "Repeat for N events" end option with the proper string values * based on the value that has been entered for N. */ private void updateEndCountText() { final String END_COUNT_MARKER = "%d"; String endString = mResources.getQuantityString(R.plurals.recurrence_end_count, mModel.endCount); int markerStart = endString.indexOf(END_COUNT_MARKER); if (markerStart != -1) { if (markerStart == 0) { Log.e(TAG, "No text to put in to recurrence's end spinner."); } else { int postTextStart = markerStart + END_COUNT_MARKER.length(); mPostEndCount.setText(endString.substring(postTextStart, endString.length()).trim()); } } } // Implements OnItemSelectedListener interface // Freq spinner // End spinner @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { if (parent == mFreqSpinner) { mModel.freq = position; } else if (parent == mEndSpinner) { switch (position) { case RecurrenceModel.END_NEVER: mModel.end = RecurrenceModel.END_NEVER; break; case RecurrenceModel.END_BY_DATE: mModel.end = RecurrenceModel.END_BY_DATE; break; case RecurrenceModel.END_BY_COUNT: mModel.end = RecurrenceModel.END_BY_COUNT; if (mModel.endCount <= 1) { mModel.endCount = 1; } else if (mModel.endCount > COUNT_MAX) { mModel.endCount = COUNT_MAX; } updateEndCountText(); break; } mEndCount.setVisibility(mModel.end == RecurrenceModel.END_BY_COUNT ? View.VISIBLE : View.GONE); mEndDateTextView.setVisibility(mModel.end == RecurrenceModel.END_BY_DATE ? View.VISIBLE : View.GONE); mPostEndCount.setVisibility( mModel.end == RecurrenceModel.END_BY_COUNT && !mHidePostEndCount ? View.VISIBLE : View.GONE); } updateDialog(); } // Implements OnItemSelectedListener interface @Override public void onNothingSelected(AdapterView arg0) { } @Override public void onDateSet(DatePickerDialog view, int year, int monthOfYear, int dayOfMonth) { if (mModel.endDate == null) { mModel.endDate = new Time(mTime.timezone); mModel.endDate.hour = mModel.endDate.minute = mModel.endDate.second = 0; } mModel.endDate.year = year; mModel.endDate.month = monthOfYear; mModel.endDate.monthDay = dayOfMonth; mModel.endDate.normalize(false); updateDialog(); } // Implements OnCheckedChangeListener interface // Week repeat by day of week @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { int itemIdx = -1; for (int i = 0; i < 7; i++) { if (itemIdx == -1 && buttonView == mWeekByDayButtons[i]) { itemIdx = i; mModel.weeklyByDayOfWeek[i] = isChecked; } } updateDialog(); } // Implements android.widget.RadioGroup.OnCheckedChangeListener interface // Month repeat by radio buttons @Override public void onCheckedChanged(RadioGroup group, int checkedId) { if (checkedId == R.id.repeatMonthlyByNthDayOfMonth) { mModel.monthlyRepeat = RecurrenceModel.MONTHLY_BY_DATE; } else if (checkedId == R.id.repeatMonthlyByNthDayOfTheWeek) { mModel.monthlyRepeat = RecurrenceModel.MONTHLY_BY_NTH_DAY_OF_WEEK; } updateDialog(); } // Implements OnClickListener interface // EndDate button // Done button @Override public void onClick(View v) { if (mEndDateTextView == v) { if (mDatePickerDialog != null) { mDatePickerDialog.dismiss(); } mDatePickerDialog = DatePickerDialog.newInstance(this, mModel.endDate.year, mModel.endDate.month, mModel.endDate.monthDay); mDatePickerDialog.setFirstDayOfWeek(Utils.getFirstDayOfWeekAsCalendar(getActivity())); mDatePickerDialog.setYearRange(Utils.YEAR_MIN, Utils.YEAR_MAX); mDatePickerDialog.show(getFragmentManager(), FRAG_TAG_DATE_PICKER); } else if (mDone == v) { String rrule; if (mModel.recurrenceState == RecurrenceModel.STATE_NO_RECURRENCE) { rrule = null; } else { copyModelToEventRecurrence(mModel, mRecurrence); rrule = mRecurrence.toString(); } if (mRecurrenceSetListener != null) { mRecurrenceSetListener.onRecurrenceSet(rrule); } dismiss(); } } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); mDatePickerDialog = (DatePickerDialog) getFragmentManager() .findFragmentByTag(FRAG_TAG_DATE_PICKER); if (mDatePickerDialog != null) { mDatePickerDialog.setOnDateSetListener(this); } } public interface OnRecurrenceSetListener { void onRecurrenceSet(String rrule); } public void setOnRecurrenceSetListener(OnRecurrenceSetListener l) { mRecurrenceSetListener = l; } private class EndSpinnerAdapter extends ArrayAdapter { final String END_DATE_MARKER = "%s"; final String END_COUNT_MARKER = "%d"; private LayoutInflater mInflater; private int mItemResourceId; private int mTextResourceId; private ArrayList mStrings; private String mEndDateString; private boolean mUseFormStrings; /** * @param context * @param textViewResourceId * @param objects */ public EndSpinnerAdapter(Context context, ArrayList strings, int itemResourceId, int textResourceId) { super(context, itemResourceId, strings); mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); mItemResourceId = itemResourceId; mTextResourceId = textResourceId; mStrings = strings; mEndDateString = getResources().getString(R.string.recurrence_end_date); // If either date or count strings don't translate well, such that we aren't assured // to have some text available to be placed in the spinner, then we'll have to use // the more form-like versions of both strings instead. int markerStart = mEndDateString.indexOf(END_DATE_MARKER); if (markerStart <= 0) { // The date string does not have any text before the "%s" so we'll have to use the // more form-like strings instead. mUseFormStrings = true; } else { String countEndStr = getResources().getQuantityString( R.plurals.recurrence_end_count, 1); markerStart = countEndStr.indexOf(END_COUNT_MARKER); if (markerStart <= 0) { // The count string does not have any text before the "%d" so we'll have to use // the more form-like strings instead. mUseFormStrings = true; } } if (mUseFormStrings) { // We'll have to set the layout for the spinner to be weight=0 so it doesn't // take up too much space. mEndSpinner.setLayoutParams( new TableLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); } } @Override public View getView(int position, View convertView, ViewGroup parent) { View v; // Check if we can recycle the view if (convertView == null) { v = mInflater.inflate(mTextResourceId, parent, false); } else { v = convertView; } TextView item = (TextView) v.findViewById(R.id.spinner_item); int markerStart; switch (position) { case RecurrenceModel.END_NEVER: item.setText(mStrings.get(RecurrenceModel.END_NEVER)); break; case RecurrenceModel.END_BY_DATE: markerStart = mEndDateString.indexOf(END_DATE_MARKER); if (markerStart != -1) { if (mUseFormStrings || markerStart == 0) { // If we get here, the translation of "Until" doesn't work correctly, // so we'll just set the whole "Until a date" string. item.setText(mEndDateLabel); } else { item.setText(mEndDateString.substring(0, markerStart).trim()); } } break; case RecurrenceModel.END_BY_COUNT: String endString = mResources.getQuantityString(R.plurals.recurrence_end_count, mModel.endCount); markerStart = endString.indexOf(END_COUNT_MARKER); if (markerStart != -1) { if (mUseFormStrings || markerStart == 0) { // If we get here, the translation of "For" doesn't work correctly, // so we'll just set the whole "For a number of events" string. item.setText(mEndCountLabel); // Also, we'll hide the " events" that would have been at the end. mPostEndCount.setVisibility(View.GONE); // Use this flag so the onItemSelected knows whether to show it later. mHidePostEndCount = true; } else { int postTextStart = markerStart + END_COUNT_MARKER.length(); mPostEndCount.setText(endString.substring(postTextStart, endString.length()).trim()); // In case it's a recycled view that wasn't visible. if (mModel.end == RecurrenceModel.END_BY_COUNT) { mPostEndCount.setVisibility(View.VISIBLE); } if (endString.charAt(markerStart - 1) == ' ') { markerStart--; } item.setText(endString.substring(0, markerStart).trim()); } } break; default: v = null; break; } return v; } @Override public View getDropDownView(int position, View convertView, ViewGroup parent) { View v; // Check if we can recycle the view if (convertView == null) { v = mInflater.inflate(mItemResourceId, parent, false); } else { v = convertView; } TextView item = (TextView) v.findViewById(R.id.spinner_item); item.setText(mStrings.get(position)); return v; } } } ================================================ FILE: library/src/main/java/be/billington/calendar/recurrencepicker/Utils.java ================================================ /* * Copyright (C) 2006 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package be.billington.calendar.recurrencepicker; import android.accounts.Account; import android.app.Activity; import android.app.SearchManager; import android.content.BroadcastReceiver; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.res.Resources; import android.database.Cursor; import android.database.MatrixCursor; import android.graphics.Color; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.provider.CalendarContract.Calendars; import android.text.Spannable; import android.text.SpannableString; import android.text.Spanned; import android.text.TextUtils; import android.text.format.Time; import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.Log; import android.widget.SearchView; import java.util.ArrayList; import java.util.Calendar; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.TimeZone; import java.util.regex.Matcher; import java.util.regex.Pattern; import static android.provider.CalendarContract.EXTRA_EVENT_BEGIN_TIME; public class Utils { private static final boolean DEBUG = false; private static final String TAG = "CalUtils"; // Set to 0 until we have UI to perform undo public static final long UNDO_DELAY = 0; // For recurring events which instances of the series are being modified public static final int MODIFY_UNINITIALIZED = 0; public static final int MODIFY_SELECTED = 1; public static final int MODIFY_ALL_FOLLOWING = 2; public static final int MODIFY_ALL = 3; // When the edit event view finishes it passes back the appropriate exit // code. public static final int DONE_REVERT = 1 << 0; public static final int DONE_SAVE = 1 << 1; public static final int DONE_DELETE = 1 << 2; // And should re run with DONE_EXIT if it should also leave the view, just // exiting is identical to reverting public static final int DONE_EXIT = 1 << 0; public static final String OPEN_EMAIL_MARKER = " <"; public static final String CLOSE_EMAIL_MARKER = ">"; public static final String INTENT_KEY_DETAIL_VIEW = "DETAIL_VIEW"; public static final String INTENT_KEY_VIEW_TYPE = "VIEW"; public static final String INTENT_VALUE_VIEW_TYPE_DAY = "DAY"; public static final String INTENT_KEY_HOME = "KEY_HOME"; public static final int MONDAY_BEFORE_JULIAN_EPOCH = Time.EPOCH_JULIAN_DAY - 3; public static final int DECLINED_EVENT_ALPHA = 0x66; public static final int DECLINED_EVENT_TEXT_ALPHA = 0xC0; private static final float SATURATION_ADJUST = 1.3f; private static final float INTENSITY_ADJUST = 0.8f; // Defines used by the DNA generation code static final int DAY_IN_MINUTES = 60 * 24; static final int WEEK_IN_MINUTES = DAY_IN_MINUTES * 7; // The work day is being counted as 6am to 8pm static int WORK_DAY_MINUTES = 14 * 60; static int WORK_DAY_START_MINUTES = 6 * 60; static int WORK_DAY_END_MINUTES = 20 * 60; static int WORK_DAY_END_LENGTH = (24 * 60) - WORK_DAY_END_MINUTES; static int CONFLICT_COLOR = 0xFF000000; static boolean mMinutesLoaded = false; public static final int YEAR_MIN = 1970; public static final int YEAR_MAX = 2036; // The name of the shared preferences file. This name must be maintained for // historical // reasons, as it's what PreferenceManager assigned the first time the file // was created. static final String SHARED_PREFS_NAME = "com.android.calendar_preferences"; public static final String KEY_QUICK_RESPONSES = "preferences_quick_responses"; public static final String KEY_ALERTS_VIBRATE_WHEN = "preferences_alerts_vibrateWhen"; public static final String APPWIDGET_DATA_TYPE = "vnd.android.data/update"; static final String MACHINE_GENERATED_ADDRESS = "calendar.google.com"; private static boolean mAllowWeekForDetailView = false; private static long mTardis = 0; private static String sVersion = null; private static final Pattern mWildcardPattern = Pattern.compile("^.*$"); /** * A coordinate must be of the following form for Google Maps to correctly use it: * Latitude, Longitude *

* This may be in decimal form: * Latitude: {-90 to 90} * Longitude: {-180 to 180} *

* Or, in degrees, minutes, and seconds: * Latitude: {-90 to 90}° {0 to 59}' {0 to 59}" * Latitude: {-180 to 180}° {0 to 59}' {0 to 59}" * + or - degrees may also be represented with N or n, S or s for latitude, and with * E or e, W or w for longitude, where the direction may either precede or follow the value. *

* Some examples of coordinates that will be accepted by the regex: * 37.422081°, -122.084576° * 37.422081,-122.084576 * +37°25'19.49", -122°5'4.47" * 37°25'19.49"N, 122°5'4.47"W * N 37° 25' 19.49", W 122° 5' 4.47" */ private static final String COORD_DEGREES_LATITUDE = "([-+NnSs]" + "(\\s)*)?" + "[1-9]?[0-9](\u00B0)" + "(\\s)*" + "([1-5]?[0-9]\')?" + "(\\s)*" + "([1-5]?[0-9]" + "(\\.[0-9]+)?\")?" + "((\\s)*" + "[NnSs])?"; private static final String COORD_DEGREES_LONGITUDE = "([-+EeWw]" + "(\\s)*)?" + "(1)?[0-9]?[0-9](\u00B0)" + "(\\s)*" + "([1-5]?[0-9]\')?" + "(\\s)*" + "([1-5]?[0-9]" + "(\\.[0-9]+)?\")?" + "((\\s)*" + "[EeWw])?"; private static final String COORD_DEGREES_PATTERN = COORD_DEGREES_LATITUDE + "(\\s)*" + "," + "(\\s)*" + COORD_DEGREES_LONGITUDE; private static final String COORD_DECIMAL_LATITUDE = "[+-]?" + "[1-9]?[0-9]" + "(\\.[0-9]+)" + "(\u00B0)?"; private static final String COORD_DECIMAL_LONGITUDE = "[+-]?" + "(1)?[0-9]?[0-9]" + "(\\.[0-9]+)" + "(\u00B0)?"; private static final String COORD_DECIMAL_PATTERN = COORD_DECIMAL_LATITUDE + "(\\s)*" + "," + "(\\s)*" + COORD_DECIMAL_LONGITUDE; private static final Pattern COORD_PATTERN = Pattern.compile(COORD_DEGREES_PATTERN + "|" + COORD_DECIMAL_PATTERN); private static final String NANP_ALLOWED_SYMBOLS = "()+-*#."; private static final int NANP_MIN_DIGITS = 7; private static final int NANP_MAX_DIGITS = 11; /** * Returns whether the SDK is the Jellybean release or later. */ public static boolean isJellybeanOrLater() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; } /** * Returns whether the SDK is the KeyLimePie release or later. */ public static boolean isKeyLimePieOrLater() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; } /** * Gets the intent action for telling the widget to update. */ public static String getWidgetUpdateAction(Context context) { return context.getPackageName() + ".APPWIDGET_UPDATE"; } /** * Gets the intent action for telling the widget to update. */ public static String getWidgetScheduledUpdateAction(Context context) { return context.getPackageName() + ".APPWIDGET_SCHEDULED_UPDATE"; } /** * Gets the intent action for telling the widget to update. */ public static String getSearchAuthority(Context context) { return context.getPackageName() + ".CalendarRecentSuggestionsProvider"; } protected static void tardis() { mTardis = System.currentTimeMillis(); } protected static long getTardis() { return mTardis; } public static MatrixCursor matrixCursorFromCursor(Cursor cursor) { if (cursor == null) { return null; } String[] columnNames = cursor.getColumnNames(); if (columnNames == null) { columnNames = new String[]{}; } MatrixCursor newCursor = new MatrixCursor(columnNames); int numColumns = cursor.getColumnCount(); String data[] = new String[numColumns]; cursor.moveToPosition(-1); while (cursor.moveToNext()) { for (int i = 0; i < numColumns; i++) { data[i] = cursor.getString(i); } newCursor.addRow(data); } return newCursor; } /** * Compares two cursors to see if they contain the same data. * * @return Returns true of the cursors contain the same data and are not * null, false otherwise */ public static boolean compareCursors(Cursor c1, Cursor c2) { if (c1 == null || c2 == null) { return false; } int numColumns = c1.getColumnCount(); if (numColumns != c2.getColumnCount()) { return false; } if (c1.getCount() != c2.getCount()) { return false; } c1.moveToPosition(-1); c2.moveToPosition(-1); while (c1.moveToNext() && c2.moveToNext()) { for (int i = 0; i < numColumns; i++) { if (!TextUtils.equals(c1.getString(i), c2.getString(i))) { return false; } } } return true; } /** * If the given intent specifies a time (in milliseconds since the epoch), * then that time is returned. Otherwise, the current time is returned. */ public static final long timeFromIntentInMillis(Intent intent) { // If the time was specified, then use that. Otherwise, use the current // time. Uri data = intent.getData(); long millis = intent.getLongExtra(EXTRA_EVENT_BEGIN_TIME, -1); if (millis == -1 && data != null && data.isHierarchical()) { List path = data.getPathSegments(); if (path.size() == 2 && path.get(0).equals("time")) { try { millis = Long.valueOf(data.getLastPathSegment()); } catch (NumberFormatException e) { Log.i("Calendar", "timeFromIntentInMillis: Data existed but no valid time " + "found. Using current time."); } } } if (millis <= 0) { millis = System.currentTimeMillis(); } return millis; } /** * Returns a list joined together by the provided delimiter, for example, * ["a", "b", "c"] could be joined into "a,b,c" * * @param things the things to join together * @param delim the delimiter to use * @return a string contained the things joined together */ public static String join(List things, String delim) { StringBuilder builder = new StringBuilder(); boolean first = true; for (Object thing : things) { if (first) { first = false; } else { builder.append(delim); } builder.append(thing.toString()); } return builder.toString(); } /** * Returns the week since {@link android.text.format.Time#EPOCH_JULIAN_DAY} (Jan 1, 1970) * adjusted for first day of week. *

* This takes a julian day and the week start day and calculates which * week since {@link android.text.format.Time#EPOCH_JULIAN_DAY} that day occurs in, starting * at 0. *Do not* use this to compute the ISO week number for the year. * * @param julianDay The julian day to calculate the week number for * @param firstDayOfWeek Which week day is the first day of the week, * see {@link android.text.format.Time#SUNDAY} * @return Weeks since the epoch */ public static int getWeeksSinceEpochFromJulianDay(int julianDay, int firstDayOfWeek) { int diff = Time.THURSDAY - firstDayOfWeek; if (diff < 0) { diff += 7; } int refDay = Time.EPOCH_JULIAN_DAY - diff; return (julianDay - refDay) / 7; } /** * Takes a number of weeks since the epoch and calculates the Julian day of * the Monday for that week. *

* This assumes that the week containing the {@link android.text.format.Time#EPOCH_JULIAN_DAY} * is considered week 0. It returns the Julian day for the Monday * {@code week} weeks after the Monday of the week containing the epoch. * * @param week Number of weeks since the epoch * @return The julian day for the Monday of the given week since the epoch */ public static int getJulianMondayFromWeeksSinceEpoch(int week) { return MONDAY_BEFORE_JULIAN_EPOCH + week * 7; } /** * Get first day of week as android.text.format.Time constant. * * @return the first day of week in android.text.format.Time */ public static int getFirstDayOfWeek(Context context) { int startDay = Calendar.getInstance().getFirstDayOfWeek(); if (startDay == Calendar.SATURDAY) { return Time.SATURDAY; } else if (startDay == Calendar.MONDAY) { return Time.MONDAY; } else { return Time.SUNDAY; } } /** * Get first day of week as java.util.Calendar constant. * * @return the first day of week as a java.util.Calendar constant */ public static int getFirstDayOfWeekAsCalendar(Context context) { return convertDayOfWeekFromTimeToCalendar(getFirstDayOfWeek(context)); } /** * Converts the day of the week from android.text.format.Time to java.util.Calendar */ public static int convertDayOfWeekFromTimeToCalendar(int timeDayOfWeek) { switch (timeDayOfWeek) { case Time.MONDAY: return Calendar.MONDAY; case Time.TUESDAY: return Calendar.TUESDAY; case Time.WEDNESDAY: return Calendar.WEDNESDAY; case Time.THURSDAY: return Calendar.THURSDAY; case Time.FRIDAY: return Calendar.FRIDAY; case Time.SATURDAY: return Calendar.SATURDAY; case Time.SUNDAY: return Calendar.SUNDAY; default: throw new IllegalArgumentException("Argument must be between Time.SUNDAY and " + "Time.SATURDAY"); } } /** * Determine whether the column position is Saturday or not. * * @param column the column position * @param firstDayOfWeek the first day of week in android.text.format.Time * @return true if the column is Saturday position */ public static boolean isSaturday(int column, int firstDayOfWeek) { return (firstDayOfWeek == Time.SUNDAY && column == 6) || (firstDayOfWeek == Time.MONDAY && column == 5) || (firstDayOfWeek == Time.SATURDAY && column == 0); } /** * Determine whether the column position is Sunday or not. * * @param column the column position * @param firstDayOfWeek the first day of week in android.text.format.Time * @return true if the column is Sunday position */ public static boolean isSunday(int column, int firstDayOfWeek) { return (firstDayOfWeek == Time.SUNDAY && column == 0) || (firstDayOfWeek == Time.MONDAY && column == 6) || (firstDayOfWeek == Time.SATURDAY && column == 1); } /** * Convert given UTC time into current local time. This assumes it is for an * allday event and will adjust the time to be on a midnight boundary. * * @param recycle Time object to recycle, otherwise null. * @param utcTime Time to convert, in UTC. * @param tz The time zone to convert this time to. */ public static long convertAlldayUtcToLocal(Time recycle, long utcTime, String tz) { if (recycle == null) { recycle = new Time(); } recycle.timezone = Time.TIMEZONE_UTC; recycle.set(utcTime); recycle.timezone = tz; return recycle.normalize(true); } public static long convertAlldayLocalToUTC(Time recycle, long localTime, String tz) { if (recycle == null) { recycle = new Time(); } recycle.timezone = tz; recycle.set(localTime); recycle.timezone = Time.TIMEZONE_UTC; return recycle.normalize(true); } /** * Finds and returns the next midnight after "theTime" in milliseconds UTC * * @param recycle - Time object to recycle, otherwise null. * @param theTime - Time used for calculations (in UTC) * @param tz The time zone to convert this time to. */ public static long getNextMidnight(Time recycle, long theTime, String tz) { if (recycle == null) { recycle = new Time(); } recycle.timezone = tz; recycle.set(theTime); recycle.monthDay++; recycle.hour = 0; recycle.minute = 0; recycle.second = 0; return recycle.normalize(true); } /** * Scan through a cursor of calendars and check if names are duplicated. * This travels a cursor containing calendar display names and fills in the * provided map with whether or not each name is repeated. * * @param isDuplicateName The map to put the duplicate check results in. * @param cursor The query of calendars to check * @param nameIndex The column of the query that contains the display name */ public static void checkForDuplicateNames( Map isDuplicateName, Cursor cursor, int nameIndex) { isDuplicateName.clear(); cursor.moveToPosition(-1); while (cursor.moveToNext()) { String displayName = cursor.getString(nameIndex); // Set it to true if we've seen this name before, false otherwise if (displayName != null) { isDuplicateName.put(displayName, isDuplicateName.containsKey(displayName)); } } } /** * Null-safe object comparison * * @param s1 * @param s2 * @return */ public static boolean equals(Object o1, Object o2) { return o1 == null ? o2 == null : o1.equals(o2); } public static void setAllowWeekForDetailView(boolean allowWeekView) { mAllowWeekForDetailView = allowWeekView; } public static boolean getAllowWeekForDetailView() { return mAllowWeekForDetailView; } public static boolean getConfigBool(Context c, int key) { return c.getResources().getBoolean(key); } /** * For devices with Jellybean or later, darkens the given color to ensure that white text is * clearly visible on top of it. For devices prior to Jellybean, does nothing, as the * sync adapter handles the color change. * * @param color */ public static int getDisplayColorFromColor(int color) { if (!isJellybeanOrLater()) { return color; } float[] hsv = new float[3]; Color.colorToHSV(color, hsv); hsv[1] = Math.min(hsv[1] * SATURATION_ADJUST, 1.0f); hsv[2] = hsv[2] * INTENSITY_ADJUST; return Color.HSVToColor(hsv); } // This takes a color and computes what it would look like blended with // white. The result is the color that should be used for declined events. public static int getDeclinedColorFromColor(int color) { int bg = 0xffffffff; int a = DECLINED_EVENT_ALPHA; int r = (((color & 0x00ff0000) * a) + ((bg & 0x00ff0000) * (0xff - a))) & 0xff000000; int g = (((color & 0x0000ff00) * a) + ((bg & 0x0000ff00) * (0xff - a))) & 0x00ff0000; int b = (((color & 0x000000ff) * a) + ((bg & 0x000000ff) * (0xff - a))) & 0x0000ff00; return (0xff000000) | ((r | g | b) >> 8); } // A single strand represents one color of events. Events are divided up by // color to make them convenient to draw. The black strand is special in // that it holds conflicting events as well as color settings for allday on // each day. public static class DNAStrand { public float[] points; public int[] allDays; // color for the allday, 0 means no event int position; public int color; int count; } // A segment is a single continuous length of time occupied by a single // color. Segments should never span multiple days. private static class DNASegment { int startMinute; // in minutes since the start of the week int endMinute; int color; // Calendar color or black for conflicts int day; // quick reference to the day this segment is on } // This processes all the segments, sorts them by color, and generates a // list of points to draw private static void weaveDNAStrands(LinkedList segments, int firstJulianDay, HashMap strands, int top, int bottom, int[] dayXs) { // First, get rid of any colors that ended up with no segments Iterator strandIterator = strands.values().iterator(); while (strandIterator.hasNext()) { DNAStrand strand = strandIterator.next(); if (strand.count < 1 && strand.allDays == null) { strandIterator.remove(); continue; } strand.points = new float[strand.count * 4]; strand.position = 0; } // Go through each segment and compute its points for (DNASegment segment : segments) { // Add the points to the strand of that color DNAStrand strand = strands.get(segment.color); int dayIndex = segment.day - firstJulianDay; int dayStartMinute = segment.startMinute % DAY_IN_MINUTES; int dayEndMinute = segment.endMinute % DAY_IN_MINUTES; int height = bottom - top; int workDayHeight = height * 3 / 4; int remainderHeight = (height - workDayHeight) / 2; int x = dayXs[dayIndex]; int y0 = 0; int y1 = 0; y0 = top + getPixelOffsetFromMinutes(dayStartMinute, workDayHeight, remainderHeight); y1 = top + getPixelOffsetFromMinutes(dayEndMinute, workDayHeight, remainderHeight); if (DEBUG) { Log.d(TAG, "Adding " + Integer.toHexString(segment.color) + " at x,y0,y1: " + x + " " + y0 + " " + y1 + " for " + dayStartMinute + " " + dayEndMinute); } strand.points[strand.position++] = x; strand.points[strand.position++] = y0; strand.points[strand.position++] = x; strand.points[strand.position++] = y1; } } /** * Compute a pixel offset from the top for a given minute from the work day * height and the height of the top area. */ private static int getPixelOffsetFromMinutes(int minute, int workDayHeight, int remainderHeight) { int y; if (minute < WORK_DAY_START_MINUTES) { y = minute * remainderHeight / WORK_DAY_START_MINUTES; } else if (minute < WORK_DAY_END_MINUTES) { y = remainderHeight + (minute - WORK_DAY_START_MINUTES) * workDayHeight / WORK_DAY_MINUTES; } else { y = remainderHeight + workDayHeight + (minute - WORK_DAY_END_MINUTES) * remainderHeight / WORK_DAY_END_LENGTH; } return y; } /** * Try to get a strand of the given color. Create it if it doesn't exist. */ private static DNAStrand getOrCreateStrand(HashMap strands, int color) { DNAStrand strand = strands.get(color); if (strand == null) { strand = new DNAStrand(); strand.color = color; strand.count = 0; strands.put(strand.color, strand); } return strand; } /** * This sets up a search view to use Calendar's search suggestions provider * and to allow refining the search. * * @param view The {@link android.widget.SearchView} to set up * @param act The activity using the view */ public static void setUpSearchView(SearchView view, Activity act) { SearchManager searchManager = (SearchManager) act.getSystemService(Context.SEARCH_SERVICE); view.setSearchableInfo(searchManager.getSearchableInfo(act.getComponentName())); view.setQueryRefinementEnabled(true); } // Calculate the time until midnight + 1 second and set the handler to // do run the runnable public static void setMidnightUpdater(Handler h, Runnable r, String timezone) { if (h == null || r == null || timezone == null) { return; } long now = System.currentTimeMillis(); Time time = new Time(timezone); time.set(now); long runInMillis = (24 * 3600 - time.hour * 3600 - time.minute * 60 - time.second + 1) * 1000; h.removeCallbacks(r); h.postDelayed(r, runInMillis); } // Stop the midnight update thread public static void resetMidnightUpdater(Handler h, Runnable r) { if (h == null || r == null) { return; } h.removeCallbacks(r); } /** * Returns the timezone to display in the event info, if the local timezone is different * from the event timezone. Otherwise returns null. */ public static String getDisplayedTimezone(long startMillis, String localTimezone, String eventTimezone) { String tzDisplay = null; if (!TextUtils.equals(localTimezone, eventTimezone)) { // Figure out if this is in DST TimeZone tz = TimeZone.getTimeZone(localTimezone); if (tz == null || tz.getID().equals("GMT")) { tzDisplay = localTimezone; } else { Time startTime = new Time(localTimezone); startTime.set(startMillis); tzDisplay = tz.getDisplayName(startTime.isDst != 0, TimeZone.SHORT); } } return tzDisplay; } /** * Returns whether the specified time interval is in a single day. */ private static boolean singleDayEvent(long startMillis, long endMillis, long localGmtOffset) { if (startMillis == endMillis) { return true; } // An event ending at midnight should still be a single-day event, so check // time end-1. int startDay = Time.getJulianDay(startMillis, localGmtOffset); int endDay = Time.getJulianDay(endMillis - 1, localGmtOffset); return startDay == endDay; } // Using int constants as a return value instead of an enum to minimize resources. private static final int TODAY = 1; private static final int TOMORROW = 2; private static final int NONE = 0; /** * Returns TODAY or TOMORROW if applicable. Otherwise returns NONE. */ private static int isTodayOrTomorrow(Resources r, long dayMillis, long currentMillis, long localGmtOffset) { int startDay = Time.getJulianDay(dayMillis, localGmtOffset); int currentDay = Time.getJulianDay(currentMillis, localGmtOffset); int days = startDay - currentDay; if (days == 1) { return TOMORROW; } else if (days == 0) { return TODAY; } else { return NONE; } } /** * Create an intent for emailing attendees of an event. * * @param resources The resources for translating strings. * @param eventTitle The title of the event to use as the email subject. * @param body The default text for the email body. * @param toEmails The list of emails for the 'to' line. * @param ccEmails The list of emails for the 'cc' line. * @param ownerAccount The owner account to use as the email sender. */ public static Intent createEmailAttendeesIntent(Resources resources, String eventTitle, String body, List toEmails, List ccEmails, String ownerAccount) { List toList = toEmails; List ccList = ccEmails; if (toEmails.size() <= 0) { if (ccEmails.size() <= 0) { // TODO: Return a SEND intent if no one to email to, to at least populate // a draft email with the subject (and no recipients). throw new IllegalArgumentException("Both toEmails and ccEmails are empty."); } // Email app does not work with no "to" recipient. Move all 'cc' to 'to' // in this case. toList = ccEmails; ccList = null; } // Use the event title as the email subject (prepended with 'Re: '). String subject = null; if (eventTitle != null) { subject = resources.getString(R.string.email_subject_prefix) + eventTitle; } // Use the SENDTO intent with a 'mailto' URI, because using SEND will cause // the picker to show apps like text messaging, which does not make sense // for email addresses. We put all data in the URI instead of using the extra // Intent fields (ie. EXTRA_CC, etc) because some email apps might not handle // those (though gmail does). Uri.Builder uriBuilder = new Uri.Builder(); uriBuilder.scheme("mailto"); // We will append the first email to the 'mailto' field later (because the // current state of the Email app requires it). Add the remaining 'to' values // here. When the email codebase is updated, we can simplify this. if (toList.size() > 1) { for (int i = 1; i < toList.size(); i++) { // The Email app requires repeated parameter settings instead of // a single comma-separated list. uriBuilder.appendQueryParameter("to", toList.get(i)); } } // Add the subject parameter. if (subject != null) { uriBuilder.appendQueryParameter("subject", subject); } // Add the subject parameter. if (body != null) { uriBuilder.appendQueryParameter("body", body); } // Add the cc parameters. if (ccList != null && ccList.size() > 0) { for (String email : ccList) { uriBuilder.appendQueryParameter("cc", email); } } // Insert the first email after 'mailto:' in the URI manually since Uri.Builder // doesn't seem to have a way to do this. String uri = uriBuilder.toString(); if (uri.startsWith("mailto:")) { StringBuilder builder = new StringBuilder(uri); builder.insert(7, Uri.encode(toList.get(0))); uri = builder.toString(); } // Start the email intent. Email from the account of the calendar owner in case there // are multiple email accounts. Intent emailIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse(uri)); emailIntent.putExtra("fromAccountString", ownerAccount); // Workaround a Email bug that overwrites the body with this intent extra. If not // set, it clears the body. if (body != null) { emailIntent.putExtra(Intent.EXTRA_TEXT, body); } return Intent.createChooser(emailIntent, resources.getString(R.string.email_picker_label)); } /** * Example fake email addresses used as attendee emails are resources like conference rooms, * or another calendar, etc. These all end in "calendar.google.com". */ public static boolean isValidEmail(String email) { return email != null && !email.endsWith(MACHINE_GENERATED_ADDRESS); } /** * Returns true if: * (1) the email is not a resource like a conference room or another calendar. * Catch most of these by filtering out suffix calendar.google.com. * (2) the email is not equal to the sync account to prevent mailing himself. */ public static boolean isEmailableFrom(String email, String syncAccountName) { return Utils.isValidEmail(email) && !email.equals(syncAccountName); } private static class CalendarBroadcastReceiver extends BroadcastReceiver { Runnable mCallBack; public CalendarBroadcastReceiver(Runnable callback) { super(); mCallBack = callback; } @Override public void onReceive(Context context, Intent intent) { if (intent.getAction().equals(Intent.ACTION_DATE_CHANGED) || intent.getAction().equals(Intent.ACTION_TIME_CHANGED) || intent.getAction().equals(Intent.ACTION_LOCALE_CHANGED) || intent.getAction().equals(Intent.ACTION_TIMEZONE_CHANGED)) { if (mCallBack != null) { mCallBack.run(); } } } } public static BroadcastReceiver setTimeChangesReceiver(Context c, Runnable callback) { IntentFilter filter = new IntentFilter(); filter.addAction(Intent.ACTION_TIME_CHANGED); filter.addAction(Intent.ACTION_DATE_CHANGED); filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); filter.addAction(Intent.ACTION_LOCALE_CHANGED); CalendarBroadcastReceiver r = new CalendarBroadcastReceiver(callback); c.registerReceiver(r, filter); return r; } public static void clearTimeChangesReceiver(Context c, BroadcastReceiver r) { c.unregisterReceiver(r); } /** * Return the app version code. */ public static String getVersionCode(Context context) { if (sVersion == null) { try { sVersion = context.getPackageManager().getPackageInfo( context.getPackageName(), 0).versionName; } catch (PackageManager.NameNotFoundException e) { // Can't find version; just leave it blank. Log.e(TAG, "Error finding package " + context.getApplicationInfo().packageName); } } return sVersion; } /** * Checks the server for an updated list of Calendars (in the background). *

* If a Calendar is added on the web (and it is selected and not * hidden) then it will be added to the list of calendars on the phone * (when this finishes). When a new calendar from the * web is added to the phone, then the events for that calendar are also * downloaded from the web. *

* This sync is done automatically in the background when the * SelectCalendars activity and fragment are started. * * @param account - The account to sync. May be null to sync all accounts. */ public static void startCalendarMetafeedSync(Account account) { Bundle extras = new Bundle(); extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); extras.putBoolean("metafeedonly", true); ContentResolver.requestSync(account, Calendars.CONTENT_URI.getAuthority(), extras); } /** * Replaces stretches of text that look like addresses and phone numbers with clickable * links. If lastDitchGeo is true, then if no links are found in the textview, the entire * string will be converted to a single geo link. Any spans that may have previously been * in the text will be cleared out. *

* This is really just an enhanced version of Linkify.addLinks(). * * @param text - The string to search for links. * @param lastDitchGeo - If no links are found, turn the entire string into one geo link. * @return Spannable object containing the list of URL spans found. */ public static Spannable extendedLinkify(String text, boolean lastDitchGeo) { // We use a copy of the string argument so it's available for later if necessary. Spannable spanText = SpannableString.valueOf(text); /* * If the text includes a street address like "1600 Amphitheater Parkway, 94043", * the current Linkify code will identify "94043" as a phone number and invite * you to dial it (and not provide a map link for the address). For outside US, * use Linkify result iff it spans the entire text. Otherwise send the user to maps. */ String defaultPhoneRegion = System.getProperty("user.region", "US"); if (!defaultPhoneRegion.equals("US")) { Linkify.addLinks(spanText, Linkify.ALL); // If Linkify links the entire text, use that result. URLSpan[] spans = spanText.getSpans(0, spanText.length(), URLSpan.class); if (spans.length == 1) { int linkStart = spanText.getSpanStart(spans[0]); int linkEnd = spanText.getSpanEnd(spans[0]); if (linkStart <= indexFirstNonWhitespaceChar(spanText) && linkEnd >= indexLastNonWhitespaceChar(spanText) + 1) { return spanText; } } // Otherwise, to be cautious and to try to prevent false positives, reset the spannable. spanText = SpannableString.valueOf(text); // If lastDitchGeo is true, default the entire string to geo. if (lastDitchGeo && !text.isEmpty()) { Linkify.addLinks(spanText, mWildcardPattern, "geo:0,0?q="); } return spanText; } /* * For within US, we want to have better recognition of phone numbers without losing * any of the existing annotations. Ideally this would be addressed by improving Linkify. * For now we manage it as a second pass over the text. * * URIs and e-mail addresses are pretty easy to pick out of text. Phone numbers * are a bit tricky because they have radically different formats in different * countries, in terms of both the digits and the way in which they are commonly * written or presented (e.g. the punctuation and spaces in "(650) 555-1212"). * The expected format of a street address is defined in WebView.findAddress(). It's * pretty narrowly defined, so it won't often match. * * The RFC 3966 specification defines the format of a "tel:" URI. * * Start by letting Linkify find anything that isn't a phone number. We have to let it * run first because every invocation removes all previous URLSpan annotations. * * Ideally we'd use the external/libphonenumber routines, but those aren't available * to unbundled applications. */ boolean linkifyFoundLinks = Linkify.addLinks(spanText, Linkify.ALL & ~(Linkify.PHONE_NUMBERS)); /* * Get a list of any spans created by Linkify, for the coordinate overlapping span check. */ URLSpan[] existingSpans = spanText.getSpans(0, spanText.length(), URLSpan.class); /* * Check for coordinates. * This must be done before phone numbers because longitude may look like a phone number. */ Matcher coordMatcher = COORD_PATTERN.matcher(spanText); int coordCount = 0; while (coordMatcher.find()) { int start = coordMatcher.start(); int end = coordMatcher.end(); if (spanWillOverlap(spanText, existingSpans, start, end)) { continue; } URLSpan span = new URLSpan("geo:0,0?q=" + coordMatcher.group()); spanText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); coordCount++; } /* * Update the list of existing spans, for the phone number overlapping span check. */ existingSpans = spanText.getSpans(0, spanText.length(), URLSpan.class); /* * Search for phone numbers. * * Some URIs contain strings of digits that look like phone numbers. If both the URI * scanner and the phone number scanner find them, we want the URI link to win. Since * the URI scanner runs first, we just need to avoid creating overlapping spans. */ int[] phoneSequences = findNanpPhoneNumbers(text); /* * Insert spans for the numbers we found. We generate "tel:" URIs. */ int phoneCount = 0; for (int match = 0; match < phoneSequences.length / 2; match++) { int start = phoneSequences[match * 2]; int end = phoneSequences[match * 2 + 1]; if (spanWillOverlap(spanText, existingSpans, start, end)) { continue; } /* * The Linkify code takes the matching span and strips out everything that isn't a * digit or '+' sign. We do the same here. Extension numbers will get appended * without a separator, but the dialer wasn't doing anything useful with ";ext=" * anyway. */ //String dialStr = phoneUtil.format(match.number(), // PhoneNumberUtil.PhoneNumberFormat.RFC3966); StringBuilder dialBuilder = new StringBuilder(); for (int i = start; i < end; i++) { char ch = spanText.charAt(i); if (ch == '+' || Character.isDigit(ch)) { dialBuilder.append(ch); } } URLSpan span = new URLSpan("tel:" + dialBuilder.toString()); spanText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); phoneCount++; } /* * If lastDitchGeo, and no other links have been found, set the entire string as a geo link. */ if (lastDitchGeo && !text.isEmpty() && !linkifyFoundLinks && phoneCount == 0 && coordCount == 0) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "No linkification matches, using geo default"); } Linkify.addLinks(spanText, mWildcardPattern, "geo:0,0?q="); } return spanText; } private static int indexFirstNonWhitespaceChar(CharSequence str) { for (int i = 0; i < str.length(); i++) { if (!Character.isWhitespace(str.charAt(i))) { return i; } } return -1; } private static int indexLastNonWhitespaceChar(CharSequence str) { for (int i = str.length() - 1; i >= 0; i--) { if (!Character.isWhitespace(str.charAt(i))) { return i; } } return -1; } /** * Finds North American Numbering Plan (NANP) phone numbers in the input text. * * @param text The text to scan. * @return A list of [start, end) pairs indicating the positions of phone numbers in the input. */ // @VisibleForTesting static int[] findNanpPhoneNumbers(CharSequence text) { ArrayList list = new ArrayList(); int startPos = 0; int endPos = text.length() - NANP_MIN_DIGITS + 1; if (endPos < 0) { return new int[]{}; } /* * We can't just strip the whitespace out and crunch it down, because the whitespace * is significant. March through, trying to figure out where numbers start and end. */ while (startPos < endPos) { // skip whitespace while (Character.isWhitespace(text.charAt(startPos)) && startPos < endPos) { startPos++; } if (startPos == endPos) { break; } // check for a match at this position int matchEnd = findNanpMatchEnd(text, startPos); if (matchEnd > startPos) { list.add(startPos); list.add(matchEnd); startPos = matchEnd; // skip past match } else { // skip to next whitespace char while (!Character.isWhitespace(text.charAt(startPos)) && startPos < endPos) { startPos++; } } } int[] result = new int[list.size()]; for (int i = list.size() - 1; i >= 0; i--) { result[i] = list.get(i); } return result; } /** * Checks to see if there is a valid phone number in the input, starting at the specified * offset. If so, the index of the last character + 1 is returned. The input is assumed * to begin with a non-whitespace character. * * @return Exclusive end position, or -1 if not a match. */ private static int findNanpMatchEnd(CharSequence text, int startPos) { /* * A few interesting cases: * 94043 # too short, ignore * 123456789012 # too long, ignore * +1 (650) 555-1212 # 11 digits, spaces * (650) 555 5555 # Second space, only when first is present. * (650) 555-1212, (650) 555-1213 # two numbers, return first * 1-650-555-1212 # 11 digits with leading '1' * *#650.555.1212#*! # 10 digits, include #*, ignore trailing '!' * 555.1212 # 7 digits * * For the most part we want to break on whitespace, but it's common to leave a space * between the initial '1' and/or after the area code. */ // Check for "tel:" URI prefix. if (text.length() > startPos + 4 && text.subSequence(startPos, startPos + 4).toString().equalsIgnoreCase("tel:")) { startPos += 4; } int endPos = text.length(); int curPos = startPos; int foundDigits = 0; char firstDigit = 'x'; boolean foundWhiteSpaceAfterAreaCode = false; while (curPos <= endPos) { char ch; if (curPos < endPos) { ch = text.charAt(curPos); } else { ch = 27; // fake invalid symbol at end to trigger loop break } if (Character.isDigit(ch)) { if (foundDigits == 0) { firstDigit = ch; } foundDigits++; if (foundDigits > NANP_MAX_DIGITS) { // too many digits, stop early return -1; } } else if (Character.isWhitespace(ch)) { if ((firstDigit == '1' && foundDigits == 4) || (foundDigits == 3)) { foundWhiteSpaceAfterAreaCode = true; } else if (firstDigit == '1' && foundDigits == 1) { } else if (foundWhiteSpaceAfterAreaCode && ((firstDigit == '1' && (foundDigits == 7)) || (foundDigits == 6))) { } else { break; } } else if (NANP_ALLOWED_SYMBOLS.indexOf(ch) == -1) { break; } // else it's an allowed symbol curPos++; } if ((firstDigit != '1' && (foundDigits == 7 || foundDigits == 10)) || (firstDigit == '1' && foundDigits == 11)) { // match return curPos; } return -1; } /** * Determines whether a new span at [start,end) will overlap with any existing span. */ private static boolean spanWillOverlap(Spannable spanText, URLSpan[] spanList, int start, int end) { if (start == end) { // empty span, ignore return false; } for (URLSpan span : spanList) { int existingStart = spanText.getSpanStart(span); int existingEnd = spanText.getSpanEnd(span); if ((start >= existingStart && start < existingEnd) || end > existingStart && end <= existingEnd) { if (Log.isLoggable(TAG, Log.VERBOSE)) { CharSequence seq = spanText.subSequence(start, end); Log.v(TAG, "Not linkifying " + seq + " as phone number due to overlap"); } return true; } } return false; } } ================================================ FILE: library/src/main/java/be/billington/calendar/recurrencepicker/WeekButton.java ================================================ /* * Copyright (C) 2013 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package be.billington.calendar.recurrencepicker; import android.content.Context; import android.util.AttributeSet; import android.view.View; public class WeekButton extends android.widget.ToggleButton { private static int mWidth; public WeekButton(Context context) { super(context); } public WeekButton(Context context, AttributeSet attrs) { super(context, attrs); } public WeekButton(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } public static void setSuggestedWidth(int w) { mWidth = w; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int h = getMeasuredHeight(); int w = getMeasuredWidth(); if (h > 0 && w > 0) { if (w < h) { if (View.MeasureSpec.getMode(getMeasuredHeightAndState()) != MeasureSpec.EXACTLY) { h = w; } } else if (h < w) { if (View.MeasureSpec.getMode(getMeasuredWidthAndState()) != MeasureSpec.EXACTLY) { w = h; } } } setMeasuredDimension(w, h); } } ================================================ FILE: library/src/main/res/color/done_text_color.xml ================================================ ================================================ FILE: library/src/main/res/color/recurrence_bubble_text_color.xml ================================================ ================================================ FILE: library/src/main/res/color/recurrence_spinner_text_color.xml ================================================ ================================================ FILE: library/src/main/res/drawable/recurrence_bubble_fill.xml ================================================ ================================================ FILE: library/src/main/res/drawable/switch_thumb.xml ================================================ ================================================ FILE: library/src/main/res/layout/recurrencepicker.xml ================================================